1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Serialize, Deserialize, Default)]
6pub struct Config {
7 #[serde(default)]
8 pub embedding: EmbeddingConfig,
9
10 #[serde(default)]
11 pub llm: LlmConfig,
12
13 #[serde(default)]
14 pub retrieval: RetrievalConfig,
15
16 #[serde(default)]
17 pub paths: PathConfig,
18
19 #[serde(default)]
20 pub server: ServerConfig,
21
22 #[serde(default)]
23 pub community: CommunityConfig,
24
25 #[serde(default)]
26 pub conversations: ConversationsConfig,
27
28 #[serde(default)]
29 pub sync: SyncConfig,
30
31 #[serde(default)]
33 pub storage: StorageConfig,
34
35 #[serde(default)]
36 pub sources_global: SourcesGlobalConfig,
37
38 #[serde(default)]
40 pub sleep_cycle: SleepCycleConfig,
41}
42
43#[derive(Debug, Clone, Serialize, Deserialize, Default)]
44pub struct SyncConfig {
45 #[serde(default = "default_sync_method")]
47 pub method: String,
48
49 #[serde(default, skip_serializing_if = "Option::is_none")]
51 pub git_remote: Option<String>,
52
53 #[serde(default)]
55 pub auto: bool,
56}
57
58fn default_sync_method() -> String {
59 "local".to_string()
60}
61
62#[derive(Debug, Clone, Serialize, Deserialize)]
63pub struct ServerConfig {
64 #[serde(default = "default_server_url")]
66 pub url: String,
67}
68
69impl Default for ServerConfig {
70 fn default() -> Self {
71 Self {
72 url: default_server_url(),
73 }
74 }
75}
76
77#[derive(Debug, Clone, Serialize, Deserialize, Default)]
78pub struct CommunityConfig {
79 #[serde(default)]
81 pub enabled: bool,
82}
83
84#[derive(Debug, Clone, Serialize, Deserialize)]
85pub struct EmbeddingConfig {
86 #[serde(default = "default_embedding_provider")]
88 pub provider: String,
89
90 #[serde(default = "default_embedding_model")]
92 pub model: String,
93
94 #[serde(default = "default_dimensions")]
96 pub dimensions: usize,
97
98 #[serde(default = "default_ollama_endpoint")]
100 pub ollama_endpoint: String,
101
102 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub api_key_env: Option<String>,
105
106 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub openai_url: Option<String>,
109}
110
111impl Default for EmbeddingConfig {
112 fn default() -> Self {
113 Self {
114 provider: default_embedding_provider(),
115 model: default_embedding_model(),
116 dimensions: default_dimensions(),
117 ollama_endpoint: default_ollama_endpoint(),
118 api_key_env: None,
119 openai_url: None,
120 }
121 }
122}
123
124#[derive(Debug, Clone, Serialize, Deserialize)]
125pub struct LlmConfig {
126 #[serde(default = "default_llm_provider")]
128 pub provider: String,
129
130 #[serde(default = "default_llm_model")]
131 pub model: String,
132
133 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub api_key_env: Option<String>,
136
137 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub openai_url: Option<String>,
140}
141
142impl Default for LlmConfig {
143 fn default() -> Self {
144 Self {
145 provider: default_llm_provider(),
146 model: default_llm_model(),
147 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
148 openai_url: None,
149 }
150 }
151}
152
153impl LlmConfig {
154 pub fn to_backend_config(&self) -> BackendConfig {
167 let provider = match self.provider.as_str() {
168 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
169 _ if self.openai_url.is_some() => "openai".into(),
170 other => other.into(), };
172 BackendConfig {
173 provider,
174 model: self.model.clone(),
175 endpoint: self.openai_url.clone(),
176 api_key_env: self.api_key_env.clone(),
177 timeout_secs: None,
178 }
179 }
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
193#[serde(default)]
194pub struct BackendConfig {
195 pub provider: String,
197 pub model: String,
199 pub endpoint: Option<String>,
202 pub api_key_env: Option<String>,
204 pub timeout_secs: Option<u64>,
206}
207
208impl Default for BackendConfig {
209 fn default() -> Self {
210 Self {
211 provider: "ollama".into(),
212 model: "qwen3:4b".into(),
213 endpoint: None,
214 api_key_env: None,
215 timeout_secs: None,
216 }
217 }
218}
219
220#[derive(Debug, Clone, Serialize, Deserialize)]
221pub struct RetrievalConfig {
222 #[serde(default = "default_max_patterns")]
224 pub max_patterns: usize,
225
226 #[serde(default = "default_max_tokens")]
228 pub max_tokens: usize,
229
230 #[serde(default = "default_min_score")]
232 pub min_score: f64,
233
234 #[serde(default = "default_mmr_threshold")]
236 pub mmr_threshold: f64,
237}
238
239impl Default for RetrievalConfig {
240 fn default() -> Self {
241 Self {
242 max_patterns: default_max_patterns(),
243 max_tokens: default_max_tokens(),
244 min_score: default_min_score(),
245 mmr_threshold: default_mmr_threshold(),
246 }
247 }
248}
249
250#[derive(Debug, Clone, Serialize, Deserialize)]
251pub struct PathConfig {
252 #[serde(default = "default_mur_dir")]
254 pub mur_dir: PathBuf,
255}
256
257impl Default for PathConfig {
258 fn default() -> Self {
259 Self {
260 mur_dir: default_mur_dir(),
261 }
262 }
263}
264
265#[derive(Debug, Clone, Serialize, Deserialize)]
266pub struct StorageConfig {
267 #[serde(default = "default_vector_backend")]
269 pub vector_backend: String,
270
271 #[serde(default, skip_serializing_if = "Option::is_none")]
273 pub qdrant_url: Option<String>,
274
275 #[serde(default, skip_serializing_if = "Option::is_none")]
277 pub qdrant_api_key_ref: Option<String>,
278}
279
280impl Default for StorageConfig {
281 fn default() -> Self {
282 Self {
283 vector_backend: default_vector_backend(),
284 qdrant_url: None,
285 qdrant_api_key_ref: None,
286 }
287 }
288}
289
290fn default_vector_backend() -> String {
291 "lancedb".to_string()
292}
293
294#[derive(Debug, Clone, Serialize, Deserialize)]
295pub struct SourcesGlobalConfig {
296 #[serde(default = "default_poll_interval_secs")]
298 pub poll_interval_secs: u64,
299
300 #[serde(default = "default_max_chunks_per_sync")]
302 pub max_chunks_per_sync: usize,
303
304 #[serde(default = "default_max_parallel_sources")]
306 pub max_parallel_sources: usize,
307
308 #[serde(default = "default_source_weight")]
310 pub default_weight: f32,
311
312 #[serde(default = "default_embedding_batch_size")]
314 pub embedding_batch_size: usize,
315}
316
317impl Default for SourcesGlobalConfig {
318 fn default() -> Self {
319 Self {
320 poll_interval_secs: default_poll_interval_secs(),
321 max_chunks_per_sync: default_max_chunks_per_sync(),
322 max_parallel_sources: default_max_parallel_sources(),
323 default_weight: default_source_weight(),
324 embedding_batch_size: default_embedding_batch_size(),
325 }
326 }
327}
328
329fn default_poll_interval_secs() -> u64 {
330 600
331}
332fn default_max_chunks_per_sync() -> usize {
333 10_000
334}
335fn default_max_parallel_sources() -> usize {
336 3
337}
338fn default_source_weight() -> f32 {
339 1.0
340}
341fn default_embedding_batch_size() -> usize {
342 32
343}
344
345fn default_embedding_provider() -> String {
346 "ollama".to_string()
347}
348fn default_embedding_model() -> String {
349 "qwen3-embedding:0.6b".to_string()
350}
351fn default_dimensions() -> usize {
352 1024
353}
354fn default_ollama_endpoint() -> String {
355 "http://localhost:11434".to_string()
356}
357fn default_llm_provider() -> String {
358 "anthropic".to_string()
359}
360fn default_llm_model() -> String {
361 "claude-opus-4-6".to_string()
362}
363fn default_max_patterns() -> usize {
364 5
365}
366fn default_max_tokens() -> usize {
367 2000
368}
369fn default_min_score() -> f64 {
370 0.35
371}
372fn default_mmr_threshold() -> f64 {
373 0.85
374}
375fn default_mur_dir() -> PathBuf {
376 let home = std::env::var("HOME")
379 .map(PathBuf::from)
380 .unwrap_or_else(|_| PathBuf::from("/tmp"));
381 home.join(".mur")
382}
383fn default_server_url() -> String {
384 "https://mur-server.fly.dev".to_string()
385}
386
387#[derive(Debug, Clone, Serialize, Deserialize)]
390pub struct AskConfig {
391 #[serde(default = "ask_default_model")]
392 pub model: String,
393 #[serde(default = "compact_default_ollama_endpoint")]
394 pub ollama_endpoint: String,
395 #[serde(default = "ask_default_k_summary")]
396 pub k_summary: u32,
397 #[serde(default = "ask_default_k_raw")]
398 pub k_raw: u32,
399 #[serde(default = "ask_default_esc")]
400 pub escalation_threshold: f64,
401 #[serde(default = "ask_default_mmr")]
402 pub mmr_threshold: f64,
403 #[serde(default = "ask_default_max_ctx")]
404 pub max_context_tokens: u32,
405 #[serde(default = "ask_default_resp_tok")]
406 pub response_tokens: u32,
407 #[serde(default = "ask_default_timeout")]
408 pub timeout_secs: u32,
409 #[serde(default = "ask_default_min_score")]
410 pub min_score: f64,
411 #[serde(default = "ask_default_continue_history_turns")]
412 pub continue_history_turns: u32,
413 #[serde(default = "ask_default_rewriter_timeout")]
419 pub rewriter_timeout_secs: u32,
420 #[serde(default = "ask_default_compress_hits_enabled")]
421 pub compress_hits_enabled: bool,
422 #[serde(default = "ask_default_summarize_hits_enabled")]
423 pub summarize_hits_enabled: bool,
424 #[serde(default)]
425 pub summarize_model: Option<String>,
426 #[serde(default)]
429 pub backend: Option<BackendConfig>,
430 #[serde(default)]
434 pub rewriter_backend: Option<BackendConfig>,
435}
436
437impl AskConfig {
438 pub fn synthesize_backend(&self) -> BackendConfig {
448 self.backend.clone().unwrap_or_else(|| BackendConfig {
449 provider: "ollama".into(),
450 model: self.model.clone(),
451 endpoint: Some(self.ollama_endpoint.clone()),
452 api_key_env: None,
453 timeout_secs: Some(self.timeout_secs as u64),
454 })
455 }
456
457 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
467 self.rewriter_backend
468 .clone()
469 .unwrap_or_else(|| BackendConfig {
470 provider: "ollama".into(),
471 model: self.model.clone(),
472 endpoint: Some(self.ollama_endpoint.clone()),
473 api_key_env: None,
474 timeout_secs: Some(self.rewriter_timeout_secs as u64),
475 })
476 }
477}
478
479impl Default for AskConfig {
480 fn default() -> Self {
481 Self {
482 model: ask_default_model(),
483 ollama_endpoint: compact_default_ollama_endpoint(),
484 k_summary: ask_default_k_summary(),
485 k_raw: ask_default_k_raw(),
486 escalation_threshold: ask_default_esc(),
487 mmr_threshold: ask_default_mmr(),
488 max_context_tokens: ask_default_max_ctx(),
489 response_tokens: ask_default_resp_tok(),
490 timeout_secs: ask_default_timeout(),
491 min_score: ask_default_min_score(),
492 continue_history_turns: ask_default_continue_history_turns(),
493 rewriter_timeout_secs: ask_default_rewriter_timeout(),
494 compress_hits_enabled: ask_default_compress_hits_enabled(),
495 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
496 summarize_model: None,
497 backend: None,
498 rewriter_backend: None,
499 }
500 }
501}
502
503fn ask_default_model() -> String {
504 "qwen3:4b".into()
505}
506fn ask_default_k_summary() -> u32 {
507 5
508}
509fn ask_default_k_raw() -> u32 {
510 10
511}
512fn ask_default_esc() -> f64 {
513 0.5
514}
515fn ask_default_mmr() -> f64 {
516 0.88
517}
518fn ask_default_max_ctx() -> u32 {
519 6000
520}
521fn ask_default_resp_tok() -> u32 {
522 1024
523}
524fn ask_default_timeout() -> u32 {
525 120
526}
527fn ask_default_min_score() -> f64 {
528 0.35
529}
530fn ask_default_rewriter_timeout() -> u32 {
531 8
532}
533fn ask_default_continue_history_turns() -> u32 {
534 3
535}
536fn ask_default_compress_hits_enabled() -> bool {
537 true
538}
539fn ask_default_summarize_hits_enabled() -> bool {
540 true
541}
542
543#[derive(Debug, Clone, Serialize, Deserialize)]
552pub struct ConversationsConfig {
553 #[serde(default)]
554 pub enabled: bool,
555 #[serde(default = "conv_default_retention_days")]
556 pub retention_days: u32,
557 #[serde(default = "conv_default_poll_interval")]
558 pub poll_interval_secs: u64,
559 #[serde(default)]
560 pub sources: ConversationsSources,
561 #[serde(default)]
562 pub filter: ConversationsFilter,
563 #[serde(default)]
564 pub compact: CompactConfig,
565 #[serde(default)]
566 pub ask: AskConfig,
567 #[serde(default)]
568 pub rollup: RollupConfig,
569}
570
571impl Default for ConversationsConfig {
572 fn default() -> Self {
573 Self {
574 enabled: false,
575 retention_days: conv_default_retention_days(),
576 poll_interval_secs: conv_default_poll_interval(),
577 sources: ConversationsSources::default(),
578 filter: ConversationsFilter::default(),
579 compact: CompactConfig::default(),
580 ask: AskConfig::default(),
581 rollup: RollupConfig::default(),
582 }
583 }
584}
585
586fn conv_default_retention_days() -> u32 {
587 30
588}
589fn conv_default_poll_interval() -> u64 {
590 300
591}
592fn conv_truthy() -> bool {
593 true
594}
595fn conv_default_dedup() -> f64 {
596 0.85
597}
598
599#[derive(Debug, Clone, Serialize, Deserialize)]
600pub struct CompactConfig {
601 #[serde(default = "conv_truthy")]
602 pub enabled_in_daemon: bool,
603 #[serde(default = "compact_default_max_days")]
604 pub max_days_per_run: u32,
605 #[serde(default = "compact_default_model")]
606 pub extractive_model: String,
607 #[serde(default = "compact_default_model")]
608 pub abstractive_model: String,
609 #[serde(default = "compact_default_ollama_endpoint")]
610 pub ollama_endpoint: String,
611 #[serde(default = "compact_default_max_spans")]
612 pub max_extractive_spans: u32,
613 #[serde(default = "compact_default_max_words")]
614 pub max_abstractive_words: u32,
615 #[serde(default = "compact_default_chunk_tokens")]
616 pub chunk_tokens: u32,
617 #[serde(default = "compact_default_history_retain")]
618 pub history_retain: u32,
619 #[serde(default = "compact_default_cron")]
620 pub daemon_cron: String,
621 #[serde(default)]
624 pub extractive_backend: Option<BackendConfig>,
625 #[serde(default)]
628 pub abstractive_backend: Option<BackendConfig>,
629}
630
631impl CompactConfig {
632 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
641 self.extractive_backend
642 .clone()
643 .unwrap_or_else(|| BackendConfig {
644 provider: "ollama".into(),
645 model: self.extractive_model.clone(),
646 endpoint: Some(self.ollama_endpoint.clone()),
647 api_key_env: None,
648 timeout_secs: Some(120),
649 })
650 }
651
652 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
655 self.abstractive_backend
656 .clone()
657 .unwrap_or_else(|| BackendConfig {
658 provider: "ollama".into(),
659 model: self.abstractive_model.clone(),
660 endpoint: Some(self.ollama_endpoint.clone()),
661 api_key_env: None,
662 timeout_secs: Some(120),
663 })
664 }
665}
666
667impl Default for CompactConfig {
668 fn default() -> Self {
669 Self {
670 enabled_in_daemon: true,
671 max_days_per_run: compact_default_max_days(),
672 extractive_model: compact_default_model(),
673 abstractive_model: compact_default_model(),
674 ollama_endpoint: compact_default_ollama_endpoint(),
675 max_extractive_spans: compact_default_max_spans(),
676 max_abstractive_words: compact_default_max_words(),
677 chunk_tokens: compact_default_chunk_tokens(),
678 history_retain: compact_default_history_retain(),
679 daemon_cron: compact_default_cron(),
680 extractive_backend: None,
681 abstractive_backend: None,
682 }
683 }
684}
685
686fn compact_default_max_days() -> u32 {
687 7
688}
689fn compact_default_model() -> String {
690 "qwen3:4b".into()
691}
692fn compact_default_ollama_endpoint() -> String {
693 "http://localhost:11434".into()
694}
695fn compact_default_max_spans() -> u32 {
696 20
697}
698fn compact_default_max_words() -> u32 {
699 400
700}
701fn compact_default_chunk_tokens() -> u32 {
702 6000
703}
704fn compact_default_history_retain() -> u32 {
705 5
706}
707fn compact_default_cron() -> String {
708 "0 0 3 * * * *".into()
709}
710
711#[derive(Debug, Clone, Serialize, Deserialize)]
714pub struct RollupConfig {
715 #[serde(default = "rollup_default_enabled")]
716 pub enabled: bool,
717 #[serde(default = "rollup_default_max_weeks")]
718 pub max_weeks_per_run: u32,
719 #[serde(default = "rollup_default_max_months")]
720 pub max_months_per_run: u32,
721 #[serde(default = "rollup_default_max_spans_week")]
722 pub max_extractive_spans_per_week: u32,
723 #[serde(default = "rollup_default_max_words_week")]
724 pub max_abstractive_words_per_week: u32,
725 #[serde(default = "rollup_default_max_spans_month")]
726 pub max_extractive_spans_per_month: u32,
727 #[serde(default = "rollup_default_max_words_month")]
728 pub max_abstractive_words_per_month: u32,
729 #[serde(default = "rollup_default_week_mmr")]
730 pub week_mmr_threshold: f64,
731 #[serde(default = "rollup_default_month_mmr")]
732 pub month_mmr_threshold: f64,
733 #[serde(default = "compact_default_model")]
734 pub extractive_model: String,
735 #[serde(default = "compact_default_model")]
736 pub abstractive_model: String,
737 #[serde(default = "compact_default_ollama_endpoint")]
738 pub ollama_endpoint: String,
739}
740
741impl Default for RollupConfig {
742 fn default() -> Self {
743 Self {
744 enabled: rollup_default_enabled(),
745 max_weeks_per_run: rollup_default_max_weeks(),
746 max_months_per_run: rollup_default_max_months(),
747 max_extractive_spans_per_week: rollup_default_max_spans_week(),
748 max_abstractive_words_per_week: rollup_default_max_words_week(),
749 max_extractive_spans_per_month: rollup_default_max_spans_month(),
750 max_abstractive_words_per_month: rollup_default_max_words_month(),
751 week_mmr_threshold: rollup_default_week_mmr(),
752 month_mmr_threshold: rollup_default_month_mmr(),
753 extractive_model: compact_default_model(),
754 abstractive_model: compact_default_model(),
755 ollama_endpoint: compact_default_ollama_endpoint(),
756 }
757 }
758}
759
760fn rollup_default_enabled() -> bool {
761 true
762}
763fn rollup_default_max_weeks() -> u32 {
764 4
765}
766fn rollup_default_max_months() -> u32 {
767 2
768}
769fn rollup_default_max_spans_week() -> u32 {
770 20
771}
772fn rollup_default_max_words_week() -> u32 {
773 500
774}
775fn rollup_default_max_spans_month() -> u32 {
776 20
777}
778fn rollup_default_max_words_month() -> u32 {
779 700
780}
781fn rollup_default_week_mmr() -> f64 {
782 0.85
783}
784fn rollup_default_month_mmr() -> f64 {
785 0.82
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct ConversationsSources {
790 #[serde(default = "conv_truthy")]
791 pub claude_code: bool,
792 #[serde(default = "conv_truthy")]
793 pub cursor: bool,
794 #[serde(default = "conv_truthy")]
795 pub gemini: bool,
796 #[serde(default)]
797 pub aider: AiderSourceConfig,
798}
799
800impl Default for ConversationsSources {
801 fn default() -> Self {
802 Self {
803 claude_code: true,
804 cursor: true,
805 gemini: true,
806 aider: AiderSourceConfig::default(),
807 }
808 }
809}
810
811#[derive(Debug, Clone, Serialize, Deserialize)]
812pub struct AiderSourceConfig {
813 #[serde(default = "conv_truthy")]
814 pub enabled: bool,
815 #[serde(default)]
816 pub watched_dirs: Vec<String>,
817}
818
819impl Default for AiderSourceConfig {
820 fn default() -> Self {
821 Self {
822 enabled: true,
823 watched_dirs: Vec::new(),
824 }
825 }
826}
827
828#[derive(Debug, Clone, Serialize, Deserialize)]
829pub struct ConversationsFilter {
830 #[serde(default = "conv_default_dedup")]
831 pub dedup_threshold: f64,
832 #[serde(default = "conv_truthy")]
833 pub reject_heartbeat: bool,
834 #[serde(default = "conv_truthy")]
835 pub reject_system_restatement: bool,
836}
837
838impl Default for ConversationsFilter {
839 fn default() -> Self {
840 Self {
841 dedup_threshold: conv_default_dedup(),
842 reject_heartbeat: true,
843 reject_system_restatement: true,
844 }
845 }
846}
847
848#[cfg(test)]
849mod conversations_tests {
850 use super::*;
851
852 #[test]
853 fn conversations_section_defaults() {
854 let c = ConversationsConfig::default();
855 assert!(!c.enabled);
856 assert_eq!(c.retention_days, 30);
857 assert_eq!(c.poll_interval_secs, 300);
858 assert!(c.sources.claude_code);
859 assert!(c.sources.cursor);
860 assert!(c.sources.gemini);
861 assert!(c.sources.aider.enabled);
862 assert!(c.sources.aider.watched_dirs.is_empty());
863 assert_eq!(c.filter.dedup_threshold, 0.85);
864 assert!(c.filter.reject_heartbeat);
865 assert!(c.filter.reject_system_restatement);
866 }
867
868 #[test]
869 fn parse_from_yaml_with_overrides() {
870 let y = r#"
871conversations:
872 enabled: true
873 retention_days: 45
874 poll_interval_secs: 120
875 sources:
876 cursor: false
877 aider:
878 watched_dirs: ["~/Projects/a", "~/Projects/b"]
879 filter:
880 dedup_threshold: 0.9
881"#;
882 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
883 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
884 assert!(conv.enabled);
885 assert_eq!(conv.retention_days, 45);
886 assert_eq!(conv.poll_interval_secs, 120);
887 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
891 assert_eq!(conv.filter.dedup_threshold, 0.9);
892 assert!(conv.filter.reject_heartbeat); }
894
895 #[test]
896 fn missing_conversations_section_is_fine() {
897 let y = r#"
898# No conversations section at all
899foo: bar
900"#;
901 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
902 let conv: ConversationsConfig = v
904 .get("conversations")
905 .cloned()
906 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
907 .unwrap_or_default();
908 assert_eq!(conv.retention_days, 30);
909 }
910
911 #[test]
912 fn compact_config_defaults() {
913 let c = CompactConfig::default();
914 assert!(c.enabled_in_daemon);
915 assert_eq!(c.max_days_per_run, 7);
916 assert_eq!(c.extractive_model, "qwen3:4b");
917 assert_eq!(c.abstractive_model, "qwen3:4b");
918 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
919 assert_eq!(c.max_extractive_spans, 20);
920 assert_eq!(c.max_abstractive_words, 400);
921 assert_eq!(c.chunk_tokens, 6000);
922 assert_eq!(c.history_retain, 5);
923 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
924 }
925
926 #[test]
927 fn compact_parses_partial_overrides() {
928 let y = r#"
929conversations:
930 compact:
931 max_days_per_run: 3
932 extractive_model: qwen3:4b
933"#;
934 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
935 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
936 assert_eq!(conv.compact.max_days_per_run, 3);
937 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
938 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3:4b"); }
941
942 #[test]
943 fn ask_config_defaults() {
944 let c = AskConfig::default();
945 assert_eq!(c.model, "qwen3:4b");
946 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
947 assert_eq!(c.k_summary, 5);
948 assert_eq!(c.k_raw, 10);
949 assert_eq!(c.escalation_threshold, 0.5);
950 assert_eq!(c.mmr_threshold, 0.88);
951 assert_eq!(c.max_context_tokens, 6000);
952 assert_eq!(c.response_tokens, 1024);
953 assert_eq!(c.timeout_secs, 120);
954 assert_eq!(c.min_score, 0.35);
955 }
956
957 #[test]
958 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
959 let c = AskConfig::default();
961 assert!(
962 (c.mmr_threshold - 0.88).abs() < 1e-9,
963 "expected 0.88, got {}",
964 c.mmr_threshold
965 );
966 }
967
968 #[test]
969 fn rollup_config_defaults() {
970 let c = RollupConfig::default();
971 assert!(c.enabled);
972 assert_eq!(c.max_weeks_per_run, 4);
973 assert_eq!(c.max_months_per_run, 2);
974 assert_eq!(c.max_extractive_spans_per_week, 20);
975 assert_eq!(c.max_abstractive_words_per_week, 500);
976 assert_eq!(c.max_extractive_spans_per_month, 20);
977 assert_eq!(c.max_abstractive_words_per_month, 700);
978 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
979 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
980 assert_eq!(c.extractive_model, "qwen3:4b");
981 assert_eq!(c.abstractive_model, "qwen3:4b");
982 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
983 }
984
985 #[test]
986 fn rollup_config_plumbed_into_conversations_config() {
987 let c = ConversationsConfig::default();
988 assert!(c.rollup.enabled);
989 }
990
991 #[test]
992 fn ask_config_default_continue_history_turns_is_3() {
993 let c = AskConfig::default();
994 assert_eq!(c.continue_history_turns, 3);
995 }
996
997 #[test]
998 fn ask_config_default_compress_hits_enabled_is_true() {
999 let c = AskConfig::default();
1000 assert!(c.compress_hits_enabled);
1001 }
1002
1003 #[test]
1004 fn ask_config_default_summarize_hits_enabled_is_true() {
1005 let c = AskConfig::default();
1006 assert!(c.summarize_hits_enabled);
1007 }
1008
1009 #[test]
1010 fn ask_config_default_summarize_model_is_none() {
1011 let c = AskConfig::default();
1012 assert!(c.summarize_model.is_none());
1013 }
1014
1015 #[test]
1016 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1017 let y = r#"
1018conversations:
1019 ask:
1020 summarize_hits_enabled: false
1021 summarize_model: qwen3:4b
1022"#;
1023 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1024 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1025 assert!(!conv.ask.summarize_hits_enabled);
1026 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1027 }
1028
1029 #[test]
1030 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1031 let y = r#"
1035conversations:
1036 ask:
1037 model: qwen3:14b
1038"#;
1039 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1040 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1041 assert!(conv.ask.summarize_hits_enabled);
1042 assert!(conv.ask.summarize_model.is_none());
1043 }
1044}
1045
1046#[cfg(test)]
1047mod tests {
1048 use super::*;
1049
1050 #[test]
1051 fn storage_config_default_is_lancedb() {
1052 let c = StorageConfig::default();
1053 assert_eq!(c.vector_backend, "lancedb");
1054 assert_eq!(c.qdrant_url, None);
1055 assert_eq!(c.qdrant_api_key_ref, None);
1056 }
1057
1058 #[test]
1059 fn sources_global_config_has_sensible_defaults() {
1060 let c = SourcesGlobalConfig::default();
1061 assert_eq!(c.poll_interval_secs, 600);
1062 assert_eq!(c.max_chunks_per_sync, 10_000);
1063 assert_eq!(c.max_parallel_sources, 3);
1064 assert_eq!(c.default_weight, 1.0);
1065 assert_eq!(c.embedding_batch_size, 32);
1066 }
1067
1068 #[test]
1069 fn config_default_has_storage_and_sources_global() {
1070 let c = Config::default();
1071 assert_eq!(c.storage.vector_backend, "lancedb");
1072 assert_eq!(c.sources_global.default_weight, 1.0);
1073 }
1074
1075 #[test]
1076 fn config_loads_yaml_without_new_fields() {
1077 let yaml = r#"
1080embedding:
1081 provider: ollama
1082 model: test-model
1083 dimensions: 512
1084 ollama_endpoint: http://localhost:11434
1085"#;
1086 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1087 assert_eq!(c.storage.vector_backend, "lancedb");
1088 assert_eq!(c.sources_global.max_parallel_sources, 3);
1089 }
1090
1091 #[test]
1092 fn llm_config_to_backend_config_anthropic_passthrough() {
1093 let cfg = LlmConfig {
1094 provider: "anthropic".into(),
1095 model: "claude-haiku-4-5".into(),
1096 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1097 openai_url: None,
1098 };
1099 let b = cfg.to_backend_config();
1100 assert_eq!(b.provider, "anthropic");
1101 assert_eq!(b.model, "claude-haiku-4-5");
1102 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1103 assert_eq!(b.endpoint, None);
1104 assert_eq!(b.timeout_secs, None);
1105 }
1106
1107 #[test]
1108 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1109 let cfg = LlmConfig {
1110 provider: "openai".into(),
1111 model: "gpt-4o-mini".into(),
1112 api_key_env: None,
1113 openai_url: Some("https://api.together.xyz/v1".into()),
1114 };
1115 let b = cfg.to_backend_config();
1116 assert_eq!(b.provider, "openai");
1117 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1118 assert_eq!(b.api_key_env, None); }
1120
1121 #[test]
1122 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1123 let cfg = LlmConfig {
1124 provider: "ollama".into(),
1125 model: "qwen3:14b".into(),
1126 api_key_env: None,
1127 openai_url: Some("http://192.168.1.10:11434".into()),
1128 };
1129 let b = cfg.to_backend_config();
1130 assert_eq!(b.provider, "ollama");
1131 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1132 }
1133
1134 #[test]
1135 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1136 let cfg = LlmConfig {
1140 provider: "custom-name".into(),
1141 model: "some-model".into(),
1142 api_key_env: Some("CUSTOM_KEY".into()),
1143 openai_url: Some("https://my-proxy.local/v1".into()),
1144 };
1145 let b = cfg.to_backend_config();
1146 assert_eq!(
1147 b.provider, "openai",
1148 "unknown provider + openai_url should alias to openai"
1149 );
1150 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1151 }
1152}
1153
1154#[cfg(test)]
1155mod backend_config_tests {
1156 use super::*;
1157
1158 #[test]
1159 fn default_is_ollama_qwen3() {
1160 let cfg = BackendConfig::default();
1161 assert_eq!(cfg.provider, "ollama");
1162 assert_eq!(cfg.model, "qwen3:4b");
1163 assert_eq!(cfg.endpoint, None);
1164 assert_eq!(cfg.api_key_env, None);
1165 assert_eq!(cfg.timeout_secs, None);
1166 }
1167
1168 #[test]
1169 fn deserializes_anthropic_full() {
1170 let yaml = "\
1171provider: anthropic
1172model: claude-haiku-4-5
1173api_key_env: ANTHROPIC_API_KEY
1174timeout_secs: 60
1175";
1176 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1177 assert_eq!(cfg.provider, "anthropic");
1178 assert_eq!(cfg.model, "claude-haiku-4-5");
1179 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1180 assert_eq!(cfg.timeout_secs, Some(60));
1181 assert_eq!(cfg.endpoint, None);
1182 }
1183
1184 #[test]
1185 fn deserializes_partial_fills_defaults() {
1186 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1187 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1188 assert_eq!(cfg.provider, "anthropic");
1189 assert_eq!(cfg.model, "claude-sonnet-4-6");
1190 assert_eq!(cfg.api_key_env, None);
1191 assert_eq!(cfg.timeout_secs, None);
1192 }
1193
1194 #[test]
1195 fn round_trips_through_yaml() {
1196 let original = BackendConfig {
1197 provider: "anthropic".into(),
1198 model: "claude-haiku-4-5".into(),
1199 endpoint: Some("https://api.anthropic.com".into()),
1200 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1201 timeout_secs: Some(60),
1202 };
1203 let yaml = serde_yaml::to_string(&original).unwrap();
1204 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1205 assert_eq!(parsed, original);
1206 }
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1214pub struct SleepCycleConfig {
1215 #[serde(default)]
1217 pub enabled: bool,
1218
1219 #[serde(default = "default_idle_threshold_minutes")]
1221 pub idle_threshold_minutes: u64,
1222
1223 #[serde(default = "default_agent_idle_minutes")]
1225 pub agent_idle_minutes: u64,
1226}
1227
1228fn default_idle_threshold_minutes() -> u64 {
1229 15
1230}
1231
1232fn default_agent_idle_minutes() -> u64 {
1233 5
1234}
1235
1236impl Default for SleepCycleConfig {
1237 fn default() -> Self {
1238 Self {
1239 enabled: false,
1240 idle_threshold_minutes: default_idle_threshold_minutes(),
1241 agent_idle_minutes: default_agent_idle_minutes(),
1242 }
1243 }
1244}
1245
1246#[cfg(test)]
1247mod per_stage_backend_tests {
1248 use super::*;
1249
1250 #[test]
1251 fn legacy_compact_config_has_no_per_stage_overrides() {
1252 let yaml = "\
1253extractive_model: qwen3:14b
1254abstractive_model: qwen3:14b
1255ollama_endpoint: http://localhost:11434
1256";
1257 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1258 assert!(cfg.extractive_backend.is_none());
1259 assert!(cfg.abstractive_backend.is_none());
1260 assert_eq!(cfg.extractive_model, "qwen3:14b");
1261 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1262 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1263 }
1264
1265 #[test]
1266 fn legacy_ask_config_has_no_per_stage_overrides() {
1267 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1268 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1269 assert!(cfg.backend.is_none());
1270 assert!(cfg.rewriter_backend.is_none());
1271 assert_eq!(cfg.model, "qwen3:14b");
1272 }
1273
1274 #[test]
1275 fn compact_extractive_backend_override_parses() {
1276 let yaml = "\
1277extractive_backend:
1278 provider: anthropic
1279 model: claude-haiku-4-5
1280 api_key_env: ANTHROPIC_API_KEY
1281abstractive_model: qwen3:14b
1282";
1283 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1284 let extractive = cfg
1285 .extractive_backend
1286 .as_ref()
1287 .expect("override should parse");
1288 assert_eq!(extractive.provider, "anthropic");
1289 assert_eq!(extractive.model, "claude-haiku-4-5");
1290 assert!(cfg.abstractive_backend.is_none());
1291 }
1292
1293 #[test]
1294 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1295 let yaml = "\
1296backend:
1297 provider: anthropic
1298 model: claude-sonnet-4-6
1299 api_key_env: ANTHROPIC_API_KEY
1300rewriter_backend:
1301 provider: ollama
1302 model: llama3.2:3b
1303";
1304 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1305 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1306 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1307 }
1308
1309 #[test]
1310 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1311 let yaml = "\
1312extractive_model: qwen3:14b
1313ollama_endpoint: http://192.168.1.10:11434
1314";
1315 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1316 let synth = cfg.synthesize_extractive_backend();
1317 assert_eq!(synth.provider, "ollama");
1318 assert_eq!(synth.model, "qwen3:14b");
1319 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1320 assert_eq!(synth.api_key_env, None);
1321 }
1322
1323 #[test]
1324 fn synthesize_legacy_to_backend_config_for_ask() {
1325 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1326 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1327 let synth = cfg.synthesize_backend();
1328 assert_eq!(synth.provider, "ollama");
1329 assert_eq!(synth.model, "qwen3:14b");
1330 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1331 }
1332
1333 #[test]
1334 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1335 let yaml = "\
1344backend:
1345 provider: anthropic
1346 model: claude-sonnet-4-6
1347 api_key_env: ANTHROPIC_API_KEY
1348";
1349 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1350 let rewriter = cfg.synthesize_rewriter_backend();
1351 assert_eq!(rewriter.provider, "ollama");
1352 assert_eq!(rewriter.model, ask_default_model());
1353 assert_eq!(
1354 rewriter.timeout_secs,
1355 Some(ask_default_rewriter_timeout() as u64)
1356 );
1357 }
1358
1359 #[test]
1360 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1361 let cfg = AskConfig {
1362 timeout_secs: 45,
1363 ..AskConfig::default()
1364 };
1365 let b = cfg.synthesize_backend();
1366 assert_eq!(
1367 b.timeout_secs,
1368 Some(45),
1369 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1370 );
1371 }
1372
1373 #[test]
1374 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1375 let mut cfg = AskConfig {
1376 timeout_secs: 45,
1377 ..AskConfig::default()
1378 };
1379 cfg.backend = Some(BackendConfig {
1380 provider: "anthropic".into(),
1381 model: "claude-haiku-4-5".into(),
1382 endpoint: None,
1383 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1384 timeout_secs: Some(10),
1385 });
1386 let b = cfg.synthesize_backend();
1387 assert_eq!(
1388 b.timeout_secs,
1389 Some(10),
1390 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1391 );
1392 }
1393
1394 #[test]
1395 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1396 let cfg = AskConfig {
1397 timeout_secs: 120,
1398 rewriter_timeout_secs: 8,
1399 ..AskConfig::default()
1400 };
1401 let b = cfg.synthesize_rewriter_backend();
1402 assert_eq!(
1403 b.timeout_secs,
1404 Some(8),
1405 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1406 );
1407 }
1408
1409 #[test]
1410 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1411 let mut cfg = AskConfig {
1412 rewriter_timeout_secs: 8,
1413 ..AskConfig::default()
1414 };
1415 cfg.rewriter_backend = Some(BackendConfig {
1416 provider: "anthropic".into(),
1417 model: "claude-haiku-4-5".into(),
1418 endpoint: None,
1419 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1420 timeout_secs: Some(30),
1421 });
1422 let b = cfg.synthesize_rewriter_backend();
1423 assert_eq!(
1424 b.timeout_secs,
1425 Some(30),
1426 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1427 );
1428 }
1429
1430 #[test]
1431 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1432 let cfg = CompactConfig::default();
1435 let b = cfg.synthesize_extractive_backend();
1436 assert_eq!(
1437 b.timeout_secs,
1438 Some(120),
1439 "compact synthesis without per-stage override must produce 120s timeout"
1440 );
1441 }
1442
1443 #[test]
1444 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1445 let cfg = CompactConfig::default();
1446 let b = cfg.synthesize_abstractive_backend();
1447 assert_eq!(b.timeout_secs, Some(120));
1448 }
1449}