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