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