1use super::*;
2
3#[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 #[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 #[serde(default)]
41 pub backend: Option<BackendConfig>,
42 #[serde(default)]
46 pub rewriter_backend: Option<BackendConfig>,
47}
48
49impl AskConfig {
50 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 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#[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 #[serde(default)]
217 pub extractive_backend: Option<BackendConfig>,
218 #[serde(default)]
221 pub abstractive_backend: Option<BackendConfig>,
222}
223
224impl CompactConfig {
225 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 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#[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 #[serde(default)]
310 pub extractive_backend: Option<BackendConfig>,
311 #[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 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 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); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
490 assert_eq!(conv.filter.dedup_threshold, 0.9);
491 assert!(conv.filter.reject_heartbeat); }
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 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); assert!(conv.compact.abstractive_backend.is_none()); }
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 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 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}