Skip to main content

mur_common/
config.rs

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