Skip to main content

mur_common/
config.rs

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