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