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