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