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