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
12/// Global MUR configuration (~/.mur/config.yaml)
13#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct Config {
15    #[serde(default)]
16    pub embedding: EmbeddingConfig,
17
18    #[serde(default)]
19    pub llm: LlmConfig,
20
21    #[serde(default)]
22    pub retrieval: RetrievalConfig,
23
24    #[serde(default)]
25    pub paths: PathConfig,
26
27    #[serde(default)]
28    pub server: ServerConfig,
29
30    #[serde(default)]
31    pub community: CommunityConfig,
32
33    #[serde(default)]
34    pub conversations: ConversationsConfig,
35
36    #[serde(default)]
37    pub sync: SyncConfig,
38
39    // --- P1.1 additions ---
40    #[serde(default)]
41    pub storage: StorageConfig,
42
43    #[serde(default)]
44    pub sources_global: SourcesGlobalConfig,
45
46    // --- E3 additions ---
47    #[serde(default)]
48    pub sleep_cycle: SleepCycleConfig,
49
50    // --- M2 additions ---
51    #[serde(default)]
52    pub skills: SkillsConfig,
53
54    // --- M6c additions ---
55    #[serde(default)]
56    pub skill_llm: SkillLlmConfig,
57
58    // --- M7a additions ---
59    #[serde(default)]
60    pub cross_agent: CrossAgentConfig,
61
62    // --- nudge additions ---
63    #[serde(default)]
64    pub nudge: NudgeConfig,
65
66    // --- mobile P4 additions ---
67    #[serde(default)]
68    pub mobile_relay: MobileRelayConfig,
69
70    // --- Ambient capture & harvest (2026-06-11 spec) ---
71    #[serde(default)]
72    pub session: SessionCfg,
73
74    #[serde(default)]
75    pub harvest: HarvestCfg,
76}
77
78/// Configuration for the mobile relay (P4).
79/// Stored in ~/.mur/config.yaml under the `mobile_relay:` key.
80#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct MobileRelayConfig {
82    /// Base URL of the mur-server relay, e.g. "wss://relay.mur.run".
83    /// Leave blank to disable relay forwarding on the Mac daemon side.
84    #[serde(default, skip_serializing_if = "Option::is_none")]
85    pub relay_url: Option<String>,
86
87    /// API key or JWT used by the Mac daemon to authenticate with the relay.
88    /// The value is typically a `mur_...` API key from app.mur.run.
89    #[serde(default, skip_serializing_if = "Option::is_none")]
90    pub api_key: Option<String>,
91}
92
93impl Config {
94    /// Read from disk, falling back to defaults.
95    pub fn load_or_default(path: &std::path::Path) -> Self {
96        std::fs::read_to_string(path)
97            .ok()
98            .and_then(|s| serde_yaml_ng::from_str(&s).ok())
99            .unwrap_or_default()
100    }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104pub struct SyncConfig {
105    /// Sync method: "cloud", "git", or "local"
106    #[serde(default = "default_sync_method")]
107    pub method: String,
108
109    /// Git remote URL for git sync
110    #[serde(default, skip_serializing_if = "Option::is_none")]
111    pub git_remote: Option<String>,
112
113    /// Auto-sync on context pull / session stop
114    #[serde(default)]
115    pub auto: bool,
116
117    /// Default team ID for cloud sync (set on first successful sync)
118    #[serde(default, skip_serializing_if = "Option::is_none")]
119    pub team_id: Option<String>,
120}
121
122fn default_sync_method() -> String {
123    "local".to_string()
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ServerConfig {
128    /// Server URL (default: https://mur-server.fly.dev)
129    #[serde(default = "default_server_url")]
130    pub url: String,
131}
132
133impl Default for ServerConfig {
134    fn default() -> Self {
135        Self {
136            url: default_server_url(),
137        }
138    }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct CommunityConfig {
143    /// Whether community pattern sharing is enabled
144    #[serde(default)]
145    pub enabled: bool,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct EmbeddingConfig {
150    /// "ollama", "openai", "gemini", or "anthropic"
151    #[serde(default = "default_embedding_provider")]
152    pub provider: String,
153
154    /// Model name (e.g. "nomic-embed-text", "text-embedding-3-small")
155    #[serde(default = "default_embedding_model")]
156    pub model: String,
157
158    /// Vector dimensions (fixed after first index build)
159    #[serde(default = "default_dimensions")]
160    pub dimensions: usize,
161
162    /// Ollama endpoint
163    #[serde(default = "default_ollama_endpoint")]
164    pub ollama_endpoint: String,
165
166    /// API key env var name (e.g. "OPENAI_API_KEY")
167    #[serde(default, skip_serializing_if = "Option::is_none")]
168    pub api_key_env: Option<String>,
169
170    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
171    #[serde(default, skip_serializing_if = "Option::is_none")]
172    pub openai_url: Option<String>,
173}
174
175impl Default for EmbeddingConfig {
176    fn default() -> Self {
177        Self {
178            provider: default_embedding_provider(),
179            model: default_embedding_model(),
180            dimensions: default_dimensions(),
181            ollama_endpoint: default_ollama_endpoint(),
182            api_key_env: None,
183            openai_url: None,
184        }
185    }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct LlmConfig {
190    /// "anthropic", "openai", "gemini", or "ollama"
191    #[serde(default = "default_llm_provider")]
192    pub provider: String,
193
194    #[serde(default = "default_llm_model")]
195    pub model: String,
196
197    /// API key env var name (e.g. "ANTHROPIC_API_KEY")
198    #[serde(default, skip_serializing_if = "Option::is_none")]
199    pub api_key_env: Option<String>,
200
201    /// Custom OpenAI-compatible API URL (e.g. for OpenRouter)
202    #[serde(default, skip_serializing_if = "Option::is_none")]
203    pub openai_url: Option<String>,
204}
205
206impl Default for LlmConfig {
207    fn default() -> Self {
208        Self {
209            provider: default_llm_provider(),
210            model: default_llm_model(),
211            api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
212            openai_url: None,
213        }
214    }
215}
216
217impl LlmConfig {
218    /// Convert legacy LlmConfig (used by extract_llm, learn, capture/starter)
219    /// into a BackendConfig that the new ChatBackend factory consumes.
220    /// Mapping:
221    /// - `provider` 1:1, except: unknown providers WITH openai_url become "openai"
222    ///   (preserves the historical LlmConfig::llm_complete fall-through for
223    ///   OpenAI-compatible passthrough proxies).
224    /// - `model` 1:1.
225    /// - `api_key_env` 1:1 (factory's resolve_api_key falls back to
226    ///   default_key_env(provider) when None — preserves LlmConfig behavior).
227    /// - `openai_url` → `endpoint` (semantic rename; same string semantics).
228    /// - `timeout_secs` always None (factory defaults to 120s — matches
229    ///   the historical 60s reqwest default behavior closely enough).
230    pub fn to_backend_config(&self) -> BackendConfig {
231        let provider = match self.provider.as_str() {
232            "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
233            _ if self.openai_url.is_some() => "openai".into(),
234            other => other.into(), // factory will reject with "unsupported provider"
235        };
236        BackendConfig {
237            provider,
238            model: self.model.clone(),
239            endpoint: self.openai_url.clone(),
240            api_key_env: self.api_key_env.clone(),
241            timeout_secs: None,
242        }
243    }
244}
245
246/// Backend selection for a single chat-completion call site.
247///
248/// Per spec §6 of cloud-LLM-backend design. Used by `CompactConfig`
249/// (per-stage) and `AskConfig` (per-stage) to override the legacy
250/// Ollama-only path. None of the `Option` fields are required;
251/// resolution falls back to provider defaults
252/// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
253///
254/// Stays in mur-common (not mur-core) because it is pure data and
255/// will be reused by mur-agent-runtime in a future phase.
256#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[serde(default)]
258pub struct BackendConfig {
259    /// "ollama" | "anthropic". Defaults to "ollama" for backward compat.
260    pub provider: String,
261    /// Model name as the provider sees it ("claude-haiku-4-5", "qwen3:4b", …).
262    pub model: String,
263    /// Provider endpoint. None = provider default
264    /// (ollama: http://localhost:11434, anthropic: https://api.anthropic.com).
265    pub endpoint: Option<String>,
266    /// Env var holding the API key. None = no auth (ollama).
267    pub api_key_env: Option<String>,
268    /// Per-call timeout in seconds. None = 120s.
269    pub timeout_secs: Option<u64>,
270}
271
272impl Default for BackendConfig {
273    fn default() -> Self {
274        Self {
275            provider: "ollama".into(),
276            model: DEFAULT_LOCAL_LLM_MODEL.into(),
277            endpoint: None,
278            api_key_env: None,
279            timeout_secs: None,
280        }
281    }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct RetrievalConfig {
286    /// Max patterns to inject per query
287    #[serde(default = "default_max_patterns")]
288    pub max_patterns: usize,
289
290    /// Max tokens for injected content
291    #[serde(default = "default_max_tokens")]
292    pub max_tokens: usize,
293
294    /// Minimum score threshold
295    #[serde(default = "default_min_score")]
296    pub min_score: f64,
297
298    /// MMR diversity threshold (cosine > this = too similar)
299    #[serde(default = "default_mmr_threshold")]
300    pub mmr_threshold: f64,
301}
302
303impl Default for RetrievalConfig {
304    fn default() -> Self {
305        Self {
306            max_patterns: default_max_patterns(),
307            max_tokens: default_max_tokens(),
308            min_score: default_min_score(),
309            mmr_threshold: default_mmr_threshold(),
310        }
311    }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct PathConfig {
316    /// Root MUR directory (default: ~/.mur)
317    #[serde(default = "default_mur_dir")]
318    pub mur_dir: PathBuf,
319}
320
321impl Default for PathConfig {
322    fn default() -> Self {
323        Self {
324            mur_dir: default_mur_dir(),
325        }
326    }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct StorageConfig {
331    /// Vector backend identifier: "lancedb" (default) or "qdrant".
332    #[serde(default = "default_vector_backend")]
333    pub vector_backend: String,
334
335    /// Qdrant connection URL (only used when vector_backend = "qdrant").
336    #[serde(default, skip_serializing_if = "Option::is_none")]
337    pub qdrant_url: Option<String>,
338
339    /// Keyring account name holding the Qdrant API key, if any.
340    #[serde(default, skip_serializing_if = "Option::is_none")]
341    pub qdrant_api_key_ref: Option<String>,
342}
343
344impl Default for StorageConfig {
345    fn default() -> Self {
346        Self {
347            vector_backend: default_vector_backend(),
348            qdrant_url: None,
349            qdrant_api_key_ref: None,
350        }
351    }
352}
353
354fn default_vector_backend() -> String {
355    "lancedb".to_string()
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct SourcesGlobalConfig {
360    /// Polling interval for cloud sources (seconds).
361    #[serde(default = "default_poll_interval_secs")]
362    pub poll_interval_secs: u64,
363
364    /// Safety cap: do not sync more than this many chunks per run.
365    #[serde(default = "default_max_chunks_per_sync")]
366    pub max_chunks_per_sync: usize,
367
368    /// Upper bound on parallel source sync tasks.
369    #[serde(default = "default_max_parallel_sources")]
370    pub max_parallel_sources: usize,
371
372    /// Weight applied to new sources unless overridden.
373    #[serde(default = "default_source_weight")]
374    pub default_weight: f32,
375
376    /// Embedding request batch size.
377    #[serde(default = "default_embedding_batch_size")]
378    pub embedding_batch_size: usize,
379}
380
381impl Default for SourcesGlobalConfig {
382    fn default() -> Self {
383        Self {
384            poll_interval_secs: default_poll_interval_secs(),
385            max_chunks_per_sync: default_max_chunks_per_sync(),
386            max_parallel_sources: default_max_parallel_sources(),
387            default_weight: default_source_weight(),
388            embedding_batch_size: default_embedding_batch_size(),
389        }
390    }
391}
392
393fn default_poll_interval_secs() -> u64 {
394    600
395}
396fn default_max_chunks_per_sync() -> usize {
397    10_000
398}
399fn default_max_parallel_sources() -> usize {
400    3
401}
402fn default_source_weight() -> f32 {
403    1.0
404}
405fn default_embedding_batch_size() -> usize {
406    32
407}
408
409fn default_embedding_provider() -> String {
410    "ollama".to_string()
411}
412fn default_embedding_model() -> String {
413    "qwen3-embedding:0.6b".to_string()
414}
415fn default_dimensions() -> usize {
416    1024
417}
418fn default_ollama_endpoint() -> String {
419    "http://localhost:11434".to_string()
420}
421fn default_llm_provider() -> String {
422    "anthropic".to_string()
423}
424fn default_llm_model() -> String {
425    "claude-opus-4-6".to_string()
426}
427fn default_max_patterns() -> usize {
428    5
429}
430fn default_max_tokens() -> usize {
431    2000
432}
433fn default_min_score() -> f64 {
434    0.35
435}
436fn default_mmr_threshold() -> f64 {
437    0.85
438}
439fn default_mur_dir() -> PathBuf {
440    // Use HOME env var directly to avoid the `dirs` dependency in mur-common.
441    // Callers in mur-core that need the real home dir should use `dirs` there.
442    let home = std::env::var("HOME")
443        .map(PathBuf::from)
444        .unwrap_or_else(|_| PathBuf::from("/tmp"));
445    home.join(".mur")
446}
447fn default_server_url() -> String {
448    "https://mur-server.fly.dev".to_string()
449}
450
451// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
452
453#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct AskConfig {
455    #[serde(default = "ask_default_model")]
456    pub model: String,
457    #[serde(default = "compact_default_ollama_endpoint")]
458    pub ollama_endpoint: String,
459    #[serde(default = "ask_default_k_summary")]
460    pub k_summary: u32,
461    #[serde(default = "ask_default_k_raw")]
462    pub k_raw: u32,
463    #[serde(default = "ask_default_esc")]
464    pub escalation_threshold: f64,
465    #[serde(default = "ask_default_mmr")]
466    pub mmr_threshold: f64,
467    #[serde(default = "ask_default_max_ctx")]
468    pub max_context_tokens: u32,
469    #[serde(default = "ask_default_resp_tok")]
470    pub response_tokens: u32,
471    #[serde(default = "ask_default_timeout")]
472    pub timeout_secs: u32,
473    #[serde(default = "ask_default_min_score")]
474    pub min_score: f64,
475    #[serde(default = "ask_default_continue_history_turns")]
476    pub continue_history_turns: u32,
477    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
478    /// Rewriter output is small (~80 tokens) and falling back to the raw
479    /// question on failure is non-fatal, so we don't want to burn the full
480    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
481    /// the user sees any response.
482    #[serde(default = "ask_default_rewriter_timeout")]
483    pub rewriter_timeout_secs: u32,
484    #[serde(default = "ask_default_compress_hits_enabled")]
485    pub compress_hits_enabled: bool,
486    #[serde(default = "ask_default_summarize_hits_enabled")]
487    pub summarize_hits_enabled: bool,
488    #[serde(default)]
489    pub summarize_model: Option<String>,
490    /// Per-stage backend override for the answer-generation model.
491    /// None = synthesize from legacy `model` + `ollama_endpoint`.
492    #[serde(default)]
493    pub backend: Option<BackendConfig>,
494    /// Per-stage backend override for the query rewriter.
495    /// None = synthesize an Ollama BackendConfig over the legacy `model` +
496    /// `ollama_endpoint` with `rewriter_timeout_secs` baked in.
497    #[serde(default)]
498    pub rewriter_backend: Option<BackendConfig>,
499}
500
501impl AskConfig {
502    /// Returns the effective backend for the answer-generation model.
503    /// Per-stage `backend` override wins; otherwise synthesize from legacy
504    /// fields (`model`, `ollama_endpoint`) into an Ollama BackendConfig.
505    ///
506    /// `timeout_secs` is baked from `self.timeout_secs` so the answer call
507    /// inherits the user's per-call budget (rather than factory's 120s
508    /// default). When the user supplied an explicit `backend` override
509    /// with its own `timeout_secs`, that wins — we only synthesize when
510    /// `self.backend` is None.
511    pub fn synthesize_backend(&self) -> BackendConfig {
512        self.backend.clone().unwrap_or_else(|| BackendConfig {
513            provider: "ollama".into(),
514            model: self.model.clone(),
515            endpoint: Some(self.ollama_endpoint.clone()),
516            api_key_env: None,
517            timeout_secs: Some(self.timeout_secs as u64),
518        })
519    }
520
521    /// Returns the effective backend for the query rewriter.
522    ///
523    /// When `self.rewriter_backend` is None, this synthesizes its OWN
524    /// Ollama BackendConfig with `self.rewriter_timeout_secs` baked in —
525    /// it does NOT fall through to `synthesize_backend()`. The rewriter
526    /// has a much tighter latency budget than the answer call (rewriter
527    /// output is small and falling back to the raw question on timeout
528    /// is non-fatal), so we don't want a slow Ollama burning the full
529    /// `timeout_secs` budget before the user sees any response.
530    pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
531        self.rewriter_backend
532            .clone()
533            .unwrap_or_else(|| BackendConfig {
534                provider: "ollama".into(),
535                model: self.model.clone(),
536                endpoint: Some(self.ollama_endpoint.clone()),
537                api_key_env: None,
538                timeout_secs: Some(self.rewriter_timeout_secs as u64),
539            })
540    }
541}
542
543impl Default for AskConfig {
544    fn default() -> Self {
545        Self {
546            model: ask_default_model(),
547            ollama_endpoint: compact_default_ollama_endpoint(),
548            k_summary: ask_default_k_summary(),
549            k_raw: ask_default_k_raw(),
550            escalation_threshold: ask_default_esc(),
551            mmr_threshold: ask_default_mmr(),
552            max_context_tokens: ask_default_max_ctx(),
553            response_tokens: ask_default_resp_tok(),
554            timeout_secs: ask_default_timeout(),
555            min_score: ask_default_min_score(),
556            continue_history_turns: ask_default_continue_history_turns(),
557            rewriter_timeout_secs: ask_default_rewriter_timeout(),
558            compress_hits_enabled: ask_default_compress_hits_enabled(),
559            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
560            summarize_model: None,
561            backend: None,
562            rewriter_backend: None,
563        }
564    }
565}
566
567fn ask_default_model() -> String {
568    DEFAULT_LOCAL_LLM_MODEL.into()
569}
570fn ask_default_k_summary() -> u32 {
571    5
572}
573fn ask_default_k_raw() -> u32 {
574    10
575}
576fn ask_default_esc() -> f64 {
577    0.5
578}
579fn ask_default_mmr() -> f64 {
580    0.88
581}
582fn ask_default_max_ctx() -> u32 {
583    6000
584}
585fn ask_default_resp_tok() -> u32 {
586    1024
587}
588fn ask_default_timeout() -> u32 {
589    120
590}
591fn ask_default_min_score() -> f64 {
592    0.35
593}
594fn ask_default_rewriter_timeout() -> u32 {
595    8
596}
597fn ask_default_continue_history_turns() -> u32 {
598    3
599}
600fn ask_default_compress_hits_enabled() -> bool {
601    true
602}
603fn ask_default_summarize_hits_enabled() -> bool {
604    true
605}
606
607// ── Conversations archive config (Task 23) ────────────────────────────────────
608
609/// Phase 1 conversations archive config (Task 23).
610///
611/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
612/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
613/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
614/// without a `conversations:` section still parses.
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct ConversationsConfig {
617    #[serde(default)]
618    pub enabled: bool,
619    #[serde(default = "conv_default_retention_days")]
620    pub retention_days: u32,
621    #[serde(default = "conv_default_poll_interval")]
622    pub poll_interval_secs: u64,
623    #[serde(default)]
624    pub sources: ConversationsSources,
625    #[serde(default)]
626    pub filter: ConversationsFilter,
627    #[serde(default)]
628    pub compact: CompactConfig,
629    #[serde(default)]
630    pub ask: AskConfig,
631    #[serde(default)]
632    pub rollup: RollupConfig,
633}
634
635impl Default for ConversationsConfig {
636    fn default() -> Self {
637        Self {
638            enabled: false,
639            retention_days: conv_default_retention_days(),
640            poll_interval_secs: conv_default_poll_interval(),
641            sources: ConversationsSources::default(),
642            filter: ConversationsFilter::default(),
643            compact: CompactConfig::default(),
644            ask: AskConfig::default(),
645            rollup: RollupConfig::default(),
646        }
647    }
648}
649
650fn conv_default_retention_days() -> u32 {
651    30
652}
653fn conv_default_poll_interval() -> u64 {
654    300
655}
656fn conv_truthy() -> bool {
657    true
658}
659fn conv_default_dedup() -> f64 {
660    0.85
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct CompactConfig {
665    #[serde(default = "conv_truthy")]
666    pub enabled_in_daemon: bool,
667    #[serde(default = "compact_default_max_days")]
668    pub max_days_per_run: u32,
669    #[serde(default = "compact_default_model")]
670    pub extractive_model: String,
671    #[serde(default = "compact_default_model")]
672    pub abstractive_model: String,
673    #[serde(default = "compact_default_ollama_endpoint")]
674    pub ollama_endpoint: String,
675    #[serde(default = "compact_default_max_spans")]
676    pub max_extractive_spans: u32,
677    #[serde(default = "compact_default_max_words")]
678    pub max_abstractive_words: u32,
679    #[serde(default = "compact_default_chunk_tokens")]
680    pub chunk_tokens: u32,
681    #[serde(default = "compact_default_history_retain")]
682    pub history_retain: u32,
683    #[serde(default = "compact_default_cron")]
684    pub daemon_cron: String,
685    /// Per-stage backend override for extractive summarization.
686    /// None = synthesize from legacy `extractive_model` + `ollama_endpoint`.
687    #[serde(default)]
688    pub extractive_backend: Option<BackendConfig>,
689    /// Per-stage backend override for abstractive summarization.
690    /// None = synthesize from legacy `abstractive_model` + `ollama_endpoint`.
691    #[serde(default)]
692    pub abstractive_backend: Option<BackendConfig>,
693}
694
695impl CompactConfig {
696    /// Returns the effective backend for the extractive stage.
697    /// Per-stage `extractive_backend` override wins; otherwise synthesize
698    /// from legacy fields into an Ollama BackendConfig.
699    ///
700    /// CompactConfig has no per-stage timeout field, so synthesis bakes
701    /// the conservative 120s default — matching the previously-hardcoded
702    /// `Duration::from_secs(120)` at the call sites (byte-identical to
703    /// the pre-trait OllamaClient construction).
704    pub fn synthesize_extractive_backend(&self) -> BackendConfig {
705        self.extractive_backend
706            .clone()
707            .unwrap_or_else(|| BackendConfig {
708                provider: "ollama".into(),
709                model: self.extractive_model.clone(),
710                endpoint: Some(self.ollama_endpoint.clone()),
711                api_key_env: None,
712                timeout_secs: Some(120),
713            })
714    }
715
716    /// Returns the effective backend for the abstractive stage.
717    /// See `synthesize_extractive_backend` for the timeout rationale.
718    pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
719        self.abstractive_backend
720            .clone()
721            .unwrap_or_else(|| BackendConfig {
722                provider: "ollama".into(),
723                model: self.abstractive_model.clone(),
724                endpoint: Some(self.ollama_endpoint.clone()),
725                api_key_env: None,
726                timeout_secs: Some(120),
727            })
728    }
729}
730
731impl Default for CompactConfig {
732    fn default() -> Self {
733        Self {
734            enabled_in_daemon: true,
735            max_days_per_run: compact_default_max_days(),
736            extractive_model: compact_default_model(),
737            abstractive_model: compact_default_model(),
738            ollama_endpoint: compact_default_ollama_endpoint(),
739            max_extractive_spans: compact_default_max_spans(),
740            max_abstractive_words: compact_default_max_words(),
741            chunk_tokens: compact_default_chunk_tokens(),
742            history_retain: compact_default_history_retain(),
743            daemon_cron: compact_default_cron(),
744            extractive_backend: None,
745            abstractive_backend: None,
746        }
747    }
748}
749
750fn compact_default_max_days() -> u32 {
751    7
752}
753fn compact_default_model() -> String {
754    DEFAULT_LOCAL_LLM_MODEL.into()
755}
756fn compact_default_ollama_endpoint() -> String {
757    "http://localhost:11434".into()
758}
759fn compact_default_max_spans() -> u32 {
760    20
761}
762fn compact_default_max_words() -> u32 {
763    400
764}
765fn compact_default_chunk_tokens() -> u32 {
766    6000
767}
768fn compact_default_history_retain() -> u32 {
769    5
770}
771fn compact_default_cron() -> String {
772    "0 0 3 * * * *".into()
773}
774
775// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
776
777#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct RollupConfig {
779    #[serde(default = "rollup_default_enabled")]
780    pub enabled: bool,
781    #[serde(default = "rollup_default_max_weeks")]
782    pub max_weeks_per_run: u32,
783    #[serde(default = "rollup_default_max_months")]
784    pub max_months_per_run: u32,
785    #[serde(default = "rollup_default_max_spans_week")]
786    pub max_extractive_spans_per_week: u32,
787    #[serde(default = "rollup_default_max_words_week")]
788    pub max_abstractive_words_per_week: u32,
789    #[serde(default = "rollup_default_max_spans_month")]
790    pub max_extractive_spans_per_month: u32,
791    #[serde(default = "rollup_default_max_words_month")]
792    pub max_abstractive_words_per_month: u32,
793    #[serde(default = "rollup_default_week_mmr")]
794    pub week_mmr_threshold: f64,
795    #[serde(default = "rollup_default_month_mmr")]
796    pub month_mmr_threshold: f64,
797    #[serde(default = "compact_default_model")]
798    pub extractive_model: String,
799    #[serde(default = "compact_default_model")]
800    pub abstractive_model: String,
801    #[serde(default = "compact_default_ollama_endpoint")]
802    pub ollama_endpoint: String,
803}
804
805impl Default for RollupConfig {
806    fn default() -> Self {
807        Self {
808            enabled: rollup_default_enabled(),
809            max_weeks_per_run: rollup_default_max_weeks(),
810            max_months_per_run: rollup_default_max_months(),
811            max_extractive_spans_per_week: rollup_default_max_spans_week(),
812            max_abstractive_words_per_week: rollup_default_max_words_week(),
813            max_extractive_spans_per_month: rollup_default_max_spans_month(),
814            max_abstractive_words_per_month: rollup_default_max_words_month(),
815            week_mmr_threshold: rollup_default_week_mmr(),
816            month_mmr_threshold: rollup_default_month_mmr(),
817            extractive_model: compact_default_model(),
818            abstractive_model: compact_default_model(),
819            ollama_endpoint: compact_default_ollama_endpoint(),
820        }
821    }
822}
823
824fn rollup_default_enabled() -> bool {
825    true
826}
827fn rollup_default_max_weeks() -> u32 {
828    4
829}
830fn rollup_default_max_months() -> u32 {
831    2
832}
833fn rollup_default_max_spans_week() -> u32 {
834    20
835}
836fn rollup_default_max_words_week() -> u32 {
837    500
838}
839fn rollup_default_max_spans_month() -> u32 {
840    20
841}
842fn rollup_default_max_words_month() -> u32 {
843    700
844}
845fn rollup_default_week_mmr() -> f64 {
846    0.85
847}
848fn rollup_default_month_mmr() -> f64 {
849    0.82
850}
851
852#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct ConversationsSources {
854    #[serde(default = "conv_truthy")]
855    pub claude_code: bool,
856    #[serde(default = "conv_truthy")]
857    pub cursor: bool,
858    #[serde(default = "conv_truthy")]
859    pub gemini: bool,
860    #[serde(default)]
861    pub aider: AiderSourceConfig,
862}
863
864impl Default for ConversationsSources {
865    fn default() -> Self {
866        Self {
867            claude_code: true,
868            cursor: true,
869            gemini: true,
870            aider: AiderSourceConfig::default(),
871        }
872    }
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize)]
876pub struct AiderSourceConfig {
877    #[serde(default = "conv_truthy")]
878    pub enabled: bool,
879    #[serde(default)]
880    pub watched_dirs: Vec<String>,
881}
882
883impl Default for AiderSourceConfig {
884    fn default() -> Self {
885        Self {
886            enabled: true,
887            watched_dirs: Vec::new(),
888        }
889    }
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct ConversationsFilter {
894    #[serde(default = "conv_default_dedup")]
895    pub dedup_threshold: f64,
896    #[serde(default = "conv_truthy")]
897    pub reject_heartbeat: bool,
898    #[serde(default = "conv_truthy")]
899    pub reject_system_restatement: bool,
900}
901
902impl Default for ConversationsFilter {
903    fn default() -> Self {
904        Self {
905            dedup_threshold: conv_default_dedup(),
906            reject_heartbeat: true,
907            reject_system_restatement: true,
908        }
909    }
910}
911
912#[cfg(test)]
913mod conversations_tests {
914    use super::*;
915
916    #[test]
917    fn conversations_section_defaults() {
918        let c = ConversationsConfig::default();
919        assert!(!c.enabled);
920        assert_eq!(c.retention_days, 30);
921        assert_eq!(c.poll_interval_secs, 300);
922        assert!(c.sources.claude_code);
923        assert!(c.sources.cursor);
924        assert!(c.sources.gemini);
925        assert!(c.sources.aider.enabled);
926        assert!(c.sources.aider.watched_dirs.is_empty());
927        assert_eq!(c.filter.dedup_threshold, 0.85);
928        assert!(c.filter.reject_heartbeat);
929        assert!(c.filter.reject_system_restatement);
930    }
931
932    #[test]
933    fn parse_from_yaml_with_overrides() {
934        let y = r#"
935conversations:
936  enabled: true
937  retention_days: 45
938  poll_interval_secs: 120
939  sources:
940    cursor: false
941    aider:
942      watched_dirs: ["~/Projects/a", "~/Projects/b"]
943  filter:
944    dedup_threshold: 0.9
945"#;
946        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
947        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
948        assert!(conv.enabled);
949        assert_eq!(conv.retention_days, 45);
950        assert_eq!(conv.poll_interval_secs, 120);
951        assert!(conv.sources.claude_code); // defaulted true
952        assert!(!conv.sources.cursor); // override
953        assert!(conv.sources.gemini); // defaulted true
954        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
955        assert_eq!(conv.filter.dedup_threshold, 0.9);
956        assert!(conv.filter.reject_heartbeat); // defaulted true
957    }
958
959    #[test]
960    fn missing_conversations_section_is_fine() {
961        let y = r#"
962# No conversations section at all
963foo: bar
964"#;
965        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
966        // Default when absent
967        let conv: ConversationsConfig = v
968            .get("conversations")
969            .cloned()
970            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
971            .unwrap_or_default();
972        assert_eq!(conv.retention_days, 30);
973    }
974
975    #[test]
976    fn compact_config_defaults() {
977        let c = CompactConfig::default();
978        assert!(c.enabled_in_daemon);
979        assert_eq!(c.max_days_per_run, 7);
980        assert_eq!(c.extractive_model, "qwen3.5:4b");
981        assert_eq!(c.abstractive_model, "qwen3.5:4b");
982        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
983        assert_eq!(c.max_extractive_spans, 20);
984        assert_eq!(c.chunk_tokens, 6000);
985        assert_eq!(c.history_retain, 5);
986        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
987    }
988
989    #[test]
990    fn compact_parses_partial_overrides() {
991        let y = r#"
992conversations:
993  compact:
994    max_days_per_run: 3
995    extractive_model: qwen3:4b
996"#;
997        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
998        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
999        assert_eq!(conv.compact.max_days_per_run, 3);
1000        assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1001        assert!(conv.compact.enabled_in_daemon); // default preserved
1002        assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); // default preserved
1003    }
1004
1005    #[test]
1006    fn ask_config_defaults() {
1007        let c = AskConfig::default();
1008        assert_eq!(c.model, "qwen3.5:4b");
1009        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1010        assert_eq!(c.k_raw, 10);
1011        assert_eq!(c.escalation_threshold, 0.5);
1012        assert_eq!(c.mmr_threshold, 0.88);
1013        assert_eq!(c.max_context_tokens, 6000);
1014        assert_eq!(c.response_tokens, 1024);
1015        assert_eq!(c.timeout_secs, 120);
1016        assert_eq!(c.min_score, 0.35);
1017    }
1018
1019    #[test]
1020    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1021        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
1022        let c = AskConfig::default();
1023        assert!(
1024            (c.mmr_threshold - 0.88).abs() < 1e-9,
1025            "expected 0.88, got {}",
1026            c.mmr_threshold
1027        );
1028    }
1029
1030    #[test]
1031    fn rollup_config_defaults() {
1032        let c = RollupConfig::default();
1033        assert!(c.enabled);
1034        assert_eq!(c.max_weeks_per_run, 4);
1035        assert_eq!(c.max_months_per_run, 2);
1036        assert_eq!(c.max_extractive_spans_per_week, 20);
1037        assert_eq!(c.max_abstractive_words_per_week, 500);
1038        assert_eq!(c.max_extractive_spans_per_month, 20);
1039        assert_eq!(c.max_abstractive_words_per_month, 700);
1040        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1041        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1042        assert_eq!(c.extractive_model, "qwen3.5:4b");
1043        assert_eq!(c.abstractive_model, "qwen3.5:4b");
1044        assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1045    }
1046
1047    #[test]
1048    fn rollup_config_plumbed_into_conversations_config() {
1049        let c = ConversationsConfig::default();
1050        assert!(c.rollup.enabled);
1051    }
1052
1053    #[test]
1054    fn ask_config_default_continue_history_turns_is_3() {
1055        let c = AskConfig::default();
1056        assert_eq!(c.continue_history_turns, 3);
1057    }
1058
1059    #[test]
1060    fn ask_config_default_compress_hits_enabled_is_true() {
1061        let c = AskConfig::default();
1062        assert!(c.compress_hits_enabled);
1063    }
1064
1065    #[test]
1066    fn ask_config_default_summarize_hits_enabled_is_true() {
1067        let c = AskConfig::default();
1068        assert!(c.summarize_hits_enabled);
1069    }
1070
1071    #[test]
1072    fn ask_config_default_summarize_model_is_none() {
1073        let c = AskConfig::default();
1074        assert!(c.summarize_model.is_none());
1075    }
1076
1077    #[test]
1078    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1079        let y = r#"
1080conversations:
1081  ask:
1082    summarize_hits_enabled: false
1083    summarize_model: qwen3:4b
1084"#;
1085        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1086        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1087        assert!(!conv.ask.summarize_hits_enabled);
1088        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1089    }
1090
1091    #[test]
1092    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1093        // Phase 3.5 must be additive: an existing config.yaml with NO
1094        // summarize_* keys must still parse and default to enabled=true,
1095        // model=None.
1096        let y = r#"
1097conversations:
1098  ask:
1099    model: qwen3:14b
1100"#;
1101        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1102        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1103        assert!(conv.ask.summarize_hits_enabled);
1104        assert!(conv.ask.summarize_model.is_none());
1105    }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110    use super::*;
1111
1112    #[test]
1113    fn default_bundled_model_id_is_qwen35_2b() {
1114        assert_eq!(
1115            crate::config::DEFAULT_BUNDLED_MODEL_ID,
1116            "Qwen3.5-2B-MLX-4bit"
1117        );
1118    }
1119
1120    #[test]
1121    fn nudge_config_defaults() {
1122        let c = NudgeConfig::default();
1123        assert!(c.enabled);
1124        assert_eq!(c.daily_cap, 3);
1125        assert_eq!(c.snooze_days, 7);
1126        assert_eq!(c.threshold, 3);
1127    }
1128
1129    #[test]
1130    fn config_has_nudge_section_with_defaults() {
1131        let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1132        assert_eq!(c.nudge.daily_cap, 3);
1133    }
1134
1135    #[test]
1136    fn storage_config_default_is_lancedb() {
1137        let c = StorageConfig::default();
1138        assert_eq!(c.vector_backend, "lancedb");
1139        assert_eq!(c.qdrant_url, None);
1140        assert_eq!(c.qdrant_api_key_ref, None);
1141    }
1142
1143    #[test]
1144    fn sources_global_config_has_sensible_defaults() {
1145        let c = SourcesGlobalConfig::default();
1146        assert_eq!(c.poll_interval_secs, 600);
1147        assert_eq!(c.max_chunks_per_sync, 10_000);
1148        assert_eq!(c.max_parallel_sources, 3);
1149        assert_eq!(c.default_weight, 1.0);
1150        assert_eq!(c.embedding_batch_size, 32);
1151    }
1152
1153    #[test]
1154    fn config_default_has_storage_and_sources_global() {
1155        let c = Config::default();
1156        assert_eq!(c.storage.vector_backend, "lancedb");
1157        assert_eq!(c.sources_global.default_weight, 1.0);
1158    }
1159
1160    #[test]
1161    fn config_loads_yaml_without_new_fields() {
1162        // Existing users' config.yaml won't mention storage or sources_global.
1163        // It must still parse.
1164        let yaml = r#"
1165embedding:
1166  provider: ollama
1167  model: test-model
1168  dimensions: 512
1169  ollama_endpoint: http://localhost:11434
1170"#;
1171        let c: Config = serde_yaml::from_str(yaml).expect("parses");
1172        assert_eq!(c.storage.vector_backend, "lancedb");
1173        assert_eq!(c.sources_global.max_parallel_sources, 3);
1174    }
1175
1176    #[test]
1177    fn llm_config_to_backend_config_anthropic_passthrough() {
1178        let cfg = LlmConfig {
1179            provider: "anthropic".into(),
1180            model: "claude-haiku-4-5".into(),
1181            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1182            openai_url: None,
1183        };
1184        let b = cfg.to_backend_config();
1185        assert_eq!(b.provider, "anthropic");
1186        assert_eq!(b.model, "claude-haiku-4-5");
1187        assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1188        assert_eq!(b.endpoint, None);
1189        assert_eq!(b.timeout_secs, None);
1190    }
1191
1192    #[test]
1193    fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1194        let cfg = LlmConfig {
1195            provider: "openai".into(),
1196            model: "gpt-4o-mini".into(),
1197            api_key_env: None,
1198            openai_url: Some("https://api.together.xyz/v1".into()),
1199        };
1200        let b = cfg.to_backend_config();
1201        assert_eq!(b.provider, "openai");
1202        assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1203        assert_eq!(b.api_key_env, None); // factory will fall back to OPENAI_API_KEY
1204    }
1205
1206    #[test]
1207    fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1208        let cfg = LlmConfig {
1209            provider: "ollama".into(),
1210            model: "qwen3:14b".into(),
1211            api_key_env: None,
1212            openai_url: Some("http://192.168.1.10:11434".into()),
1213        };
1214        let b = cfg.to_backend_config();
1215        assert_eq!(b.provider, "ollama");
1216        assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1217    }
1218
1219    #[test]
1220    fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1221        // Historical LlmConfig allowed provider="custom" + openai_url to act as
1222        // an OpenAI-compatible passthrough. Preserve that by re-tagging as
1223        // "openai" so factory dispatches to OpenAIBackend.
1224        let cfg = LlmConfig {
1225            provider: "custom-name".into(),
1226            model: "some-model".into(),
1227            api_key_env: Some("CUSTOM_KEY".into()),
1228            openai_url: Some("https://my-proxy.local/v1".into()),
1229        };
1230        let b = cfg.to_backend_config();
1231        assert_eq!(
1232            b.provider, "openai",
1233            "unknown provider + openai_url should alias to openai"
1234        );
1235        assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1236    }
1237}
1238
1239#[cfg(test)]
1240mod backend_config_tests {
1241    use super::*;
1242
1243    #[test]
1244    fn default_is_ollama_qwen3() {
1245        let cfg = BackendConfig::default();
1246        assert_eq!(cfg.provider, "ollama");
1247        assert_eq!(cfg.model, "qwen3.5:4b");
1248        assert_eq!(cfg.endpoint, None);
1249        assert_eq!(cfg.api_key_env, None);
1250        assert_eq!(cfg.timeout_secs, None);
1251    }
1252
1253    #[test]
1254    fn deserializes_anthropic_full() {
1255        let yaml = "\
1256provider: anthropic
1257model: claude-haiku-4-5
1258api_key_env: ANTHROPIC_API_KEY
1259timeout_secs: 60
1260";
1261        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1262        assert_eq!(cfg.provider, "anthropic");
1263        assert_eq!(cfg.model, "claude-haiku-4-5");
1264        assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1265        assert_eq!(cfg.timeout_secs, Some(60));
1266        assert_eq!(cfg.endpoint, None);
1267    }
1268
1269    #[test]
1270    fn deserializes_partial_fills_defaults() {
1271        let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1272        let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1273        assert_eq!(cfg.provider, "anthropic");
1274        assert_eq!(cfg.model, "claude-sonnet-4-6");
1275        assert_eq!(cfg.api_key_env, None);
1276        assert_eq!(cfg.timeout_secs, None);
1277    }
1278
1279    #[test]
1280    fn round_trips_through_yaml() {
1281        let original = BackendConfig {
1282            provider: "anthropic".into(),
1283            model: "claude-haiku-4-5".into(),
1284            endpoint: Some("https://api.anthropic.com".into()),
1285            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1286            timeout_secs: Some(60),
1287        };
1288        let yaml = serde_yaml::to_string(&original).unwrap();
1289        let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1290        assert_eq!(parsed, original);
1291    }
1292
1293    #[test]
1294    fn skills_config_curation_gate_defaults_on() {
1295        let c = SkillsConfig::default();
1296        assert!(c.require_human_curation_before_stable);
1297    }
1298}
1299
1300/// Configuration for the daemon-side sleep cycle (idle background learning).
1301///
1302/// Skill injection configuration (M2 — runtime injection).
1303#[derive(Debug, Clone, Serialize, Deserialize)]
1304#[serde(default)]
1305pub struct SkillsConfig {
1306    pub max_skills_in_prompt: usize,
1307    pub max_total_tokens: usize,
1308    pub priority_order: Vec<String>,
1309    pub adaptive: Option<AdaptiveSkillsConfig>,
1310
1311    /// When true (default), LLM-authored skills cannot auto-promote past
1312    /// `Emerging` until a human curates them (amendment A1). Set false to
1313    /// let LLM-extracted skills promote on run stats alone.
1314    #[serde(default = "default_require_human_curation")]
1315    pub require_human_curation_before_stable: bool,
1316
1317    /// Lifecycle scoring thresholds (W3b-P4). All fields default to the
1318    /// compile-time constants in `mur_common::skill::lifecycle` so existing
1319    /// deployments see no behaviour change without an explicit config entry.
1320    #[serde(default)]
1321    pub lifecycle: SkillLifecycleConfig,
1322}
1323
1324fn default_require_human_curation() -> bool {
1325    true
1326}
1327
1328impl Default for SkillsConfig {
1329    fn default() -> Self {
1330        Self {
1331            max_skills_in_prompt: 5,
1332            max_total_tokens: 2000,
1333            priority_order: vec!["agent".into(), "global".into()],
1334            adaptive: Some(AdaptiveSkillsConfig::default()),
1335            require_human_curation_before_stable: default_require_human_curation(),
1336            lifecycle: SkillLifecycleConfig::default(),
1337        }
1338    }
1339}
1340
1341/// Per-skill lifecycle scoring thresholds.
1342///
1343/// Stored under `skill.lifecycle.*` in `~/.mur/config.yaml`.
1344/// All fields are optional on disk — missing keys fall back to the
1345/// compile-time defaults so a partial config is always valid.
1346#[derive(Debug, Clone, Serialize, Deserialize)]
1347#[serde(default)]
1348pub struct SkillLifecycleConfig {
1349    // ── Promotion thresholds (must be exceeded) ──────────────────────────
1350    pub promote_draft_uses: u64,
1351    pub promote_emerging_uses: u64,
1352    pub promote_emerging_success_rate: f64,
1353    pub promote_emerging_age_days: i64,
1354    pub promote_stable_uses: u64,
1355    pub promote_stable_success_rate: f64,
1356    pub promote_stable_age_days: i64,
1357
1358    // ── Demotion thresholds (must drop below) ────────────────────────────
1359    pub demote_emerging_uses: u64,
1360    pub demote_emerging_success_rate: f64,
1361    pub demote_stable_uses: u64,
1362    pub demote_stable_success_rate: f64,
1363    pub deprecated_success_rate: f64,
1364    pub deprecated_no_success_days: i64,
1365
1366    // ── Auto-archive thresholds ───────────────────────────────────────────
1367    pub auto_archive_confidence: f64,
1368    pub auto_archive_age_days: i64,
1369
1370    // ── P4: broken fast-path ─────────────────────────────────────────────
1371    /// Number of consecutive `Execution` events with `env_class == "workflow"`
1372    /// that immediately triggers a `Deprecated` transition, bypassing the
1373    /// normal scoring path. Set to 0 to disable the fast-path.
1374    pub broken_workflow_streak: u32,
1375
1376    // ── P4: archived hard-delete ─────────────────────────────────────────
1377    /// Days a skill must remain in `Archived` state before `mur skill sweep`
1378    /// transitions it to `Destroyed` and removes its directory from disk.
1379    /// Set to 0 to disable hard-delete.
1380    pub archive_destroy_grace_days: i64,
1381}
1382
1383impl Default for SkillLifecycleConfig {
1384    fn default() -> Self {
1385        Self {
1386            promote_draft_uses: 3,
1387            promote_emerging_uses: 10,
1388            promote_emerging_success_rate: 0.6,
1389            promote_emerging_age_days: 7,
1390            promote_stable_uses: 30,
1391            promote_stable_success_rate: 0.8,
1392            promote_stable_age_days: 30,
1393            demote_emerging_uses: 8,
1394            demote_emerging_success_rate: 0.55,
1395            demote_stable_uses: 25,
1396            demote_stable_success_rate: 0.75,
1397            deprecated_success_rate: 0.3,
1398            deprecated_no_success_days: 90,
1399            auto_archive_confidence: 0.10,
1400            auto_archive_age_days: 180,
1401            broken_workflow_streak: 3,
1402            archive_destroy_grace_days: 30,
1403        }
1404    }
1405}
1406
1407#[derive(Debug, Clone, Serialize, Deserialize)]
1408#[serde(default)]
1409pub struct AdaptiveSkillsConfig {
1410    pub context_fill_decay: f64,
1411    pub min_remaining_context_ratio: f64,
1412    pub recent_fire_boost_turns: usize,
1413    /// Model max context window in tokens. Used to compute
1414    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
1415    /// Default 200_000 (Claude 3.5/4.x).
1416    pub model_max_context_tokens: u64,
1417}
1418
1419impl Default for AdaptiveSkillsConfig {
1420    fn default() -> Self {
1421        Self {
1422            context_fill_decay: 1.5,
1423            min_remaining_context_ratio: 0.20,
1424            recent_fire_boost_turns: 5,
1425            model_max_context_tokens: 200_000,
1426        }
1427    }
1428}
1429
1430/// When enabled, the daemon fires a consolidation pipeline after the user has been
1431/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
1432#[derive(Debug, Clone, Serialize, Deserialize)]
1433pub struct SleepCycleConfig {
1434    /// Master switch. False by default (opt-in).
1435    #[serde(default)]
1436    pub enabled: bool,
1437
1438    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
1439    #[serde(default = "default_idle_threshold_minutes")]
1440    pub idle_threshold_minutes: u64,
1441
1442    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
1443    #[serde(default = "default_agent_idle_minutes")]
1444    pub agent_idle_minutes: u64,
1445}
1446
1447fn default_idle_threshold_minutes() -> u64 {
1448    15
1449}
1450
1451fn default_agent_idle_minutes() -> u64 {
1452    5
1453}
1454
1455impl Default for SleepCycleConfig {
1456    fn default() -> Self {
1457        Self {
1458            enabled: false,
1459            idle_threshold_minutes: default_idle_threshold_minutes(),
1460            agent_idle_minutes: default_agent_idle_minutes(),
1461        }
1462    }
1463}
1464
1465// ── Nudge config ───────────────────────────────────────────────────
1466
1467#[derive(Debug, Clone, Serialize, Deserialize)]
1468pub struct NudgeConfig {
1469    /// Master switch. Default on — Phase 2 companion surface is live.
1470    #[serde(default = "default_nudge_enabled")]
1471    pub enabled: bool,
1472    #[serde(default = "default_nudge_daily_cap")]
1473    pub daily_cap: u32,
1474    #[serde(default = "default_nudge_snooze_days")]
1475    pub snooze_days: u32,
1476    #[serde(default = "default_nudge_threshold")]
1477    pub threshold: usize,
1478}
1479
1480fn default_nudge_enabled() -> bool {
1481    true
1482}
1483fn default_nudge_daily_cap() -> u32 {
1484    3
1485}
1486fn default_nudge_snooze_days() -> u32 {
1487    7
1488}
1489fn default_nudge_threshold() -> usize {
1490    3
1491}
1492
1493impl Default for NudgeConfig {
1494    fn default() -> Self {
1495        Self {
1496            enabled: true,
1497            daily_cap: default_nudge_daily_cap(),
1498            snooze_days: default_nudge_snooze_days(),
1499            threshold: default_nudge_threshold(),
1500        }
1501    }
1502}
1503
1504// ── Ambient capture & harvest (2026-06-11 spec) ────────────────────
1505
1506/// Ambient session capture (spec 2026-06-11-mur-ambient-capture-and-harvest §3.1).
1507#[derive(Debug, Clone, Serialize, Deserialize)]
1508pub struct SessionCfg {
1509    /// "ambient" (hooks always record) | "manual" (legacy `mur session in` gate) | "off"
1510    #[serde(default = "default_capture_mode")]
1511    pub capture: String,
1512    /// Recordings older than this many days are removed by `mur session gc`.
1513    #[serde(default = "default_retention_days")]
1514    pub retention_days: u32,
1515}
1516
1517impl Default for SessionCfg {
1518    fn default() -> Self {
1519        Self {
1520            capture: default_capture_mode(),
1521            retention_days: default_retention_days(),
1522        }
1523    }
1524}
1525
1526fn default_capture_mode() -> String {
1527    "ambient".to_string()
1528}
1529fn default_retention_days() -> u32 {
1530    14
1531}
1532
1533/// Harvest gate + token-budget defenses (spec §3.2, §3.7).
1534#[derive(Debug, Clone, Serialize, Deserialize)]
1535pub struct HarvestCfg {
1536    /// Run the heuristic gate automatically (from `mur session gc` / `mur out`).
1537    #[serde(default = "default_harvest_enabled")]
1538    pub auto_gate: bool,
1539    /// "local-first" | "cloud" | "off" — W1/W2 only persist this; LLM wiring lands with v2 P5a.
1540    #[serde(default = "default_harvest_llm")]
1541    pub llm: String,
1542    /// Gate thresholds — a session must clear at least one of these (see harvest::gate).
1543    #[serde(default = "default_min_events")]
1544    pub min_events: usize,
1545    #[serde(default = "default_min_user_turns")]
1546    pub min_user_turns: usize,
1547    #[serde(default = "default_min_duration_secs")]
1548    pub min_duration_secs: i64,
1549    /// A session is considered ended when its last event is older than this.
1550    #[serde(default = "default_idle_minutes")]
1551    pub idle_minutes: i64,
1552    /// §3.7 hard caps (persisted now; enforced when the LLM extract path lands in v2 P5a).
1553    #[serde(default = "default_max_llm_calls_per_day")]
1554    pub max_llm_calls_per_day: u32,
1555    #[serde(default = "default_max_extract_input_tokens")]
1556    pub max_extract_input_tokens: usize,
1557    /// §3.8 tier-1: one-line pending-proposals hint at SessionStart.
1558    #[serde(default = "default_harvest_enabled")]
1559    pub session_start_hint: bool,
1560    /// Step-skeleton Jaccard similarity at/above which a proposal becomes a merge suggestion.
1561    #[serde(default = "default_similarity_merge_threshold")]
1562    pub similarity_merge_threshold: f32,
1563}
1564
1565impl Default for HarvestCfg {
1566    fn default() -> Self {
1567        serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1568    }
1569}
1570
1571fn default_harvest_enabled() -> bool {
1572    true
1573}
1574fn default_harvest_llm() -> String {
1575    "local-first".to_string()
1576}
1577fn default_min_events() -> usize {
1578    5
1579}
1580fn default_min_user_turns() -> usize {
1581    2
1582}
1583fn default_min_duration_secs() -> i64 {
1584    120
1585}
1586fn default_idle_minutes() -> i64 {
1587    30
1588}
1589fn default_max_llm_calls_per_day() -> u32 {
1590    10
1591}
1592fn default_max_extract_input_tokens() -> usize {
1593    12000
1594}
1595fn default_similarity_merge_threshold() -> f32 {
1596    0.6
1597}
1598
1599// ── M7a: Cross-agent observability ─────────────────────────────────
1600
1601#[derive(Debug, Clone, Serialize, Deserialize)]
1602#[serde(default)]
1603pub struct CrossAgentConfig {
1604    #[serde(default = "default_half_life_days")]
1605    pub fitness_half_life_days: u32,
1606    #[serde(default = "default_fitness_floor")]
1607    pub fitness_floor: f64,
1608}
1609
1610fn default_half_life_days() -> u32 {
1611    7
1612}
1613fn default_fitness_floor() -> f64 {
1614    0.1
1615}
1616
1617impl Default for CrossAgentConfig {
1618    fn default() -> Self {
1619        Self {
1620            fitness_half_life_days: default_half_life_days(),
1621            fitness_floor: default_fitness_floor(),
1622        }
1623    }
1624}
1625
1626// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
1627
1628#[derive(Debug, Clone, Serialize, Deserialize)]
1629#[serde(default)]
1630pub struct SkillLlmConfig {
1631    /// Per-call output token cap.
1632    #[serde(default = "default_per_call_token_cap")]
1633    pub per_call_token_cap: u32,
1634
1635    /// Per-day USD cap for all maintenance LLM calls.
1636    #[serde(default = "default_per_day_usd_cap")]
1637    pub per_day_usd_cap: f64,
1638
1639    /// Cache TTL in days.
1640    #[serde(default = "default_cache_ttl_days")]
1641    pub cache_ttl_days: u32,
1642
1643    /// Optional explicit model key override. When `None`, role resolution picks.
1644    #[serde(default, skip_serializing_if = "Option::is_none")]
1645    pub model_ref: Option<String>,
1646}
1647
1648fn default_per_call_token_cap() -> u32 {
1649    1500
1650}
1651fn default_per_day_usd_cap() -> f64 {
1652    0.50
1653}
1654fn default_cache_ttl_days() -> u32 {
1655    30
1656}
1657
1658impl Default for SkillLlmConfig {
1659    fn default() -> Self {
1660        Self {
1661            per_call_token_cap: default_per_call_token_cap(),
1662            per_day_usd_cap: default_per_day_usd_cap(),
1663            cache_ttl_days: default_cache_ttl_days(),
1664            model_ref: None,
1665        }
1666    }
1667}
1668#[cfg(test)]
1669mod per_stage_backend_tests {
1670    use super::*;
1671
1672    #[test]
1673    fn legacy_compact_config_has_no_per_stage_overrides() {
1674        let yaml = "\
1675extractive_model: qwen3:14b
1676abstractive_model: qwen3:14b
1677ollama_endpoint: http://localhost:11434
1678";
1679        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1680        assert!(cfg.extractive_backend.is_none());
1681        assert!(cfg.abstractive_backend.is_none());
1682        assert_eq!(cfg.extractive_model, "qwen3:14b");
1683        assert_eq!(cfg.abstractive_model, "qwen3:14b");
1684        assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1685    }
1686
1687    #[test]
1688    fn legacy_ask_config_has_no_per_stage_overrides() {
1689        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1690        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1691        assert!(cfg.backend.is_none());
1692        assert!(cfg.rewriter_backend.is_none());
1693        assert_eq!(cfg.model, "qwen3:14b");
1694    }
1695
1696    #[test]
1697    fn compact_extractive_backend_override_parses() {
1698        let yaml = "\
1699extractive_backend:
1700  provider: anthropic
1701  model: claude-haiku-4-5
1702  api_key_env: ANTHROPIC_API_KEY
1703abstractive_model: qwen3:14b
1704";
1705        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1706        let extractive = cfg
1707            .extractive_backend
1708            .as_ref()
1709            .expect("override should parse");
1710        assert_eq!(extractive.provider, "anthropic");
1711        assert_eq!(extractive.model, "claude-haiku-4-5");
1712        assert!(cfg.abstractive_backend.is_none());
1713    }
1714
1715    #[test]
1716    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1717        let yaml = "\
1718backend:
1719  provider: anthropic
1720  model: claude-sonnet-4-6
1721  api_key_env: ANTHROPIC_API_KEY
1722rewriter_backend:
1723  provider: ollama
1724  model: llama3.2:3b
1725";
1726        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1727        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1728        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1729    }
1730
1731    #[test]
1732    fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1733        let yaml = "\
1734extractive_model: qwen3:14b
1735ollama_endpoint: http://192.168.1.10:11434
1736";
1737        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1738        let synth = cfg.synthesize_extractive_backend();
1739        assert_eq!(synth.provider, "ollama");
1740        assert_eq!(synth.model, "qwen3:14b");
1741        assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1742        assert_eq!(synth.api_key_env, None);
1743    }
1744
1745    #[test]
1746    fn synthesize_legacy_to_backend_config_for_ask() {
1747        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1748        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1749        let synth = cfg.synthesize_backend();
1750        assert_eq!(synth.provider, "ollama");
1751        assert_eq!(synth.model, "qwen3:14b");
1752        assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1753    }
1754
1755    #[test]
1756    fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1757        // Rewriter no longer falls through to synthesize_backend() when
1758        // `rewriter_backend` is unset (see I2 fix in P3 task 1). It now
1759        // always synthesizes its own ollama BackendConfig over the legacy
1760        // model + endpoint with `rewriter_timeout_secs` baked in, so a
1761        // slow rewriter call doesn't burn the full ask budget. The
1762        // per-stage `ask.backend` override therefore does NOT propagate to
1763        // the rewriter — set `ask.rewriter_backend` explicitly if you want
1764        // a non-Ollama rewriter.
1765        let yaml = "\
1766backend:
1767  provider: anthropic
1768  model: claude-sonnet-4-6
1769  api_key_env: ANTHROPIC_API_KEY
1770";
1771        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1772        let rewriter = cfg.synthesize_rewriter_backend();
1773        assert_eq!(rewriter.provider, "ollama");
1774        assert_eq!(rewriter.model, ask_default_model());
1775        assert_eq!(
1776            rewriter.timeout_secs,
1777            Some(ask_default_rewriter_timeout() as u64)
1778        );
1779    }
1780
1781    #[test]
1782    fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1783        let cfg = AskConfig {
1784            timeout_secs: 45,
1785            ..AskConfig::default()
1786        };
1787        let b = cfg.synthesize_backend();
1788        assert_eq!(
1789            b.timeout_secs,
1790            Some(45),
1791            "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1792        );
1793    }
1794
1795    #[test]
1796    fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1797        let mut cfg = AskConfig {
1798            timeout_secs: 45,
1799            ..AskConfig::default()
1800        };
1801        cfg.backend = Some(BackendConfig {
1802            provider: "anthropic".into(),
1803            model: "claude-haiku-4-5".into(),
1804            endpoint: None,
1805            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1806            timeout_secs: Some(10),
1807        });
1808        let b = cfg.synthesize_backend();
1809        assert_eq!(
1810            b.timeout_secs,
1811            Some(10),
1812            "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1813        );
1814    }
1815
1816    #[test]
1817    fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1818        let cfg = AskConfig {
1819            timeout_secs: 120,
1820            rewriter_timeout_secs: 8,
1821            ..AskConfig::default()
1822        };
1823        let b = cfg.synthesize_rewriter_backend();
1824        assert_eq!(
1825            b.timeout_secs,
1826            Some(8),
1827            "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1828        );
1829    }
1830
1831    #[test]
1832    fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1833        let mut cfg = AskConfig {
1834            rewriter_timeout_secs: 8,
1835            ..AskConfig::default()
1836        };
1837        cfg.rewriter_backend = Some(BackendConfig {
1838            provider: "anthropic".into(),
1839            model: "claude-haiku-4-5".into(),
1840            endpoint: None,
1841            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1842            timeout_secs: Some(30),
1843        });
1844        let b = cfg.synthesize_rewriter_backend();
1845        assert_eq!(
1846            b.timeout_secs,
1847            Some(30),
1848            "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1849        );
1850    }
1851
1852    #[test]
1853    fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1854        // CompactConfig has no per-stage timeout field — extractive synthesis
1855        // should fall back to the conservative 120s default.
1856        let cfg = CompactConfig::default();
1857        let b = cfg.synthesize_extractive_backend();
1858        assert_eq!(
1859            b.timeout_secs,
1860            Some(120),
1861            "compact synthesis without per-stage override must produce 120s timeout"
1862        );
1863    }
1864
1865    #[test]
1866    fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1867        let cfg = CompactConfig::default();
1868        let b = cfg.synthesize_abstractive_backend();
1869        assert_eq!(b.timeout_secs, Some(120));
1870    }
1871}
1872
1873#[cfg(test)]
1874mod skills_config_tests {
1875    use super::*;
1876
1877    #[test]
1878    fn empty_yaml_hydrates_defaults() {
1879        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1880        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1881        assert_eq!(cfg.skills.max_total_tokens, 2000);
1882        assert!(cfg.skills.adaptive.is_some());
1883    }
1884
1885    #[test]
1886    fn load_or_default_missing_file_returns_default() {
1887        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1888        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1889    }
1890}
1891
1892#[cfg(test)]
1893mod ambient_capture_cfg_tests {
1894    use super::*;
1895
1896    #[test]
1897    fn session_and_harvest_defaults() {
1898        let cfg: Config = serde_yaml::from_str("{}").unwrap();
1899        assert_eq!(cfg.session.capture, "ambient");
1900        assert_eq!(cfg.session.retention_days, 14);
1901        assert!(cfg.harvest.auto_gate);
1902        assert_eq!(cfg.harvest.llm, "local-first");
1903        assert_eq!(cfg.harvest.min_events, 5);
1904        assert_eq!(cfg.harvest.min_user_turns, 2);
1905        assert_eq!(cfg.harvest.min_duration_secs, 120);
1906        assert_eq!(cfg.harvest.idle_minutes, 30);
1907        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
1908        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
1909        assert!(cfg.harvest.session_start_hint);
1910        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
1911    }
1912
1913    #[test]
1914    fn session_capture_override_parses() {
1915        let cfg: Config =
1916            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
1917        assert_eq!(cfg.session.capture, "off");
1918        assert_eq!(cfg.session.retention_days, 3);
1919    }
1920}