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