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
12pub const DEFAULT_MAX_RETRIES: u32 = 1;
13pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
14pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
15pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
16pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
17
18/// The Ollama provider default endpoint. Single definition — `BackendConfig`
19/// resolution (`default_ollama_endpoint`), `config_migrate`, and the
20/// conversations `doctor`/`preflight` probes all read this constant instead of
21/// repeating the literal.
22pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
23
24fn default_max_retries() -> u32 {
25    DEFAULT_MAX_RETRIES
26}
27fn default_backoff_base_ms() -> u64 {
28    DEFAULT_BACKOFF_BASE_MS
29}
30fn default_cooldown_secs() -> u64 {
31    DEFAULT_COOLDOWN_SECS
32}
33fn default_smart_max_escalations() -> u32 {
34    DEFAULT_SMART_MAX_ESCALATIONS
35}
36
37/// Smart background routing: auto-pick a cheap model for low-stakes/background
38/// requests instead of always dialing the agent's primary model_ref. Defaults
39/// ON with `cheap: None` (auto-pick the cheapest chat-capable registry entry
40/// via `mur_common::model::pick_cheap_model`).
41#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct SmartConfig {
43    #[serde(default = "default_true")]
44    pub enabled: bool,
45    #[serde(default, skip_serializing_if = "Option::is_none")]
46    pub cheap: Option<String>,
47    #[serde(default = "default_smart_max_escalations")]
48    pub max_escalations: u32,
49}
50
51impl Default for SmartConfig {
52    fn default() -> Self {
53        Self {
54            enabled: true,
55            cheap: None,
56            max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
57        }
58    }
59}
60
61/// Config-layered model selection + failure fallback. See
62/// docs/superpowers/specs/2026-07-12-intelligent-model-switch-design.md.
63#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct ModelSwitchConfig {
65    /// Global default model_ref when an agent has no `model_ref`.
66    #[serde(default, skip_serializing_if = "Option::is_none")]
67    pub default: Option<String>,
68    /// Global fallback chain (ordered model_refs).
69    #[serde(default, skip_serializing_if = "Vec::is_empty")]
70    pub fallback_chain: Vec<String>,
71    #[serde(default)]
72    pub retry: RetryConfig,
73    #[serde(default)]
74    pub routing: RoutingConfig,
75    #[serde(default)]
76    pub smart: SmartConfig,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RetryConfig {
81    #[serde(default = "default_max_retries")]
82    pub max_retries: u32,
83    #[serde(default = "default_backoff_base_ms")]
84    pub backoff_base_ms: u64,
85    #[serde(default = "default_cooldown_secs")]
86    pub cooldown_secs: u64,
87}
88
89impl Default for RetryConfig {
90    fn default() -> Self {
91        Self {
92            max_retries: DEFAULT_MAX_RETRIES,
93            backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
94            cooldown_secs: DEFAULT_COOLDOWN_SECS,
95        }
96    }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
100pub struct RoutingConfig {
101    #[serde(default)]
102    pub enabled: bool,
103    #[serde(default, skip_serializing_if = "Option::is_none")]
104    pub cheap: Option<String>,
105    #[serde(default, skip_serializing_if = "Option::is_none")]
106    pub frontier: Option<String>,
107    #[serde(default, skip_serializing_if = "Option::is_none")]
108    pub threshold_input_tokens: Option<u32>,
109    #[serde(default, skip_serializing_if = "Option::is_none")]
110    pub smart: Option<SmartConfig>,
111}
112
113/// Global MUR configuration (~/.mur/config.yaml)
114#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct Config {
116    #[serde(default)]
117    pub embedding: EmbeddingConfig,
118
119    #[serde(default)]
120    pub llm: LlmConfig,
121
122    #[serde(default)]
123    pub models: ModelSwitchConfig,
124
125    #[serde(default)]
126    pub retrieval: RetrievalConfig,
127
128    #[serde(default)]
129    pub paths: PathConfig,
130
131    #[serde(default)]
132    pub server: ServerConfig,
133
134    #[serde(default)]
135    pub community: CommunityConfig,
136
137    #[serde(default)]
138    pub conversations: ConversationsConfig,
139
140    #[serde(default)]
141    pub sync: SyncConfig,
142
143    // --- P1.1 additions ---
144    #[serde(default)]
145    pub storage: StorageConfig,
146
147    #[serde(default)]
148    pub sources_global: SourcesGlobalConfig,
149
150    // --- E3 additions ---
151    #[serde(default)]
152    pub sleep_cycle: SleepCycleConfig,
153
154    // --- M2 additions ---
155    #[serde(default)]
156    pub skills: SkillsConfig,
157
158    // --- M6c additions ---
159    #[serde(default)]
160    pub skill_llm: SkillLlmConfig,
161
162    // --- M7a additions ---
163    #[serde(default)]
164    pub cross_agent: CrossAgentConfig,
165
166    // --- nudge additions ---
167    #[serde(default)]
168    pub nudge: NudgeConfig,
169
170    // --- mobile P4 additions ---
171    #[serde(default)]
172    pub mobile_relay: MobileRelayConfig,
173
174    // --- Ambient capture & harvest (2026-06-11 spec) ---
175    #[serde(default)]
176    pub session: SessionCfg,
177
178    #[serde(default)]
179    pub harvest: HarvestCfg,
180
181    // --- OAuth bridge (cc-proxy) routing for subscription tokens ---
182    #[serde(default)]
183    pub cc_proxy: CcProxyConfig,
184
185    // --- Agent CLI TUI ---
186    #[serde(default)]
187    pub cli: CliConfig,
188
189    // --- parallel_jobs MCP tool ---
190    #[serde(default)]
191    pub parallel_jobs: ParallelJobsConfig,
192
193    /// Memory-federation snapshot settings (`federation_snapshot:`).
194    #[serde(default)]
195    pub federation_snapshot: SnapshotConfig,
196
197    /// Proactive memory capture (`memory:`, federation P2).
198    #[serde(default)]
199    pub memory: MemoryConfig,
200
201    // --- fleet_run runtime built-in tool ---
202    #[serde(default)]
203    pub fleet_run: FleetRunConfig,
204
205    // --- `mur open` display policy ---
206    #[serde(default)]
207    pub open_items: OpenItemsConfig,
208
209    // --- Hub Fleet Manager redesign ---
210    #[serde(default)]
211    pub fleet: FleetConfig,
212
213    /// `mur update` post-upgrade behavior (`update:`, issue #866).
214    #[serde(default)]
215    pub update: UpdateConfig,
216}
217
218/// Post-upgrade settings for `mur update`. Stored under `update:` in
219/// `~/.mur/config.yaml`.
220#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
221pub struct UpdateConfig {
222    /// macOS code-signing identity used to re-sign installed binaries after an
223    /// upgrade. Keychain grants bind to the signing identity; fresh installs
224    /// are ad-hoc (new CDHash per build), so without a stable identity every
225    /// upgrade kills the grants and service-launched agents fail silently
226    /// (#849/#866). Fallback: the `MUR_CODESIGN_IDENTITY` env var.
227    #[serde(default, skip_serializing_if = "Option::is_none")]
228    pub codesign_identity: Option<String>,
229    /// Agent names `mur update --restart-agents` must never touch.
230    #[serde(default, skip_serializing_if = "Vec::is_empty")]
231    pub restart_exclude: Vec<String>,
232}
233
234/// Authorization gate for the `parallel_jobs` MCP tool. Stored under `parallel_jobs:`
235/// in `~/.mur/config.yaml`. Deny-by-default: an empty `targets` list means the
236/// tool cannot delegate to ANY agent (inert until the user opts specific
237/// agents in). This is a deterministic, out-of-model gate that a
238/// prompt-injected concierge cannot widen (OWASP Agentic ASI02/03/04).
239#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
240pub struct ParallelJobsConfig {
241    /// Canonical agent names the `parallel_jobs` tool is allowed to delegate to.
242    /// Empty = deny all.
243    #[serde(default)]
244    pub targets: Vec<String>,
245}
246
247// --- memory-federation snapshot (spec 2026-08-04-unified-memory-federation) ---
248
249/// Daemon-side settings for the signed snapshot pull. Stored under
250/// `federation_snapshot:` in `~/.mur/config.yaml`; every field has a default
251/// so an absent block means "defaults", never "off".
252#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253#[serde(default)]
254pub struct SnapshotConfig {
255    /// How often the daemon sweeps `inbox/snapshot-requests/`, in seconds.
256    pub poll_secs: u64,
257    /// Reject requests older than this (replay blunting), in seconds.
258    pub request_max_age_secs: u64,
259    /// Minimum lifecycle state a global skill needs to enter a snapshot.
260    pub min_lifecycle: crate::skill::stats::LifecycleState,
261}
262
263impl Default for SnapshotConfig {
264    fn default() -> Self {
265        Self {
266            poll_secs: 30,
267            request_max_age_secs: 600,
268            min_lifecycle: crate::skill::stats::LifecycleState::Stable,
269        }
270    }
271}
272
273/// Proactive memory capture (memory federation P2): gates the runtime's
274/// built-in `remember` tool and its system-prompt directive. Stored under
275/// `memory:` in `~/.mur/config.yaml`.
276#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
277#[serde(default)]
278pub struct MemoryConfig {
279    pub capture: CaptureMode,
280}
281
282impl Default for MemoryConfig {
283    fn default() -> Self {
284        Self {
285            capture: CaptureMode::AutoAnnounce,
286        }
287    }
288}
289
290/// How agents capture memories mid-conversation.
291#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
292#[serde(rename_all = "snake_case")]
293pub enum CaptureMode {
294    /// Agent asks for a one-line confirmation before saving.
295    Ask,
296    /// Agent saves immediately and announces in the same reply (default).
297    AutoAnnounce,
298    /// The `remember` tool is not registered at all.
299    Off,
300}
301
302/// Authorization gate for the runtime's built-in `fleet_run` tool. Stored under
303/// `fleet_run:` in `~/.mur/config.yaml`. Deny-by-default on BOTH axes: an agent
304/// not named in `agents` never even sees the tool, and a fleet not named in
305/// `fleets` cannot be run. Lives in the global config (not the agent profile)
306/// because the profile is writable by the concierge itself — this gate must be
307/// out of reach of a prompt-injected agent (same rationale as
308/// [`ParallelJobsConfig`]).
309#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
310pub struct FleetRunConfig {
311    /// Canonical agent names allowed to call `fleet_run`. Empty = deny all.
312    #[serde(default)]
313    pub agents: Vec<String>,
314    /// Fleet names those agents may run. Empty = deny all.
315    #[serde(default)]
316    pub fleets: Vec<String>,
317}
318
319/// Display policy for `mur open`.
320///
321/// Lives in `config.yaml` rather than in `open-items.jsonl` because that log
322/// is append-only and agent-writable via the `open_item` tool. A user's
323/// decision to stop looking at a source must not be overturnable by an agent
324/// appending a record. Same reasoning as `fleet_run.agents`.
325#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
326pub struct OpenItemsConfig {
327    /// Exact `origin` strings to collapse out of `mur open`. Exact match
328    /// only — `fleet` never matches `fleet:acme`.
329    #[serde(default)]
330    pub muted: Vec<String>,
331}
332
333/// Daemon-wide gate for unattended fleet auto-run (`mur-daemon`'s `fleet_tick`).
334/// Stored under `fleet:` in `~/.mur/config.yaml`. Either this flag OR the
335/// `MUR_FLEET_AUTORUN` env var satisfies the gate — both are equally explicit,
336/// off-by-default opt-ins; the env var remains for ops/CI use, this flag is
337/// what the Hub's Settings toggle controls. Per-fleet `budget_usd > 0` and the
338/// `.stopped` kill-switch are unaffected — see `mur-daemon/src/fleet_tick.rs`.
339#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
340pub struct FleetConfig {
341    /// Allow fleets with a trigger + budget configured to auto-run unattended.
342    #[serde(default)]
343    pub autorun: bool,
344}
345
346#[cfg(test)]
347mod fleet_config_tests {
348    use super::*;
349
350    #[test]
351    fn fleet_config_defaults_off_and_roundtrips() {
352        assert!(!FleetConfig::default().autorun);
353
354        let cfg: Config = serde_yaml_ng::from_str("fleet:\n  autorun: true\n").unwrap();
355        assert!(cfg.fleet.autorun);
356
357        // `fleet:` key entirely absent → defaults to off
358        let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
359        assert!(!cfg2.fleet.autorun);
360    }
361
362    #[test]
363    fn fleet_run_config_defaults_deny_all_and_roundtrips() {
364        // Absent section → both allowlists empty → deny all.
365        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
366        assert!(cfg.fleet_run.agents.is_empty());
367        assert!(cfg.fleet_run.fleets.is_empty());
368
369        let cfg2: Config =
370            serde_yaml_ng::from_str("fleet_run:\n  agents: [mur]\n  fleets: [deep-research]\n")
371                .unwrap();
372        assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
373        assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
374    }
375}
376
377/// Routing for Anthropic subscription-OAuth (`sk-ant-oat*`) tokens through a
378/// local bridge — cc-proxy — that swaps `x-api-key` for the Bearer +
379/// claude-code betas disguise the upstream requires.
380///
381/// The Hub injects `ANTHROPIC_BASE_URL` pointing at [`url`](Self::url) when it
382/// spawns an agent runtime, but only when [`enabled`](Self::enabled) is set and
383/// the bridge is actually listening; otherwise it leaves the runtime on the
384/// direct `api.anthropic.com` path (where an oat token would 401). A runtime
385/// launched with `ANTHROPIC_BASE_URL` already in its environment is never
386/// overridden.
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct CcProxyConfig {
389    /// Bridge base URL. Defaults to cc-proxy's default bind.
390    #[serde(default = "default_cc_proxy_url")]
391    pub url: String,
392
393    /// Master switch. When false the Hub never routes runtimes through the
394    /// bridge, regardless of reachability.
395    #[serde(default = "default_true")]
396    pub enabled: bool,
397}
398
399fn default_cc_proxy_url() -> String {
400    "http://127.0.0.1:8088".to_string()
401}
402
403fn default_true() -> bool {
404    true
405}
406
407impl Default for CcProxyConfig {
408    fn default() -> Self {
409        Self {
410            url: default_cc_proxy_url(),
411            enabled: true,
412        }
413    }
414}
415
416/// Configuration for the agent CLI TUI.
417/// Stored in ~/.mur/config.yaml under the `cli:` key.
418#[derive(Debug, Clone, Serialize, Deserialize, Default)]
419pub struct CliConfig {
420    /// Default visual skin for `mur agent cli`. Overridable with --skin.
421    /// Valid values: "dark" (default), "light", "mur".
422    pub skin: Option<String>,
423}
424
425/// Configuration for the mobile relay (P4).
426/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
427#[derive(Debug, Clone, Serialize, Deserialize, Default)]
428pub struct MobileRelayConfig {
429    /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
430    /// Leave blank to disable relay forwarding on the Mac daemon side.
431    #[serde(default, skip_serializing_if = "Option::is_none")]
432    pub relay_url: Option<String>,
433
434    /// API key or JWT used by the Mac daemon to authenticate with the relay.
435    /// The value is typically a `mur_...` API key from app.mur.run.
436    #[serde(default, skip_serializing_if = "Option::is_none")]
437    pub api_key: Option<String>,
438}
439
440impl Config {
441    /// Read from disk, falling back to defaults. Legacy conversation model
442    /// fields are migrated **in memory only** — this is called from agent
443    /// runtime processes (`mur-agent-runtime`), which must never write the
444    /// user's config file.
445    pub fn load_or_default(path: &std::path::Path) -> Self {
446        let Ok(text) = std::fs::read_to_string(path) else {
447            return Self::default();
448        };
449        let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
450        serde_yaml_ng::from_str(&text).unwrap_or_default()
451    }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, Default)]
455pub struct SyncConfig {
456    /// Sync method: "cloud", "git", or "local"
457    #[serde(default = "default_sync_method")]
458    pub method: String,
459
460    /// Git remote URL for git sync
461    #[serde(default, skip_serializing_if = "Option::is_none")]
462    pub git_remote: Option<String>,
463
464    /// Auto-sync on context pull / session stop
465    #[serde(default)]
466    pub auto: bool,
467
468    /// Default team ID for cloud sync (set on first successful sync)
469    #[serde(default, skip_serializing_if = "Option::is_none")]
470    pub team_id: Option<String>,
471}
472
473fn default_sync_method() -> String {
474    "local".to_string()
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct ServerConfig {
479    /// Server URL (default: https://mur-server.fly.dev)
480    #[serde(default = "default_server_url")]
481    pub url: String,
482}
483
484impl Default for ServerConfig {
485    fn default() -> Self {
486        Self {
487            url: default_server_url(),
488        }
489    }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, Default)]
493pub struct CommunityConfig {
494    /// Whether community pattern sharing is enabled
495    #[serde(default)]
496    pub enabled: bool,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct EmbeddingConfig {
501    /// "ollama", "openai", "gemini", or "anthropic"
502    #[serde(default = "default_embedding_provider")]
503    pub provider: String,
504
505    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
506    #[serde(default = "default_embedding_model")]
507    pub model: String,
508
509    /// Vector dimensions (fixed after first index build)
510    #[serde(default = "default_dimensions")]
511    pub dimensions: usize,
512
513    /// Ollama endpoint. `None` for every non-Ollama provider — the OpenAI
514    /// path uses `openai_url`. Kept out of the serialized document when
515    /// unset so it stops reappearing in configs that never use it.
516    #[serde(default, skip_serializing_if = "Option::is_none")]
517    pub ollama_endpoint: Option<String>,
518
519    /// API key env var name (e.g. "OPENAI_API_KEY")
520    #[serde(default, skip_serializing_if = "Option::is_none")]
521    pub api_key_env: Option<String>,
522
523    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
524    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
525    #[serde(default, skip_serializing_if = "Option::is_none")]
526    pub api_key_ref: Option<String>,
527
528    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
529    #[serde(default, skip_serializing_if = "Option::is_none")]
530    pub openai_url: Option<String>,
531}
532
533impl Default for EmbeddingConfig {
534    fn default() -> Self {
535        Self {
536            provider: default_embedding_provider(),
537            model: default_embedding_model(),
538            dimensions: default_dimensions(),
539            ollama_endpoint: Some(default_ollama_endpoint()),
540            api_key_env: None,
541            api_key_ref: None,
542            openai_url: None,
543        }
544    }
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct LlmConfig {
549    /// "anthropic", "openai", "gemini", or "ollama"
550    #[serde(default = "default_llm_provider")]
551    pub provider: String,
552
553    #[serde(default = "default_llm_model")]
554    pub model: String,
555
556    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
557    #[serde(default, skip_serializing_if = "Option::is_none")]
558    pub api_key_env: Option<String>,
559
560    /// SecretRef string for the API key (e.g. "keychain:mur/anthropic",
561    /// "env:ANTHROPIC_API_KEY"). Takes precedence over `api_key_env`.
562    #[serde(default, skip_serializing_if = "Option::is_none")]
563    pub api_key_ref: Option<String>,
564
565    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
566    #[serde(default, skip_serializing_if = "Option::is_none")]
567    pub openai_url: Option<String>,
568}
569
570impl Default for LlmConfig {
571    fn default() -> Self {
572        Self {
573            provider: default_llm_provider(),
574            model: default_llm_model(),
575            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
576            api_key_ref: None,
577            openai_url: None,
578        }
579    }
580}
581
582impl LlmConfig {
583    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
584    /// into a BackendConfig that the new ChatBackend factory consumes.
585    /// Mapping:
586    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
587    ///   (preserves the historical LlmConfig::llm_complete fall-through for
588    ///   OpenAI-compatible passthrough proxies).
589    /// - `model` 1:1.
590    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
591    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
592    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
593    /// - `timeout_secs` always None (factory defaults to 120s — matches
594    ///   the historical 60s reqwest default behavior closely enough).
595    pub fn to_backend_config(&self) -> BackendConfig {
596        let provider = match self.provider.as_str() {
597            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
598            _ if self.openai_url.is_some() => "openai".into(),
599            other => other.into(), // factory will reject with "unsupported provider"
600        };
601        BackendConfig {
602            provider,
603            model: self.model.clone(),
604            endpoint: self.openai_url.clone(),
605            api_key_env: self.api_key_env.clone(),
606            api_key_ref: self.api_key_ref.clone(),
607            timeout_secs: None,
608        }
609    }
610}
611
612/// Backend selection for a single chat-completion call site.
613///
614/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
615/// (per-stage) and `AskConfig` (per-stage) to override the legacy
616/// Ollama-only path. None of the `Option` fields are required;
617/// resolution falls back to provider defaults
618/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
619///
620/// Stays in mur-common (not mur-core) because it is pure data and
621/// will be reused by mur-agent-runtime in a future phase.
622#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
623#[serde(default)]
624pub struct BackendConfig {
625    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
626    pub provider: String,
627    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
628    pub model: String,
629    /// Provider endpoint. None = provider default
630    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
631    pub endpoint: Option<String>,
632    /// Env var holding the API key. None = no auth (ollama).
633    pub api_key_env: Option<String>,
634    /// SecretRef string for the API key. Takes precedence over `api_key_env`.
635    pub api_key_ref: Option<String>,
636    /// Per-call timeout in seconds. None = 120s.
637    pub timeout_secs: Option<u64>,
638}
639
640impl Default for BackendConfig {
641    fn default() -> Self {
642        Self {
643            provider: "ollama".into(),
644            model: DEFAULT_LOCAL_LLM_MODEL.into(),
645            endpoint: None,
646            api_key_env: None,
647            api_key_ref: None,
648            timeout_secs: None,
649        }
650    }
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize)]
654pub struct RetrievalConfig {
655    /// Max patterns to inject per query
656    #[serde(default = "default_max_patterns")]
657    pub max_patterns: usize,
658
659    /// Max tokens for injected content
660    #[serde(default = "default_max_tokens")]
661    pub max_tokens: usize,
662
663    /// Minimum score threshold
664    #[serde(default = "default_min_score")]
665    pub min_score: f64,
666
667    /// MMR diversity threshold (cosine > this = too similar)
668    #[serde(default = "default_mmr_threshold")]
669    pub mmr_threshold: f64,
670
671    /// Injection slots reserved for notes when mature skills would otherwise
672    /// fill every seat (memory federation P1). 0 disables the reservation.
673    #[serde(default = "default_reserved_note_slots")]
674    pub reserved_note_slots: usize,
675}
676
677impl Default for RetrievalConfig {
678    fn default() -> Self {
679        Self {
680            max_patterns: default_max_patterns(),
681            max_tokens: default_max_tokens(),
682            min_score: default_min_score(),
683            mmr_threshold: default_mmr_threshold(),
684            reserved_note_slots: default_reserved_note_slots(),
685        }
686    }
687}
688
689fn default_reserved_note_slots() -> usize {
690    1
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct PathConfig {
695    /// Root MUR directory (default: ~/.mur)
696    #[serde(default = "default_mur_dir")]
697    pub mur_dir: PathBuf,
698}
699
700impl Default for PathConfig {
701    fn default() -> Self {
702        Self {
703            mur_dir: default_mur_dir(),
704        }
705    }
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct StorageConfig {
710    /// Vector backend identifier: "lancedb" (default) or "qdrant".
711    #[serde(default = "default_vector_backend")]
712    pub vector_backend: String,
713
714    /// Qdrant connection URL (only used when vector_backend = "qdrant").
715    #[serde(default, skip_serializing_if = "Option::is_none")]
716    pub qdrant_url: Option<String>,
717
718    /// Keyring account name holding the Qdrant API key, if any.
719    #[serde(default, skip_serializing_if = "Option::is_none")]
720    pub qdrant_api_key_ref: Option<String>,
721}
722
723impl Default for StorageConfig {
724    fn default() -> Self {
725        Self {
726            vector_backend: default_vector_backend(),
727            qdrant_url: None,
728            qdrant_api_key_ref: None,
729        }
730    }
731}
732
733fn default_vector_backend() -> String {
734    "lancedb".to_string()
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
738pub struct SourcesGlobalConfig {
739    /// Polling interval for cloud sources (seconds).
740    #[serde(default = "default_poll_interval_secs")]
741    pub poll_interval_secs: u64,
742
743    /// Safety cap: do not sync more than this many chunks per run.
744    #[serde(default = "default_max_chunks_per_sync")]
745    pub max_chunks_per_sync: usize,
746
747    /// Upper bound on parallel source sync tasks.
748    #[serde(default = "default_max_parallel_sources")]
749    pub max_parallel_sources: usize,
750
751    /// Weight applied to new sources unless overridden.
752    #[serde(default = "default_source_weight")]
753    pub default_weight: f32,
754
755    /// Embedding request batch size.
756    #[serde(default = "default_embedding_batch_size")]
757    pub embedding_batch_size: usize,
758}
759
760impl Default for SourcesGlobalConfig {
761    fn default() -> Self {
762        Self {
763            poll_interval_secs: default_poll_interval_secs(),
764            max_chunks_per_sync: default_max_chunks_per_sync(),
765            max_parallel_sources: default_max_parallel_sources(),
766            default_weight: default_source_weight(),
767            embedding_batch_size: default_embedding_batch_size(),
768        }
769    }
770}
771
772fn default_poll_interval_secs() -> u64 {
773    600
774}
775fn default_max_chunks_per_sync() -> usize {
776    10_000
777}
778fn default_max_parallel_sources() -> usize {
779    3
780}
781fn default_source_weight() -> f32 {
782    1.0
783}
784fn default_embedding_batch_size() -> usize {
785    32
786}
787
788fn default_embedding_provider() -> String {
789    "ollama".to_string()
790}
791fn default_embedding_model() -> String {
792    "qwen3-embedding:0.6b".to_string()
793}
794fn default_dimensions() -> usize {
795    1024
796}
797fn default_ollama_endpoint() -> String {
798    DEFAULT_OLLAMA_ENDPOINT.to_string()
799}
800fn default_llm_provider() -> String {
801    "anthropic".to_string()
802}
803fn default_llm_model() -> String {
804    "claude-opus-5".to_string()
805}
806fn default_max_patterns() -> usize {
807    5
808}
809fn default_max_tokens() -> usize {
810    2000
811}
812fn default_min_score() -> f64 {
813    0.35
814}
815fn default_mmr_threshold() -> f64 {
816    0.85
817}
818fn default_mur_dir() -> PathBuf {
819    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
820    // Callers in mur-core that need the real home dir should use `dirs` there.
821    let home = std::env::var("HOME")
822        .map(PathBuf::from)
823        .unwrap_or_else(|_| PathBuf::from("/tmp"));
824    home.join(".mur")
825}
826fn default_server_url() -> String {
827    "https://mur-server.fly.dev".to_string()
828}
829
830// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
831
832#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct AskConfig {
834    #[serde(default = "ask_default_k_summary")]
835    pub k_summary: u32,
836    #[serde(default = "ask_default_k_raw")]
837    pub k_raw: u32,
838    #[serde(default = "ask_default_esc")]
839    pub escalation_threshold: f64,
840    #[serde(default = "ask_default_mmr")]
841    pub mmr_threshold: f64,
842    #[serde(default = "ask_default_max_ctx")]
843    pub max_context_tokens: u32,
844    #[serde(default = "ask_default_resp_tok")]
845    pub response_tokens: u32,
846    #[serde(default = "ask_default_timeout")]
847    pub timeout_secs: u32,
848    #[serde(default = "ask_default_min_score")]
849    pub min_score: f64,
850    #[serde(default = "ask_default_continue_history_turns")]
851    pub continue_history_turns: u32,
852    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
853    /// Rewriter output is small (~80 tokens) and falling back to the raw
854    /// question on failure is non-fatal, so we don't want to burn the full
855    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
856    /// the user sees any response.
857    #[serde(default = "ask_default_rewriter_timeout")]
858    pub rewriter_timeout_secs: u32,
859    #[serde(default = "ask_default_compress_hits_enabled")]
860    pub compress_hits_enabled: bool,
861    #[serde(default = "ask_default_summarize_hits_enabled")]
862    pub summarize_hits_enabled: bool,
863    #[serde(default)]
864    pub summarize_model: Option<String>,
865    /// Per-stage backend override for the answer-generation model.
866    /// None = inherit the smart slot (`config.llm`).
867    #[serde(default)]
868    pub backend: Option<BackendConfig>,
869    /// Per-stage backend override for the query rewriter.
870    /// None = inherit the answer stage's backend (`self.backend`), falling
871    /// through to the smart slot (`config.llm`) only if that is also unset.
872    #[serde(default)]
873    pub rewriter_backend: Option<BackendConfig>,
874}
875
876impl AskConfig {
877    /// Effective backend for answer generation. An explicit per-stage
878    /// override wins; otherwise the stage inherits the smart slot
879    /// (`config.llm`) with this stage's own timeout baked in, so a slow
880    /// backend cannot silently fall back to the factory's 120s default.
881    pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
882        self.backend.clone().unwrap_or_else(|| BackendConfig {
883            timeout_secs: Some(self.timeout_secs as u64),
884            ..llm.to_backend_config()
885        })
886    }
887
888    /// Effective backend for the query rewriter. An explicit
889    /// `rewriter_backend` override wins outright. Otherwise the rewriter
890    /// follows the answer stage's backend (`self.backend`), and only falls
891    /// through to the smart slot (`llm`) when the answer stage has no
892    /// override either — the rewriter is not an independent pinning point,
893    /// only an independent timeout. Whichever source it resolves from, it
894    /// always keeps its own much tighter `rewriter_timeout_secs` budget:
895    /// the rewriter's output is small and falling back to the raw question
896    /// on timeout is non-fatal.
897    pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
898        self.rewriter_backend
899            .clone()
900            .unwrap_or_else(|| BackendConfig {
901                timeout_secs: Some(self.rewriter_timeout_secs as u64),
902                ..self
903                    .backend
904                    .clone()
905                    .unwrap_or_else(|| llm.to_backend_config())
906            })
907    }
908}
909
910impl Default for AskConfig {
911    fn default() -> Self {
912        Self {
913            k_summary: ask_default_k_summary(),
914            k_raw: ask_default_k_raw(),
915            escalation_threshold: ask_default_esc(),
916            mmr_threshold: ask_default_mmr(),
917            max_context_tokens: ask_default_max_ctx(),
918            response_tokens: ask_default_resp_tok(),
919            timeout_secs: ask_default_timeout(),
920            min_score: ask_default_min_score(),
921            continue_history_turns: ask_default_continue_history_turns(),
922            rewriter_timeout_secs: ask_default_rewriter_timeout(),
923            compress_hits_enabled: ask_default_compress_hits_enabled(),
924            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
925            summarize_model: None,
926            backend: None,
927            rewriter_backend: None,
928        }
929    }
930}
931
932fn ask_default_k_summary() -> u32 {
933    5
934}
935fn ask_default_k_raw() -> u32 {
936    10
937}
938fn ask_default_esc() -> f64 {
939    0.5
940}
941fn ask_default_mmr() -> f64 {
942    0.88
943}
944fn ask_default_max_ctx() -> u32 {
945    6000
946}
947fn ask_default_resp_tok() -> u32 {
948    1024
949}
950fn ask_default_timeout() -> u32 {
951    120
952}
953fn ask_default_min_score() -> f64 {
954    0.35
955}
956fn ask_default_rewriter_timeout() -> u32 {
957    8
958}
959fn ask_default_continue_history_turns() -> u32 {
960    3
961}
962fn ask_default_compress_hits_enabled() -> bool {
963    true
964}
965fn ask_default_summarize_hits_enabled() -> bool {
966    true
967}
968
969// ── Conversations archive config (Task 23) ────────────────────────────────────
970
971/// Phase 1 conversations archive config (Task 23).
972///
973/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
974/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
975/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
976/// without a `conversations:` section still parses.
977#[derive(Debug, Clone, Serialize, Deserialize)]
978pub struct ConversationsConfig {
979    #[serde(default)]
980    pub enabled: bool,
981    #[serde(default = "conv_default_retention_days")]
982    pub retention_days: u32,
983    #[serde(default = "conv_default_poll_interval")]
984    pub poll_interval_secs: u64,
985    #[serde(default)]
986    pub sources: ConversationsSources,
987    #[serde(default)]
988    pub filter: ConversationsFilter,
989    #[serde(default)]
990    pub compact: CompactConfig,
991    #[serde(default)]
992    pub ask: AskConfig,
993    #[serde(default)]
994    pub rollup: RollupConfig,
995}
996
997impl Default for ConversationsConfig {
998    fn default() -> Self {
999        Self {
1000            enabled: false,
1001            retention_days: conv_default_retention_days(),
1002            poll_interval_secs: conv_default_poll_interval(),
1003            sources: ConversationsSources::default(),
1004            filter: ConversationsFilter::default(),
1005            compact: CompactConfig::default(),
1006            ask: AskConfig::default(),
1007            rollup: RollupConfig::default(),
1008        }
1009    }
1010}
1011
1012fn conv_default_retention_days() -> u32 {
1013    30
1014}
1015fn conv_default_poll_interval() -> u64 {
1016    300
1017}
1018fn conv_truthy() -> bool {
1019    true
1020}
1021fn conv_default_dedup() -> f64 {
1022    0.85
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub struct CompactConfig {
1027    #[serde(default = "conv_truthy")]
1028    pub enabled_in_daemon: bool,
1029    #[serde(default = "compact_default_max_days")]
1030    pub max_days_per_run: u32,
1031    #[serde(default = "compact_default_max_spans")]
1032    pub max_extractive_spans: u32,
1033    #[serde(default = "compact_default_max_words")]
1034    pub max_abstractive_words: u32,
1035    #[serde(default = "compact_default_chunk_tokens")]
1036    pub chunk_tokens: u32,
1037    #[serde(default = "compact_default_history_retain")]
1038    pub history_retain: u32,
1039    #[serde(default = "compact_default_cron")]
1040    pub daemon_cron: String,
1041    /// Per-stage backend override for extractive summarization.
1042    /// None = inherit the smart slot (`config.llm`).
1043    #[serde(default)]
1044    pub extractive_backend: Option<BackendConfig>,
1045    /// Per-stage backend override for abstractive summarization.
1046    /// None = inherit the smart slot (`config.llm`).
1047    #[serde(default)]
1048    pub abstractive_backend: Option<BackendConfig>,
1049}
1050
1051impl CompactConfig {
1052    /// Effective backend for the extractive stage. Override wins; otherwise
1053    /// inherit the smart slot. CompactConfig has no per-stage timeout field,
1054    /// so inheritance bakes the same conservative 120s the fabricated Ollama
1055    /// config used.
1056    pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1057        self.extractive_backend
1058            .clone()
1059            .unwrap_or_else(|| BackendConfig {
1060                timeout_secs: Some(120),
1061                ..llm.to_backend_config()
1062            })
1063    }
1064
1065    /// Effective backend for the abstractive stage. See
1066    /// `effective_extractive_backend` for the timeout rationale.
1067    pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1068        self.abstractive_backend
1069            .clone()
1070            .unwrap_or_else(|| BackendConfig {
1071                timeout_secs: Some(120),
1072                ..llm.to_backend_config()
1073            })
1074    }
1075}
1076
1077impl Default for CompactConfig {
1078    fn default() -> Self {
1079        Self {
1080            enabled_in_daemon: true,
1081            max_days_per_run: compact_default_max_days(),
1082            max_extractive_spans: compact_default_max_spans(),
1083            max_abstractive_words: compact_default_max_words(),
1084            chunk_tokens: compact_default_chunk_tokens(),
1085            history_retain: compact_default_history_retain(),
1086            daemon_cron: compact_default_cron(),
1087            extractive_backend: None,
1088            abstractive_backend: None,
1089        }
1090    }
1091}
1092
1093fn compact_default_max_days() -> u32 {
1094    7
1095}
1096fn compact_default_max_spans() -> u32 {
1097    20
1098}
1099fn compact_default_max_words() -> u32 {
1100    400
1101}
1102fn compact_default_chunk_tokens() -> u32 {
1103    6000
1104}
1105fn compact_default_history_retain() -> u32 {
1106    5
1107}
1108fn compact_default_cron() -> String {
1109    "0 0 3 * * * *".into()
1110}
1111
1112// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
1113
1114#[derive(Debug, Clone, Serialize, Deserialize)]
1115pub struct RollupConfig {
1116    #[serde(default = "rollup_default_enabled")]
1117    pub enabled: bool,
1118    #[serde(default = "rollup_default_max_weeks")]
1119    pub max_weeks_per_run: u32,
1120    #[serde(default = "rollup_default_max_months")]
1121    pub max_months_per_run: u32,
1122    #[serde(default = "rollup_default_max_spans_week")]
1123    pub max_extractive_spans_per_week: u32,
1124    #[serde(default = "rollup_default_max_words_week")]
1125    pub max_abstractive_words_per_week: u32,
1126    #[serde(default = "rollup_default_max_spans_month")]
1127    pub max_extractive_spans_per_month: u32,
1128    #[serde(default = "rollup_default_max_words_month")]
1129    pub max_abstractive_words_per_month: u32,
1130    #[serde(default = "rollup_default_week_mmr")]
1131    pub week_mmr_threshold: f64,
1132    #[serde(default = "rollup_default_month_mmr")]
1133    pub month_mmr_threshold: f64,
1134    /// Per-stage backend override for the extractive stage.
1135    /// None = inherit the smart slot (`config.llm`).
1136    #[serde(default)]
1137    pub extractive_backend: Option<BackendConfig>,
1138    /// Per-stage backend override for the abstractive stage.
1139    /// None = inherit the smart slot (`config.llm`).
1140    #[serde(default)]
1141    pub abstractive_backend: Option<BackendConfig>,
1142}
1143
1144impl Default for RollupConfig {
1145    fn default() -> Self {
1146        Self {
1147            enabled: rollup_default_enabled(),
1148            max_weeks_per_run: rollup_default_max_weeks(),
1149            max_months_per_run: rollup_default_max_months(),
1150            max_extractive_spans_per_week: rollup_default_max_spans_week(),
1151            max_abstractive_words_per_week: rollup_default_max_words_week(),
1152            max_extractive_spans_per_month: rollup_default_max_spans_month(),
1153            max_abstractive_words_per_month: rollup_default_max_words_month(),
1154            week_mmr_threshold: rollup_default_week_mmr(),
1155            month_mmr_threshold: rollup_default_month_mmr(),
1156            extractive_backend: None,
1157            abstractive_backend: None,
1158        }
1159    }
1160}
1161
1162impl RollupConfig {
1163    /// Effective backend for the extractive stage. Override wins; otherwise
1164    /// inherit the smart slot with the same 120s budget the previously
1165    /// hardcoded inline config used (`summarize/rollup.rs`).
1166    pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1167        self.extractive_backend
1168            .clone()
1169            .unwrap_or_else(|| BackendConfig {
1170                timeout_secs: Some(120),
1171                ..llm.to_backend_config()
1172            })
1173    }
1174
1175    /// Effective backend for the abstractive stage.
1176    pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1177        self.abstractive_backend
1178            .clone()
1179            .unwrap_or_else(|| BackendConfig {
1180                timeout_secs: Some(120),
1181                ..llm.to_backend_config()
1182            })
1183    }
1184}
1185
1186fn rollup_default_enabled() -> bool {
1187    true
1188}
1189fn rollup_default_max_weeks() -> u32 {
1190    4
1191}
1192fn rollup_default_max_months() -> u32 {
1193    2
1194}
1195fn rollup_default_max_spans_week() -> u32 {
1196    20
1197}
1198fn rollup_default_max_words_week() -> u32 {
1199    500
1200}
1201fn rollup_default_max_spans_month() -> u32 {
1202    20
1203}
1204fn rollup_default_max_words_month() -> u32 {
1205    700
1206}
1207fn rollup_default_week_mmr() -> f64 {
1208    0.85
1209}
1210fn rollup_default_month_mmr() -> f64 {
1211    0.82
1212}
1213
1214#[derive(Debug, Clone, Serialize, Deserialize)]
1215pub struct ConversationsSources {
1216    #[serde(default = "conv_truthy")]
1217    pub claude_code: bool,
1218    #[serde(default = "conv_truthy")]
1219    pub cursor: bool,
1220    #[serde(default = "conv_truthy")]
1221    pub gemini: bool,
1222    #[serde(default)]
1223    pub aider: AiderSourceConfig,
1224}
1225
1226impl Default for ConversationsSources {
1227    fn default() -> Self {
1228        Self {
1229            claude_code: true,
1230            cursor: true,
1231            gemini: true,
1232            aider: AiderSourceConfig::default(),
1233        }
1234    }
1235}
1236
1237#[derive(Debug, Clone, Serialize, Deserialize)]
1238pub struct AiderSourceConfig {
1239    #[serde(default = "conv_truthy")]
1240    pub enabled: bool,
1241    #[serde(default)]
1242    pub watched_dirs: Vec<String>,
1243}
1244
1245impl Default for AiderSourceConfig {
1246    fn default() -> Self {
1247        Self {
1248            enabled: true,
1249            watched_dirs: Vec::new(),
1250        }
1251    }
1252}
1253
1254#[derive(Debug, Clone, Serialize, Deserialize)]
1255pub struct ConversationsFilter {
1256    #[serde(default = "conv_default_dedup")]
1257    pub dedup_threshold: f64,
1258    #[serde(default = "conv_truthy")]
1259    pub reject_heartbeat: bool,
1260    #[serde(default = "conv_truthy")]
1261    pub reject_system_restatement: bool,
1262}
1263
1264impl Default for ConversationsFilter {
1265    fn default() -> Self {
1266        Self {
1267            dedup_threshold: conv_default_dedup(),
1268            reject_heartbeat: true,
1269            reject_system_restatement: true,
1270        }
1271    }
1272}
1273
1274#[cfg(test)]
1275mod conversations_tests {
1276    use super::*;
1277
1278    #[test]
1279    fn conversations_section_defaults() {
1280        let c = ConversationsConfig::default();
1281        assert!(!c.enabled);
1282        assert_eq!(c.retention_days, 30);
1283        assert_eq!(c.poll_interval_secs, 300);
1284        assert!(c.sources.claude_code);
1285        assert!(c.sources.cursor);
1286        assert!(c.sources.gemini);
1287        assert!(c.sources.aider.enabled);
1288        assert!(c.sources.aider.watched_dirs.is_empty());
1289        assert_eq!(c.filter.dedup_threshold, 0.85);
1290        assert!(c.filter.reject_heartbeat);
1291        assert!(c.filter.reject_system_restatement);
1292    }
1293
1294    #[test]
1295    fn parse_from_yaml_with_overrides() {
1296        let y = r#"
1297conversations:
1298  enabled: true
1299  retention_days: 45
1300  poll_interval_secs: 120
1301  sources:
1302    cursor: false
1303    aider:
1304      watched_dirs: ["~/Projects/a", "~/Projects/b"]
1305  filter:
1306    dedup_threshold: 0.9
1307"#;
1308        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1309        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1310        assert!(conv.enabled);
1311        assert_eq!(conv.retention_days, 45);
1312        assert_eq!(conv.poll_interval_secs, 120);
1313        assert!(conv.sources.claude_code); // defaulted true
1314        assert!(!conv.sources.cursor); // override
1315        assert!(conv.sources.gemini); // defaulted true
1316        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1317        assert_eq!(conv.filter.dedup_threshold, 0.9);
1318        assert!(conv.filter.reject_heartbeat); // defaulted true
1319    }
1320
1321    #[test]
1322    fn missing_conversations_section_is_fine() {
1323        let y = r#"
1324# No conversations section at all
1325foo: bar
1326"#;
1327        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1328        // Default when absent
1329        let conv: ConversationsConfig = v
1330            .get("conversations")
1331            .cloned()
1332            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1333            .unwrap_or_default();
1334        assert_eq!(conv.retention_days, 30);
1335    }
1336
1337    #[test]
1338    fn compact_config_defaults() {
1339        let c = CompactConfig::default();
1340        assert!(c.enabled_in_daemon);
1341        assert_eq!(c.max_days_per_run, 7);
1342        assert_eq!(c.max_extractive_spans, 20);
1343        assert_eq!(c.chunk_tokens, 6000);
1344        assert_eq!(c.history_retain, 5);
1345        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1346    }
1347
1348    #[test]
1349    fn compact_parses_partial_overrides() {
1350        let y = r#"
1351conversations:
1352  compact:
1353    max_days_per_run: 3
1354    extractive_backend:
1355      provider: anthropic
1356      model: claude-haiku-4-5
1357"#;
1358        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1359        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1360        assert_eq!(conv.compact.max_days_per_run, 3);
1361        assert_eq!(
1362            conv.compact.extractive_backend.as_ref().unwrap().model,
1363            "claude-haiku-4-5"
1364        );
1365        assert!(conv.compact.enabled_in_daemon); // default preserved
1366        assert!(conv.compact.abstractive_backend.is_none()); // default preserved
1367    }
1368
1369    #[test]
1370    fn ask_config_defaults() {
1371        let c = AskConfig::default();
1372        assert_eq!(c.k_raw, 10);
1373        assert_eq!(c.escalation_threshold, 0.5);
1374        assert_eq!(c.mmr_threshold, 0.88);
1375        assert_eq!(c.max_context_tokens, 6000);
1376        assert_eq!(c.response_tokens, 1024);
1377        assert_eq!(c.timeout_secs, 120);
1378        assert_eq!(c.min_score, 0.35);
1379    }
1380
1381    #[test]
1382    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1383        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
1384        let c = AskConfig::default();
1385        assert!(
1386            (c.mmr_threshold - 0.88).abs() < 1e-9,
1387            "expected 0.88, got {}",
1388            c.mmr_threshold
1389        );
1390    }
1391
1392    #[test]
1393    fn rollup_config_defaults() {
1394        let c = RollupConfig::default();
1395        assert!(c.enabled);
1396        assert_eq!(c.max_weeks_per_run, 4);
1397        assert_eq!(c.max_months_per_run, 2);
1398        assert_eq!(c.max_extractive_spans_per_week, 20);
1399        assert_eq!(c.max_abstractive_words_per_week, 500);
1400        assert_eq!(c.max_extractive_spans_per_month, 20);
1401        assert_eq!(c.max_abstractive_words_per_month, 700);
1402        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1403        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1404    }
1405
1406    #[test]
1407    fn rollup_config_plumbed_into_conversations_config() {
1408        let c = ConversationsConfig::default();
1409        assert!(c.rollup.enabled);
1410    }
1411
1412    #[test]
1413    fn ask_config_default_continue_history_turns_is_3() {
1414        let c = AskConfig::default();
1415        assert_eq!(c.continue_history_turns, 3);
1416    }
1417
1418    #[test]
1419    fn ask_config_default_compress_hits_enabled_is_true() {
1420        let c = AskConfig::default();
1421        assert!(c.compress_hits_enabled);
1422    }
1423
1424    #[test]
1425    fn ask_config_default_summarize_hits_enabled_is_true() {
1426        let c = AskConfig::default();
1427        assert!(c.summarize_hits_enabled);
1428    }
1429
1430    #[test]
1431    fn ask_config_default_summarize_model_is_none() {
1432        let c = AskConfig::default();
1433        assert!(c.summarize_model.is_none());
1434    }
1435
1436    #[test]
1437    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1438        let y = r#"
1439conversations:
1440  ask:
1441    summarize_hits_enabled: false
1442    summarize_model: qwen3:4b
1443"#;
1444        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1445        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1446        assert!(!conv.ask.summarize_hits_enabled);
1447        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1448    }
1449
1450    #[test]
1451    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1452        // Phase 3.5 must be additive: an existing config.yaml with NO
1453        // summarize_* keys must still parse and default to enabled=true,
1454        // model=None.
1455        let y = r#"
1456conversations:
1457  ask:
1458    min_score: 0.4
1459"#;
1460        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1461        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1462        assert!(conv.ask.summarize_hits_enabled);
1463        assert!(conv.ask.summarize_model.is_none());
1464    }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469    #[test]
1470    fn update_defaults_apply_when_block_absent_and_fields_parse() {
1471        let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1472        assert_eq!(c.update, super::UpdateConfig::default());
1473        assert!(c.update.codesign_identity.is_none());
1474        assert!(c.update.restart_exclude.is_empty());
1475
1476        let c: super::Config = serde_yaml_ng::from_str(
1477            "update:\n  codesign_identity: \"Developer ID Application: X (TEAM)\"\n  restart_exclude: [dr_worker_1]\n",
1478        )
1479        .unwrap();
1480        assert_eq!(
1481            c.update.codesign_identity.as_deref(),
1482            Some("Developer ID Application: X (TEAM)")
1483        );
1484        assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
1485    }
1486
1487    #[test]
1488    fn federation_snapshot_defaults_apply_when_block_absent() {
1489        let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1490        assert_eq!(c.federation_snapshot.poll_secs, 30);
1491        assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1492        assert_eq!(
1493            c.federation_snapshot.min_lifecycle,
1494            crate::skill::stats::LifecycleState::Stable
1495        );
1496        // Memory capture (P2a): defaults to auto_announce; `off` parses.
1497        assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1498        let c: super::Config = serde_yaml_ng::from_str("memory:\n  capture: off\n").unwrap();
1499        assert_eq!(c.memory.capture, super::CaptureMode::Off);
1500    }
1501
1502    use super::*;
1503
1504    #[test]
1505    fn default_bundled_model_id_is_qwen35_2b() {
1506        assert_eq!(
1507            crate::config::DEFAULT_BUNDLED_MODEL_ID,
1508            "Qwen3.5-2B-MLX-4bit"
1509        );
1510    }
1511
1512    #[test]
1513    fn nudge_config_defaults() {
1514        let c = NudgeConfig::default();
1515        assert!(c.enabled);
1516        assert_eq!(c.daily_cap, 3);
1517        assert_eq!(c.snooze_days, 7);
1518        assert_eq!(c.threshold, 3);
1519    }
1520
1521    #[test]
1522    fn config_has_nudge_section_with_defaults() {
1523        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1524        assert_eq!(c.nudge.daily_cap, 3);
1525    }
1526
1527    #[test]
1528    fn storage_config_default_is_lancedb() {
1529        let c = StorageConfig::default();
1530        assert_eq!(c.vector_backend, "lancedb");
1531        assert_eq!(c.qdrant_url, None);
1532        assert_eq!(c.qdrant_api_key_ref, None);
1533    }
1534
1535    #[test]
1536    fn sources_global_config_has_sensible_defaults() {
1537        let c = SourcesGlobalConfig::default();
1538        assert_eq!(c.poll_interval_secs, 600);
1539        assert_eq!(c.max_chunks_per_sync, 10_000);
1540        assert_eq!(c.max_parallel_sources, 3);
1541        assert_eq!(c.default_weight, 1.0);
1542        assert_eq!(c.embedding_batch_size, 32);
1543    }
1544
1545    #[test]
1546    fn config_default_has_storage_and_sources_global() {
1547        let c = Config::default();
1548        assert_eq!(c.storage.vector_backend, "lancedb");
1549        assert_eq!(c.sources_global.default_weight, 1.0);
1550    }
1551
1552    #[test]
1553    fn config_loads_yaml_without_new_fields() {
1554        // Existing users' config.yaml won't mention storage or sources_global.
1555        // It must still parse.
1556        let yaml = r#"
1557embedding:
1558  provider: ollama
1559  model: test-model
1560  dimensions: 512
1561  ollama_endpoint: http://localhost:11434
1562"#;
1563        let c: Config = serde_yaml::from_str(yaml).expect("parses");
1564        assert_eq!(c.storage.vector_backend, "lancedb");
1565        assert_eq!(c.sources_global.max_parallel_sources, 3);
1566    }
1567
1568    #[test]
1569    fn llm_config_to_backend_config_anthropic_passthrough() {
1570        let cfg = LlmConfig {
1571            provider: "anthropic".into(),
1572            model: "claude-haiku-4-5".into(),
1573            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1574            api_key_ref: None,
1575            openai_url: None,
1576        };
1577        let b = cfg.to_backend_config();
1578        assert_eq!(b.provider, "anthropic");
1579        assert_eq!(b.model, "claude-haiku-4-5");
1580        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1581        assert_eq!(b.endpoint, None);
1582        assert_eq!(b.timeout_secs, None);
1583    }
1584
1585    #[test]
1586    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1587        let cfg = LlmConfig {
1588            provider: "openai".into(),
1589            model: "gpt-4o-mini".into(),
1590            api_key_env: None,
1591            api_key_ref: None,
1592            openai_url: Some("https://api.together.xyz/v1".into()),
1593        };
1594        let b = cfg.to_backend_config();
1595        assert_eq!(b.provider, "openai");
1596        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1597        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
1598    }
1599
1600    #[test]
1601    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1602        let cfg = LlmConfig {
1603            provider: "ollama".into(),
1604            model: "qwen3:14b".into(),
1605            api_key_env: None,
1606            api_key_ref: None,
1607            openai_url: Some("http://192.168.1.10:11434".into()),
1608        };
1609        let b = cfg.to_backend_config();
1610        assert_eq!(b.provider, "ollama");
1611        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1612    }
1613
1614    #[test]
1615    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1616        // Historical LlmConfig allowed provider="custom" + openai_url to act as
1617        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
1618        // "openai" so factory dispatches to OpenAIBackend.
1619        let cfg = LlmConfig {
1620            provider: "custom-name".into(),
1621            model: "some-model".into(),
1622            api_key_env: Some("CUSTOM_KEY".into()),
1623            api_key_ref: None,
1624            openai_url: Some("https://my-proxy.local/v1".into()),
1625        };
1626        let b = cfg.to_backend_config();
1627        assert_eq!(
1628            b.provider, "openai",
1629            "unknown provider + openai_url should alias to openai"
1630        );
1631        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1632    }
1633
1634    #[test]
1635    fn api_key_ref_roundtrips_and_defaults_none() {
1636        // Old YAML without the field still parses, field defaults to None.
1637        let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1638        assert_eq!(b.api_key_ref, None);
1639        let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1640        assert_eq!(l.api_key_ref, None);
1641        let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1642        assert_eq!(e.api_key_ref, None);
1643
1644        // Set → survives YAML round-trip and to_backend_config.
1645        let l2 = LlmConfig {
1646            api_key_ref: Some("keychain:mur/anthropic".into()),
1647            ..Default::default()
1648        };
1649        let y = serde_yaml_ng::to_string(&l2).unwrap();
1650        let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1651        assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1652        assert_eq!(
1653            l3.to_backend_config().api_key_ref.as_deref(),
1654            Some("keychain:mur/anthropic")
1655        );
1656    }
1657
1658    #[test]
1659    fn open_items_muted_parses_and_defaults_empty() {
1660        let c: Config = serde_yaml::from_str("open_items:\n  muted:\n    - inbox\n").unwrap();
1661        assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
1662
1663        let d: Config = serde_yaml::from_str("llm:\n  model: x\n").unwrap();
1664        assert!(d.open_items.muted.is_empty(), "must default to no mutes");
1665    }
1666
1667    /// Fail toward showing. A config that will not parse must yield an empty
1668    /// mute set, never a quiet, confident, incomplete list.
1669    #[test]
1670    fn unreadable_config_yields_no_mutes() {
1671        let tmp = tempfile::tempdir().unwrap();
1672        let path = tmp.path().join("config.yaml");
1673        std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
1674        let cfg = Config::load_or_default(&path);
1675        assert!(
1676            cfg.open_items.muted.is_empty(),
1677            "a broken config must hide nothing"
1678        );
1679
1680        // Same for a config that is simply absent.
1681        let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
1682        assert!(missing.open_items.muted.is_empty());
1683    }
1684
1685    #[test]
1686    fn rollup_config_accepts_backend_overrides() {
1687        let yaml = r#"
1688enabled: true
1689extractive_backend:
1690  provider: openai
1691  model: Qwen3.5-4B-MLX-4bit
1692  endpoint: http://127.0.0.1:8000/v1
1693"#;
1694        let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1695        let b = c.extractive_backend.expect("override present");
1696        assert_eq!(b.provider, "openai");
1697        assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
1698        assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
1699        assert!(c.abstractive_backend.is_none());
1700    }
1701
1702    #[test]
1703    fn legacy_conversation_fields_are_gone_from_serialized_output() {
1704        let cfg = Config::default();
1705        // Scoped to the conversations block intentionally: `embedding` still
1706        // carries its own `ollama_endpoint`, and asserting over the whole
1707        // document here would require that field to be omitted, which is not
1708        // the case. The Config type will never serialize without it, so this
1709        // scoping to conversations is permanent.
1710        let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
1711        for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
1712            assert!(
1713                !yaml.contains(key),
1714                "legacy key {key} still serialized:\n{yaml}"
1715            );
1716        }
1717    }
1718
1719    #[test]
1720    fn embedding_ollama_endpoint_is_omitted_when_unset() {
1721        let mut cfg = Config::default();
1722        cfg.embedding.provider = "omlx".into();
1723        cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
1724        cfg.embedding.ollama_endpoint = None;
1725        let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
1726        assert!(
1727            !yaml.contains("ollama_endpoint"),
1728            "dead field re-emitted:\n{yaml}"
1729        );
1730    }
1731
1732    #[test]
1733    fn embedding_ollama_endpoint_still_round_trips_when_set() {
1734        let yaml =
1735            "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
1736        let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1737        assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
1738    }
1739}
1740
1741#[cfg(test)]
1742mod backend_config_tests {
1743    use super::*;
1744
1745    #[test]
1746    fn default_is_ollama_qwen3() {
1747        let cfg = BackendConfig::default();
1748        assert_eq!(cfg.provider, "ollama");
1749        assert_eq!(cfg.model, "qwen3.5:4b");
1750        assert_eq!(cfg.endpoint, None);
1751        assert_eq!(cfg.api_key_env, None);
1752        assert_eq!(cfg.timeout_secs, None);
1753    }
1754
1755    #[test]
1756    fn deserializes_anthropic_full() {
1757        let yaml = "\
1758provider: anthropic
1759model: claude-haiku-4-5
1760api_key_env: ANTHROPIC_API_KEY
1761timeout_secs: 60
1762";
1763        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1764        assert_eq!(cfg.provider, "anthropic");
1765        assert_eq!(cfg.model, "claude-haiku-4-5");
1766        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1767        assert_eq!(cfg.timeout_secs, Some(60));
1768        assert_eq!(cfg.endpoint, None);
1769    }
1770
1771    #[test]
1772    fn deserializes_partial_fills_defaults() {
1773        let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
1774        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1775        assert_eq!(cfg.provider, "anthropic");
1776        assert_eq!(cfg.model, "claude-sonnet-5");
1777        assert_eq!(cfg.api_key_env, None);
1778        assert_eq!(cfg.timeout_secs, None);
1779    }
1780
1781    #[test]
1782    fn round_trips_through_yaml() {
1783        let original = BackendConfig {
1784            provider: "anthropic".into(),
1785            model: "claude-haiku-4-5".into(),
1786            endpoint: Some("https://api.anthropic.com".into()),
1787            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1788            api_key_ref: None,
1789            timeout_secs: Some(60),
1790        };
1791        let yaml = serde_yaml::to_string(&original).unwrap();
1792        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1793        assert_eq!(parsed, original);
1794    }
1795
1796    #[test]
1797    fn skills_config_curation_gate_defaults_on() {
1798        let c = SkillsConfig::default();
1799        assert!(c.require_human_curation_before_stable);
1800    }
1801}
1802
1803/// Configuration for the daemon-side sleep cycle (idle background learning).
1804///
1805/// Skill injection configuration (M2 — runtime injection).
1806///
1807/// Whether the `mur-dev` discipline hub appears in the session-start learning
1808/// index on the AI-tool (CLI hook) surface. Runtime injection for MUR agents
1809/// is never affected by this setting.
1810#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1811#[serde(rename_all = "lowercase")]
1812pub enum DevDisciplineIndex {
1813    /// Suppress the hub when a superpowers plugin install is detected (default).
1814    #[default]
1815    Auto,
1816    /// Always list the hub, even when superpowers is installed.
1817    Always,
1818    /// Never list the hub on the CLI surface.
1819    Never,
1820}
1821
1822#[derive(Debug, Clone, Serialize, Deserialize)]
1823#[serde(default)]
1824pub struct SkillsConfig {
1825    pub max_skills_in_prompt: usize,
1826    pub max_total_tokens: usize,
1827    pub priority_order: Vec<String>,
1828    pub adaptive: Option<AdaptiveSkillsConfig>,
1829
1830    /// When true (default), LLM-authored skills cannot auto-promote past
1831    /// `Emerging` until a human curates them (amendment A1). Set false to
1832    /// let LLM-extracted skills promote on run stats alone.
1833    #[serde(default = "default_require_human_curation")]
1834    pub require_human_curation_before_stable: bool,
1835
1836    /// Lifecycle scoring thresholds (W3b-P4). All fields default to the
1837    /// compile-time constants in `mur_common::skill::lifecycle` so existing
1838    /// deployments see no behaviour change without an explicit config entry.
1839    #[serde(default)]
1840    pub lifecycle: SkillLifecycleConfig,
1841
1842    /// Daily daemon auto-upgrade of origin-stamped (registry-installed)
1843    /// skills (`mur-daemon` `skill_upgrade_tick`). Non-destructive: never
1844    /// overwrites a locally-modified skill (origin hash drift blocks it).
1845    /// Defaults to `true`.
1846    #[serde(default = "default_auto_upgrade")]
1847    pub auto_upgrade: bool,
1848
1849    /// See [`DevDisciplineIndex`]. Key: `skills.dev_discipline_index`.
1850    #[serde(default)]
1851    pub dev_discipline_index: DevDisciplineIndex,
1852}
1853
1854fn default_require_human_curation() -> bool {
1855    true
1856}
1857
1858fn default_auto_upgrade() -> bool {
1859    true
1860}
1861
1862impl Default for SkillsConfig {
1863    fn default() -> Self {
1864        Self {
1865            max_skills_in_prompt: 5,
1866            max_total_tokens: 2000,
1867            priority_order: vec!["agent".into(), "global".into()],
1868            adaptive: Some(AdaptiveSkillsConfig::default()),
1869            require_human_curation_before_stable: default_require_human_curation(),
1870            lifecycle: SkillLifecycleConfig::default(),
1871            auto_upgrade: default_auto_upgrade(),
1872            dev_discipline_index: DevDisciplineIndex::default(),
1873        }
1874    }
1875}
1876
1877/// Per-skill lifecycle scoring thresholds.
1878///
1879/// Stored under `skill.lifecycle.*` in `~/.mur/config.yaml`.
1880/// All fields are optional on disk — missing keys fall back to the
1881/// compile-time defaults so a partial config is always valid.
1882#[derive(Debug, Clone, Serialize, Deserialize)]
1883#[serde(default)]
1884pub struct SkillLifecycleConfig {
1885    // ── Per-kind decay curves (memory federation P1) ─────────────────────
1886    /// Half-life multiplier for `kind=rule` notes — behavioral guidance
1887    /// iterates fast, so it decays fast.
1888    pub note_rule_half_life_factor: f64,
1889    /// Half-life multiplier for `kind=fact` notes — environment truths
1890    /// decay slowly.
1891    pub note_fact_half_life_factor: f64,
1892    // ── Promotion thresholds (must be exceeded) ──────────────────────────
1893    pub promote_draft_uses: u64,
1894    pub promote_emerging_uses: u64,
1895    pub promote_emerging_success_rate: f64,
1896    pub promote_emerging_age_days: i64,
1897    pub promote_stable_uses: u64,
1898    pub promote_stable_success_rate: f64,
1899    pub promote_stable_age_days: i64,
1900
1901    // ── Demotion thresholds (must drop below) ────────────────────────────
1902    pub demote_emerging_uses: u64,
1903    pub demote_emerging_success_rate: f64,
1904    pub demote_stable_uses: u64,
1905    pub demote_stable_success_rate: f64,
1906    pub deprecated_success_rate: f64,
1907    pub deprecated_no_success_days: i64,
1908
1909    // ── Auto-archive thresholds ───────────────────────────────────────────
1910    pub auto_archive_confidence: f64,
1911    pub auto_archive_age_days: i64,
1912
1913    // ── P4: broken fast-path ─────────────────────────────────────────────
1914    /// Number of consecutive `Execution` events with `env_class == "workflow"`
1915    /// that immediately triggers a `Deprecated` transition, bypassing the
1916    /// normal scoring path. Set to 0 to disable the fast-path.
1917    pub broken_workflow_streak: u32,
1918
1919    // ── P4: archived hard-delete ─────────────────────────────────────────
1920    /// Days a skill must remain in `Archived` state before `mur skill sweep`
1921    /// transitions it to `Destroyed` and removes its directory from disk.
1922    /// Set to 0 to disable hard-delete.
1923    pub archive_destroy_grace_days: i64,
1924}
1925
1926impl Default for SkillLifecycleConfig {
1927    fn default() -> Self {
1928        Self {
1929            promote_draft_uses: 3,
1930            promote_emerging_uses: 10,
1931            promote_emerging_success_rate: 0.6,
1932            promote_emerging_age_days: 7,
1933            promote_stable_uses: 30,
1934            promote_stable_success_rate: 0.8,
1935            promote_stable_age_days: 30,
1936            demote_emerging_uses: 8,
1937            demote_emerging_success_rate: 0.55,
1938            demote_stable_uses: 25,
1939            demote_stable_success_rate: 0.75,
1940            deprecated_success_rate: 0.3,
1941            deprecated_no_success_days: 90,
1942            auto_archive_confidence: 0.10,
1943            auto_archive_age_days: 180,
1944            broken_workflow_streak: 3,
1945            archive_destroy_grace_days: 30,
1946            note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
1947            note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
1948        }
1949    }
1950}
1951
1952#[derive(Debug, Clone, Serialize, Deserialize)]
1953#[serde(default)]
1954pub struct AdaptiveSkillsConfig {
1955    pub context_fill_decay: f64,
1956    pub min_remaining_context_ratio: f64,
1957    pub recent_fire_boost_turns: usize,
1958    /// Model max context window in tokens. Used to compute
1959    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
1960    /// Default 200_000 (Claude 3.5/4.x).
1961    pub model_max_context_tokens: u64,
1962}
1963
1964impl Default for AdaptiveSkillsConfig {
1965    fn default() -> Self {
1966        Self {
1967            context_fill_decay: 1.5,
1968            min_remaining_context_ratio: 0.20,
1969            recent_fire_boost_turns: 5,
1970            model_max_context_tokens: 200_000,
1971        }
1972    }
1973}
1974
1975/// When enabled, the daemon fires a consolidation pipeline after the user has been
1976/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
1977#[derive(Debug, Clone, Serialize, Deserialize)]
1978pub struct SleepCycleConfig {
1979    /// Master switch. False by default (opt-in).
1980    #[serde(default)]
1981    pub enabled: bool,
1982
1983    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
1984    #[serde(default = "default_idle_threshold_minutes")]
1985    pub idle_threshold_minutes: u64,
1986
1987    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
1988    #[serde(default = "default_agent_idle_minutes")]
1989    pub agent_idle_minutes: u64,
1990}
1991
1992fn default_idle_threshold_minutes() -> u64 {
1993    15
1994}
1995
1996fn default_agent_idle_minutes() -> u64 {
1997    5
1998}
1999
2000impl Default for SleepCycleConfig {
2001    fn default() -> Self {
2002        Self {
2003            enabled: false,
2004            idle_threshold_minutes: default_idle_threshold_minutes(),
2005            agent_idle_minutes: default_agent_idle_minutes(),
2006        }
2007    }
2008}
2009
2010// ── Nudge config ───────────────────────────────────────────────────
2011
2012#[derive(Debug, Clone, Serialize, Deserialize)]
2013pub struct NudgeConfig {
2014    /// Master switch. Default on — Phase 2 companion surface is live.
2015    #[serde(default = "default_nudge_enabled")]
2016    pub enabled: bool,
2017    #[serde(default = "default_nudge_daily_cap")]
2018    pub daily_cap: u32,
2019    #[serde(default = "default_nudge_snooze_days")]
2020    pub snooze_days: u32,
2021    #[serde(default = "default_nudge_threshold")]
2022    pub threshold: usize,
2023}
2024
2025fn default_nudge_enabled() -> bool {
2026    true
2027}
2028fn default_nudge_daily_cap() -> u32 {
2029    3
2030}
2031fn default_nudge_snooze_days() -> u32 {
2032    7
2033}
2034fn default_nudge_threshold() -> usize {
2035    3
2036}
2037
2038impl Default for NudgeConfig {
2039    fn default() -> Self {
2040        Self {
2041            enabled: true,
2042            daily_cap: default_nudge_daily_cap(),
2043            snooze_days: default_nudge_snooze_days(),
2044            threshold: default_nudge_threshold(),
2045        }
2046    }
2047}
2048
2049// ── Ambient capture & harvest (2026-06-11 spec) ────────────────────
2050
2051/// Ambient session capture (spec 2026-06-11-mur-ambient-capture-and-harvest §3.1).
2052#[derive(Debug, Clone, Serialize, Deserialize)]
2053pub struct SessionCfg {
2054    /// "ambient" (hooks always record) | "manual" (legacy `mur session in` gate) | "off"
2055    #[serde(default = "default_capture_mode")]
2056    pub capture: String,
2057    /// Recordings older than this many days are removed by `mur session gc`.
2058    #[serde(default = "default_retention_days")]
2059    pub retention_days: u32,
2060}
2061
2062impl Default for SessionCfg {
2063    fn default() -> Self {
2064        Self {
2065            capture: default_capture_mode(),
2066            retention_days: default_retention_days(),
2067        }
2068    }
2069}
2070
2071fn default_capture_mode() -> String {
2072    "ambient".to_string()
2073}
2074fn default_retention_days() -> u32 {
2075    14
2076}
2077
2078/// Harvest gate + token-budget defenses (spec §3.2, §3.7).
2079#[derive(Debug, Clone, Serialize, Deserialize)]
2080pub struct HarvestCfg {
2081    /// Run the heuristic gate automatically (from `mur session gc` / `mur out`).
2082    #[serde(default = "default_harvest_enabled")]
2083    pub auto_gate: bool,
2084    /// "local-first" | "cloud" | "off" — W1/W2 only persist this; LLM wiring lands with v2 P5a.
2085    #[serde(default = "default_harvest_llm")]
2086    pub llm: String,
2087    /// Gate thresholds — a session must clear at least one of these (see harvest::gate).
2088    #[serde(default = "default_min_events")]
2089    pub min_events: usize,
2090    #[serde(default = "default_min_user_turns")]
2091    pub min_user_turns: usize,
2092    #[serde(default = "default_min_duration_secs")]
2093    pub min_duration_secs: i64,
2094    /// A session is considered ended when its last event is older than this.
2095    #[serde(default = "default_idle_minutes")]
2096    pub idle_minutes: i64,
2097    /// Ceilings — past these a recording is a session, not a procedure (#781).
2098    /// A session marked with `mur in` bypasses both.
2099    #[serde(default = "default_max_steps")]
2100    pub max_steps: usize,
2101    #[serde(default = "default_max_duration_secs")]
2102    pub max_duration_secs: i64,
2103    /// §3.7 hard caps (persisted now; enforced when the LLM extract path lands in v2 P5a).
2104    #[serde(default = "default_max_llm_calls_per_day")]
2105    pub max_llm_calls_per_day: u32,
2106    #[serde(default = "default_max_extract_input_tokens")]
2107    pub max_extract_input_tokens: usize,
2108    /// §3.8 tier-1: one-line pending-proposals hint at SessionStart.
2109    #[serde(default = "default_harvest_enabled")]
2110    pub session_start_hint: bool,
2111    /// Step-skeleton Jaccard similarity at/above which a proposal becomes a merge suggestion.
2112    /// Doubles as the "same procedure?" test for the recurrence index (#783).
2113    #[serde(default = "default_similarity_merge_threshold")]
2114    pub similarity_merge_threshold: f32,
2115    /// A procedure is something done more than once (#783): a session's skeleton
2116    /// must have been seen this many times before it becomes a proposal.
2117    /// A session marked with `mur in` bypasses it.
2118    #[serde(default = "default_min_occurrences")]
2119    pub min_occurrences: usize,
2120}
2121
2122impl Default for HarvestCfg {
2123    fn default() -> Self {
2124        serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2125    }
2126}
2127
2128fn default_harvest_enabled() -> bool {
2129    true
2130}
2131fn default_harvest_llm() -> String {
2132    "local-first".to_string()
2133}
2134fn default_min_events() -> usize {
2135    5
2136}
2137fn default_min_user_turns() -> usize {
2138    2
2139}
2140fn default_min_duration_secs() -> i64 {
2141    120
2142}
2143fn default_idle_minutes() -> i64 {
2144    30
2145}
2146/// Above ~20 distinct commands a recording reads as a transcript, not a
2147/// procedure a human would write down. Measured against a real 38-proposal
2148/// inbox: everything plausible sat below it, nothing accepted sat above (#781).
2149fn default_max_steps() -> usize {
2150    20
2151}
2152/// 30 minutes. Long enough for a real deploy/release procedure including waits,
2153/// short enough to exclude debugging sessions (#781).
2154fn default_max_duration_secs() -> i64 {
2155    1800
2156}
2157fn default_max_llm_calls_per_day() -> u32 {
2158    10
2159}
2160fn default_max_extract_input_tokens() -> usize {
2161    12000
2162}
2163fn default_similarity_merge_threshold() -> f32 {
2164    0.6
2165}
2166/// Twice. The minimum that can distinguish "did it again" from "did it" — a
2167/// higher bar would silently discard real routines while the index is young (#783).
2168fn default_min_occurrences() -> usize {
2169    2
2170}
2171
2172// ── M7a: Cross-agent observability ─────────────────────────────────
2173
2174#[derive(Debug, Clone, Serialize, Deserialize)]
2175#[serde(default)]
2176pub struct CrossAgentConfig {
2177    #[serde(default = "default_half_life_days")]
2178    pub fitness_half_life_days: u32,
2179    #[serde(default = "default_fitness_floor")]
2180    pub fitness_floor: f64,
2181}
2182
2183fn default_half_life_days() -> u32 {
2184    7
2185}
2186fn default_fitness_floor() -> f64 {
2187    0.1
2188}
2189
2190impl Default for CrossAgentConfig {
2191    fn default() -> Self {
2192        Self {
2193            fitness_half_life_days: default_half_life_days(),
2194            fitness_floor: default_fitness_floor(),
2195        }
2196    }
2197}
2198
2199// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
2200
2201#[derive(Debug, Clone, Serialize, Deserialize)]
2202#[serde(default)]
2203pub struct SkillLlmConfig {
2204    /// Per-call output token cap.
2205    #[serde(default = "default_per_call_token_cap")]
2206    pub per_call_token_cap: u32,
2207
2208    /// Per-day USD cap for all maintenance LLM calls.
2209    #[serde(default = "default_per_day_usd_cap")]
2210    pub per_day_usd_cap: f64,
2211
2212    /// Cache TTL in days.
2213    #[serde(default = "default_cache_ttl_days")]
2214    pub cache_ttl_days: u32,
2215
2216    /// Optional explicit model key override. When `None`, role resolution picks.
2217    #[serde(default, skip_serializing_if = "Option::is_none")]
2218    pub model_ref: Option<String>,
2219}
2220
2221fn default_per_call_token_cap() -> u32 {
2222    1500
2223}
2224fn default_per_day_usd_cap() -> f64 {
2225    0.50
2226}
2227fn default_cache_ttl_days() -> u32 {
2228    30
2229}
2230
2231impl Default for SkillLlmConfig {
2232    fn default() -> Self {
2233        Self {
2234            per_call_token_cap: default_per_call_token_cap(),
2235            per_day_usd_cap: default_per_day_usd_cap(),
2236            cache_ttl_days: default_cache_ttl_days(),
2237            model_ref: None,
2238        }
2239    }
2240}
2241#[cfg(test)]
2242mod per_stage_backend_tests {
2243    use super::*;
2244
2245    #[test]
2246    fn compact_extractive_backend_override_parses() {
2247        let yaml = "\
2248extractive_backend:
2249  provider: anthropic
2250  model: claude-haiku-4-5
2251  api_key_env: ANTHROPIC_API_KEY
2252";
2253        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2254        let extractive = cfg
2255            .extractive_backend
2256            .as_ref()
2257            .expect("override should parse");
2258        assert_eq!(extractive.provider, "anthropic");
2259        assert_eq!(extractive.model, "claude-haiku-4-5");
2260        assert!(cfg.abstractive_backend.is_none());
2261    }
2262
2263    #[test]
2264    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2265        let yaml = "\
2266backend:
2267  provider: anthropic
2268  model: claude-sonnet-5
2269  api_key_env: ANTHROPIC_API_KEY
2270rewriter_backend:
2271  provider: ollama
2272  model: llama3.2:3b
2273";
2274        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2275        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2276        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2277    }
2278
2279    #[test]
2280    fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2281        // C1 regression test: effective_rewriter_backend must follow the
2282        // answer stage's `backend` when `rewriter_backend` is unset, NOT
2283        // fall straight through to the smart slot (`llm`) — the rewriter is
2284        // not an independent pinning point, only an independent timeout.
2285        // `llm` below uses a distinctly different provider ("omlx", which
2286        // to_backend_config() maps to "openai") than `cfg.backend`
2287        // ("anthropic"), so this test cannot pass by coincidentally landing
2288        // on the same provider from either source: if the fix regresses to
2289        // falling through to `llm`, `rewriter.provider` comes back
2290        // "openai" and the first assertion fails.
2291        let cfg = AskConfig {
2292            backend: Some(BackendConfig {
2293                provider: "anthropic".into(),
2294                model: "claude-sonnet-5".into(),
2295                endpoint: None,
2296                api_key_env: Some("ANTHROPIC_API_KEY".into()),
2297                api_key_ref: None,
2298                timeout_secs: None,
2299            }),
2300            ..Default::default()
2301        };
2302        let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2303        assert_eq!(rewriter.provider, "anthropic");
2304        assert_eq!(rewriter.model, "claude-sonnet-5");
2305        assert_eq!(
2306            rewriter.timeout_secs,
2307            Some(cfg.rewriter_timeout_secs as u64),
2308            "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2309        );
2310    }
2311
2312    #[test]
2313    fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2314        let mut cfg = AskConfig {
2315            rewriter_timeout_secs: 8,
2316            ..AskConfig::default()
2317        };
2318        cfg.rewriter_backend = Some(BackendConfig {
2319            provider: "anthropic".into(),
2320            model: "claude-haiku-4-5".into(),
2321            endpoint: None,
2322            api_key_env: Some("ANTHROPIC_API_KEY".into()),
2323            api_key_ref: None,
2324            timeout_secs: Some(30),
2325        });
2326        let b = cfg.effective_rewriter_backend(&omlx_llm());
2327        assert_eq!(
2328            b.timeout_secs,
2329            Some(30),
2330            "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2331        );
2332    }
2333
2334    fn omlx_llm() -> LlmConfig {
2335        LlmConfig {
2336            provider: "omlx".into(),
2337            model: "Qwen3.5-4B-MLX-4bit".into(),
2338            api_key_env: None,
2339            api_key_ref: Some("env:OMLX_API_KEY".into()),
2340            openai_url: Some("http://127.0.0.1:8000/v1".into()),
2341        }
2342    }
2343
2344    #[test]
2345    fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2346        let ask = AskConfig::default();
2347        let b = ask.effective_backend(&omlx_llm());
2348        assert_eq!(b.provider, "openai");
2349        assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2350        assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2351        assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2352        // stage timeout is baked in, not left to the factory's 120s default
2353        assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2354    }
2355
2356    #[test]
2357    fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2358        let ask = AskConfig::default();
2359        let b = ask.effective_rewriter_backend(&omlx_llm());
2360        assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2361        assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2362    }
2363
2364    #[test]
2365    fn explicit_override_wins_over_the_smart_slot() {
2366        let ask = AskConfig {
2367            backend: Some(BackendConfig {
2368                provider: "anthropic".into(),
2369                model: "claude-haiku-4-5".into(),
2370                endpoint: None,
2371                api_key_env: None,
2372                api_key_ref: None,
2373                timeout_secs: Some(42),
2374            }),
2375            ..Default::default()
2376        };
2377        let b = ask.effective_backend(&omlx_llm());
2378        assert_eq!(b.provider, "anthropic");
2379        assert_eq!(b.timeout_secs, Some(42));
2380    }
2381
2382    #[test]
2383    fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2384        let llm = omlx_llm();
2385        for b in [
2386            CompactConfig::default().effective_extractive_backend(&llm),
2387            CompactConfig::default().effective_abstractive_backend(&llm),
2388            RollupConfig::default().effective_extractive_backend(&llm),
2389            RollupConfig::default().effective_abstractive_backend(&llm),
2390        ] {
2391            assert_eq!(b.provider, "openai");
2392            assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2393            assert_eq!(b.timeout_secs, Some(120));
2394        }
2395    }
2396
2397    #[test]
2398    fn rollup_override_is_honored() {
2399        let r = RollupConfig {
2400            abstractive_backend: Some(BackendConfig {
2401                provider: "ollama".into(),
2402                model: "qwen3:4b".into(),
2403                endpoint: Some("http://box.local:11434".into()),
2404                api_key_env: None,
2405                api_key_ref: None,
2406                timeout_secs: None,
2407            }),
2408            ..Default::default()
2409        };
2410        let b = r.effective_abstractive_backend(&omlx_llm());
2411        assert_eq!(b.provider, "ollama");
2412        assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2413    }
2414}
2415
2416#[cfg(test)]
2417mod skills_config_tests {
2418    use super::*;
2419
2420    #[test]
2421    fn empty_yaml_hydrates_defaults() {
2422        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2423        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2424        assert_eq!(cfg.skills.max_total_tokens, 2000);
2425        assert!(cfg.skills.adaptive.is_some());
2426    }
2427
2428    #[test]
2429    fn load_or_default_missing_file_returns_default() {
2430        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2431        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2432    }
2433
2434    #[test]
2435    fn dev_discipline_index_defaults_auto_and_parses() {
2436        use crate::config::DevDisciplineIndex;
2437        let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2438        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2439        let cfg: Config =
2440            serde_yaml_ng::from_str("skills:\n  dev_discipline_index: never\n").unwrap();
2441        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2442        let cfg: Config =
2443            serde_yaml_ng::from_str("skills:\n  dev_discipline_index: always\n").unwrap();
2444        assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2445    }
2446}
2447
2448#[cfg(test)]
2449mod ambient_capture_cfg_tests {
2450    use super::*;
2451
2452    #[test]
2453    fn session_and_harvest_defaults() {
2454        let cfg: Config = serde_yaml::from_str("{}").unwrap();
2455        assert_eq!(cfg.session.capture, "ambient");
2456        assert_eq!(cfg.session.retention_days, 14);
2457        assert!(cfg.harvest.auto_gate);
2458        assert_eq!(cfg.harvest.llm, "local-first");
2459        assert_eq!(cfg.harvest.min_events, 5);
2460        assert_eq!(cfg.harvest.min_user_turns, 2);
2461        assert_eq!(cfg.harvest.min_duration_secs, 120);
2462        assert_eq!(cfg.harvest.idle_minutes, 30);
2463        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2464        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2465        assert!(cfg.harvest.session_start_hint);
2466        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2467    }
2468
2469    #[test]
2470    fn session_capture_override_parses() {
2471        let cfg: Config =
2472            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
2473        assert_eq!(cfg.session.capture, "off");
2474        assert_eq!(cfg.session.retention_days, 3);
2475    }
2476}
2477
2478#[cfg(test)]
2479mod cc_proxy_cfg_tests {
2480    use super::*;
2481
2482    #[test]
2483    fn defaults_to_local_cc_proxy_enabled() {
2484        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2485        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2486        assert!(cfg.cc_proxy.enabled);
2487    }
2488
2489    #[test]
2490    fn url_and_enabled_override_parse() {
2491        let cfg: Config =
2492            serde_yaml_ng::from_str("cc_proxy:\n  url: http://127.0.0.1:9999\n  enabled: false\n")
2493                .unwrap();
2494        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2495        assert!(!cfg.cc_proxy.enabled);
2496    }
2497
2498    #[test]
2499    fn partial_section_keeps_other_default() {
2500        // Only `enabled` given → url stays at the default.
2501        let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n  enabled: false\n").unwrap();
2502        assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2503        assert!(!cfg.cc_proxy.enabled);
2504    }
2505}
2506
2507#[cfg(test)]
2508mod model_switch_config_tests {
2509    use super::*;
2510
2511    #[test]
2512    fn model_switch_config_defaults_and_omitted_block() {
2513        // Omitted `models:` block deserializes to defaults.
2514        let cfg: Config = serde_yaml::from_str("{}").unwrap();
2515        assert_eq!(cfg.models.default, None);
2516        assert!(cfg.models.fallback_chain.is_empty());
2517        assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2518        assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2519        assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2520        assert!(!cfg.models.routing.enabled);
2521
2522        // A populated block round-trips.
2523        let yaml = "models:\n  default: claude_sonnet\n  fallback_chain: [claude_sonnet, deepseek_v4_pro]\n  routing:\n    enabled: true\n    cheap: deepseek_v4_flash\n    frontier: claude_opus\n    threshold_input_tokens: 1500\n";
2524        let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2525        assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2526        assert_eq!(
2527            cfg.models.fallback_chain,
2528            vec!["claude_sonnet", "deepseek_v4_pro"]
2529        );
2530        assert!(cfg.models.routing.enabled);
2531        assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2532    }
2533
2534    #[test]
2535    fn smart_config_defaults_on_with_autopick() {
2536        let cfg: Config = serde_yaml::from_str("{}").unwrap();
2537        assert!(cfg.models.smart.enabled); // default ON
2538        assert_eq!(cfg.models.smart.cheap, None); // auto-pick
2539        assert_eq!(
2540            cfg.models.smart.max_escalations,
2541            DEFAULT_SMART_MAX_ESCALATIONS
2542        );
2543    }
2544}