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
1318fn default_require_human_curation() -> bool {
1319    true
1320}
1321
1322impl Default for SkillsConfig {
1323    fn default() -> Self {
1324        Self {
1325            max_skills_in_prompt: 5,
1326            max_total_tokens: 2000,
1327            priority_order: vec!["agent".into(), "global".into()],
1328            adaptive: Some(AdaptiveSkillsConfig::default()),
1329            require_human_curation_before_stable: default_require_human_curation(),
1330        }
1331    }
1332}
1333
1334#[derive(Debug, Clone, Serialize, Deserialize)]
1335#[serde(default)]
1336pub struct AdaptiveSkillsConfig {
1337    pub context_fill_decay: f64,
1338    pub min_remaining_context_ratio: f64,
1339    pub recent_fire_boost_turns: usize,
1340    /// Model max context window in tokens. Used to compute
1341    /// `context_fill_ratio = cumulative_input_tokens / model_max_context_tokens`.
1342    /// Default 200_000 (Claude 3.5/4.x).
1343    pub model_max_context_tokens: u64,
1344}
1345
1346impl Default for AdaptiveSkillsConfig {
1347    fn default() -> Self {
1348        Self {
1349            context_fill_decay: 1.5,
1350            min_remaining_context_ratio: 0.20,
1351            recent_fire_boost_turns: 5,
1352            model_max_context_tokens: 200_000,
1353        }
1354    }
1355}
1356
1357/// When enabled, the daemon fires a consolidation pipeline after the user has been
1358/// idle for `idle_threshold_minutes` minutes (default 15). Opt-in only — off by default.
1359#[derive(Debug, Clone, Serialize, Deserialize)]
1360pub struct SleepCycleConfig {
1361    /// Master switch. False by default (opt-in).
1362    #[serde(default)]
1363    pub enabled: bool,
1364
1365    /// Minutes of idle (no events) before triggering the daemon sleep cycle.
1366    #[serde(default = "default_idle_threshold_minutes")]
1367    pub idle_threshold_minutes: u64,
1368
1369    /// Minutes of agent idle before the agent-side cycle fires (outbox flush + snapshot pull).
1370    #[serde(default = "default_agent_idle_minutes")]
1371    pub agent_idle_minutes: u64,
1372}
1373
1374fn default_idle_threshold_minutes() -> u64 {
1375    15
1376}
1377
1378fn default_agent_idle_minutes() -> u64 {
1379    5
1380}
1381
1382impl Default for SleepCycleConfig {
1383    fn default() -> Self {
1384        Self {
1385            enabled: false,
1386            idle_threshold_minutes: default_idle_threshold_minutes(),
1387            agent_idle_minutes: default_agent_idle_minutes(),
1388        }
1389    }
1390}
1391
1392// ── Nudge config ───────────────────────────────────────────────────
1393
1394#[derive(Debug, Clone, Serialize, Deserialize)]
1395pub struct NudgeConfig {
1396    /// Master switch. Default on — Phase 2 companion surface is live.
1397    #[serde(default = "default_nudge_enabled")]
1398    pub enabled: bool,
1399    #[serde(default = "default_nudge_daily_cap")]
1400    pub daily_cap: u32,
1401    #[serde(default = "default_nudge_snooze_days")]
1402    pub snooze_days: u32,
1403    #[serde(default = "default_nudge_threshold")]
1404    pub threshold: usize,
1405}
1406
1407fn default_nudge_enabled() -> bool {
1408    true
1409}
1410fn default_nudge_daily_cap() -> u32 {
1411    3
1412}
1413fn default_nudge_snooze_days() -> u32 {
1414    7
1415}
1416fn default_nudge_threshold() -> usize {
1417    3
1418}
1419
1420impl Default for NudgeConfig {
1421    fn default() -> Self {
1422        Self {
1423            enabled: true,
1424            daily_cap: default_nudge_daily_cap(),
1425            snooze_days: default_nudge_snooze_days(),
1426            threshold: default_nudge_threshold(),
1427        }
1428    }
1429}
1430
1431// ── Ambient capture & harvest (2026-06-11 spec) ────────────────────
1432
1433/// Ambient session capture (spec 2026-06-11-mur-ambient-capture-and-harvest §3.1).
1434#[derive(Debug, Clone, Serialize, Deserialize)]
1435pub struct SessionCfg {
1436    /// "ambient" (hooks always record) | "manual" (legacy `mur session in` gate) | "off"
1437    #[serde(default = "default_capture_mode")]
1438    pub capture: String,
1439    /// Recordings older than this many days are removed by `mur session gc`.
1440    #[serde(default = "default_retention_days")]
1441    pub retention_days: u32,
1442}
1443
1444impl Default for SessionCfg {
1445    fn default() -> Self {
1446        Self {
1447            capture: default_capture_mode(),
1448            retention_days: default_retention_days(),
1449        }
1450    }
1451}
1452
1453fn default_capture_mode() -> String {
1454    "ambient".to_string()
1455}
1456fn default_retention_days() -> u32 {
1457    14
1458}
1459
1460/// Harvest gate + token-budget defenses (spec §3.2, §3.7).
1461#[derive(Debug, Clone, Serialize, Deserialize)]
1462pub struct HarvestCfg {
1463    /// Run the heuristic gate automatically (from `mur session gc` / `mur out`).
1464    #[serde(default = "default_harvest_enabled")]
1465    pub auto_gate: bool,
1466    /// "local-first" | "cloud" | "off" — W1/W2 only persist this; LLM wiring lands with v2 P5a.
1467    #[serde(default = "default_harvest_llm")]
1468    pub llm: String,
1469    /// Gate thresholds — a session must clear at least one of these (see harvest::gate).
1470    #[serde(default = "default_min_events")]
1471    pub min_events: usize,
1472    #[serde(default = "default_min_user_turns")]
1473    pub min_user_turns: usize,
1474    #[serde(default = "default_min_duration_secs")]
1475    pub min_duration_secs: i64,
1476    /// A session is considered ended when its last event is older than this.
1477    #[serde(default = "default_idle_minutes")]
1478    pub idle_minutes: i64,
1479    /// §3.7 hard caps (persisted now; enforced when the LLM extract path lands in v2 P5a).
1480    #[serde(default = "default_max_llm_calls_per_day")]
1481    pub max_llm_calls_per_day: u32,
1482    #[serde(default = "default_max_extract_input_tokens")]
1483    pub max_extract_input_tokens: usize,
1484    /// §3.8 tier-1: one-line pending-proposals hint at SessionStart.
1485    #[serde(default = "default_harvest_enabled")]
1486    pub session_start_hint: bool,
1487    /// Step-skeleton Jaccard similarity at/above which a proposal becomes a merge suggestion.
1488    #[serde(default = "default_similarity_merge_threshold")]
1489    pub similarity_merge_threshold: f32,
1490}
1491
1492impl Default for HarvestCfg {
1493    fn default() -> Self {
1494        serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1495    }
1496}
1497
1498fn default_harvest_enabled() -> bool {
1499    true
1500}
1501fn default_harvest_llm() -> String {
1502    "local-first".to_string()
1503}
1504fn default_min_events() -> usize {
1505    5
1506}
1507fn default_min_user_turns() -> usize {
1508    2
1509}
1510fn default_min_duration_secs() -> i64 {
1511    120
1512}
1513fn default_idle_minutes() -> i64 {
1514    30
1515}
1516fn default_max_llm_calls_per_day() -> u32 {
1517    10
1518}
1519fn default_max_extract_input_tokens() -> usize {
1520    12000
1521}
1522fn default_similarity_merge_threshold() -> f32 {
1523    0.6
1524}
1525
1526// ── M7a: Cross-agent observability ─────────────────────────────────
1527
1528#[derive(Debug, Clone, Serialize, Deserialize)]
1529#[serde(default)]
1530pub struct CrossAgentConfig {
1531    #[serde(default = "default_half_life_days")]
1532    pub fitness_half_life_days: u32,
1533    #[serde(default = "default_fitness_floor")]
1534    pub fitness_floor: f64,
1535}
1536
1537fn default_half_life_days() -> u32 {
1538    7
1539}
1540fn default_fitness_floor() -> f64 {
1541    0.1
1542}
1543
1544impl Default for CrossAgentConfig {
1545    fn default() -> Self {
1546        Self {
1547            fitness_half_life_days: default_half_life_days(),
1548            fitness_floor: default_fitness_floor(),
1549        }
1550    }
1551}
1552
1553// ── M6c: LLM-augmented skill maintenance ─────────────────────────────
1554
1555#[derive(Debug, Clone, Serialize, Deserialize)]
1556#[serde(default)]
1557pub struct SkillLlmConfig {
1558    /// Per-call output token cap.
1559    #[serde(default = "default_per_call_token_cap")]
1560    pub per_call_token_cap: u32,
1561
1562    /// Per-day USD cap for all maintenance LLM calls.
1563    #[serde(default = "default_per_day_usd_cap")]
1564    pub per_day_usd_cap: f64,
1565
1566    /// Cache TTL in days.
1567    #[serde(default = "default_cache_ttl_days")]
1568    pub cache_ttl_days: u32,
1569
1570    /// Optional explicit model key override. When `None`, role resolution picks.
1571    #[serde(default, skip_serializing_if = "Option::is_none")]
1572    pub model_ref: Option<String>,
1573}
1574
1575fn default_per_call_token_cap() -> u32 {
1576    1500
1577}
1578fn default_per_day_usd_cap() -> f64 {
1579    0.50
1580}
1581fn default_cache_ttl_days() -> u32 {
1582    30
1583}
1584
1585impl Default for SkillLlmConfig {
1586    fn default() -> Self {
1587        Self {
1588            per_call_token_cap: default_per_call_token_cap(),
1589            per_day_usd_cap: default_per_day_usd_cap(),
1590            cache_ttl_days: default_cache_ttl_days(),
1591            model_ref: None,
1592        }
1593    }
1594}
1595#[cfg(test)]
1596mod per_stage_backend_tests {
1597    use super::*;
1598
1599    #[test]
1600    fn legacy_compact_config_has_no_per_stage_overrides() {
1601        let yaml = "\
1602extractive_model: qwen3:14b
1603abstractive_model: qwen3:14b
1604ollama_endpoint: http://localhost:11434
1605";
1606        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1607        assert!(cfg.extractive_backend.is_none());
1608        assert!(cfg.abstractive_backend.is_none());
1609        assert_eq!(cfg.extractive_model, "qwen3:14b");
1610        assert_eq!(cfg.abstractive_model, "qwen3:14b");
1611        assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1612    }
1613
1614    #[test]
1615    fn legacy_ask_config_has_no_per_stage_overrides() {
1616        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1617        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1618        assert!(cfg.backend.is_none());
1619        assert!(cfg.rewriter_backend.is_none());
1620        assert_eq!(cfg.model, "qwen3:14b");
1621    }
1622
1623    #[test]
1624    fn compact_extractive_backend_override_parses() {
1625        let yaml = "\
1626extractive_backend:
1627  provider: anthropic
1628  model: claude-haiku-4-5
1629  api_key_env: ANTHROPIC_API_KEY
1630abstractive_model: qwen3:14b
1631";
1632        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1633        let extractive = cfg
1634            .extractive_backend
1635            .as_ref()
1636            .expect("override should parse");
1637        assert_eq!(extractive.provider, "anthropic");
1638        assert_eq!(extractive.model, "claude-haiku-4-5");
1639        assert!(cfg.abstractive_backend.is_none());
1640    }
1641
1642    #[test]
1643    fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1644        let yaml = "\
1645backend:
1646  provider: anthropic
1647  model: claude-sonnet-4-6
1648  api_key_env: ANTHROPIC_API_KEY
1649rewriter_backend:
1650  provider: ollama
1651  model: llama3.2:3b
1652";
1653        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1654        assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1655        assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1656    }
1657
1658    #[test]
1659    fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1660        let yaml = "\
1661extractive_model: qwen3:14b
1662ollama_endpoint: http://192.168.1.10:11434
1663";
1664        let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1665        let synth = cfg.synthesize_extractive_backend();
1666        assert_eq!(synth.provider, "ollama");
1667        assert_eq!(synth.model, "qwen3:14b");
1668        assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1669        assert_eq!(synth.api_key_env, None);
1670    }
1671
1672    #[test]
1673    fn synthesize_legacy_to_backend_config_for_ask() {
1674        let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1675        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1676        let synth = cfg.synthesize_backend();
1677        assert_eq!(synth.provider, "ollama");
1678        assert_eq!(synth.model, "qwen3:14b");
1679        assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1680    }
1681
1682    #[test]
1683    fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1684        // Rewriter no longer falls through to synthesize_backend() when
1685        // `rewriter_backend` is unset (see I2 fix in P3 task 1). It now
1686        // always synthesizes its own ollama BackendConfig over the legacy
1687        // model + endpoint with `rewriter_timeout_secs` baked in, so a
1688        // slow rewriter call doesn't burn the full ask budget. The
1689        // per-stage `ask.backend` override therefore does NOT propagate to
1690        // the rewriter — set `ask.rewriter_backend` explicitly if you want
1691        // a non-Ollama rewriter.
1692        let yaml = "\
1693backend:
1694  provider: anthropic
1695  model: claude-sonnet-4-6
1696  api_key_env: ANTHROPIC_API_KEY
1697";
1698        let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1699        let rewriter = cfg.synthesize_rewriter_backend();
1700        assert_eq!(rewriter.provider, "ollama");
1701        assert_eq!(rewriter.model, ask_default_model());
1702        assert_eq!(
1703            rewriter.timeout_secs,
1704            Some(ask_default_rewriter_timeout() as u64)
1705        );
1706    }
1707
1708    #[test]
1709    fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1710        let cfg = AskConfig {
1711            timeout_secs: 45,
1712            ..AskConfig::default()
1713        };
1714        let b = cfg.synthesize_backend();
1715        assert_eq!(
1716            b.timeout_secs,
1717            Some(45),
1718            "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1719        );
1720    }
1721
1722    #[test]
1723    fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1724        let mut cfg = AskConfig {
1725            timeout_secs: 45,
1726            ..AskConfig::default()
1727        };
1728        cfg.backend = Some(BackendConfig {
1729            provider: "anthropic".into(),
1730            model: "claude-haiku-4-5".into(),
1731            endpoint: None,
1732            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1733            timeout_secs: Some(10),
1734        });
1735        let b = cfg.synthesize_backend();
1736        assert_eq!(
1737            b.timeout_secs,
1738            Some(10),
1739            "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1740        );
1741    }
1742
1743    #[test]
1744    fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1745        let cfg = AskConfig {
1746            timeout_secs: 120,
1747            rewriter_timeout_secs: 8,
1748            ..AskConfig::default()
1749        };
1750        let b = cfg.synthesize_rewriter_backend();
1751        assert_eq!(
1752            b.timeout_secs,
1753            Some(8),
1754            "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1755        );
1756    }
1757
1758    #[test]
1759    fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1760        let mut cfg = AskConfig {
1761            rewriter_timeout_secs: 8,
1762            ..AskConfig::default()
1763        };
1764        cfg.rewriter_backend = Some(BackendConfig {
1765            provider: "anthropic".into(),
1766            model: "claude-haiku-4-5".into(),
1767            endpoint: None,
1768            api_key_env: Some("ANTHROPIC_API_KEY".into()),
1769            timeout_secs: Some(30),
1770        });
1771        let b = cfg.synthesize_rewriter_backend();
1772        assert_eq!(
1773            b.timeout_secs,
1774            Some(30),
1775            "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1776        );
1777    }
1778
1779    #[test]
1780    fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1781        // CompactConfig has no per-stage timeout field — extractive synthesis
1782        // should fall back to the conservative 120s default.
1783        let cfg = CompactConfig::default();
1784        let b = cfg.synthesize_extractive_backend();
1785        assert_eq!(
1786            b.timeout_secs,
1787            Some(120),
1788            "compact synthesis without per-stage override must produce 120s timeout"
1789        );
1790    }
1791
1792    #[test]
1793    fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1794        let cfg = CompactConfig::default();
1795        let b = cfg.synthesize_abstractive_backend();
1796        assert_eq!(b.timeout_secs, Some(120));
1797    }
1798}
1799
1800#[cfg(test)]
1801mod skills_config_tests {
1802    use super::*;
1803
1804    #[test]
1805    fn empty_yaml_hydrates_defaults() {
1806        let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1807        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1808        assert_eq!(cfg.skills.max_total_tokens, 2000);
1809        assert!(cfg.skills.adaptive.is_some());
1810    }
1811
1812    #[test]
1813    fn load_or_default_missing_file_returns_default() {
1814        let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1815        assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1816    }
1817}
1818
1819#[cfg(test)]
1820mod ambient_capture_cfg_tests {
1821    use super::*;
1822
1823    #[test]
1824    fn session_and_harvest_defaults() {
1825        let cfg: Config = serde_yaml::from_str("{}").unwrap();
1826        assert_eq!(cfg.session.capture, "ambient");
1827        assert_eq!(cfg.session.retention_days, 14);
1828        assert!(cfg.harvest.auto_gate);
1829        assert_eq!(cfg.harvest.llm, "local-first");
1830        assert_eq!(cfg.harvest.min_events, 5);
1831        assert_eq!(cfg.harvest.min_user_turns, 2);
1832        assert_eq!(cfg.harvest.min_duration_secs, 120);
1833        assert_eq!(cfg.harvest.idle_minutes, 30);
1834        assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
1835        assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
1836        assert!(cfg.harvest.session_start_hint);
1837        assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
1838    }
1839
1840    #[test]
1841    fn session_capture_override_parses() {
1842        let cfg: Config =
1843            serde_yaml::from_str("session:\n  capture: off\n  retention_days: 3\n").unwrap();
1844        assert_eq!(cfg.session.capture, "off");
1845        assert_eq!(cfg.session.retention_days, 3);
1846    }
1847}