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