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