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