Skip to main content

mur_common/
config.rs

1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6/// Default model id seeded for the built-in "Mur" agent and used to name the
7/// bundled MLX weights. This is the DEFAULT VALUE only — it is written into the
8/// seed agent's profile and can be changed by the user afterwards; it is not a
9/// behavioural constant baked into logic.
10pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
11
12/// Global MUR configuration (~/.mur/config.yaml)
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct Config {
15    #[serde(default)]
16    pub embedding: EmbeddingConfig,
17
18    #[serde(default)]
19    pub llm: LlmConfig,
20
21    #[serde(default)]
22    pub retrieval: RetrievalConfig,
23
24    #[serde(default)]
25    pub paths: PathConfig,
26
27    #[serde(default)]
28    pub server: ServerConfig,
29
30    #[serde(default)]
31    pub community: CommunityConfig,
32
33    #[serde(default)]
34    pub conversations: ConversationsConfig,
35
36    #[serde(default)]
37    pub sync: SyncConfig,
38
39    // --- P1.1 additions ---
40    #[serde(default)]
41    pub storage: StorageConfig,
42
43    #[serde(default)]
44    pub sources_global: SourcesGlobalConfig,
45
46    // --- E3 additions ---
47    #[serde(default)]
48    pub sleep_cycle: SleepCycleConfig,
49
50    // --- M2 additions ---
51    #[serde(default)]
52    pub skills: SkillsConfig,
53
54    // --- M6c additions ---
55    #[serde(default)]
56    pub skill_llm: SkillLlmConfig,
57
58    // --- M7a additions ---
59    #[serde(default)]
60    pub cross_agent: CrossAgentConfig,
61
62    // --- nudge additions ---
63    #[serde(default)]
64    pub nudge: NudgeConfig,
65
66    // --- mobile P4 additions ---
67    #[serde(default)]
68    pub mobile_relay: MobileRelayConfig,
69
70    // --- Ambient capture & harvest (2026-06-11 spec) ---
71    #[serde(default)]
72    pub session: SessionCfg,
73
74    #[serde(default)]
75    pub harvest: HarvestCfg,
76
77    // --- OAuth bridge (cc-proxy) routing for subscription tokens ---
78    #[serde(default)]
79    pub cc_proxy: CcProxyConfig,
80
81    // --- Agent CLI TUI ---
82    #[serde(default)]
83    pub cli: CliConfig,
84
85    // --- parallel_jobs MCP tool ---
86    #[serde(default)]
87    pub parallel_jobs: ParallelJobsConfig,
88
89    // --- Hub Fleet Manager redesign ---
90    #[serde(default)]
91    pub fleet: FleetConfig,
92}
93
94/// Authorization gate for the `parallel_jobs` MCP tool. Stored under `parallel_jobs:`
95/// in `~/.mur/config.yaml`. Deny-by-default: an empty `targets` list means the
96/// tool cannot delegate to ANY agent (inert until the user opts specific
97/// agents in). This is a deterministic, out-of-model gate that a
98/// prompt-injected concierge cannot widen (OWASP Agentic ASI02/03/04).
99#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
100pub struct ParallelJobsConfig {
101    /// Canonical agent names the `parallel_jobs` tool is allowed to delegate to.
102    /// Empty = deny all.
103    #[serde(default)]
104    pub targets: Vec<String>,
105}
106
107/// Daemon-wide gate for unattended fleet auto-run (`mur-daemon`'s `fleet_tick`).
108/// Stored under `fleet:` in `~/.mur/config.yaml`. Either this flag OR the
109/// `MUR_FLEET_AUTORUN` env var satisfies the gate — both are equally explicit,
110/// off-by-default opt-ins; the env var remains for ops/CI use, this flag is
111/// what the Hub's Settings toggle controls. Per-fleet `budget_usd > 0` and the
112/// `.stopped` kill-switch are unaffected — see `mur-daemon/src/fleet_tick.rs`.
113#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
114pub struct FleetConfig {
115    /// Allow fleets with a trigger + budget configured to auto-run unattended.
116    #[serde(default)]
117    pub autorun: bool,
118}
119
120#[cfg(test)]
121mod fleet_config_tests {
122    use super::*;
123
124    #[test]
125    fn fleet_config_defaults_off_and_roundtrips() {
126        assert!(!FleetConfig::default().autorun);
127
128        let cfg: Config = serde_yaml_ng::from_str("fleet:\n  autorun: true\n").unwrap();
129        assert!(cfg.fleet.autorun);
130
131        // `fleet:` key entirely absent → defaults to off
132        let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
133        assert!(!cfg2.fleet.autorun);
134    }
135}
136
137/// Routing for Anthropic subscription-OAuth (`sk-ant-oat*`) tokens through a
138/// local bridge — cc-proxy — that swaps `x-api-key` for the Bearer +
139/// claude-code betas disguise the upstream requires.
140///
141/// The Hub injects `ANTHROPIC_BASE_URL` pointing at [`url`](Self::url) when it
142/// spawns an agent runtime, but only when [`enabled`](Self::enabled) is set and
143/// the bridge is actually listening; otherwise it leaves the runtime on the
144/// direct `api.anthropic.com` path (where an oat token would 401). A runtime
145/// launched with `ANTHROPIC_BASE_URL` already in its environment is never
146/// overridden.
147#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct CcProxyConfig {
149    /// Bridge base URL. Defaults to cc-proxy's default bind.
150    #[serde(default = "default_cc_proxy_url")]
151    pub url: String,
152
153    /// Master switch. When false the Hub never routes runtimes through the
154    /// bridge, regardless of reachability.
155    #[serde(default = "default_true")]
156    pub enabled: bool,
157}
158
159fn default_cc_proxy_url() -> String {
160    "http://127.0.0.1:8088".to_string()
161}
162
163fn default_true() -> bool {
164    true
165}
166
167impl Default for CcProxyConfig {
168    fn default() -> Self {
169        Self {
170            url: default_cc_proxy_url(),
171            enabled: true,
172        }
173    }
174}
175
176/// Configuration for the agent CLI TUI.
177/// Stored in ~/.mur/config.yaml under the `cli:` key.
178#[derive(Debug, Clone, Serialize, Deserialize, Default)]
179pub struct CliConfig {
180    /// Default visual skin for `mur agent cli`. Overridable with --skin.
181    /// Valid values: "dark" (default), "light", "mur".
182    pub skin: Option<String>,
183}
184
185/// Configuration for the mobile relay (P4).
186/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
187#[derive(Debug, Clone, Serialize, Deserialize, Default)]
188pub struct MobileRelayConfig {
189    /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
190    /// Leave blank to disable relay forwarding on the Mac daemon side.
191    #[serde(default, skip_serializing_if = "Option::is_none")]
192    pub relay_url: Option<String>,
193
194    /// API key or JWT used by the Mac daemon to authenticate with the relay.
195    /// The value is typically a `mur_...` API key from app.mur.run.
196    #[serde(default, skip_serializing_if = "Option::is_none")]
197    pub api_key: Option<String>,
198}
199
200impl Config {
201    /// Read from disk, falling back to defaults.
202    pub fn load_or_default(path: &std::path::Path) -> Self {
203        std::fs::read_to_string(path)
204            .ok()
205            .and_then(|s| serde_yaml_ng::from_str(&s).ok())
206            .unwrap_or_default()
207    }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211pub struct SyncConfig {
212    /// Sync method: "cloud", "git", or "local"
213    #[serde(default = "default_sync_method")]
214    pub method: String,
215
216    /// Git remote URL for git sync
217    #[serde(default, skip_serializing_if = "Option::is_none")]
218    pub git_remote: Option<String>,
219
220    /// Auto-sync on context pull / session stop
221    #[serde(default)]
222    pub auto: bool,
223
224    /// Default team ID for cloud sync (set on first successful sync)
225    #[serde(default, skip_serializing_if = "Option::is_none")]
226    pub team_id: Option<String>,
227}
228
229fn default_sync_method() -> String {
230    "local".to_string()
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ServerConfig {
235    /// Server URL (default: https://mur-server.fly.dev)
236    #[serde(default = "default_server_url")]
237    pub url: String,
238}
239
240impl Default for ServerConfig {
241    fn default() -> Self {
242        Self {
243            url: default_server_url(),
244        }
245    }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct CommunityConfig {
250    /// Whether community pattern sharing is enabled
251    #[serde(default)]
252    pub enabled: bool,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct EmbeddingConfig {
257    /// "ollama", "openai", "gemini", or "anthropic"
258    #[serde(default = "default_embedding_provider")]
259    pub provider: String,
260
261    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
262    #[serde(default = "default_embedding_model")]
263    pub model: String,
264
265    /// Vector dimensions (fixed after first index build)
266    #[serde(default = "default_dimensions")]
267    pub dimensions: usize,
268
269    /// Ollama endpoint
270    #[serde(default = "default_ollama_endpoint")]
271    pub ollama_endpoint: String,
272
273    /// API key env var name (e.g. "OPENAI_API_KEY")
274    #[serde(default, skip_serializing_if = "Option::is_none")]
275    pub api_key_env: Option<String>,
276
277    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
278    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
279    #[serde(default, skip_serializing_if = "Option::is_none")]
280    pub api_key_ref: Option<String>,
281
282    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
283    #[serde(default, skip_serializing_if = "Option::is_none")]
284    pub openai_url: Option<String>,
285}
286
287impl Default for EmbeddingConfig {
288    fn default() -> Self {
289        Self {
290            provider: default_embedding_provider(),
291            model: default_embedding_model(),
292            dimensions: default_dimensions(),
293            ollama_endpoint: default_ollama_endpoint(),
294            api_key_env: None,
295            api_key_ref: None,
296            openai_url: None,
297        }
298    }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct LlmConfig {
303    /// "anthropic", "openai", "gemini", or "ollama"
304    #[serde(default = "default_llm_provider")]
305    pub provider: String,
306
307    #[serde(default = "default_llm_model")]
308    pub model: String,
309
310    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
311    #[serde(default, skip_serializing_if = "Option::is_none")]
312    pub api_key_env: Option<String>,
313
314    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
315    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
316    #[serde(default, skip_serializing_if = "Option::is_none")]
317    pub api_key_ref: Option<String>,
318
319    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
320    #[serde(default, skip_serializing_if = "Option::is_none")]
321    pub openai_url: Option<String>,
322}
323
324impl Default for LlmConfig {
325    fn default() -> Self {
326        Self {
327            provider: default_llm_provider(),
328            model: default_llm_model(),
329            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
330            api_key_ref: None,
331            openai_url: None,
332        }
333    }
334}
335
336impl LlmConfig {
337    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
338    /// into a BackendConfig that the new ChatBackend factory consumes.
339    /// Mapping:
340    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
341    ///   (preserves the historical LlmConfig::llm_complete fall-through for
342    ///   OpenAI-compatible passthrough proxies).
343    /// - `model` 1:1.
344    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
345    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
346    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
347    /// - `timeout_secs` always None (factory defaults to 120s — matches
348    ///   the historical 60s reqwest default behavior closely enough).
349    pub fn to_backend_config(&self) -> BackendConfig {
350        let provider = match self.provider.as_str() {
351            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
352            _ if self.openai_url.is_some() => "openai".into(),
353            other => other.into(), // factory will reject with "unsupported provider"
354        };
355        BackendConfig {
356            provider,
357            model: self.model.clone(),
358            endpoint: self.openai_url.clone(),
359            api_key_env: self.api_key_env.clone(),
360            api_key_ref: self.api_key_ref.clone(),
361            timeout_secs: None,
362        }
363    }
364}
365
366/// Backend selection for a single chat-completion call site.
367///
368/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
369/// (per-stage) and `AskConfig` (per-stage) to override the legacy
370/// Ollama-only path. None of the `Option` fields are required;
371/// resolution falls back to provider defaults
372/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
373///
374/// Stays in mur-common (not mur-core) because it is pure data and
375/// will be reused by mur-agent-runtime in a future phase.
376#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
377#[serde(default)]
378pub struct BackendConfig {
379    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
380    pub provider: String,
381    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
382    pub model: String,
383    /// Provider endpoint. None = provider default
384    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
385    pub endpoint: Option<String>,
386    /// Env var holding the API key. None = no auth (ollama).
387    pub api_key_env: Option<String>,
388    /// SecretRef string for the API key. Takes precedence over `api_key_env`.
389    pub api_key_ref: Option<String>,
390    /// Per-call timeout in seconds. None = 120s.
391    pub timeout_secs: Option<u64>,
392}
393
394impl Default for BackendConfig {
395    fn default() -> Self {
396        Self {
397            provider: "ollama".into(),
398            model: DEFAULT_LOCAL_LLM_MODEL.into(),
399            endpoint: None,
400            api_key_env: None,
401            api_key_ref: None,
402            timeout_secs: None,
403        }
404    }
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct RetrievalConfig {
409    /// Max patterns to inject per query
410    #[serde(default = "default_max_patterns")]
411    pub max_patterns: usize,
412
413    /// Max tokens for injected content
414    #[serde(default = "default_max_tokens")]
415    pub max_tokens: usize,
416
417    /// Minimum score threshold
418    #[serde(default = "default_min_score")]
419    pub min_score: f64,
420
421    /// MMR diversity threshold (cosine > this = too similar)
422    #[serde(default = "default_mmr_threshold")]
423    pub mmr_threshold: f64,
424}
425
426impl Default for RetrievalConfig {
427    fn default() -> Self {
428        Self {
429            max_patterns: default_max_patterns(),
430            max_tokens: default_max_tokens(),
431            min_score: default_min_score(),
432            mmr_threshold: default_mmr_threshold(),
433        }
434    }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct PathConfig {
439    /// Root MUR directory (default: ~/.mur)
440    #[serde(default = "default_mur_dir")]
441    pub mur_dir: PathBuf,
442}
443
444impl Default for PathConfig {
445    fn default() -> Self {
446        Self {
447            mur_dir: default_mur_dir(),
448        }
449    }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct StorageConfig {
454    /// Vector backend identifier: "lancedb" (default) or "qdrant".
455    #[serde(default = "default_vector_backend")]
456    pub vector_backend: String,
457
458    /// Qdrant connection URL (only used when vector_backend = "qdrant").
459    #[serde(default, skip_serializing_if = "Option::is_none")]
460    pub qdrant_url: Option<String>,
461
462    /// Keyring account name holding the Qdrant API key, if any.
463    #[serde(default, skip_serializing_if = "Option::is_none")]
464    pub qdrant_api_key_ref: Option<String>,
465}
466
467impl Default for StorageConfig {
468    fn default() -> Self {
469        Self {
470            vector_backend: default_vector_backend(),
471            qdrant_url: None,
472            qdrant_api_key_ref: None,
473        }
474    }
475}
476
477fn default_vector_backend() -> String {
478    "lancedb".to_string()
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct SourcesGlobalConfig {
483    /// Polling interval for cloud sources (seconds).
484    #[serde(default = "default_poll_interval_secs")]
485    pub poll_interval_secs: u64,
486
487    /// Safety cap: do not sync more than this many chunks per run.
488    #[serde(default = "default_max_chunks_per_sync")]
489    pub max_chunks_per_sync: usize,
490
491    /// Upper bound on parallel source sync tasks.
492    #[serde(default = "default_max_parallel_sources")]
493    pub max_parallel_sources: usize,
494
495    /// Weight applied to new sources unless overridden.
496    #[serde(default = "default_source_weight")]
497    pub default_weight: f32,
498
499    /// Embedding request batch size.
500    #[serde(default = "default_embedding_batch_size")]
501    pub embedding_batch_size: usize,
502}
503
504impl Default for SourcesGlobalConfig {
505    fn default() -> Self {
506        Self {
507            poll_interval_secs: default_poll_interval_secs(),
508            max_chunks_per_sync: default_max_chunks_per_sync(),
509            max_parallel_sources: default_max_parallel_sources(),
510            default_weight: default_source_weight(),
511            embedding_batch_size: default_embedding_batch_size(),
512        }
513    }
514}
515
516fn default_poll_interval_secs() -> u64 {
517    600
518}
519fn default_max_chunks_per_sync() -> usize {
520    10_000
521}
522fn default_max_parallel_sources() -> usize {
523    3
524}
525fn default_source_weight() -> f32 {
526    1.0
527}
528fn default_embedding_batch_size() -> usize {
529    32
530}
531
532fn default_embedding_provider() -> String {
533    "ollama".to_string()
534}
535fn default_embedding_model() -> String {
536    "qwen3-embedding:0.6b".to_string()
537}
538fn default_dimensions() -> usize {
539    1024
540}
541fn default_ollama_endpoint() -> String {
542    "http://localhost:11434".to_string()
543}
544fn default_llm_provider() -> String {
545    "anthropic".to_string()
546}
547fn default_llm_model() -> String {
548    "claude-opus-4-6".to_string()
549}
550fn default_max_patterns() -> usize {
551    5
552}
553fn default_max_tokens() -> usize {
554    2000
555}
556fn default_min_score() -> f64 {
557    0.35
558}
559fn default_mmr_threshold() -> f64 {
560    0.85
561}
562fn default_mur_dir() -> PathBuf {
563    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
564    // Callers in mur-core that need the real home dir should use `dirs` there.
565    let home = std::env::var("HOME")
566        .map(PathBuf::from)
567        .unwrap_or_else(|_| PathBuf::from("/tmp"));
568    home.join(".mur")
569}
570fn default_server_url() -> String {
571    "https://mur-server.fly.dev".to_string()
572}
573
574// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
575
576#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct AskConfig {
578    #[serde(default = "ask_default_model")]
579    pub model: String,
580    #[serde(default = "compact_default_ollama_endpoint")]
581    pub ollama_endpoint: String,
582    #[serde(default = "ask_default_k_summary")]
583    pub k_summary: u32,
584    #[serde(default = "ask_default_k_raw")]
585    pub k_raw: u32,
586    #[serde(default = "ask_default_esc")]
587    pub escalation_threshold: f64,
588    #[serde(default = "ask_default_mmr")]
589    pub mmr_threshold: f64,
590    #[serde(default = "ask_default_max_ctx")]
591    pub max_context_tokens: u32,
592    #[serde(default = "ask_default_resp_tok")]
593    pub response_tokens: u32,
594    #[serde(default = "ask_default_timeout")]
595    pub timeout_secs: u32,
596    #[serde(default = "ask_default_min_score")]
597    pub min_score: f64,
598    #[serde(default = "ask_default_continue_history_turns")]
599    pub continue_history_turns: u32,
600    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
601    /// Rewriter output is small (~80 tokens) and falling back to the raw
602    /// question on failure is non-fatal, so we don't want to burn the full
603    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
604    /// the user sees any response.
605    #[serde(default = "ask_default_rewriter_timeout")]
606    pub rewriter_timeout_secs: u32,
607    #[serde(default = "ask_default_compress_hits_enabled")]
608    pub compress_hits_enabled: bool,
609    #[serde(default = "ask_default_summarize_hits_enabled")]
610    pub summarize_hits_enabled: bool,
611    #[serde(default)]
612    pub summarize_model: Option<String>,
613    /// Per-stage backend override for the answer-generation model.
614    /// None = synthesize from legacy `model` + `ollama_endpoint`.
615    #[serde(default)]
616    pub backend: Option<BackendConfig>,
617    /// Per-stage backend override for the query rewriter.
618    /// None = synthesize an Ollama BackendConfig over the legacy `model` +
619    /// `ollama_endpoint` with `rewriter_timeout_secs` baked in.
620    #[serde(default)]
621    pub rewriter_backend: Option<BackendConfig>,
622}
623
624impl AskConfig {
625    /// Returns the effective backend for the answer-generation model.
626    /// Per-stage `backend` override wins; otherwise synthesize from legacy
627    /// fields (`model`, `ollama_endpoint`) into an Ollama BackendConfig.
628    ///
629    /// `timeout_secs` is baked from `self.timeout_secs` so the answer call
630    /// inherits the user's per-call budget (rather than factory's 120s
631    /// default). When the user supplied an explicit `backend` override
632    /// with its own `timeout_secs`, that wins — we only synthesize when
633    /// `self.backend` is None.
634    pub fn synthesize_backend(&self) -> BackendConfig {
635        self.backend.clone().unwrap_or_else(|| BackendConfig {
636            provider: "ollama".into(),
637            model: self.model.clone(),
638            endpoint: Some(self.ollama_endpoint.clone()),
639            api_key_env: None,
640            api_key_ref: None,
641            timeout_secs: Some(self.timeout_secs as u64),
642        })
643    }
644
645    /// Returns the effective backend for the query rewriter.
646    ///
647    /// When `self.rewriter_backend` is None, this synthesizes its OWN
648    /// Ollama BackendConfig with `self.rewriter_timeout_secs` baked in —
649    /// it does NOT fall through to `synthesize_backend()`. The rewriter
650    /// has a much tighter latency budget than the answer call (rewriter
651    /// output is small and falling back to the raw question on timeout
652    /// is non-fatal), so we don't want a slow Ollama burning the full
653    /// `timeout_secs` budget before the user sees any response.
654    pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
655        self.rewriter_backend
656            .clone()
657            .unwrap_or_else(|| BackendConfig {
658                provider: "ollama".into(),
659                model: self.model.clone(),
660                endpoint: Some(self.ollama_endpoint.clone()),
661                api_key_env: None,
662                api_key_ref: None,
663                timeout_secs: Some(self.rewriter_timeout_secs as u64),
664            })
665    }
666}
667
668impl Default for AskConfig {
669    fn default() -> Self {
670        Self {
671            model: ask_default_model(),
672            ollama_endpoint: compact_default_ollama_endpoint(),
673            k_summary: ask_default_k_summary(),
674            k_raw: ask_default_k_raw(),
675            escalation_threshold: ask_default_esc(),
676            mmr_threshold: ask_default_mmr(),
677            max_context_tokens: ask_default_max_ctx(),
678            response_tokens: ask_default_resp_tok(),
679            timeout_secs: ask_default_timeout(),
680            min_score: ask_default_min_score(),
681            continue_history_turns: ask_default_continue_history_turns(),
682            rewriter_timeout_secs: ask_default_rewriter_timeout(),
683            compress_hits_enabled: ask_default_compress_hits_enabled(),
684            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
685            summarize_model: None,
686            backend: None,
687            rewriter_backend: None,
688        }
689    }
690}
691
692fn ask_default_model() -> String {
693    DEFAULT_LOCAL_LLM_MODEL.into()
694}
695fn ask_default_k_summary() -> u32 {
696    5
697}
698fn ask_default_k_raw() -> u32 {
699    10
700}
701fn ask_default_esc() -> f64 {
702    0.5
703}
704fn ask_default_mmr() -> f64 {
705    0.88
706}
707fn ask_default_max_ctx() -> u32 {
708    6000
709}
710fn ask_default_resp_tok() -> u32 {
711    1024
712}
713fn ask_default_timeout() -> u32 {
714    120
715}
716fn ask_default_min_score() -> f64 {
717    0.35
718}
719fn ask_default_rewriter_timeout() -> u32 {
720    8
721}
722fn ask_default_continue_history_turns() -> u32 {
723    3
724}
725fn ask_default_compress_hits_enabled() -> bool {
726    true
727}
728fn ask_default_summarize_hits_enabled() -> bool {
729    true
730}
731
732// ── Conversations archive config (Task 23) ────────────────────────────────────
733
734/// Phase 1 conversations archive config (Task 23).
735///
736/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
737/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
738/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
739/// without a `conversations:` section still parses.
740#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct ConversationsConfig {
742    #[serde(default)]
743    pub enabled: bool,
744    #[serde(default = "conv_default_retention_days")]
745    pub retention_days: u32,
746    #[serde(default = "conv_default_poll_interval")]
747    pub poll_interval_secs: u64,
748    #[serde(default)]
749    pub sources: ConversationsSources,
750    #[serde(default)]
751    pub filter: ConversationsFilter,
752    #[serde(default)]
753    pub compact: CompactConfig,
754    #[serde(default)]
755    pub ask: AskConfig,
756    #[serde(default)]
757    pub rollup: RollupConfig,
758}
759
760impl Default for ConversationsConfig {
761    fn default() -> Self {
762        Self {
763            enabled: false,
764            retention_days: conv_default_retention_days(),
765            poll_interval_secs: conv_default_poll_interval(),
766            sources: ConversationsSources::default(),
767            filter: ConversationsFilter::default(),
768            compact: CompactConfig::default(),
769            ask: AskConfig::default(),
770            rollup: RollupConfig::default(),
771        }
772    }
773}
774
775fn conv_default_retention_days() -> u32 {
776    30
777}
778fn conv_default_poll_interval() -> u64 {
779    300
780}
781fn conv_truthy() -> bool {
782    true
783}
784fn conv_default_dedup() -> f64 {
785    0.85
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct CompactConfig {
790    #[serde(default = "conv_truthy")]
791    pub enabled_in_daemon: bool,
792    #[serde(default = "compact_default_max_days")]
793    pub max_days_per_run: u32,
794    #[serde(default = "compact_default_model")]
795    pub extractive_model: String,
796    #[serde(default = "compact_default_model")]
797    pub abstractive_model: String,
798    #[serde(default = "compact_default_ollama_endpoint")]
799    pub ollama_endpoint: String,
800    #[serde(default = "compact_default_max_spans")]
801    pub max_extractive_spans: u32,
802    #[serde(default = "compact_default_max_words")]
803    pub max_abstractive_words: u32,
804    #[serde(default = "compact_default_chunk_tokens")]
805    pub chunk_tokens: u32,
806    #[serde(default = "compact_default_history_retain")]
807    pub history_retain: u32,
808    #[serde(default = "compact_default_cron")]
809    pub daemon_cron: String,
810    /// Per-stage backend override for extractive summarization.
811    /// None = synthesize from legacy `extractive_model` + `ollama_endpoint`.
812    #[serde(default)]
813    pub extractive_backend: Option<BackendConfig>,
814    /// Per-stage backend override for abstractive summarization.
815    /// None = synthesize from legacy `abstractive_model` + `ollama_endpoint`.
816    #[serde(default)]
817    pub abstractive_backend: Option<BackendConfig>,
818}
819
820impl CompactConfig {
821    /// Returns the effective backend for the extractive stage.
822    /// Per-stage `extractive_backend` override wins; otherwise synthesize
823    /// from legacy fields into an Ollama BackendConfig.
824    ///
825    /// CompactConfig has no per-stage timeout field, so synthesis bakes
826    /// the conservative 120s default — matching the previously-hardcoded
827    /// `Duration::from_secs(120)` at the call sites (byte-identical to
828    /// the pre-trait OllamaClient construction).
829    pub fn synthesize_extractive_backend(&self) -> BackendConfig {
830        self.extractive_backend
831            .clone()
832            .unwrap_or_else(|| BackendConfig {
833                provider: "ollama".into(),
834                model: self.extractive_model.clone(),
835                endpoint: Some(self.ollama_endpoint.clone()),
836                api_key_env: None,
837                api_key_ref: None,
838                timeout_secs: Some(120),
839            })
840    }
841
842    /// Returns the effective backend for the abstractive stage.
843    /// See `synthesize_extractive_backend` for the timeout rationale.
844    pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
845        self.abstractive_backend
846            .clone()
847            .unwrap_or_else(|| BackendConfig {
848                provider: "ollama".into(),
849                model: self.abstractive_model.clone(),
850                endpoint: Some(self.ollama_endpoint.clone()),
851                api_key_env: None,
852                api_key_ref: None,
853                timeout_secs: Some(120),
854            })
855    }
856}
857
858impl Default for CompactConfig {
859    fn default() -> Self {
860        Self {
861            enabled_in_daemon: true,
862            max_days_per_run: compact_default_max_days(),
863            extractive_model: compact_default_model(),
864            abstractive_model: compact_default_model(),
865            ollama_endpoint: compact_default_ollama_endpoint(),
866            max_extractive_spans: compact_default_max_spans(),
867            max_abstractive_words: compact_default_max_words(),
868            chunk_tokens: compact_default_chunk_tokens(),
869            history_retain: compact_default_history_retain(),
870            daemon_cron: compact_default_cron(),
871            extractive_backend: None,
872            abstractive_backend: None,
873        }
874    }
875}
876
877fn compact_default_max_days() -> u32 {
878    7
879}
880fn compact_default_model() -> String {
881    DEFAULT_LOCAL_LLM_MODEL.into()
882}
883fn compact_default_ollama_endpoint() -> String {
884    "http://localhost:11434".into()
885}
886fn compact_default_max_spans() -> u32 {
887    20
888}
889fn compact_default_max_words() -> u32 {
890    400
891}
892fn compact_default_chunk_tokens() -> u32 {
893    6000
894}
895fn compact_default_history_retain() -> u32 {
896    5
897}
898fn compact_default_cron() -> String {
899    "0 0 3 * * * *".into()
900}
901
902// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
903
904#[derive(Debug, Clone, Serialize, Deserialize)]
905pub struct RollupConfig {
906    #[serde(default = "rollup_default_enabled")]
907    pub enabled: bool,
908    #[serde(default = "rollup_default_max_weeks")]
909    pub max_weeks_per_run: u32,
910    #[serde(default = "rollup_default_max_months")]
911    pub max_months_per_run: u32,
912    #[serde(default = "rollup_default_max_spans_week")]
913    pub max_extractive_spans_per_week: u32,
914    #[serde(default = "rollup_default_max_words_week")]
915    pub max_abstractive_words_per_week: u32,
916    #[serde(default = "rollup_default_max_spans_month")]
917    pub max_extractive_spans_per_month: u32,
918    #[serde(default = "rollup_default_max_words_month")]
919    pub max_abstractive_words_per_month: u32,
920    #[serde(default = "rollup_default_week_mmr")]
921    pub week_mmr_threshold: f64,
922    #[serde(default = "rollup_default_month_mmr")]
923    pub month_mmr_threshold: f64,
924    #[serde(default = "compact_default_model")]
925    pub extractive_model: String,
926    #[serde(default = "compact_default_model")]
927    pub abstractive_model: String,
928    #[serde(default = "compact_default_ollama_endpoint")]
929    pub ollama_endpoint: String,
930}
931
932impl Default for RollupConfig {
933    fn default() -> Self {
934        Self {
935            enabled: rollup_default_enabled(),
936            max_weeks_per_run: rollup_default_max_weeks(),
937            max_months_per_run: rollup_default_max_months(),
938            max_extractive_spans_per_week: rollup_default_max_spans_week(),
939            max_abstractive_words_per_week: rollup_default_max_words_week(),
940            max_extractive_spans_per_month: rollup_default_max_spans_month(),
941            max_abstractive_words_per_month: rollup_default_max_words_month(),
942            week_mmr_threshold: rollup_default_week_mmr(),
943            month_mmr_threshold: rollup_default_month_mmr(),
944            extractive_model: compact_default_model(),
945            abstractive_model: compact_default_model(),
946            ollama_endpoint: compact_default_ollama_endpoint(),
947        }
948    }
949}
950
951fn rollup_default_enabled() -> bool {
952    true
953}
954fn rollup_default_max_weeks() -> u32 {
955    4
956}
957fn rollup_default_max_months() -> u32 {
958    2
959}
960fn rollup_default_max_spans_week() -> u32 {
961    20
962}
963fn rollup_default_max_words_week() -> u32 {
964    500
965}
966fn rollup_default_max_spans_month() -> u32 {
967    20
968}
969fn rollup_default_max_words_month() -> u32 {
970    700
971}
972fn rollup_default_week_mmr() -> f64 {
973    0.85
974}
975fn rollup_default_month_mmr() -> f64 {
976    0.82
977}
978
979#[derive(Debug, Clone, Serialize, Deserialize)]
980pub struct ConversationsSources {
981    #[serde(default = "conv_truthy")]
982    pub claude_code: bool,
983    #[serde(default = "conv_truthy")]
984    pub cursor: bool,
985    #[serde(default = "conv_truthy")]
986    pub gemini: bool,
987    #[serde(default)]
988    pub aider: AiderSourceConfig,
989}
990
991impl Default for ConversationsSources {
992    fn default() -> Self {
993        Self {
994            claude_code: true,
995            cursor: true,
996            gemini: true,
997            aider: AiderSourceConfig::default(),
998        }
999    }
1000}
1001
1002#[derive(Debug, Clone, Serialize, Deserialize)]
1003pub struct AiderSourceConfig {
1004    #[serde(default = "conv_truthy")]
1005    pub enabled: bool,
1006    #[serde(default)]
1007    pub watched_dirs: Vec<String>,
1008}
1009
1010impl Default for AiderSourceConfig {
1011    fn default() -> Self {
1012        Self {
1013            enabled: true,
1014            watched_dirs: Vec::new(),
1015        }
1016    }
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize)]
1020pub struct ConversationsFilter {
1021    #[serde(default = "conv_default_dedup")]
1022    pub dedup_threshold: f64,
1023    #[serde(default = "conv_truthy")]
1024    pub reject_heartbeat: bool,
1025    #[serde(default = "conv_truthy")]
1026    pub reject_system_restatement: bool,
1027}
1028
1029impl Default for ConversationsFilter {
1030    fn default() -> Self {
1031        Self {
1032            dedup_threshold: conv_default_dedup(),
1033            reject_heartbeat: true,
1034            reject_system_restatement: true,
1035        }
1036    }
1037}
1038
1039#[cfg(test)]
1040mod conversations_tests {
1041    use super::*;
1042
1043    #[test]
1044    fn conversations_section_defaults() {
1045        let c = ConversationsConfig::default();
1046        assert!(!c.enabled);
1047        assert_eq!(c.retention_days, 30);
1048        assert_eq!(c.poll_interval_secs, 300);
1049        assert!(c.sources.claude_code);
1050        assert!(c.sources.cursor);
1051        assert!(c.sources.gemini);
1052        assert!(c.sources.aider.enabled);
1053        assert!(c.sources.aider.watched_dirs.is_empty());
1054        assert_eq!(c.filter.dedup_threshold, 0.85);
1055        assert!(c.filter.reject_heartbeat);
1056        assert!(c.filter.reject_system_restatement);
1057    }
1058
1059    #[test]
1060    fn parse_from_yaml_with_overrides() {
1061        let y = r#"
1062conversations:
1063  enabled: true
1064  retention_days: 45
1065  poll_interval_secs: 120
1066  sources:
1067    cursor: false
1068    aider:
1069      watched_dirs: ["~/Projects/a", "~/Projects/b"]
1070  filter:
1071    dedup_threshold: 0.9
1072"#;
1073        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1074        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1075        assert!(conv.enabled);
1076        assert_eq!(conv.retention_days, 45);
1077        assert_eq!(conv.poll_interval_secs, 120);
1078        assert!(conv.sources.claude_code); // defaulted true
1079        assert!(!conv.sources.cursor); // override
1080        assert!(conv.sources.gemini); // defaulted true
1081        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1082        assert_eq!(conv.filter.dedup_threshold, 0.9);
1083        assert!(conv.filter.reject_heartbeat); // defaulted true
1084    }
1085
1086    #[test]
1087    fn missing_conversations_section_is_fine() {
1088        let y = r#"
1089# No conversations section at all
1090foo: bar
1091"#;
1092        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1093        // Default when absent
1094        let conv: ConversationsConfig = v
1095            .get("conversations")
1096            .cloned()
1097            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1098            .unwrap_or_default();
1099        assert_eq!(conv.retention_days, 30);
1100    }
1101
1102    #[test]
1103    fn compact_config_defaults() {
1104        let c = CompactConfig::default();
1105        assert!(c.enabled_in_daemon);
1106        assert_eq!(c.max_days_per_run, 7);
1107        assert_eq!(c.extractive_model, "qwen3.5:4b");
1108        assert_eq!(c.abstractive_model, "qwen3.5:4b");
1109        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1110        assert_eq!(c.max_extractive_spans, 20);
1111        assert_eq!(c.chunk_tokens, 6000);
1112        assert_eq!(c.history_retain, 5);
1113        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1114    }
1115
1116    #[test]
1117    fn compact_parses_partial_overrides() {
1118        let y = r#"
1119conversations:
1120  compact:
1121    max_days_per_run: 3
1122    extractive_model: qwen3:4b
1123"#;
1124        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1125        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1126        assert_eq!(conv.compact.max_days_per_run, 3);
1127        assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1128        assert!(conv.compact.enabled_in_daemon); // default preserved
1129        assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); // default preserved
1130    }
1131
1132    #[test]
1133    fn ask_config_defaults() {
1134        let c = AskConfig::default();
1135        assert_eq!(c.model, "qwen3.5:4b");
1136        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1137        assert_eq!(c.k_raw, 10);
1138        assert_eq!(c.escalation_threshold, 0.5);
1139        assert_eq!(c.mmr_threshold, 0.88);
1140        assert_eq!(c.max_context_tokens, 6000);
1141        assert_eq!(c.response_tokens, 1024);
1142        assert_eq!(c.timeout_secs, 120);
1143        assert_eq!(c.min_score, 0.35);
1144    }
1145
1146    #[test]
1147    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1148        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
1149        let c = AskConfig::default();
1150        assert!(
1151            (c.mmr_threshold - 0.88).abs() < 1e-9,
1152            "expected 0.88, got {}",
1153            c.mmr_threshold
1154        );
1155    }
1156
1157    #[test]
1158    fn rollup_config_defaults() {
1159        let c = RollupConfig::default();
1160        assert!(c.enabled);
1161        assert_eq!(c.max_weeks_per_run, 4);
1162        assert_eq!(c.max_months_per_run, 2);
1163        assert_eq!(c.max_extractive_spans_per_week, 20);
1164        assert_eq!(c.max_abstractive_words_per_week, 500);
1165        assert_eq!(c.max_extractive_spans_per_month, 20);
1166        assert_eq!(c.max_abstractive_words_per_month, 700);
1167        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1168        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1169        assert_eq!(c.extractive_model, "qwen3.5:4b");
1170        assert_eq!(c.abstractive_model, "qwen3.5:4b");
1171        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1172    }
1173
1174    #[test]
1175    fn rollup_config_plumbed_into_conversations_config() {
1176        let c = ConversationsConfig::default();
1177        assert!(c.rollup.enabled);
1178    }
1179
1180    #[test]
1181    fn ask_config_default_continue_history_turns_is_3() {
1182        let c = AskConfig::default();
1183        assert_eq!(c.continue_history_turns, 3);
1184    }
1185
1186    #[test]
1187    fn ask_config_default_compress_hits_enabled_is_true() {
1188        let c = AskConfig::default();
1189        assert!(c.compress_hits_enabled);
1190    }
1191
1192    #[test]
1193    fn ask_config_default_summarize_hits_enabled_is_true() {
1194        let c = AskConfig::default();
1195        assert!(c.summarize_hits_enabled);
1196    }
1197
1198    #[test]
1199    fn ask_config_default_summarize_model_is_none() {
1200        let c = AskConfig::default();
1201        assert!(c.summarize_model.is_none());
1202    }
1203
1204    #[test]
1205    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1206        let y = r#"
1207conversations:
1208  ask:
1209    summarize_hits_enabled: false
1210    summarize_model: qwen3:4b
1211"#;
1212        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1213        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1214        assert!(!conv.ask.summarize_hits_enabled);
1215        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1216    }
1217
1218    #[test]
1219    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1220        // Phase 3.5 must be additive: an existing config.yaml with NO
1221        // summarize_* keys must still parse and default to enabled=true,
1222        // model=None.
1223        let y = r#"
1224conversations:
1225  ask:
1226    model: qwen3:14b
1227"#;
1228        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1229        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1230        assert!(conv.ask.summarize_hits_enabled);
1231        assert!(conv.ask.summarize_model.is_none());
1232    }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237    use super::*;
1238
1239    #[test]
1240    fn default_bundled_model_id_is_qwen35_2b() {
1241        assert_eq!(
1242            crate::config::DEFAULT_BUNDLED_MODEL_ID,
1243            "Qwen3.5-2B-MLX-4bit"
1244        );
1245    }
1246
1247    #[test]
1248    fn nudge_config_defaults() {
1249        let c = NudgeConfig::default();
1250        assert!(c.enabled);
1251        assert_eq!(c.daily_cap, 3);
1252        assert_eq!(c.snooze_days, 7);
1253        assert_eq!(c.threshold, 3);
1254    }
1255
1256    #[test]
1257    fn config_has_nudge_section_with_defaults() {
1258        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1259        assert_eq!(c.nudge.daily_cap, 3);
1260    }
1261
1262    #[test]
1263    fn storage_config_default_is_lancedb() {
1264        let c = StorageConfig::default();
1265        assert_eq!(c.vector_backend, "lancedb");
1266        assert_eq!(c.qdrant_url, None);
1267        assert_eq!(c.qdrant_api_key_ref, None);
1268    }
1269
1270    #[test]
1271    fn sources_global_config_has_sensible_defaults() {
1272        let c = SourcesGlobalConfig::default();
1273        assert_eq!(c.poll_interval_secs, 600);
1274        assert_eq!(c.max_chunks_per_sync, 10_000);
1275        assert_eq!(c.max_parallel_sources, 3);
1276        assert_eq!(c.default_weight, 1.0);
1277        assert_eq!(c.embedding_batch_size, 32);
1278    }
1279
1280    #[test]
1281    fn config_default_has_storage_and_sources_global() {
1282        let c = Config::default();
1283        assert_eq!(c.storage.vector_backend, "lancedb");
1284        assert_eq!(c.sources_global.default_weight, 1.0);
1285    }
1286
1287    #[test]
1288    fn config_loads_yaml_without_new_fields() {
1289        // Existing users' config.yaml won't mention storage or sources_global.
1290        // It must still parse.
1291        let yaml = r#"
1292embedding:
1293  provider: ollama
1294  model: test-model
1295  dimensions: 512
1296  ollama_endpoint: http://localhost:11434
1297"#;
1298        let c: Config = serde_yaml::from_str(yaml).expect("parses");
1299        assert_eq!(c.storage.vector_backend, "lancedb");
1300        assert_eq!(c.sources_global.max_parallel_sources, 3);
1301    }
1302
1303    #[test]
1304    fn llm_config_to_backend_config_anthropic_passthrough() {
1305        let cfg = LlmConfig {
1306            provider: "anthropic".into(),
1307            model: "claude-haiku-4-5".into(),
1308            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1309            api_key_ref: None,
1310            openai_url: None,
1311        };
1312        let b = cfg.to_backend_config();
1313        assert_eq!(b.provider, "anthropic");
1314        assert_eq!(b.model, "claude-haiku-4-5");
1315        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1316        assert_eq!(b.endpoint, None);
1317        assert_eq!(b.timeout_secs, None);
1318    }
1319
1320    #[test]
1321    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1322        let cfg = LlmConfig {
1323            provider: "openai".into(),
1324            model: "gpt-4o-mini".into(),
1325            api_key_env: None,
1326            api_key_ref: None,
1327            openai_url: Some("https://api.together.xyz/v1".into()),
1328        };
1329        let b = cfg.to_backend_config();
1330        assert_eq!(b.provider, "openai");
1331        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1332        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
1333    }
1334
1335    #[test]
1336    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1337        let cfg = LlmConfig {
1338            provider: "ollama".into(),
1339            model: "qwen3:14b".into(),
1340            api_key_env: None,
1341            api_key_ref: None,
1342            openai_url: Some("http://192.168.1.10:11434".into()),
1343        };
1344        let b = cfg.to_backend_config();
1345        assert_eq!(b.provider, "ollama");
1346        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1347    }
1348
1349    #[test]
1350    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1351        // Historical LlmConfig allowed provider="custom" + openai_url to act as
1352        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
1353        // "openai" so factory dispatches to OpenAIBackend.
1354        let cfg = LlmConfig {
1355            provider: "custom-name".into(),
1356            model: "some-model".into(),
1357            api_key_env: Some("CUSTOM_KEY".into()),
1358            api_key_ref: None,
1359            openai_url: Some("https://my-proxy.local/v1".into()),
1360        };
1361        let b = cfg.to_backend_config();
1362        assert_eq!(
1363            b.provider, "openai",
1364            "unknown provider + openai_url should alias to openai"
1365        );
1366        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1367    }
1368
1369    #[test]
1370    fn api_key_ref_roundtrips_and_defaults_none() {
1371        // Old YAML without the field still parses, field defaults to None.
1372        let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1373        assert_eq!(b.api_key_ref, None);
1374        let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1375        assert_eq!(l.api_key_ref, None);
1376        let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1377        assert_eq!(e.api_key_ref, None);
1378
1379        // Set → survives YAML round-trip and to_backend_config.
1380        let mut l2 = LlmConfig::default();
1381        l2.api_key_ref = Some("keychain:mur/anthropic".into());
1382        let y = serde_yaml_ng::to_string(&l2).unwrap();
1383        let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1384        assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1385        assert_eq!(
1386            l3.to_backend_config().api_key_ref.as_deref(),
1387            Some("keychain:mur/anthropic")
1388        );
1389    }
1390}
1391
1392#[cfg(test)]
1393mod backend_config_tests {
1394    use super::*;
1395
1396    #[test]
1397    fn default_is_ollama_qwen3() {
1398        let cfg = BackendConfig::default();
1399        assert_eq!(cfg.provider, "ollama");
1400        assert_eq!(cfg.model, "qwen3.5:4b");
1401        assert_eq!(cfg.endpoint, None);
1402        assert_eq!(cfg.api_key_env, None);
1403        assert_eq!(cfg.timeout_secs, None);
1404    }
1405
1406    #[test]
1407    fn deserializes_anthropic_full() {
1408        let yaml = "\
1409provider: anthropic
1410model: claude-haiku-4-5
1411api_key_env: ANTHROPIC_API_KEY
1412timeout_secs: 60
1413";
1414        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1415        assert_eq!(cfg.provider, "anthropic");
1416        assert_eq!(cfg.model, "claude-haiku-4-5");
1417        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1418        assert_eq!(cfg.timeout_secs, Some(60));
1419        assert_eq!(cfg.endpoint, None);
1420    }
1421
1422    #[test]
1423    fn deserializes_partial_fills_defaults() {
1424        let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1425        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1426        assert_eq!(cfg.provider, "anthropic");
1427        assert_eq!(cfg.model, "claude-sonnet-4-6");
1428        assert_eq!(cfg.api_key_env, None);
1429        assert_eq!(cfg.timeout_secs, None);
1430    }
1431
1432    #[test]
1433    fn round_trips_through_yaml() {
1434        let original = BackendConfig {
1435            provider: "anthropic".into(),
1436            model: "claude-haiku-4-5".into(),
1437            endpoint: Some("https://api.anthropic.com".into()),
1438            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1439            api_key_ref: None,
1440            timeout_secs: Some(60),
1441        };
1442        let yaml = serde_yaml::to_string(&original).unwrap();
1443        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1444        assert_eq!(parsed, original);
1445    }
1446
1447    #[test]
1448    fn skills_config_curation_gate_defaults_on() {
1449        let c = SkillsConfig::default();
1450        assert!(c.require_human_curation_before_stable);
1451    }
1452}
1453
1454/// Configuration for the daemon-side sleep cycle (idle background learning).
1455///
1456/// Skill injection configuration (M2 — runtime injection).
1457#[derive(Debug, Clone, Serialize, Deserialize)]
1458#[serde(default)]
1459pub struct SkillsConfig {
1460    pub max_skills_in_prompt: usize,
1461    pub max_total_tokens: usize,
1462    pub priority_order: Vec<String>,
1463    pub adaptive: Option<AdaptiveSkillsConfig>,
1464
1465    /// When true (default), LLM-authored skills cannot auto-promote past
1466    /// `Emerging` until a human curates them (amendment A1). Set false to
1467    /// let LLM-extracted skills promote on run stats alone.
1468    #[serde(default = "default_require_human_curation")]
1469    pub require_human_curation_before_stable: bool,
1470
1471    /// Lifecycle scoring thresholds (W3b-P4). All fields default to the
1472    /// compile-time constants in `mur_common::skill::lifecycle` so existing
1473    /// deployments see no behaviour change without an explicit config entry.
1474    #[serde(default)]
1475    pub lifecycle: SkillLifecycleConfig,
1476
1477    /// Daily daemon auto-upgrade of origin-stamped (registry-installed)
1478    /// skills (`mur-daemon` `skill_upgrade_tick`). Non-destructive: never
1479    /// overwrites a locally-modified skill (origin hash drift blocks it).
1480    /// Defaults to `true`.
1481    #[serde(default = "default_auto_upgrade")]
1482    pub auto_upgrade: bool,
1483}
1484
1485fn default_require_human_curation() -> bool {
1486    true
1487}
1488
1489fn default_auto_upgrade() -> bool {
1490    true
1491}
1492
1493impl Default for SkillsConfig {
1494    fn default() -> Self {
1495        Self {
1496            max_skills_in_prompt: 5,
1497            max_total_tokens: 2000,
1498            priority_order: vec!["agent".into(), "global".into()],
1499            adaptive: Some(AdaptiveSkillsConfig::default()),
1500            require_human_curation_before_stable: default_require_human_curation(),
1501            lifecycle: SkillLifecycleConfig::default(),
1502            auto_upgrade: default_auto_upgrade(),
1503        }
1504    }
1505}
1506
1507/// Per-skill lifecycle scoring thresholds.
1508///
1509/// Stored under `skill.lifecycle.*` in `~/.mur/config.yaml`.
1510/// All fields are optional on disk — missing keys fall back to the
1511/// compile-time defaults so a partial config is always valid.
1512#[derive(Debug, Clone, Serialize, Deserialize)]
1513#[serde(default)]
1514pub struct SkillLifecycleConfig {
1515    // ── Promotion thresholds (must be exceeded) ──────────────────────────
1516    pub promote_draft_uses: u64,
1517    pub promote_emerging_uses: u64,
1518    pub promote_emerging_success_rate: f64,
1519    pub promote_emerging_age_days: i64,
1520    pub promote_stable_uses: u64,
1521    pub promote_stable_success_rate: f64,
1522    pub promote_stable_age_days: i64,
1523
1524    // ── Demotion thresholds (must drop below) ────────────────────────────
1525    pub demote_emerging_uses: u64,
1526    pub demote_emerging_success_rate: f64,
1527    pub demote_stable_uses: u64,
1528    pub demote_stable_success_rate: f64,
1529    pub deprecated_success_rate: f64,
1530    pub deprecated_no_success_days: i64,
1531
1532    // ── Auto-archive thresholds ───────────────────────────────────────────
1533    pub auto_archive_confidence: f64,
1534    pub auto_archive_age_days: i64,
1535
1536    // ── P4: broken fast-path ─────────────────────────────────────────────
1537    /// Number of consecutive `Execution` events with `env_class == "workflow"`
1538    /// that immediately triggers a `Deprecated` transition, bypassing the
1539    /// normal scoring path. Set to 0 to disable the fast-path.
1540    pub broken_workflow_streak: u32,
1541
1542    // ── P4: archived hard-delete ─────────────────────────────────────────
1543    /// Days a skill must remain in `Archived` state before `mur skill sweep`
1544    /// transitions it to `Destroyed` and removes its directory from disk.
1545    /// Set to 0 to disable hard-delete.
1546    pub archive_destroy_grace_days: i64,
1547}
1548
1549impl Default for SkillLifecycleConfig {
1550    fn default() -> Self {
1551        Self {
1552            promote_draft_uses: 3,
1553            promote_emerging_uses: 10,
1554            promote_emerging_success_rate: 0.6,
1555            promote_emerging_age_days: 7,
1556            promote_stable_uses: 30,
1557            promote_stable_success_rate: 0.8,
1558            promote_stable_age_days: 30,
1559            demote_emerging_uses: 8,
1560            demote_emerging_success_rate: 0.55,
1561            demote_stable_uses: 25,
1562            demote_stable_success_rate: 0.75,
1563            deprecated_success_rate: 0.3,
1564            deprecated_no_success_days: 90,
1565            auto_archive_confidence: 0.10,
1566            auto_archive_age_days: 180,
1567            broken_workflow_streak: 3,
1568            archive_destroy_grace_days: 30,
1569        }
1570    }
1571}
1572
1573#[derive(Debug, Clone, Serialize, Deserialize)]
1574#[serde(default)]
1575pub struct AdaptiveSkillsConfig {
1576    pub context_fill_decay: f64,
1577    pub min_remaining_context_ratio: f64,
1578    pub recent_fire_boost_turns: usize,
1579    /// Model max context window in tokens. Used to compute
1580    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
1581    /// Default 200_000 (Claude 3.5/4.x).
1582    pub model_max_context_tokens: u64,
1583}
1584
1585impl Default for AdaptiveSkillsConfig {
1586    fn default() -> Self {
1587        Self {
1588            context_fill_decay: 1.5,
1589            min_remaining_context_ratio: 0.20,
1590            recent_fire_boost_turns: 5,
1591            model_max_context_tokens: 200_000,
1592        }
1593    }
1594}
1595
1596/// When enabled, the daemon fires a consolidation pipeline after the user has been
1597/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
1598#[derive(Debug, Clone, Serialize, Deserialize)]
1599pub struct SleepCycleConfig {
1600    /// Master switch. False by default (opt-in).
1601    #[serde(default)]
1602    pub enabled: bool,
1603
1604    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
1605    #[serde(default = "default_idle_threshold_minutes")]
1606    pub idle_threshold_minutes: u64,
1607
1608    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
1609    #[serde(default = "default_agent_idle_minutes")]
1610    pub agent_idle_minutes: u64,
1611}
1612
1613fn default_idle_threshold_minutes() -> u64 {
1614    15
1615}
1616
1617fn default_agent_idle_minutes() -> u64 {
1618    5
1619}
1620
1621impl Default for SleepCycleConfig {
1622    fn default() -> Self {
1623        Self {
1624            enabled: false,
1625            idle_threshold_minutes: default_idle_threshold_minutes(),
1626            agent_idle_minutes: default_agent_idle_minutes(),
1627        }
1628    }
1629}
1630
1631// ── Nudge config ───────────────────────────────────────────────────
1632
1633#[derive(Debug, Clone, Serialize, Deserialize)]
1634pub struct NudgeConfig {
1635    /// Master switch. Default on — Phase 2 companion surface is live.
1636    #[serde(default = "default_nudge_enabled")]
1637    pub enabled: bool,
1638    #[serde(default = "default_nudge_daily_cap")]
1639    pub daily_cap: u32,
1640    #[serde(default = "default_nudge_snooze_days")]
1641    pub snooze_days: u32,
1642    #[serde(default = "default_nudge_threshold")]
1643    pub threshold: usize,
1644}
1645
1646fn default_nudge_enabled() -> bool {
1647    true
1648}
1649fn default_nudge_daily_cap() -> u32 {
1650    3
1651}
1652fn default_nudge_snooze_days() -> u32 {
1653    7
1654}
1655fn default_nudge_threshold() -> usize {
1656    3
1657}
1658
1659impl Default for NudgeConfig {
1660    fn default() -> Self {
1661        Self {
1662            enabled: true,
1663            daily_cap: default_nudge_daily_cap(),
1664            snooze_days: default_nudge_snooze_days(),
1665            threshold: default_nudge_threshold(),
1666        }
1667    }
1668}
1669
1670// ── Ambient capture & harvest (2026-06-11 spec) ────────────────────
1671
1672/// Ambient session capture (spec 2026-06-11-mur-ambient-capture-and-harvest §3.1).
1673#[derive(Debug, Clone, Serialize, Deserialize)]
1674pub struct SessionCfg {
1675    /// "ambient" (hooks always record) | "manual" (legacy `mur session in` gate) | "off"
1676    #[serde(default = "default_capture_mode")]
1677    pub capture: String,
1678    /// Recordings older than this many days are removed by `mur session gc`.
1679    #[serde(default = "default_retention_days")]
1680    pub retention_days: u32,
1681}
1682
1683impl Default for SessionCfg {
1684    fn default() -> Self {
1685        Self {
1686            capture: default_capture_mode(),
1687            retention_days: default_retention_days(),
1688        }
1689    }
1690}
1691
1692fn default_capture_mode() -> String {
1693    "ambient".to_string()
1694}
1695fn default_retention_days() -> u32 {
1696    14
1697}
1698
1699/// Harvest gate + token-budget defenses (spec §3.2, §3.7).
1700#[derive(Debug, Clone, Serialize, Deserialize)]
1701pub struct HarvestCfg {
1702    /// Run the heuristic gate automatically (from `mur session gc` / `mur out`).
1703    #[serde(default = "default_harvest_enabled")]
1704    pub auto_gate: bool,
1705    /// "local-first" | "cloud" | "off" — W1/W2 only persist this; LLM wiring lands with v2 P5a.
1706    #[serde(default = "default_harvest_llm")]
1707    pub llm: String,
1708    /// Gate thresholds — a session must clear at least one of these (see harvest::gate).
1709    #[serde(default = "default_min_events")]
1710    pub min_events: usize,
1711    #[serde(default = "default_min_user_turns")]
1712    pub min_user_turns: usize,
1713    #[serde(default = "default_min_duration_secs")]
1714    pub min_duration_secs: i64,
1715    /// A session is considered ended when its last event is older than this.
1716    #[serde(default = "default_idle_minutes")]
1717    pub idle_minutes: i64,
1718    /// §3.7 hard caps (persisted now; enforced when the LLM extract path lands in v2 P5a).
1719    #[serde(default = "default_max_llm_calls_per_day")]
1720    pub max_llm_calls_per_day: u32,
1721    #[serde(default = "default_max_extract_input_tokens")]
1722    pub max_extract_input_tokens: usize,
1723    /// §3.8 tier-1: one-line pending-proposals hint at SessionStart.
1724    #[serde(default = "default_harvest_enabled")]
1725    pub session_start_hint: bool,
1726    /// Step-skeleton Jaccard similarity at/above which a proposal becomes a merge suggestion.
1727    #[serde(default = "default_similarity_merge_threshold")]
1728    pub similarity_merge_threshold: f32,
1729}
1730
1731impl Default for HarvestCfg {
1732    fn default() -> Self {
1733        serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1734    }
1735}
1736
1737fn default_harvest_enabled() -> bool {
1738    true
1739}
1740fn default_harvest_llm() -> String {
1741    "local-first".to_string()
1742}
1743fn default_min_events() -> usize {
1744    5
1745}
1746fn default_min_user_turns() -> usize {
1747    2
1748}
1749fn default_min_duration_secs() -> i64 {
1750    120
1751}
1752fn default_idle_minutes() -> i64 {
1753    30
1754}
1755fn default_max_llm_calls_per_day() -> u32 {
1756    10
1757}
1758fn default_max_extract_input_tokens() -> usize {
1759    12000
1760}
1761fn default_similarity_merge_threshold() -> f32 {
1762    0.6
1763}
1764
1765// ── M7a: Cross-agent observability ─────────────────────────────────
1766
1767#[derive(Debug, Clone, Serialize, Deserialize)]
1768#[serde(default)]
1769pub struct CrossAgentConfig {
1770    #[serde(default = "default_half_life_days")]
1771    pub fitness_half_life_days: u32,
1772    #[serde(default = "default_fitness_floor")]
1773    pub fitness_floor: f64,
1774}
1775
1776fn default_half_life_days() -> u32 {
1777    7
1778}
1779fn default_fitness_floor() -> f64 {
1780    0.1
1781}
1782
1783impl Default for CrossAgentConfig {
1784    fn default() -> Self {
1785        Self {
1786            fitness_half_life_days: default_half_life_days(),
1787            fitness_floor: default_fitness_floor(),
1788        }
1789    }
1790}
1791
1792// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
1793
1794#[derive(Debug, Clone, Serialize, Deserialize)]
1795#[serde(default)]
1796pub struct SkillLlmConfig {
1797    /// Per-call output token cap.
1798    #[serde(default = "default_per_call_token_cap")]
1799    pub per_call_token_cap: u32,
1800
1801    /// Per-day USD cap for all maintenance LLM calls.
1802    #[serde(default = "default_per_day_usd_cap")]
1803    pub per_day_usd_cap: f64,
1804
1805    /// Cache TTL in days.
1806    #[serde(default = "default_cache_ttl_days")]
1807    pub cache_ttl_days: u32,
1808
1809    /// Optional explicit model key override. When `None`, role resolution picks.
1810    #[serde(default, skip_serializing_if = "Option::is_none")]
1811    pub model_ref: Option<String>,
1812}
1813
1814fn default_per_call_token_cap() -> u32 {
1815    1500
1816}
1817fn default_per_day_usd_cap() -> f64 {
1818    0.50
1819}
1820fn default_cache_ttl_days() -> u32 {
1821    30
1822}
1823
1824impl Default for SkillLlmConfig {
1825    fn default() -> Self {
1826        Self {
1827            per_call_token_cap: default_per_call_token_cap(),
1828            per_day_usd_cap: default_per_day_usd_cap(),
1829            cache_ttl_days: default_cache_ttl_days(),
1830            model_ref: None,
1831        }
1832    }
1833}
1834#[cfg(test)]
1835mod per_stage_backend_tests {
1836    use super::*;
1837
1838    #[test]
1839    fn legacy_compact_config_has_no_per_stage_overrides() {
1840        let yaml = "\
1841extractive_model: qwen3:14b
1842abstractive_model: qwen3:14b
1843ollama_endpoint: http://localhost:11434
1844";
1845        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1846        assert!(cfg.extractive_backend.is_none());
1847        assert!(cfg.abstractive_backend.is_none());
1848        assert_eq!(cfg.extractive_model, "qwen3:14b");
1849        assert_eq!(cfg.abstractive_model, "qwen3:14b");
1850        assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1851    }
1852
1853    #[test]
1854    fn legacy_ask_config_has_no_per_stage_overrides() {
1855        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1856        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1857        assert!(cfg.backend.is_none());
1858        assert!(cfg.rewriter_backend.is_none());
1859        assert_eq!(cfg.model, "qwen3:14b");
1860    }
1861
1862    #[test]
1863    fn compact_extractive_backend_override_parses() {
1864        let yaml = "\
1865extractive_backend:
1866  provider: anthropic
1867  model: claude-haiku-4-5
1868  api_key_env: ANTHROPIC_API_KEY
1869abstractive_model: qwen3:14b
1870";
1871        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1872        let extractive = cfg
1873            .extractive_backend
1874            .as_ref()
1875            .expect("override should parse");
1876        assert_eq!(extractive.provider, "anthropic");
1877        assert_eq!(extractive.model, "claude-haiku-4-5");
1878        assert!(cfg.abstractive_backend.is_none());
1879    }
1880
1881    #[test]
1882    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1883        let yaml = "\
1884backend:
1885  provider: anthropic
1886  model: claude-sonnet-4-6
1887  api_key_env: ANTHROPIC_API_KEY
1888rewriter_backend:
1889  provider: ollama
1890  model: llama3.2:3b
1891";
1892        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1893        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1894        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1895    }
1896
1897    #[test]
1898    fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1899        let yaml = "\
1900extractive_model: qwen3:14b
1901ollama_endpoint: http://192.168.1.10:11434
1902";
1903        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1904        let synth = cfg.synthesize_extractive_backend();
1905        assert_eq!(synth.provider, "ollama");
1906        assert_eq!(synth.model, "qwen3:14b");
1907        assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1908        assert_eq!(synth.api_key_env, None);
1909    }
1910
1911    #[test]
1912    fn synthesize_legacy_to_backend_config_for_ask() {
1913        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1914        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1915        let synth = cfg.synthesize_backend();
1916        assert_eq!(synth.provider, "ollama");
1917        assert_eq!(synth.model, "qwen3:14b");
1918        assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1919    }
1920
1921    #[test]
1922    fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1923        // Rewriter no longer falls through to synthesize_backend() when
1924        // `rewriter_backend` is unset (see I2 fix in P3 task 1). It now
1925        // always synthesizes its own ollama BackendConfig over the legacy
1926        // model + endpoint with `rewriter_timeout_secs` baked in, so a
1927        // slow rewriter call doesn't burn the full ask budget. The
1928        // per-stage `ask.backend` override therefore does NOT propagate to
1929        // the rewriter — set `ask.rewriter_backend` explicitly if you want
1930        // a non-Ollama rewriter.
1931        let yaml = "\
1932backend:
1933  provider: anthropic
1934  model: claude-sonnet-4-6
1935  api_key_env: ANTHROPIC_API_KEY
1936";
1937        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1938        let rewriter = cfg.synthesize_rewriter_backend();
1939        assert_eq!(rewriter.provider, "ollama");
1940        assert_eq!(rewriter.model, ask_default_model());
1941        assert_eq!(
1942            rewriter.timeout_secs,
1943            Some(ask_default_rewriter_timeout() as u64)
1944        );
1945    }
1946
1947    #[test]
1948    fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1949        let cfg = AskConfig {
1950            timeout_secs: 45,
1951            ..AskConfig::default()
1952        };
1953        let b = cfg.synthesize_backend();
1954        assert_eq!(
1955            b.timeout_secs,
1956            Some(45),
1957            "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1958        );
1959    }
1960
1961    #[test]
1962    fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1963        let mut cfg = AskConfig {
1964            timeout_secs: 45,
1965            ..AskConfig::default()
1966        };
1967        cfg.backend = Some(BackendConfig {
1968            provider: "anthropic".into(),
1969            model: "claude-haiku-4-5".into(),
1970            endpoint: None,
1971            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1972            api_key_ref: None,
1973            timeout_secs: Some(10),
1974        });
1975        let b = cfg.synthesize_backend();
1976        assert_eq!(
1977            b.timeout_secs,
1978            Some(10),
1979            "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1980        );
1981    }
1982
1983    #[test]
1984    fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1985        let cfg = AskConfig {
1986            timeout_secs: 120,
1987            rewriter_timeout_secs: 8,
1988            ..AskConfig::default()
1989        };
1990        let b = cfg.synthesize_rewriter_backend();
1991        assert_eq!(
1992            b.timeout_secs,
1993            Some(8),
1994            "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1995        );
1996    }
1997
1998    #[test]
1999    fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
2000        let mut cfg = AskConfig {
2001            rewriter_timeout_secs: 8,
2002            ..AskConfig::default()
2003        };
2004        cfg.rewriter_backend = Some(BackendConfig {
2005            provider: "anthropic".into(),
2006            model: "claude-haiku-4-5".into(),
2007            endpoint: None,
2008            api_key_env: Some("ANTHROPIC_API_KEY".into()),
2009            api_key_ref: None,
2010            timeout_secs: Some(30),
2011        });
2012        let b = cfg.synthesize_rewriter_backend();
2013        assert_eq!(
2014            b.timeout_secs,
2015            Some(30),
2016            "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
2017        );
2018    }
2019
2020    #[test]
2021    fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
2022        // CompactConfig has no per-stage timeout field — extractive synthesis
2023        // should fall back to the conservative 120s default.
2024        let cfg = CompactConfig::default();
2025        let b = cfg.synthesize_extractive_backend();
2026        assert_eq!(
2027            b.timeout_secs,
2028            Some(120),
2029            "compact synthesis without per-stage override must produce 120s timeout"
2030        );
2031    }
2032
2033    #[test]
2034    fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
2035        let cfg = CompactConfig::default();
2036        let b = cfg.synthesize_abstractive_backend();
2037        assert_eq!(b.timeout_secs, Some(120));
2038    }
2039}
2040
2041#[cfg(test)]
2042mod skills_config_tests {
2043    use super::*;
2044
2045    #[test]
2046    fn empty_yaml_hydrates_defaults() {
2047        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2048        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2049        assert_eq!(cfg.skills.max_total_tokens, 2000);
2050        assert!(cfg.skills.adaptive.is_some());
2051    }
2052
2053    #[test]
2054    fn load_or_default_missing_file_returns_default() {
2055        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2056        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2057    }
2058}
2059
2060#[cfg(test)]
2061mod ambient_capture_cfg_tests {
2062    use super::*;
2063
2064    #[test]
2065    fn session_and_harvest_defaults() {
2066        let cfg: Config = serde_yaml::from_str("{}").unwrap();
2067        assert_eq!(cfg.session.capture, "ambient");
2068        assert_eq!(cfg.session.retention_days, 14);
2069        assert!(cfg.harvest.auto_gate);
2070        assert_eq!(cfg.harvest.llm, "local-first");
2071        assert_eq!(cfg.harvest.min_events, 5);
2072        assert_eq!(cfg.harvest.min_user_turns, 2);
2073        assert_eq!(cfg.harvest.min_duration_secs, 120);
2074        assert_eq!(cfg.harvest.idle_minutes, 30);
2075        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2076        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2077        assert!(cfg.harvest.session_start_hint);
2078        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2079    }
2080
2081    #[test]
2082    fn session_capture_override_parses() {
2083        let cfg: Config =
2084            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
2085        assert_eq!(cfg.session.capture, "off");
2086        assert_eq!(cfg.session.retention_days, 3);
2087    }
2088}
2089
2090#[cfg(test)]
2091mod cc_proxy_cfg_tests {
2092    use super::*;
2093
2094    #[test]
2095    fn defaults_to_local_cc_proxy_enabled() {
2096        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2097        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2098        assert!(cfg.cc_proxy.enabled);
2099    }
2100
2101    #[test]
2102    fn url_and_enabled_override_parse() {
2103        let cfg: Config =
2104            serde_yaml_ng::from_str("cc_proxy:\n  url: http://127.0.0.1:9999\n  enabled: false\n")
2105                .unwrap();
2106        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2107        assert!(!cfg.cc_proxy.enabled);
2108    }
2109
2110    #[test]
2111    fn partial_section_keeps_other_default() {
2112        // Only `enabled` given → url stays at the default.
2113        let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n  enabled: false\n").unwrap();
2114        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2115        assert!(!cfg.cc_proxy.enabled);
2116    }
2117}