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