Skip to main content

mur_common/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6/// Default model id seeded for the built-in "Mur" agent and used to name the
7/// bundled MLX weights. This is the DEFAULT VALUE only — it is written into the
8/// seed agent's profile and can be changed by the user afterwards; it is not a
9/// behavioural constant baked into logic.
10pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
11
12/// Global MUR configuration (~/.mur/config.yaml)
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct Config {
15    #[serde(default)]
16    pub embedding: EmbeddingConfig,
17
18    #[serde(default)]
19    pub llm: LlmConfig,
20
21    #[serde(default)]
22    pub retrieval: RetrievalConfig,
23
24    #[serde(default)]
25    pub paths: PathConfig,
26
27    #[serde(default)]
28    pub server: ServerConfig,
29
30    #[serde(default)]
31    pub community: CommunityConfig,
32
33    #[serde(default)]
34    pub conversations: ConversationsConfig,
35
36    #[serde(default)]
37    pub sync: SyncConfig,
38
39    // --- P1.1 additions ---
40    #[serde(default)]
41    pub storage: StorageConfig,
42
43    #[serde(default)]
44    pub sources_global: SourcesGlobalConfig,
45
46    // --- E3 additions ---
47    #[serde(default)]
48    pub sleep_cycle: SleepCycleConfig,
49
50    // --- M2 additions ---
51    #[serde(default)]
52    pub skills: SkillsConfig,
53
54    // --- M6c additions ---
55    #[serde(default)]
56    pub skill_llm: SkillLlmConfig,
57
58    // --- M7a additions ---
59    #[serde(default)]
60    pub cross_agent: CrossAgentConfig,
61
62    // --- nudge additions ---
63    #[serde(default)]
64    pub nudge: NudgeConfig,
65}
66
67impl Config {
68    /// Read from disk, falling back to defaults.
69    pub fn load_or_default(path: &std::path::Path) -> Self {
70        std::fs::read_to_string(path)
71            .ok()
72            .and_then(|s| serde_yaml_ng::from_str(&s).ok())
73            .unwrap_or_default()
74    }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct SyncConfig {
79    /// Sync method: "cloud", "git", or "local"
80    #[serde(default = "default_sync_method")]
81    pub method: String,
82
83    /// Git remote URL for git sync
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub git_remote: Option<String>,
86
87    /// Auto-sync on context pull / session stop
88    #[serde(default)]
89    pub auto: bool,
90
91    /// Default team ID for cloud sync (set on first successful sync)
92    #[serde(default, skip_serializing_if = "Option::is_none")]
93    pub team_id: Option<String>,
94}
95
96fn default_sync_method() -> String {
97    "local".to_string()
98}
99
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub struct ServerConfig {
102    /// Server URL (default: https://mur-server.fly.dev)
103    #[serde(default = "default_server_url")]
104    pub url: String,
105}
106
107impl Default for ServerConfig {
108    fn default() -> Self {
109        Self {
110            url: default_server_url(),
111        }
112    }
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, Default)]
116pub struct CommunityConfig {
117    /// Whether community pattern sharing is enabled
118    #[serde(default)]
119    pub enabled: bool,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize)]
123pub struct EmbeddingConfig {
124    /// "ollama", "openai", "gemini", or "anthropic"
125    #[serde(default = "default_embedding_provider")]
126    pub provider: String,
127
128    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
129    #[serde(default = "default_embedding_model")]
130    pub model: String,
131
132    /// Vector dimensions (fixed after first index build)
133    #[serde(default = "default_dimensions")]
134    pub dimensions: usize,
135
136    /// Ollama endpoint
137    #[serde(default = "default_ollama_endpoint")]
138    pub ollama_endpoint: String,
139
140    /// API key env var name (e.g. "OPENAI_API_KEY")
141    #[serde(default, skip_serializing_if = "Option::is_none")]
142    pub api_key_env: Option<String>,
143
144    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
145    #[serde(default, skip_serializing_if = "Option::is_none")]
146    pub openai_url: Option<String>,
147}
148
149impl Default for EmbeddingConfig {
150    fn default() -> Self {
151        Self {
152            provider: default_embedding_provider(),
153            model: default_embedding_model(),
154            dimensions: default_dimensions(),
155            ollama_endpoint: default_ollama_endpoint(),
156            api_key_env: None,
157            openai_url: None,
158        }
159    }
160}
161
162#[derive(Debug, Clone, Serialize, Deserialize)]
163pub struct LlmConfig {
164    /// "anthropic", "openai", "gemini", or "ollama"
165    #[serde(default = "default_llm_provider")]
166    pub provider: String,
167
168    #[serde(default = "default_llm_model")]
169    pub model: String,
170
171    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
172    #[serde(default, skip_serializing_if = "Option::is_none")]
173    pub api_key_env: Option<String>,
174
175    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
176    #[serde(default, skip_serializing_if = "Option::is_none")]
177    pub openai_url: Option<String>,
178}
179
180impl Default for LlmConfig {
181    fn default() -> Self {
182        Self {
183            provider: default_llm_provider(),
184            model: default_llm_model(),
185            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
186            openai_url: None,
187        }
188    }
189}
190
191impl LlmConfig {
192    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
193    /// into a BackendConfig that the new ChatBackend factory consumes.
194    /// Mapping:
195    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
196    ///   (preserves the historical LlmConfig::llm_complete fall-through for
197    ///   OpenAI-compatible passthrough proxies).
198    /// - `model` 1:1.
199    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
200    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
201    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
202    /// - `timeout_secs` always None (factory defaults to 120s — matches
203    ///   the historical 60s reqwest default behavior closely enough).
204    pub fn to_backend_config(&self) -> BackendConfig {
205        let provider = match self.provider.as_str() {
206            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
207            _ if self.openai_url.is_some() => "openai".into(),
208            other => other.into(), // factory will reject with "unsupported provider"
209        };
210        BackendConfig {
211            provider,
212            model: self.model.clone(),
213            endpoint: self.openai_url.clone(),
214            api_key_env: self.api_key_env.clone(),
215            timeout_secs: None,
216        }
217    }
218}
219
220/// Backend selection for a single chat-completion call site.
221///
222/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
223/// (per-stage) and `AskConfig` (per-stage) to override the legacy
224/// Ollama-only path. None of the `Option` fields are required;
225/// resolution falls back to provider defaults
226/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
227///
228/// Stays in mur-common (not mur-core) because it is pure data and
229/// will be reused by mur-agent-runtime in a future phase.
230#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
231#[serde(default)]
232pub struct BackendConfig {
233    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
234    pub provider: String,
235    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
236    pub model: String,
237    /// Provider endpoint. None = provider default
238    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
239    pub endpoint: Option<String>,
240    /// Env var holding the API key. None = no auth (ollama).
241    pub api_key_env: Option<String>,
242    /// Per-call timeout in seconds. None = 120s.
243    pub timeout_secs: Option<u64>,
244}
245
246impl Default for BackendConfig {
247    fn default() -> Self {
248        Self {
249            provider: "ollama".into(),
250            model: DEFAULT_LOCAL_LLM_MODEL.into(),
251            endpoint: None,
252            api_key_env: None,
253            timeout_secs: None,
254        }
255    }
256}
257
258#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct RetrievalConfig {
260    /// Max patterns to inject per query
261    #[serde(default = "default_max_patterns")]
262    pub max_patterns: usize,
263
264    /// Max tokens for injected content
265    #[serde(default = "default_max_tokens")]
266    pub max_tokens: usize,
267
268    /// Minimum score threshold
269    #[serde(default = "default_min_score")]
270    pub min_score: f64,
271
272    /// MMR diversity threshold (cosine > this = too similar)
273    #[serde(default = "default_mmr_threshold")]
274    pub mmr_threshold: f64,
275}
276
277impl Default for RetrievalConfig {
278    fn default() -> Self {
279        Self {
280            max_patterns: default_max_patterns(),
281            max_tokens: default_max_tokens(),
282            min_score: default_min_score(),
283            mmr_threshold: default_mmr_threshold(),
284        }
285    }
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
289pub struct PathConfig {
290    /// Root MUR directory (default: ~/.mur)
291    #[serde(default = "default_mur_dir")]
292    pub mur_dir: PathBuf,
293}
294
295impl Default for PathConfig {
296    fn default() -> Self {
297        Self {
298            mur_dir: default_mur_dir(),
299        }
300    }
301}
302
303#[derive(Debug, Clone, Serialize, Deserialize)]
304pub struct StorageConfig {
305    /// Vector backend identifier: "lancedb" (default) or "qdrant".
306    #[serde(default = "default_vector_backend")]
307    pub vector_backend: String,
308
309    /// Qdrant connection URL (only used when vector_backend = "qdrant").
310    #[serde(default, skip_serializing_if = "Option::is_none")]
311    pub qdrant_url: Option<String>,
312
313    /// Keyring account name holding the Qdrant API key, if any.
314    #[serde(default, skip_serializing_if = "Option::is_none")]
315    pub qdrant_api_key_ref: Option<String>,
316}
317
318impl Default for StorageConfig {
319    fn default() -> Self {
320        Self {
321            vector_backend: default_vector_backend(),
322            qdrant_url: None,
323            qdrant_api_key_ref: None,
324        }
325    }
326}
327
328fn default_vector_backend() -> String {
329    "lancedb".to_string()
330}
331
332#[derive(Debug, Clone, Serialize, Deserialize)]
333pub struct SourcesGlobalConfig {
334    /// Polling interval for cloud sources (seconds).
335    #[serde(default = "default_poll_interval_secs")]
336    pub poll_interval_secs: u64,
337
338    /// Safety cap: do not sync more than this many chunks per run.
339    #[serde(default = "default_max_chunks_per_sync")]
340    pub max_chunks_per_sync: usize,
341
342    /// Upper bound on parallel source sync tasks.
343    #[serde(default = "default_max_parallel_sources")]
344    pub max_parallel_sources: usize,
345
346    /// Weight applied to new sources unless overridden.
347    #[serde(default = "default_source_weight")]
348    pub default_weight: f32,
349
350    /// Embedding request batch size.
351    #[serde(default = "default_embedding_batch_size")]
352    pub embedding_batch_size: usize,
353}
354
355impl Default for SourcesGlobalConfig {
356    fn default() -> Self {
357        Self {
358            poll_interval_secs: default_poll_interval_secs(),
359            max_chunks_per_sync: default_max_chunks_per_sync(),
360            max_parallel_sources: default_max_parallel_sources(),
361            default_weight: default_source_weight(),
362            embedding_batch_size: default_embedding_batch_size(),
363        }
364    }
365}
366
367fn default_poll_interval_secs() -> u64 {
368    600
369}
370fn default_max_chunks_per_sync() -> usize {
371    10_000
372}
373fn default_max_parallel_sources() -> usize {
374    3
375}
376fn default_source_weight() -> f32 {
377    1.0
378}
379fn default_embedding_batch_size() -> usize {
380    32
381}
382
383fn default_embedding_provider() -> String {
384    "ollama".to_string()
385}
386fn default_embedding_model() -> String {
387    "qwen3-embedding:0.6b".to_string()
388}
389fn default_dimensions() -> usize {
390    1024
391}
392fn default_ollama_endpoint() -> String {
393    "http://localhost:11434".to_string()
394}
395fn default_llm_provider() -> String {
396    "anthropic".to_string()
397}
398fn default_llm_model() -> String {
399    "claude-opus-4-6".to_string()
400}
401fn default_max_patterns() -> usize {
402    5
403}
404fn default_max_tokens() -> usize {
405    2000
406}
407fn default_min_score() -> f64 {
408    0.35
409}
410fn default_mmr_threshold() -> f64 {
411    0.85
412}
413fn default_mur_dir() -> PathBuf {
414    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
415    // Callers in mur-core that need the real home dir should use `dirs` there.
416    let home = std::env::var("HOME")
417        .map(PathBuf::from)
418        .unwrap_or_else(|_| PathBuf::from("/tmp"));
419    home.join(".mur")
420}
421fn default_server_url() -> String {
422    "https://mur-server.fly.dev".to_string()
423}
424
425// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct AskConfig {
429    #[serde(default = "ask_default_model")]
430    pub model: String,
431    #[serde(default = "compact_default_ollama_endpoint")]
432    pub ollama_endpoint: String,
433    #[serde(default = "ask_default_k_summary")]
434    pub k_summary: u32,
435    #[serde(default = "ask_default_k_raw")]
436    pub k_raw: u32,
437    #[serde(default = "ask_default_esc")]
438    pub escalation_threshold: f64,
439    #[serde(default = "ask_default_mmr")]
440    pub mmr_threshold: f64,
441    #[serde(default = "ask_default_max_ctx")]
442    pub max_context_tokens: u32,
443    #[serde(default = "ask_default_resp_tok")]
444    pub response_tokens: u32,
445    #[serde(default = "ask_default_timeout")]
446    pub timeout_secs: u32,
447    #[serde(default = "ask_default_min_score")]
448    pub min_score: f64,
449    #[serde(default = "ask_default_continue_history_turns")]
450    pub continue_history_turns: u32,
451    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
452    /// Rewriter output is small (~80 tokens) and falling back to the raw
453    /// question on failure is non-fatal, so we don't want to burn the full
454    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
455    /// the user sees any response.
456    #[serde(default = "ask_default_rewriter_timeout")]
457    pub rewriter_timeout_secs: u32,
458    #[serde(default = "ask_default_compress_hits_enabled")]
459    pub compress_hits_enabled: bool,
460    #[serde(default = "ask_default_summarize_hits_enabled")]
461    pub summarize_hits_enabled: bool,
462    #[serde(default)]
463    pub summarize_model: Option<String>,
464    /// Per-stage backend override for the answer-generation model.
465    /// None = synthesize from legacy `model` + `ollama_endpoint`.
466    #[serde(default)]
467    pub backend: Option<BackendConfig>,
468    /// Per-stage backend override for the query rewriter.
469    /// None = synthesize an Ollama BackendConfig over the legacy `model` +
470    /// `ollama_endpoint` with `rewriter_timeout_secs` baked in.
471    #[serde(default)]
472    pub rewriter_backend: Option<BackendConfig>,
473}
474
475impl AskConfig {
476    /// Returns the effective backend for the answer-generation model.
477    /// Per-stage `backend` override wins; otherwise synthesize from legacy
478    /// fields (`model`, `ollama_endpoint`) into an Ollama BackendConfig.
479    ///
480    /// `timeout_secs` is baked from `self.timeout_secs` so the answer call
481    /// inherits the user's per-call budget (rather than factory's 120s
482    /// default). When the user supplied an explicit `backend` override
483    /// with its own `timeout_secs`, that wins — we only synthesize when
484    /// `self.backend` is None.
485    pub fn synthesize_backend(&self) -> BackendConfig {
486        self.backend.clone().unwrap_or_else(|| BackendConfig {
487            provider: "ollama".into(),
488            model: self.model.clone(),
489            endpoint: Some(self.ollama_endpoint.clone()),
490            api_key_env: None,
491            timeout_secs: Some(self.timeout_secs as u64),
492        })
493    }
494
495    /// Returns the effective backend for the query rewriter.
496    ///
497    /// When `self.rewriter_backend` is None, this synthesizes its OWN
498    /// Ollama BackendConfig with `self.rewriter_timeout_secs` baked in —
499    /// it does NOT fall through to `synthesize_backend()`. The rewriter
500    /// has a much tighter latency budget than the answer call (rewriter
501    /// output is small and falling back to the raw question on timeout
502    /// is non-fatal), so we don't want a slow Ollama burning the full
503    /// `timeout_secs` budget before the user sees any response.
504    pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
505        self.rewriter_backend
506            .clone()
507            .unwrap_or_else(|| BackendConfig {
508                provider: "ollama".into(),
509                model: self.model.clone(),
510                endpoint: Some(self.ollama_endpoint.clone()),
511                api_key_env: None,
512                timeout_secs: Some(self.rewriter_timeout_secs as u64),
513            })
514    }
515}
516
517impl Default for AskConfig {
518    fn default() -> Self {
519        Self {
520            model: ask_default_model(),
521            ollama_endpoint: compact_default_ollama_endpoint(),
522            k_summary: ask_default_k_summary(),
523            k_raw: ask_default_k_raw(),
524            escalation_threshold: ask_default_esc(),
525            mmr_threshold: ask_default_mmr(),
526            max_context_tokens: ask_default_max_ctx(),
527            response_tokens: ask_default_resp_tok(),
528            timeout_secs: ask_default_timeout(),
529            min_score: ask_default_min_score(),
530            continue_history_turns: ask_default_continue_history_turns(),
531            rewriter_timeout_secs: ask_default_rewriter_timeout(),
532            compress_hits_enabled: ask_default_compress_hits_enabled(),
533            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
534            summarize_model: None,
535            backend: None,
536            rewriter_backend: None,
537        }
538    }
539}
540
541fn ask_default_model() -> String {
542    DEFAULT_LOCAL_LLM_MODEL.into()
543}
544fn ask_default_k_summary() -> u32 {
545    5
546}
547fn ask_default_k_raw() -> u32 {
548    10
549}
550fn ask_default_esc() -> f64 {
551    0.5
552}
553fn ask_default_mmr() -> f64 {
554    0.88
555}
556fn ask_default_max_ctx() -> u32 {
557    6000
558}
559fn ask_default_resp_tok() -> u32 {
560    1024
561}
562fn ask_default_timeout() -> u32 {
563    120
564}
565fn ask_default_min_score() -> f64 {
566    0.35
567}
568fn ask_default_rewriter_timeout() -> u32 {
569    8
570}
571fn ask_default_continue_history_turns() -> u32 {
572    3
573}
574fn ask_default_compress_hits_enabled() -> bool {
575    true
576}
577fn ask_default_summarize_hits_enabled() -> bool {
578    true
579}
580
581// ── Conversations archive config (Task 23) ────────────────────────────────────
582
583/// Phase 1 conversations archive config (Task 23).
584///
585/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
586/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
587/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
588/// without a `conversations:` section still parses.
589#[derive(Debug, Clone, Serialize, Deserialize)]
590pub struct ConversationsConfig {
591    #[serde(default)]
592    pub enabled: bool,
593    #[serde(default = "conv_default_retention_days")]
594    pub retention_days: u32,
595    #[serde(default = "conv_default_poll_interval")]
596    pub poll_interval_secs: u64,
597    #[serde(default)]
598    pub sources: ConversationsSources,
599    #[serde(default)]
600    pub filter: ConversationsFilter,
601    #[serde(default)]
602    pub compact: CompactConfig,
603    #[serde(default)]
604    pub ask: AskConfig,
605    #[serde(default)]
606    pub rollup: RollupConfig,
607}
608
609impl Default for ConversationsConfig {
610    fn default() -> Self {
611        Self {
612            enabled: false,
613            retention_days: conv_default_retention_days(),
614            poll_interval_secs: conv_default_poll_interval(),
615            sources: ConversationsSources::default(),
616            filter: ConversationsFilter::default(),
617            compact: CompactConfig::default(),
618            ask: AskConfig::default(),
619            rollup: RollupConfig::default(),
620        }
621    }
622}
623
624fn conv_default_retention_days() -> u32 {
625    30
626}
627fn conv_default_poll_interval() -> u64 {
628    300
629}
630fn conv_truthy() -> bool {
631    true
632}
633fn conv_default_dedup() -> f64 {
634    0.85
635}
636
637#[derive(Debug, Clone, Serialize, Deserialize)]
638pub struct CompactConfig {
639    #[serde(default = "conv_truthy")]
640    pub enabled_in_daemon: bool,
641    #[serde(default = "compact_default_max_days")]
642    pub max_days_per_run: u32,
643    #[serde(default = "compact_default_model")]
644    pub extractive_model: String,
645    #[serde(default = "compact_default_model")]
646    pub abstractive_model: String,
647    #[serde(default = "compact_default_ollama_endpoint")]
648    pub ollama_endpoint: String,
649    #[serde(default = "compact_default_max_spans")]
650    pub max_extractive_spans: u32,
651    #[serde(default = "compact_default_max_words")]
652    pub max_abstractive_words: u32,
653    #[serde(default = "compact_default_chunk_tokens")]
654    pub chunk_tokens: u32,
655    #[serde(default = "compact_default_history_retain")]
656    pub history_retain: u32,
657    #[serde(default = "compact_default_cron")]
658    pub daemon_cron: String,
659    /// Per-stage backend override for extractive summarization.
660    /// None = synthesize from legacy `extractive_model` + `ollama_endpoint`.
661    #[serde(default)]
662    pub extractive_backend: Option<BackendConfig>,
663    /// Per-stage backend override for abstractive summarization.
664    /// None = synthesize from legacy `abstractive_model` + `ollama_endpoint`.
665    #[serde(default)]
666    pub abstractive_backend: Option<BackendConfig>,
667}
668
669impl CompactConfig {
670    /// Returns the effective backend for the extractive stage.
671    /// Per-stage `extractive_backend` override wins; otherwise synthesize
672    /// from legacy fields into an Ollama BackendConfig.
673    ///
674    /// CompactConfig has no per-stage timeout field, so synthesis bakes
675    /// the conservative 120s default — matching the previously-hardcoded
676    /// `Duration::from_secs(120)` at the call sites (byte-identical to
677    /// the pre-trait OllamaClient construction).
678    pub fn synthesize_extractive_backend(&self) -> BackendConfig {
679        self.extractive_backend
680            .clone()
681            .unwrap_or_else(|| BackendConfig {
682                provider: "ollama".into(),
683                model: self.extractive_model.clone(),
684                endpoint: Some(self.ollama_endpoint.clone()),
685                api_key_env: None,
686                timeout_secs: Some(120),
687            })
688    }
689
690    /// Returns the effective backend for the abstractive stage.
691    /// See `synthesize_extractive_backend` for the timeout rationale.
692    pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
693        self.abstractive_backend
694            .clone()
695            .unwrap_or_else(|| BackendConfig {
696                provider: "ollama".into(),
697                model: self.abstractive_model.clone(),
698                endpoint: Some(self.ollama_endpoint.clone()),
699                api_key_env: None,
700                timeout_secs: Some(120),
701            })
702    }
703}
704
705impl Default for CompactConfig {
706    fn default() -> Self {
707        Self {
708            enabled_in_daemon: true,
709            max_days_per_run: compact_default_max_days(),
710            extractive_model: compact_default_model(),
711            abstractive_model: compact_default_model(),
712            ollama_endpoint: compact_default_ollama_endpoint(),
713            max_extractive_spans: compact_default_max_spans(),
714            max_abstractive_words: compact_default_max_words(),
715            chunk_tokens: compact_default_chunk_tokens(),
716            history_retain: compact_default_history_retain(),
717            daemon_cron: compact_default_cron(),
718            extractive_backend: None,
719            abstractive_backend: None,
720        }
721    }
722}
723
724fn compact_default_max_days() -> u32 {
725    7
726}
727fn compact_default_model() -> String {
728    DEFAULT_LOCAL_LLM_MODEL.into()
729}
730fn compact_default_ollama_endpoint() -> String {
731    "http://localhost:11434".into()
732}
733fn compact_default_max_spans() -> u32 {
734    20
735}
736fn compact_default_max_words() -> u32 {
737    400
738}
739fn compact_default_chunk_tokens() -> u32 {
740    6000
741}
742fn compact_default_history_retain() -> u32 {
743    5
744}
745fn compact_default_cron() -> String {
746    "0 0 3 * * * *".into()
747}
748
749// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
750
751#[derive(Debug, Clone, Serialize, Deserialize)]
752pub struct RollupConfig {
753    #[serde(default = "rollup_default_enabled")]
754    pub enabled: bool,
755    #[serde(default = "rollup_default_max_weeks")]
756    pub max_weeks_per_run: u32,
757    #[serde(default = "rollup_default_max_months")]
758    pub max_months_per_run: u32,
759    #[serde(default = "rollup_default_max_spans_week")]
760    pub max_extractive_spans_per_week: u32,
761    #[serde(default = "rollup_default_max_words_week")]
762    pub max_abstractive_words_per_week: u32,
763    #[serde(default = "rollup_default_max_spans_month")]
764    pub max_extractive_spans_per_month: u32,
765    #[serde(default = "rollup_default_max_words_month")]
766    pub max_abstractive_words_per_month: u32,
767    #[serde(default = "rollup_default_week_mmr")]
768    pub week_mmr_threshold: f64,
769    #[serde(default = "rollup_default_month_mmr")]
770    pub month_mmr_threshold: f64,
771    #[serde(default = "compact_default_model")]
772    pub extractive_model: String,
773    #[serde(default = "compact_default_model")]
774    pub abstractive_model: String,
775    #[serde(default = "compact_default_ollama_endpoint")]
776    pub ollama_endpoint: String,
777}
778
779impl Default for RollupConfig {
780    fn default() -> Self {
781        Self {
782            enabled: rollup_default_enabled(),
783            max_weeks_per_run: rollup_default_max_weeks(),
784            max_months_per_run: rollup_default_max_months(),
785            max_extractive_spans_per_week: rollup_default_max_spans_week(),
786            max_abstractive_words_per_week: rollup_default_max_words_week(),
787            max_extractive_spans_per_month: rollup_default_max_spans_month(),
788            max_abstractive_words_per_month: rollup_default_max_words_month(),
789            week_mmr_threshold: rollup_default_week_mmr(),
790            month_mmr_threshold: rollup_default_month_mmr(),
791            extractive_model: compact_default_model(),
792            abstractive_model: compact_default_model(),
793            ollama_endpoint: compact_default_ollama_endpoint(),
794        }
795    }
796}
797
798fn rollup_default_enabled() -> bool {
799    true
800}
801fn rollup_default_max_weeks() -> u32 {
802    4
803}
804fn rollup_default_max_months() -> u32 {
805    2
806}
807fn rollup_default_max_spans_week() -> u32 {
808    20
809}
810fn rollup_default_max_words_week() -> u32 {
811    500
812}
813fn rollup_default_max_spans_month() -> u32 {
814    20
815}
816fn rollup_default_max_words_month() -> u32 {
817    700
818}
819fn rollup_default_week_mmr() -> f64 {
820    0.85
821}
822fn rollup_default_month_mmr() -> f64 {
823    0.82
824}
825
826#[derive(Debug, Clone, Serialize, Deserialize)]
827pub struct ConversationsSources {
828    #[serde(default = "conv_truthy")]
829    pub claude_code: bool,
830    #[serde(default = "conv_truthy")]
831    pub cursor: bool,
832    #[serde(default = "conv_truthy")]
833    pub gemini: bool,
834    #[serde(default)]
835    pub aider: AiderSourceConfig,
836}
837
838impl Default for ConversationsSources {
839    fn default() -> Self {
840        Self {
841            claude_code: true,
842            cursor: true,
843            gemini: true,
844            aider: AiderSourceConfig::default(),
845        }
846    }
847}
848
849#[derive(Debug, Clone, Serialize, Deserialize)]
850pub struct AiderSourceConfig {
851    #[serde(default = "conv_truthy")]
852    pub enabled: bool,
853    #[serde(default)]
854    pub watched_dirs: Vec<String>,
855}
856
857impl Default for AiderSourceConfig {
858    fn default() -> Self {
859        Self {
860            enabled: true,
861            watched_dirs: Vec::new(),
862        }
863    }
864}
865
866#[derive(Debug, Clone, Serialize, Deserialize)]
867pub struct ConversationsFilter {
868    #[serde(default = "conv_default_dedup")]
869    pub dedup_threshold: f64,
870    #[serde(default = "conv_truthy")]
871    pub reject_heartbeat: bool,
872    #[serde(default = "conv_truthy")]
873    pub reject_system_restatement: bool,
874}
875
876impl Default for ConversationsFilter {
877    fn default() -> Self {
878        Self {
879            dedup_threshold: conv_default_dedup(),
880            reject_heartbeat: true,
881            reject_system_restatement: true,
882        }
883    }
884}
885
886#[cfg(test)]
887mod conversations_tests {
888    use super::*;
889
890    #[test]
891    fn conversations_section_defaults() {
892        let c = ConversationsConfig::default();
893        assert!(!c.enabled);
894        assert_eq!(c.retention_days, 30);
895        assert_eq!(c.poll_interval_secs, 300);
896        assert!(c.sources.claude_code);
897        assert!(c.sources.cursor);
898        assert!(c.sources.gemini);
899        assert!(c.sources.aider.enabled);
900        assert!(c.sources.aider.watched_dirs.is_empty());
901        assert_eq!(c.filter.dedup_threshold, 0.85);
902        assert!(c.filter.reject_heartbeat);
903        assert!(c.filter.reject_system_restatement);
904    }
905
906    #[test]
907    fn parse_from_yaml_with_overrides() {
908        let y = r#"
909conversations:
910  enabled: true
911  retention_days: 45
912  poll_interval_secs: 120
913  sources:
914    cursor: false
915    aider:
916      watched_dirs: ["~/Projects/a", "~/Projects/b"]
917  filter:
918    dedup_threshold: 0.9
919"#;
920        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
921        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
922        assert!(conv.enabled);
923        assert_eq!(conv.retention_days, 45);
924        assert_eq!(conv.poll_interval_secs, 120);
925        assert!(conv.sources.claude_code); // defaulted true
926        assert!(!conv.sources.cursor); // override
927        assert!(conv.sources.gemini); // defaulted true
928        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
929        assert_eq!(conv.filter.dedup_threshold, 0.9);
930        assert!(conv.filter.reject_heartbeat); // defaulted true
931    }
932
933    #[test]
934    fn missing_conversations_section_is_fine() {
935        let y = r#"
936# No conversations section at all
937foo: bar
938"#;
939        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
940        // Default when absent
941        let conv: ConversationsConfig = v
942            .get("conversations")
943            .cloned()
944            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
945            .unwrap_or_default();
946        assert_eq!(conv.retention_days, 30);
947    }
948
949    #[test]
950    fn compact_config_defaults() {
951        let c = CompactConfig::default();
952        assert!(c.enabled_in_daemon);
953        assert_eq!(c.max_days_per_run, 7);
954        assert_eq!(c.extractive_model, "qwen3.5:4b");
955        assert_eq!(c.abstractive_model, "qwen3.5:4b");
956        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
957        assert_eq!(c.max_extractive_spans, 20);
958        assert_eq!(c.chunk_tokens, 6000);
959        assert_eq!(c.history_retain, 5);
960        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
961    }
962
963    #[test]
964    fn compact_parses_partial_overrides() {
965        let y = r#"
966conversations:
967  compact:
968    max_days_per_run: 3
969    extractive_model: qwen3:4b
970"#;
971        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
972        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
973        assert_eq!(conv.compact.max_days_per_run, 3);
974        assert_eq!(conv.compact.extractive_model, "qwen3:4b");
975        assert!(conv.compact.enabled_in_daemon); // default preserved
976        assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); // default preserved
977    }
978
979    #[test]
980    fn ask_config_defaults() {
981        let c = AskConfig::default();
982        assert_eq!(c.model, "qwen3.5:4b");
983        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
984        assert_eq!(c.k_raw, 10);
985        assert_eq!(c.escalation_threshold, 0.5);
986        assert_eq!(c.mmr_threshold, 0.88);
987        assert_eq!(c.max_context_tokens, 6000);
988        assert_eq!(c.response_tokens, 1024);
989        assert_eq!(c.timeout_secs, 120);
990        assert_eq!(c.min_score, 0.35);
991    }
992
993    #[test]
994    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
995        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
996        let c = AskConfig::default();
997        assert!(
998            (c.mmr_threshold - 0.88).abs() < 1e-9,
999            "expected 0.88, got {}",
1000            c.mmr_threshold
1001        );
1002    }
1003
1004    #[test]
1005    fn rollup_config_defaults() {
1006        let c = RollupConfig::default();
1007        assert!(c.enabled);
1008        assert_eq!(c.max_weeks_per_run, 4);
1009        assert_eq!(c.max_months_per_run, 2);
1010        assert_eq!(c.max_extractive_spans_per_week, 20);
1011        assert_eq!(c.max_abstractive_words_per_week, 500);
1012        assert_eq!(c.max_extractive_spans_per_month, 20);
1013        assert_eq!(c.max_abstractive_words_per_month, 700);
1014        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1015        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1016        assert_eq!(c.extractive_model, "qwen3.5:4b");
1017        assert_eq!(c.abstractive_model, "qwen3.5:4b");
1018        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1019    }
1020
1021    #[test]
1022    fn rollup_config_plumbed_into_conversations_config() {
1023        let c = ConversationsConfig::default();
1024        assert!(c.rollup.enabled);
1025    }
1026
1027    #[test]
1028    fn ask_config_default_continue_history_turns_is_3() {
1029        let c = AskConfig::default();
1030        assert_eq!(c.continue_history_turns, 3);
1031    }
1032
1033    #[test]
1034    fn ask_config_default_compress_hits_enabled_is_true() {
1035        let c = AskConfig::default();
1036        assert!(c.compress_hits_enabled);
1037    }
1038
1039    #[test]
1040    fn ask_config_default_summarize_hits_enabled_is_true() {
1041        let c = AskConfig::default();
1042        assert!(c.summarize_hits_enabled);
1043    }
1044
1045    #[test]
1046    fn ask_config_default_summarize_model_is_none() {
1047        let c = AskConfig::default();
1048        assert!(c.summarize_model.is_none());
1049    }
1050
1051    #[test]
1052    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1053        let y = r#"
1054conversations:
1055  ask:
1056    summarize_hits_enabled: false
1057    summarize_model: qwen3:4b
1058"#;
1059        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1060        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1061        assert!(!conv.ask.summarize_hits_enabled);
1062        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1063    }
1064
1065    #[test]
1066    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1067        // Phase 3.5 must be additive: an existing config.yaml with NO
1068        // summarize_* keys must still parse and default to enabled=true,
1069        // model=None.
1070        let y = r#"
1071conversations:
1072  ask:
1073    model: qwen3:14b
1074"#;
1075        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1076        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1077        assert!(conv.ask.summarize_hits_enabled);
1078        assert!(conv.ask.summarize_model.is_none());
1079    }
1080}
1081
1082#[cfg(test)]
1083mod tests {
1084    use super::*;
1085
1086    #[test]
1087    fn default_bundled_model_id_is_qwen35_2b() {
1088        assert_eq!(
1089            crate::config::DEFAULT_BUNDLED_MODEL_ID,
1090            "Qwen3.5-2B-MLX-4bit"
1091        );
1092    }
1093
1094    #[test]
1095    fn nudge_config_defaults() {
1096        let c = NudgeConfig::default();
1097        assert!(c.enabled);
1098        assert_eq!(c.daily_cap, 3);
1099        assert_eq!(c.snooze_days, 7);
1100        assert_eq!(c.threshold, 3);
1101    }
1102
1103    #[test]
1104    fn config_has_nudge_section_with_defaults() {
1105        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1106        assert_eq!(c.nudge.daily_cap, 3);
1107    }
1108
1109    #[test]
1110    fn storage_config_default_is_lancedb() {
1111        let c = StorageConfig::default();
1112        assert_eq!(c.vector_backend, "lancedb");
1113        assert_eq!(c.qdrant_url, None);
1114        assert_eq!(c.qdrant_api_key_ref, None);
1115    }
1116
1117    #[test]
1118    fn sources_global_config_has_sensible_defaults() {
1119        let c = SourcesGlobalConfig::default();
1120        assert_eq!(c.poll_interval_secs, 600);
1121        assert_eq!(c.max_chunks_per_sync, 10_000);
1122        assert_eq!(c.max_parallel_sources, 3);
1123        assert_eq!(c.default_weight, 1.0);
1124        assert_eq!(c.embedding_batch_size, 32);
1125    }
1126
1127    #[test]
1128    fn config_default_has_storage_and_sources_global() {
1129        let c = Config::default();
1130        assert_eq!(c.storage.vector_backend, "lancedb");
1131        assert_eq!(c.sources_global.default_weight, 1.0);
1132    }
1133
1134    #[test]
1135    fn config_loads_yaml_without_new_fields() {
1136        // Existing users' config.yaml won't mention storage or sources_global.
1137        // It must still parse.
1138        let yaml = r#"
1139embedding:
1140  provider: ollama
1141  model: test-model
1142  dimensions: 512
1143  ollama_endpoint: http://localhost:11434
1144"#;
1145        let c: Config = serde_yaml::from_str(yaml).expect("parses");
1146        assert_eq!(c.storage.vector_backend, "lancedb");
1147        assert_eq!(c.sources_global.max_parallel_sources, 3);
1148    }
1149
1150    #[test]
1151    fn llm_config_to_backend_config_anthropic_passthrough() {
1152        let cfg = LlmConfig {
1153            provider: "anthropic".into(),
1154            model: "claude-haiku-4-5".into(),
1155            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1156            openai_url: None,
1157        };
1158        let b = cfg.to_backend_config();
1159        assert_eq!(b.provider, "anthropic");
1160        assert_eq!(b.model, "claude-haiku-4-5");
1161        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1162        assert_eq!(b.endpoint, None);
1163        assert_eq!(b.timeout_secs, None);
1164    }
1165
1166    #[test]
1167    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1168        let cfg = LlmConfig {
1169            provider: "openai".into(),
1170            model: "gpt-4o-mini".into(),
1171            api_key_env: None,
1172            openai_url: Some("https://api.together.xyz/v1".into()),
1173        };
1174        let b = cfg.to_backend_config();
1175        assert_eq!(b.provider, "openai");
1176        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1177        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
1178    }
1179
1180    #[test]
1181    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1182        let cfg = LlmConfig {
1183            provider: "ollama".into(),
1184            model: "qwen3:14b".into(),
1185            api_key_env: None,
1186            openai_url: Some("http://192.168.1.10:11434".into()),
1187        };
1188        let b = cfg.to_backend_config();
1189        assert_eq!(b.provider, "ollama");
1190        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1191    }
1192
1193    #[test]
1194    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1195        // Historical LlmConfig allowed provider="custom" + openai_url to act as
1196        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
1197        // "openai" so factory dispatches to OpenAIBackend.
1198        let cfg = LlmConfig {
1199            provider: "custom-name".into(),
1200            model: "some-model".into(),
1201            api_key_env: Some("CUSTOM_KEY".into()),
1202            openai_url: Some("https://my-proxy.local/v1".into()),
1203        };
1204        let b = cfg.to_backend_config();
1205        assert_eq!(
1206            b.provider, "openai",
1207            "unknown provider + openai_url should alias to openai"
1208        );
1209        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1210    }
1211}
1212
1213#[cfg(test)]
1214mod backend_config_tests {
1215    use super::*;
1216
1217    #[test]
1218    fn default_is_ollama_qwen3() {
1219        let cfg = BackendConfig::default();
1220        assert_eq!(cfg.provider, "ollama");
1221        assert_eq!(cfg.model, "qwen3.5:4b");
1222        assert_eq!(cfg.endpoint, None);
1223        assert_eq!(cfg.api_key_env, None);
1224        assert_eq!(cfg.timeout_secs, None);
1225    }
1226
1227    #[test]
1228    fn deserializes_anthropic_full() {
1229        let yaml = "\
1230provider: anthropic
1231model: claude-haiku-4-5
1232api_key_env: ANTHROPIC_API_KEY
1233timeout_secs: 60
1234";
1235        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1236        assert_eq!(cfg.provider, "anthropic");
1237        assert_eq!(cfg.model, "claude-haiku-4-5");
1238        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1239        assert_eq!(cfg.timeout_secs, Some(60));
1240        assert_eq!(cfg.endpoint, None);
1241    }
1242
1243    #[test]
1244    fn deserializes_partial_fills_defaults() {
1245        let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1246        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1247        assert_eq!(cfg.provider, "anthropic");
1248        assert_eq!(cfg.model, "claude-sonnet-4-6");
1249        assert_eq!(cfg.api_key_env, None);
1250        assert_eq!(cfg.timeout_secs, None);
1251    }
1252
1253    #[test]
1254    fn round_trips_through_yaml() {
1255        let original = BackendConfig {
1256            provider: "anthropic".into(),
1257            model: "claude-haiku-4-5".into(),
1258            endpoint: Some("https://api.anthropic.com".into()),
1259            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1260            timeout_secs: Some(60),
1261        };
1262        let yaml = serde_yaml::to_string(&original).unwrap();
1263        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1264        assert_eq!(parsed, original);
1265    }
1266
1267    #[test]
1268    fn skills_config_curation_gate_defaults_on() {
1269        let c = SkillsConfig::default();
1270        assert!(c.require_human_curation_before_stable);
1271    }
1272}
1273
1274/// Configuration for the daemon-side sleep cycle (idle background learning).
1275///
1276/// Skill injection configuration (M2 — runtime injection).
1277#[derive(Debug, Clone, Serialize, Deserialize)]
1278#[serde(default)]
1279pub struct SkillsConfig {
1280    pub max_skills_in_prompt: usize,
1281    pub max_total_tokens: usize,
1282    pub priority_order: Vec<String>,
1283    pub adaptive: Option<AdaptiveSkillsConfig>,
1284
1285    /// When true (default), LLM-authored skills cannot auto-promote past
1286    /// `Emerging` until a human curates them (amendment A1). Set false to
1287    /// let LLM-extracted skills promote on run stats alone.
1288    #[serde(default = "default_require_human_curation")]
1289    pub require_human_curation_before_stable: bool,
1290}
1291
1292fn default_require_human_curation() -> bool {
1293    true
1294}
1295
1296impl Default for SkillsConfig {
1297    fn default() -> Self {
1298        Self {
1299            max_skills_in_prompt: 5,
1300            max_total_tokens: 2000,
1301            priority_order: vec!["agent".into(), "global".into()],
1302            adaptive: Some(AdaptiveSkillsConfig::default()),
1303            require_human_curation_before_stable: default_require_human_curation(),
1304        }
1305    }
1306}
1307
1308#[derive(Debug, Clone, Serialize, Deserialize)]
1309#[serde(default)]
1310pub struct AdaptiveSkillsConfig {
1311    pub context_fill_decay: f64,
1312    pub min_remaining_context_ratio: f64,
1313    pub recent_fire_boost_turns: usize,
1314    /// Model max context window in tokens. Used to compute
1315    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
1316    /// Default 200_000 (Claude 3.5/4.x).
1317    pub model_max_context_tokens: u64,
1318}
1319
1320impl Default for AdaptiveSkillsConfig {
1321    fn default() -> Self {
1322        Self {
1323            context_fill_decay: 1.5,
1324            min_remaining_context_ratio: 0.20,
1325            recent_fire_boost_turns: 5,
1326            model_max_context_tokens: 200_000,
1327        }
1328    }
1329}
1330
1331/// When enabled, the daemon fires a consolidation pipeline after the user has been
1332/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
1333#[derive(Debug, Clone, Serialize, Deserialize)]
1334pub struct SleepCycleConfig {
1335    /// Master switch. False by default (opt-in).
1336    #[serde(default)]
1337    pub enabled: bool,
1338
1339    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
1340    #[serde(default = "default_idle_threshold_minutes")]
1341    pub idle_threshold_minutes: u64,
1342
1343    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
1344    #[serde(default = "default_agent_idle_minutes")]
1345    pub agent_idle_minutes: u64,
1346}
1347
1348fn default_idle_threshold_minutes() -> u64 {
1349    15
1350}
1351
1352fn default_agent_idle_minutes() -> u64 {
1353    5
1354}
1355
1356impl Default for SleepCycleConfig {
1357    fn default() -> Self {
1358        Self {
1359            enabled: false,
1360            idle_threshold_minutes: default_idle_threshold_minutes(),
1361            agent_idle_minutes: default_agent_idle_minutes(),
1362        }
1363    }
1364}
1365
1366// ── Nudge config ───────────────────────────────────────────────────
1367
1368#[derive(Debug, Clone, Serialize, Deserialize)]
1369pub struct NudgeConfig {
1370    /// Master switch. Default on — Phase 2 companion surface is live.
1371    #[serde(default = "default_nudge_enabled")]
1372    pub enabled: bool,
1373    #[serde(default = "default_nudge_daily_cap")]
1374    pub daily_cap: u32,
1375    #[serde(default = "default_nudge_snooze_days")]
1376    pub snooze_days: u32,
1377    #[serde(default = "default_nudge_threshold")]
1378    pub threshold: usize,
1379}
1380
1381fn default_nudge_enabled() -> bool {
1382    true
1383}
1384fn default_nudge_daily_cap() -> u32 {
1385    3
1386}
1387fn default_nudge_snooze_days() -> u32 {
1388    7
1389}
1390fn default_nudge_threshold() -> usize {
1391    3
1392}
1393
1394impl Default for NudgeConfig {
1395    fn default() -> Self {
1396        Self {
1397            enabled: true,
1398            daily_cap: default_nudge_daily_cap(),
1399            snooze_days: default_nudge_snooze_days(),
1400            threshold: default_nudge_threshold(),
1401        }
1402    }
1403}
1404
1405// ── M7a: Cross-agent observability ─────────────────────────────────
1406
1407#[derive(Debug, Clone, Serialize, Deserialize)]
1408#[serde(default)]
1409pub struct CrossAgentConfig {
1410    #[serde(default = "default_half_life_days")]
1411    pub fitness_half_life_days: u32,
1412    #[serde(default = "default_fitness_floor")]
1413    pub fitness_floor: f64,
1414}
1415
1416fn default_half_life_days() -> u32 {
1417    7
1418}
1419fn default_fitness_floor() -> f64 {
1420    0.1
1421}
1422
1423impl Default for CrossAgentConfig {
1424    fn default() -> Self {
1425        Self {
1426            fitness_half_life_days: default_half_life_days(),
1427            fitness_floor: default_fitness_floor(),
1428        }
1429    }
1430}
1431
1432// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
1433
1434#[derive(Debug, Clone, Serialize, Deserialize)]
1435#[serde(default)]
1436pub struct SkillLlmConfig {
1437    /// Per-call output token cap.
1438    #[serde(default = "default_per_call_token_cap")]
1439    pub per_call_token_cap: u32,
1440
1441    /// Per-day USD cap for all maintenance LLM calls.
1442    #[serde(default = "default_per_day_usd_cap")]
1443    pub per_day_usd_cap: f64,
1444
1445    /// Cache TTL in days.
1446    #[serde(default = "default_cache_ttl_days")]
1447    pub cache_ttl_days: u32,
1448
1449    /// Optional explicit model key override. When `None`, role resolution picks.
1450    #[serde(default, skip_serializing_if = "Option::is_none")]
1451    pub model_ref: Option<String>,
1452}
1453
1454fn default_per_call_token_cap() -> u32 {
1455    1500
1456}
1457fn default_per_day_usd_cap() -> f64 {
1458    0.50
1459}
1460fn default_cache_ttl_days() -> u32 {
1461    30
1462}
1463
1464impl Default for SkillLlmConfig {
1465    fn default() -> Self {
1466        Self {
1467            per_call_token_cap: default_per_call_token_cap(),
1468            per_day_usd_cap: default_per_day_usd_cap(),
1469            cache_ttl_days: default_cache_ttl_days(),
1470            model_ref: None,
1471        }
1472    }
1473}
1474#[cfg(test)]
1475mod per_stage_backend_tests {
1476    use super::*;
1477
1478    #[test]
1479    fn legacy_compact_config_has_no_per_stage_overrides() {
1480        let yaml = "\
1481extractive_model: qwen3:14b
1482abstractive_model: qwen3:14b
1483ollama_endpoint: http://localhost:11434
1484";
1485        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1486        assert!(cfg.extractive_backend.is_none());
1487        assert!(cfg.abstractive_backend.is_none());
1488        assert_eq!(cfg.extractive_model, "qwen3:14b");
1489        assert_eq!(cfg.abstractive_model, "qwen3:14b");
1490        assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1491    }
1492
1493    #[test]
1494    fn legacy_ask_config_has_no_per_stage_overrides() {
1495        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1496        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1497        assert!(cfg.backend.is_none());
1498        assert!(cfg.rewriter_backend.is_none());
1499        assert_eq!(cfg.model, "qwen3:14b");
1500    }
1501
1502    #[test]
1503    fn compact_extractive_backend_override_parses() {
1504        let yaml = "\
1505extractive_backend:
1506  provider: anthropic
1507  model: claude-haiku-4-5
1508  api_key_env: ANTHROPIC_API_KEY
1509abstractive_model: qwen3:14b
1510";
1511        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1512        let extractive = cfg
1513            .extractive_backend
1514            .as_ref()
1515            .expect("override should parse");
1516        assert_eq!(extractive.provider, "anthropic");
1517        assert_eq!(extractive.model, "claude-haiku-4-5");
1518        assert!(cfg.abstractive_backend.is_none());
1519    }
1520
1521    #[test]
1522    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1523        let yaml = "\
1524backend:
1525  provider: anthropic
1526  model: claude-sonnet-4-6
1527  api_key_env: ANTHROPIC_API_KEY
1528rewriter_backend:
1529  provider: ollama
1530  model: llama3.2:3b
1531";
1532        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1533        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1534        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1535    }
1536
1537    #[test]
1538    fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1539        let yaml = "\
1540extractive_model: qwen3:14b
1541ollama_endpoint: http://192.168.1.10:11434
1542";
1543        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1544        let synth = cfg.synthesize_extractive_backend();
1545        assert_eq!(synth.provider, "ollama");
1546        assert_eq!(synth.model, "qwen3:14b");
1547        assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1548        assert_eq!(synth.api_key_env, None);
1549    }
1550
1551    #[test]
1552    fn synthesize_legacy_to_backend_config_for_ask() {
1553        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1554        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1555        let synth = cfg.synthesize_backend();
1556        assert_eq!(synth.provider, "ollama");
1557        assert_eq!(synth.model, "qwen3:14b");
1558        assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1559    }
1560
1561    #[test]
1562    fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1563        // Rewriter no longer falls through to synthesize_backend() when
1564        // `rewriter_backend` is unset (see I2 fix in P3 task 1). It now
1565        // always synthesizes its own ollama BackendConfig over the legacy
1566        // model + endpoint with `rewriter_timeout_secs` baked in, so a
1567        // slow rewriter call doesn't burn the full ask budget. The
1568        // per-stage `ask.backend` override therefore does NOT propagate to
1569        // the rewriter — set `ask.rewriter_backend` explicitly if you want
1570        // a non-Ollama rewriter.
1571        let yaml = "\
1572backend:
1573  provider: anthropic
1574  model: claude-sonnet-4-6
1575  api_key_env: ANTHROPIC_API_KEY
1576";
1577        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1578        let rewriter = cfg.synthesize_rewriter_backend();
1579        assert_eq!(rewriter.provider, "ollama");
1580        assert_eq!(rewriter.model, ask_default_model());
1581        assert_eq!(
1582            rewriter.timeout_secs,
1583            Some(ask_default_rewriter_timeout() as u64)
1584        );
1585    }
1586
1587    #[test]
1588    fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1589        let cfg = AskConfig {
1590            timeout_secs: 45,
1591            ..AskConfig::default()
1592        };
1593        let b = cfg.synthesize_backend();
1594        assert_eq!(
1595            b.timeout_secs,
1596            Some(45),
1597            "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1598        );
1599    }
1600
1601    #[test]
1602    fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1603        let mut cfg = AskConfig {
1604            timeout_secs: 45,
1605            ..AskConfig::default()
1606        };
1607        cfg.backend = Some(BackendConfig {
1608            provider: "anthropic".into(),
1609            model: "claude-haiku-4-5".into(),
1610            endpoint: None,
1611            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1612            timeout_secs: Some(10),
1613        });
1614        let b = cfg.synthesize_backend();
1615        assert_eq!(
1616            b.timeout_secs,
1617            Some(10),
1618            "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1619        );
1620    }
1621
1622    #[test]
1623    fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1624        let cfg = AskConfig {
1625            timeout_secs: 120,
1626            rewriter_timeout_secs: 8,
1627            ..AskConfig::default()
1628        };
1629        let b = cfg.synthesize_rewriter_backend();
1630        assert_eq!(
1631            b.timeout_secs,
1632            Some(8),
1633            "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1634        );
1635    }
1636
1637    #[test]
1638    fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1639        let mut cfg = AskConfig {
1640            rewriter_timeout_secs: 8,
1641            ..AskConfig::default()
1642        };
1643        cfg.rewriter_backend = Some(BackendConfig {
1644            provider: "anthropic".into(),
1645            model: "claude-haiku-4-5".into(),
1646            endpoint: None,
1647            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1648            timeout_secs: Some(30),
1649        });
1650        let b = cfg.synthesize_rewriter_backend();
1651        assert_eq!(
1652            b.timeout_secs,
1653            Some(30),
1654            "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1655        );
1656    }
1657
1658    #[test]
1659    fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1660        // CompactConfig has no per-stage timeout field — extractive synthesis
1661        // should fall back to the conservative 120s default.
1662        let cfg = CompactConfig::default();
1663        let b = cfg.synthesize_extractive_backend();
1664        assert_eq!(
1665            b.timeout_secs,
1666            Some(120),
1667            "compact synthesis without per-stage override must produce 120s timeout"
1668        );
1669    }
1670
1671    #[test]
1672    fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1673        let cfg = CompactConfig::default();
1674        let b = cfg.synthesize_abstractive_backend();
1675        assert_eq!(b.timeout_secs, Some(120));
1676    }
1677}
1678
1679#[cfg(test)]
1680mod skills_config_tests {
1681    use super::*;
1682
1683    #[test]
1684    fn empty_yaml_hydrates_defaults() {
1685        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1686        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1687        assert_eq!(cfg.skills.max_total_tokens, 2000);
1688        assert!(cfg.skills.adaptive.is_some());
1689    }
1690
1691    #[test]
1692    fn load_or_default_missing_file_returns_default() {
1693        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1694        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1695    }
1696}