Skip to main content

mur_common/config/
conversations.rs

1use super::*;
2
3// ── Ask config (Phase 2B, Task 18) ───────────────────────────────────────────
4
5#[derive(Debug, Clone, Serialize, Deserialize)]
6pub struct AskConfig {
7    #[serde(default = "ask_default_k_summary")]
8    pub k_summary: u32,
9    #[serde(default = "ask_default_k_raw")]
10    pub k_raw: u32,
11    #[serde(default = "ask_default_esc")]
12    pub escalation_threshold: f64,
13    #[serde(default = "ask_default_mmr")]
14    pub mmr_threshold: f64,
15    #[serde(default = "ask_default_max_ctx")]
16    pub max_context_tokens: u32,
17    #[serde(default = "ask_default_resp_tok")]
18    pub response_tokens: u32,
19    #[serde(default = "ask_default_timeout")]
20    pub timeout_secs: u32,
21    #[serde(default = "ask_default_min_score")]
22    pub min_score: f64,
23    #[serde(default = "ask_default_continue_history_turns")]
24    pub continue_history_turns: u32,
25    /// Separate, shorter timeout for the rewriter LLM call (Phase 3.3).
26    /// Rewriter output is small (~80 tokens) and falling back to the raw
27    /// question on failure is non-fatal, so we don't want to burn the full
28    /// `timeout_secs` budget waiting on a slow/unreachable Ollama before
29    /// the user sees any response.
30    #[serde(default = "ask_default_rewriter_timeout")]
31    pub rewriter_timeout_secs: u32,
32    #[serde(default = "ask_default_compress_hits_enabled")]
33    pub compress_hits_enabled: bool,
34    #[serde(default = "ask_default_summarize_hits_enabled")]
35    pub summarize_hits_enabled: bool,
36    #[serde(default)]
37    pub summarize_model: Option<String>,
38    /// Per-stage backend override for the answer-generation model.
39    /// None = inherit the smart slot (`config.llm`).
40    #[serde(default)]
41    pub backend: Option<BackendConfig>,
42    /// Per-stage backend override for the query rewriter.
43    /// None = inherit the answer stage's backend (`self.backend`), falling
44    /// through to the smart slot (`config.llm`) only if that is also unset.
45    #[serde(default)]
46    pub rewriter_backend: Option<BackendConfig>,
47}
48
49impl AskConfig {
50    /// Effective backend for answer generation. An explicit per-stage
51    /// override wins; otherwise the stage inherits the smart slot
52    /// (`config.llm`) with this stage's own timeout baked in, so a slow
53    /// backend cannot silently fall back to the factory's 120s default.
54    pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
55        self.backend.clone().unwrap_or_else(|| BackendConfig {
56            timeout_secs: Some(self.timeout_secs as u64),
57            ..llm.to_backend_config()
58        })
59    }
60
61    /// Effective backend for the query rewriter. An explicit
62    /// `rewriter_backend` override wins outright. Otherwise the rewriter
63    /// follows the answer stage's backend (`self.backend`), and only falls
64    /// through to the smart slot (`llm`) when the answer stage has no
65    /// override either — the rewriter is not an independent pinning point,
66    /// only an independent timeout. Whichever source it resolves from, it
67    /// always keeps its own much tighter `rewriter_timeout_secs` budget:
68    /// the rewriter's output is small and falling back to the raw question
69    /// on timeout is non-fatal.
70    pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
71        self.rewriter_backend
72            .clone()
73            .unwrap_or_else(|| BackendConfig {
74                timeout_secs: Some(self.rewriter_timeout_secs as u64),
75                ..self
76                    .backend
77                    .clone()
78                    .unwrap_or_else(|| llm.to_backend_config())
79            })
80    }
81}
82
83impl Default for AskConfig {
84    fn default() -> Self {
85        Self {
86            k_summary: ask_default_k_summary(),
87            k_raw: ask_default_k_raw(),
88            escalation_threshold: ask_default_esc(),
89            mmr_threshold: ask_default_mmr(),
90            max_context_tokens: ask_default_max_ctx(),
91            response_tokens: ask_default_resp_tok(),
92            timeout_secs: ask_default_timeout(),
93            min_score: ask_default_min_score(),
94            continue_history_turns: ask_default_continue_history_turns(),
95            rewriter_timeout_secs: ask_default_rewriter_timeout(),
96            compress_hits_enabled: ask_default_compress_hits_enabled(),
97            summarize_hits_enabled: ask_default_summarize_hits_enabled(),
98            summarize_model: None,
99            backend: None,
100            rewriter_backend: None,
101        }
102    }
103}
104
105fn ask_default_k_summary() -> u32 {
106    5
107}
108fn ask_default_k_raw() -> u32 {
109    10
110}
111fn ask_default_esc() -> f64 {
112    0.5
113}
114fn ask_default_mmr() -> f64 {
115    0.88
116}
117fn ask_default_max_ctx() -> u32 {
118    6000
119}
120fn ask_default_resp_tok() -> u32 {
121    1024
122}
123fn ask_default_timeout() -> u32 {
124    120
125}
126fn ask_default_min_score() -> f64 {
127    0.35
128}
129fn ask_default_rewriter_timeout() -> u32 {
130    8
131}
132fn ask_default_continue_history_turns() -> u32 {
133    3
134}
135fn ask_default_compress_hits_enabled() -> bool {
136    true
137}
138fn ask_default_summarize_hits_enabled() -> bool {
139    true
140}
141
142// ── Conversations archive config (Task 23) ────────────────────────────────────
143
144/// Phase 1 conversations archive config (Task 23).
145///
146/// Hard defaults: off-by-default (`enabled: false`), 30-day retention,
147/// 5-minute poll interval, all sources enabled, Mem0-style REJECT filters on,
148/// dedup threshold 0.85. Every sub-field is serde-default so a config.yaml
149/// without a `conversations:` section still parses.
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct ConversationsConfig {
152    #[serde(default)]
153    pub enabled: bool,
154    #[serde(default = "conv_default_retention_days")]
155    pub retention_days: u32,
156    #[serde(default = "conv_default_poll_interval")]
157    pub poll_interval_secs: u64,
158    #[serde(default)]
159    pub sources: ConversationsSources,
160    #[serde(default)]
161    pub filter: ConversationsFilter,
162    #[serde(default)]
163    pub compact: CompactConfig,
164    #[serde(default)]
165    pub ask: AskConfig,
166    #[serde(default)]
167    pub rollup: RollupConfig,
168}
169
170impl Default for ConversationsConfig {
171    fn default() -> Self {
172        Self {
173            enabled: false,
174            retention_days: conv_default_retention_days(),
175            poll_interval_secs: conv_default_poll_interval(),
176            sources: ConversationsSources::default(),
177            filter: ConversationsFilter::default(),
178            compact: CompactConfig::default(),
179            ask: AskConfig::default(),
180            rollup: RollupConfig::default(),
181        }
182    }
183}
184
185fn conv_default_retention_days() -> u32 {
186    30
187}
188fn conv_default_poll_interval() -> u64 {
189    300
190}
191fn conv_truthy() -> bool {
192    true
193}
194fn conv_default_dedup() -> f64 {
195    0.85
196}
197
198#[derive(Debug, Clone, Serialize, Deserialize)]
199pub struct CompactConfig {
200    #[serde(default = "conv_truthy")]
201    pub enabled_in_daemon: bool,
202    #[serde(default = "compact_default_max_days")]
203    pub max_days_per_run: u32,
204    #[serde(default = "compact_default_max_spans")]
205    pub max_extractive_spans: u32,
206    #[serde(default = "compact_default_max_words")]
207    pub max_abstractive_words: u32,
208    #[serde(default = "compact_default_chunk_tokens")]
209    pub chunk_tokens: u32,
210    #[serde(default = "compact_default_history_retain")]
211    pub history_retain: u32,
212    #[serde(default = "compact_default_cron")]
213    pub daemon_cron: String,
214    /// Per-stage backend override for extractive summarization.
215    /// None = inherit the smart slot (`config.llm`).
216    #[serde(default)]
217    pub extractive_backend: Option<BackendConfig>,
218    /// Per-stage backend override for abstractive summarization.
219    /// None = inherit the smart slot (`config.llm`).
220    #[serde(default)]
221    pub abstractive_backend: Option<BackendConfig>,
222}
223
224impl CompactConfig {
225    /// Effective backend for the extractive stage. Override wins; otherwise
226    /// inherit the smart slot. CompactConfig has no per-stage timeout field,
227    /// so inheritance bakes the same conservative 120s the fabricated Ollama
228    /// config used.
229    pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
230        self.extractive_backend
231            .clone()
232            .unwrap_or_else(|| BackendConfig {
233                timeout_secs: Some(120),
234                ..llm.to_backend_config()
235            })
236    }
237
238    /// Effective backend for the abstractive stage. See
239    /// `effective_extractive_backend` for the timeout rationale.
240    pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
241        self.abstractive_backend
242            .clone()
243            .unwrap_or_else(|| BackendConfig {
244                timeout_secs: Some(120),
245                ..llm.to_backend_config()
246            })
247    }
248}
249
250impl Default for CompactConfig {
251    fn default() -> Self {
252        Self {
253            enabled_in_daemon: true,
254            max_days_per_run: compact_default_max_days(),
255            max_extractive_spans: compact_default_max_spans(),
256            max_abstractive_words: compact_default_max_words(),
257            chunk_tokens: compact_default_chunk_tokens(),
258            history_retain: compact_default_history_retain(),
259            daemon_cron: compact_default_cron(),
260            extractive_backend: None,
261            abstractive_backend: None,
262        }
263    }
264}
265
266fn compact_default_max_days() -> u32 {
267    7
268}
269fn compact_default_max_spans() -> u32 {
270    20
271}
272fn compact_default_max_words() -> u32 {
273    400
274}
275fn compact_default_chunk_tokens() -> u32 {
276    6000
277}
278fn compact_default_history_retain() -> u32 {
279    5
280}
281fn compact_default_cron() -> String {
282    "0 0 3 * * * *".into()
283}
284
285// ── Rollup config (Phase 3.2, Task 1) ─────────────────────────────────────────
286
287#[derive(Debug, Clone, Serialize, Deserialize)]
288pub struct RollupConfig {
289    #[serde(default = "rollup_default_enabled")]
290    pub enabled: bool,
291    #[serde(default = "rollup_default_max_weeks")]
292    pub max_weeks_per_run: u32,
293    #[serde(default = "rollup_default_max_months")]
294    pub max_months_per_run: u32,
295    #[serde(default = "rollup_default_max_spans_week")]
296    pub max_extractive_spans_per_week: u32,
297    #[serde(default = "rollup_default_max_words_week")]
298    pub max_abstractive_words_per_week: u32,
299    #[serde(default = "rollup_default_max_spans_month")]
300    pub max_extractive_spans_per_month: u32,
301    #[serde(default = "rollup_default_max_words_month")]
302    pub max_abstractive_words_per_month: u32,
303    #[serde(default = "rollup_default_week_mmr")]
304    pub week_mmr_threshold: f64,
305    #[serde(default = "rollup_default_month_mmr")]
306    pub month_mmr_threshold: f64,
307    /// Per-stage backend override for the extractive stage.
308    /// None = inherit the smart slot (`config.llm`).
309    #[serde(default)]
310    pub extractive_backend: Option<BackendConfig>,
311    /// Per-stage backend override for the abstractive stage.
312    /// None = inherit the smart slot (`config.llm`).
313    #[serde(default)]
314    pub abstractive_backend: Option<BackendConfig>,
315}
316
317impl Default for RollupConfig {
318    fn default() -> Self {
319        Self {
320            enabled: rollup_default_enabled(),
321            max_weeks_per_run: rollup_default_max_weeks(),
322            max_months_per_run: rollup_default_max_months(),
323            max_extractive_spans_per_week: rollup_default_max_spans_week(),
324            max_abstractive_words_per_week: rollup_default_max_words_week(),
325            max_extractive_spans_per_month: rollup_default_max_spans_month(),
326            max_abstractive_words_per_month: rollup_default_max_words_month(),
327            week_mmr_threshold: rollup_default_week_mmr(),
328            month_mmr_threshold: rollup_default_month_mmr(),
329            extractive_backend: None,
330            abstractive_backend: None,
331        }
332    }
333}
334
335impl RollupConfig {
336    /// Effective backend for the extractive stage. Override wins; otherwise
337    /// inherit the smart slot with the same 120s budget the previously
338    /// hardcoded inline config used (`summarize/rollup.rs`).
339    pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
340        self.extractive_backend
341            .clone()
342            .unwrap_or_else(|| BackendConfig {
343                timeout_secs: Some(120),
344                ..llm.to_backend_config()
345            })
346    }
347
348    /// Effective backend for the abstractive stage.
349    pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
350        self.abstractive_backend
351            .clone()
352            .unwrap_or_else(|| BackendConfig {
353                timeout_secs: Some(120),
354                ..llm.to_backend_config()
355            })
356    }
357}
358
359fn rollup_default_enabled() -> bool {
360    true
361}
362fn rollup_default_max_weeks() -> u32 {
363    4
364}
365fn rollup_default_max_months() -> u32 {
366    2
367}
368fn rollup_default_max_spans_week() -> u32 {
369    20
370}
371fn rollup_default_max_words_week() -> u32 {
372    500
373}
374fn rollup_default_max_spans_month() -> u32 {
375    20
376}
377fn rollup_default_max_words_month() -> u32 {
378    700
379}
380fn rollup_default_week_mmr() -> f64 {
381    0.85
382}
383fn rollup_default_month_mmr() -> f64 {
384    0.82
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct ConversationsSources {
389    #[serde(default = "conv_truthy")]
390    pub claude_code: bool,
391    #[serde(default = "conv_truthy")]
392    pub cursor: bool,
393    #[serde(default = "conv_truthy")]
394    pub gemini: bool,
395    #[serde(default)]
396    pub aider: AiderSourceConfig,
397}
398
399impl Default for ConversationsSources {
400    fn default() -> Self {
401        Self {
402            claude_code: true,
403            cursor: true,
404            gemini: true,
405            aider: AiderSourceConfig::default(),
406        }
407    }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
411pub struct AiderSourceConfig {
412    #[serde(default = "conv_truthy")]
413    pub enabled: bool,
414    #[serde(default)]
415    pub watched_dirs: Vec<String>,
416}
417
418impl Default for AiderSourceConfig {
419    fn default() -> Self {
420        Self {
421            enabled: true,
422            watched_dirs: Vec::new(),
423        }
424    }
425}
426
427#[derive(Debug, Clone, Serialize, Deserialize)]
428pub struct ConversationsFilter {
429    #[serde(default = "conv_default_dedup")]
430    pub dedup_threshold: f64,
431    #[serde(default = "conv_truthy")]
432    pub reject_heartbeat: bool,
433    #[serde(default = "conv_truthy")]
434    pub reject_system_restatement: bool,
435}
436
437impl Default for ConversationsFilter {
438    fn default() -> Self {
439        Self {
440            dedup_threshold: conv_default_dedup(),
441            reject_heartbeat: true,
442            reject_system_restatement: true,
443        }
444    }
445}
446
447#[cfg(test)]
448mod conversations_tests {
449    use super::*;
450
451    #[test]
452    fn conversations_section_defaults() {
453        let c = ConversationsConfig::default();
454        assert!(!c.enabled);
455        assert_eq!(c.retention_days, 30);
456        assert_eq!(c.poll_interval_secs, 300);
457        assert!(c.sources.claude_code);
458        assert!(c.sources.cursor);
459        assert!(c.sources.gemini);
460        assert!(c.sources.aider.enabled);
461        assert!(c.sources.aider.watched_dirs.is_empty());
462        assert_eq!(c.filter.dedup_threshold, 0.85);
463        assert!(c.filter.reject_heartbeat);
464        assert!(c.filter.reject_system_restatement);
465    }
466
467    #[test]
468    fn parse_from_yaml_with_overrides() {
469        let y = r#"
470conversations:
471  enabled: true
472  retention_days: 45
473  poll_interval_secs: 120
474  sources:
475    cursor: false
476    aider:
477      watched_dirs: ["~/Projects/a", "~/Projects/b"]
478  filter:
479    dedup_threshold: 0.9
480"#;
481        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
482        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
483        assert!(conv.enabled);
484        assert_eq!(conv.retention_days, 45);
485        assert_eq!(conv.poll_interval_secs, 120);
486        assert!(conv.sources.claude_code); // defaulted true
487        assert!(!conv.sources.cursor); // override
488        assert!(conv.sources.gemini); // defaulted true
489        assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
490        assert_eq!(conv.filter.dedup_threshold, 0.9);
491        assert!(conv.filter.reject_heartbeat); // defaulted true
492    }
493
494    #[test]
495    fn missing_conversations_section_is_fine() {
496        let y = r#"
497# No conversations section at all
498foo: bar
499"#;
500        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
501        // Default when absent
502        let conv: ConversationsConfig = v
503            .get("conversations")
504            .cloned()
505            .map(|x| serde_yaml::from_value(x).unwrap_or_default())
506            .unwrap_or_default();
507        assert_eq!(conv.retention_days, 30);
508    }
509
510    #[test]
511    fn compact_config_defaults() {
512        let c = CompactConfig::default();
513        assert!(c.enabled_in_daemon);
514        assert_eq!(c.max_days_per_run, 7);
515        assert_eq!(c.max_extractive_spans, 20);
516        assert_eq!(c.chunk_tokens, 6000);
517        assert_eq!(c.history_retain, 5);
518        assert_eq!(c.daemon_cron, "0 0 3 * * * *");
519    }
520
521    #[test]
522    fn compact_parses_partial_overrides() {
523        let y = r#"
524conversations:
525  compact:
526    max_days_per_run: 3
527    extractive_backend:
528      provider: anthropic
529      model: claude-haiku-4-5
530"#;
531        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
532        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
533        assert_eq!(conv.compact.max_days_per_run, 3);
534        assert_eq!(
535            conv.compact.extractive_backend.as_ref().unwrap().model,
536            "claude-haiku-4-5"
537        );
538        assert!(conv.compact.enabled_in_daemon); // default preserved
539        assert!(conv.compact.abstractive_backend.is_none()); // default preserved
540    }
541
542    #[test]
543    fn ask_config_defaults() {
544        let c = AskConfig::default();
545        assert_eq!(c.k_raw, 10);
546        assert_eq!(c.escalation_threshold, 0.5);
547        assert_eq!(c.mmr_threshold, 0.88);
548        assert_eq!(c.max_context_tokens, 6000);
549        assert_eq!(c.response_tokens, 1024);
550        assert_eq!(c.timeout_secs, 120);
551        assert_eq!(c.min_score, 0.35);
552    }
553
554    #[test]
555    fn ask_config_mmr_threshold_default_is_cosine_scaled() {
556        // Phase 3.1: default shifts from 0.85 (word-Jaccard) to 0.88 (cosine).
557        let c = AskConfig::default();
558        assert!(
559            (c.mmr_threshold - 0.88).abs() < 1e-9,
560            "expected 0.88, got {}",
561            c.mmr_threshold
562        );
563    }
564
565    #[test]
566    fn rollup_config_defaults() {
567        let c = RollupConfig::default();
568        assert!(c.enabled);
569        assert_eq!(c.max_weeks_per_run, 4);
570        assert_eq!(c.max_months_per_run, 2);
571        assert_eq!(c.max_extractive_spans_per_week, 20);
572        assert_eq!(c.max_abstractive_words_per_week, 500);
573        assert_eq!(c.max_extractive_spans_per_month, 20);
574        assert_eq!(c.max_abstractive_words_per_month, 700);
575        assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
576        assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
577    }
578
579    #[test]
580    fn rollup_config_plumbed_into_conversations_config() {
581        let c = ConversationsConfig::default();
582        assert!(c.rollup.enabled);
583    }
584
585    #[test]
586    fn ask_config_default_continue_history_turns_is_3() {
587        let c = AskConfig::default();
588        assert_eq!(c.continue_history_turns, 3);
589    }
590
591    #[test]
592    fn ask_config_default_compress_hits_enabled_is_true() {
593        let c = AskConfig::default();
594        assert!(c.compress_hits_enabled);
595    }
596
597    #[test]
598    fn ask_config_default_summarize_hits_enabled_is_true() {
599        let c = AskConfig::default();
600        assert!(c.summarize_hits_enabled);
601    }
602
603    #[test]
604    fn ask_config_default_summarize_model_is_none() {
605        let c = AskConfig::default();
606        assert!(c.summarize_model.is_none());
607    }
608
609    #[test]
610    fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
611        let y = r#"
612conversations:
613  ask:
614    summarize_hits_enabled: false
615    summarize_model: qwen3:4b
616"#;
617        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
618        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
619        assert!(!conv.ask.summarize_hits_enabled);
620        assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
621    }
622
623    #[test]
624    fn ask_config_yaml_without_summarize_fields_uses_defaults() {
625        // Phase 3.5 must be additive: an existing config.yaml with NO
626        // summarize_* keys must still parse and default to enabled=true,
627        // model=None.
628        let y = r#"
629conversations:
630  ask:
631    min_score: 0.4
632"#;
633        let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
634        let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
635        assert!(conv.ask.summarize_hits_enabled);
636        assert!(conv.ask.summarize_model.is_none());
637    }
638}