1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
11
12#[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 #[serde(default)]
41 pub storage: StorageConfig,
42
43 #[serde(default)]
44 pub sources_global: SourcesGlobalConfig,
45
46 #[serde(default)]
48 pub sleep_cycle: SleepCycleConfig,
49
50 #[serde(default)]
52 pub skills: SkillsConfig,
53
54 #[serde(default)]
56 pub skill_llm: SkillLlmConfig,
57
58 #[serde(default)]
60 pub cross_agent: CrossAgentConfig,
61
62 #[serde(default)]
64 pub nudge: NudgeConfig,
65
66 #[serde(default)]
68 pub mobile_relay: MobileRelayConfig,
69
70 #[serde(default)]
72 pub session: SessionCfg,
73
74 #[serde(default)]
75 pub harvest: HarvestCfg,
76}
77
78#[derive(Debug, Clone, Serialize, Deserialize, Default)]
81pub struct MobileRelayConfig {
82 #[serde(default, skip_serializing_if = "Option::is_none")]
85 pub relay_url: Option<String>,
86
87 #[serde(default, skip_serializing_if = "Option::is_none")]
90 pub api_key: Option<String>,
91}
92
93impl Config {
94 pub fn load_or_default(path: &std::path::Path) -> Self {
96 std::fs::read_to_string(path)
97 .ok()
98 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
99 .unwrap_or_default()
100 }
101}
102
103#[derive(Debug, Clone, Serialize, Deserialize, Default)]
104pub struct SyncConfig {
105 #[serde(default = "default_sync_method")]
107 pub method: String,
108
109 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub git_remote: Option<String>,
112
113 #[serde(default)]
115 pub auto: bool,
116
117 #[serde(default, skip_serializing_if = "Option::is_none")]
119 pub team_id: Option<String>,
120}
121
122fn default_sync_method() -> String {
123 "local".to_string()
124}
125
126#[derive(Debug, Clone, Serialize, Deserialize)]
127pub struct ServerConfig {
128 #[serde(default = "default_server_url")]
130 pub url: String,
131}
132
133impl Default for ServerConfig {
134 fn default() -> Self {
135 Self {
136 url: default_server_url(),
137 }
138 }
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, Default)]
142pub struct CommunityConfig {
143 #[serde(default)]
145 pub enabled: bool,
146}
147
148#[derive(Debug, Clone, Serialize, Deserialize)]
149pub struct EmbeddingConfig {
150 #[serde(default = "default_embedding_provider")]
152 pub provider: String,
153
154 #[serde(default = "default_embedding_model")]
156 pub model: String,
157
158 #[serde(default = "default_dimensions")]
160 pub dimensions: usize,
161
162 #[serde(default = "default_ollama_endpoint")]
164 pub ollama_endpoint: String,
165
166 #[serde(default, skip_serializing_if = "Option::is_none")]
168 pub api_key_env: Option<String>,
169
170 #[serde(default, skip_serializing_if = "Option::is_none")]
172 pub openai_url: Option<String>,
173}
174
175impl Default for EmbeddingConfig {
176 fn default() -> Self {
177 Self {
178 provider: default_embedding_provider(),
179 model: default_embedding_model(),
180 dimensions: default_dimensions(),
181 ollama_endpoint: default_ollama_endpoint(),
182 api_key_env: None,
183 openai_url: None,
184 }
185 }
186}
187
188#[derive(Debug, Clone, Serialize, Deserialize)]
189pub struct LlmConfig {
190 #[serde(default = "default_llm_provider")]
192 pub provider: String,
193
194 #[serde(default = "default_llm_model")]
195 pub model: String,
196
197 #[serde(default, skip_serializing_if = "Option::is_none")]
199 pub api_key_env: Option<String>,
200
201 #[serde(default, skip_serializing_if = "Option::is_none")]
203 pub openai_url: Option<String>,
204}
205
206impl Default for LlmConfig {
207 fn default() -> Self {
208 Self {
209 provider: default_llm_provider(),
210 model: default_llm_model(),
211 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
212 openai_url: None,
213 }
214 }
215}
216
217impl LlmConfig {
218 pub fn to_backend_config(&self) -> BackendConfig {
231 let provider = match self.provider.as_str() {
232 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
233 _ if self.openai_url.is_some() => "openai".into(),
234 other => other.into(), };
236 BackendConfig {
237 provider,
238 model: self.model.clone(),
239 endpoint: self.openai_url.clone(),
240 api_key_env: self.api_key_env.clone(),
241 timeout_secs: None,
242 }
243 }
244}
245
246#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
257#[serde(default)]
258pub struct BackendConfig {
259 pub provider: String,
261 pub model: String,
263 pub endpoint: Option<String>,
266 pub api_key_env: Option<String>,
268 pub timeout_secs: Option<u64>,
270}
271
272impl Default for BackendConfig {
273 fn default() -> Self {
274 Self {
275 provider: "ollama".into(),
276 model: DEFAULT_LOCAL_LLM_MODEL.into(),
277 endpoint: None,
278 api_key_env: None,
279 timeout_secs: None,
280 }
281 }
282}
283
284#[derive(Debug, Clone, Serialize, Deserialize)]
285pub struct RetrievalConfig {
286 #[serde(default = "default_max_patterns")]
288 pub max_patterns: usize,
289
290 #[serde(default = "default_max_tokens")]
292 pub max_tokens: usize,
293
294 #[serde(default = "default_min_score")]
296 pub min_score: f64,
297
298 #[serde(default = "default_mmr_threshold")]
300 pub mmr_threshold: f64,
301}
302
303impl Default for RetrievalConfig {
304 fn default() -> Self {
305 Self {
306 max_patterns: default_max_patterns(),
307 max_tokens: default_max_tokens(),
308 min_score: default_min_score(),
309 mmr_threshold: default_mmr_threshold(),
310 }
311 }
312}
313
314#[derive(Debug, Clone, Serialize, Deserialize)]
315pub struct PathConfig {
316 #[serde(default = "default_mur_dir")]
318 pub mur_dir: PathBuf,
319}
320
321impl Default for PathConfig {
322 fn default() -> Self {
323 Self {
324 mur_dir: default_mur_dir(),
325 }
326 }
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize)]
330pub struct StorageConfig {
331 #[serde(default = "default_vector_backend")]
333 pub vector_backend: String,
334
335 #[serde(default, skip_serializing_if = "Option::is_none")]
337 pub qdrant_url: Option<String>,
338
339 #[serde(default, skip_serializing_if = "Option::is_none")]
341 pub qdrant_api_key_ref: Option<String>,
342}
343
344impl Default for StorageConfig {
345 fn default() -> Self {
346 Self {
347 vector_backend: default_vector_backend(),
348 qdrant_url: None,
349 qdrant_api_key_ref: None,
350 }
351 }
352}
353
354fn default_vector_backend() -> String {
355 "lancedb".to_string()
356}
357
358#[derive(Debug, Clone, Serialize, Deserialize)]
359pub struct SourcesGlobalConfig {
360 #[serde(default = "default_poll_interval_secs")]
362 pub poll_interval_secs: u64,
363
364 #[serde(default = "default_max_chunks_per_sync")]
366 pub max_chunks_per_sync: usize,
367
368 #[serde(default = "default_max_parallel_sources")]
370 pub max_parallel_sources: usize,
371
372 #[serde(default = "default_source_weight")]
374 pub default_weight: f32,
375
376 #[serde(default = "default_embedding_batch_size")]
378 pub embedding_batch_size: usize,
379}
380
381impl Default for SourcesGlobalConfig {
382 fn default() -> Self {
383 Self {
384 poll_interval_secs: default_poll_interval_secs(),
385 max_chunks_per_sync: default_max_chunks_per_sync(),
386 max_parallel_sources: default_max_parallel_sources(),
387 default_weight: default_source_weight(),
388 embedding_batch_size: default_embedding_batch_size(),
389 }
390 }
391}
392
393fn default_poll_interval_secs() -> u64 {
394 600
395}
396fn default_max_chunks_per_sync() -> usize {
397 10_000
398}
399fn default_max_parallel_sources() -> usize {
400 3
401}
402fn default_source_weight() -> f32 {
403 1.0
404}
405fn default_embedding_batch_size() -> usize {
406 32
407}
408
409fn default_embedding_provider() -> String {
410 "ollama".to_string()
411}
412fn default_embedding_model() -> String {
413 "qwen3-embedding:0.6b".to_string()
414}
415fn default_dimensions() -> usize {
416 1024
417}
418fn default_ollama_endpoint() -> String {
419 "http://localhost:11434".to_string()
420}
421fn default_llm_provider() -> String {
422 "anthropic".to_string()
423}
424fn default_llm_model() -> String {
425 "claude-opus-4-6".to_string()
426}
427fn default_max_patterns() -> usize {
428 5
429}
430fn default_max_tokens() -> usize {
431 2000
432}
433fn default_min_score() -> f64 {
434 0.35
435}
436fn default_mmr_threshold() -> f64 {
437 0.85
438}
439fn default_mur_dir() -> PathBuf {
440 let home = std::env::var("HOME")
443 .map(PathBuf::from)
444 .unwrap_or_else(|_| PathBuf::from("/tmp"));
445 home.join(".mur")
446}
447fn default_server_url() -> String {
448 "https://mur-server.fly.dev".to_string()
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize)]
454pub struct AskConfig {
455 #[serde(default = "ask_default_model")]
456 pub model: String,
457 #[serde(default = "compact_default_ollama_endpoint")]
458 pub ollama_endpoint: String,
459 #[serde(default = "ask_default_k_summary")]
460 pub k_summary: u32,
461 #[serde(default = "ask_default_k_raw")]
462 pub k_raw: u32,
463 #[serde(default = "ask_default_esc")]
464 pub escalation_threshold: f64,
465 #[serde(default = "ask_default_mmr")]
466 pub mmr_threshold: f64,
467 #[serde(default = "ask_default_max_ctx")]
468 pub max_context_tokens: u32,
469 #[serde(default = "ask_default_resp_tok")]
470 pub response_tokens: u32,
471 #[serde(default = "ask_default_timeout")]
472 pub timeout_secs: u32,
473 #[serde(default = "ask_default_min_score")]
474 pub min_score: f64,
475 #[serde(default = "ask_default_continue_history_turns")]
476 pub continue_history_turns: u32,
477 #[serde(default = "ask_default_rewriter_timeout")]
483 pub rewriter_timeout_secs: u32,
484 #[serde(default = "ask_default_compress_hits_enabled")]
485 pub compress_hits_enabled: bool,
486 #[serde(default = "ask_default_summarize_hits_enabled")]
487 pub summarize_hits_enabled: bool,
488 #[serde(default)]
489 pub summarize_model: Option<String>,
490 #[serde(default)]
493 pub backend: Option<BackendConfig>,
494 #[serde(default)]
498 pub rewriter_backend: Option<BackendConfig>,
499}
500
501impl AskConfig {
502 pub fn synthesize_backend(&self) -> BackendConfig {
512 self.backend.clone().unwrap_or_else(|| BackendConfig {
513 provider: "ollama".into(),
514 model: self.model.clone(),
515 endpoint: Some(self.ollama_endpoint.clone()),
516 api_key_env: None,
517 timeout_secs: Some(self.timeout_secs as u64),
518 })
519 }
520
521 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
531 self.rewriter_backend
532 .clone()
533 .unwrap_or_else(|| BackendConfig {
534 provider: "ollama".into(),
535 model: self.model.clone(),
536 endpoint: Some(self.ollama_endpoint.clone()),
537 api_key_env: None,
538 timeout_secs: Some(self.rewriter_timeout_secs as u64),
539 })
540 }
541}
542
543impl Default for AskConfig {
544 fn default() -> Self {
545 Self {
546 model: ask_default_model(),
547 ollama_endpoint: compact_default_ollama_endpoint(),
548 k_summary: ask_default_k_summary(),
549 k_raw: ask_default_k_raw(),
550 escalation_threshold: ask_default_esc(),
551 mmr_threshold: ask_default_mmr(),
552 max_context_tokens: ask_default_max_ctx(),
553 response_tokens: ask_default_resp_tok(),
554 timeout_secs: ask_default_timeout(),
555 min_score: ask_default_min_score(),
556 continue_history_turns: ask_default_continue_history_turns(),
557 rewriter_timeout_secs: ask_default_rewriter_timeout(),
558 compress_hits_enabled: ask_default_compress_hits_enabled(),
559 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
560 summarize_model: None,
561 backend: None,
562 rewriter_backend: None,
563 }
564 }
565}
566
567fn ask_default_model() -> String {
568 DEFAULT_LOCAL_LLM_MODEL.into()
569}
570fn ask_default_k_summary() -> u32 {
571 5
572}
573fn ask_default_k_raw() -> u32 {
574 10
575}
576fn ask_default_esc() -> f64 {
577 0.5
578}
579fn ask_default_mmr() -> f64 {
580 0.88
581}
582fn ask_default_max_ctx() -> u32 {
583 6000
584}
585fn ask_default_resp_tok() -> u32 {
586 1024
587}
588fn ask_default_timeout() -> u32 {
589 120
590}
591fn ask_default_min_score() -> f64 {
592 0.35
593}
594fn ask_default_rewriter_timeout() -> u32 {
595 8
596}
597fn ask_default_continue_history_turns() -> u32 {
598 3
599}
600fn ask_default_compress_hits_enabled() -> bool {
601 true
602}
603fn ask_default_summarize_hits_enabled() -> bool {
604 true
605}
606
607#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct ConversationsConfig {
617 #[serde(default)]
618 pub enabled: bool,
619 #[serde(default = "conv_default_retention_days")]
620 pub retention_days: u32,
621 #[serde(default = "conv_default_poll_interval")]
622 pub poll_interval_secs: u64,
623 #[serde(default)]
624 pub sources: ConversationsSources,
625 #[serde(default)]
626 pub filter: ConversationsFilter,
627 #[serde(default)]
628 pub compact: CompactConfig,
629 #[serde(default)]
630 pub ask: AskConfig,
631 #[serde(default)]
632 pub rollup: RollupConfig,
633}
634
635impl Default for ConversationsConfig {
636 fn default() -> Self {
637 Self {
638 enabled: false,
639 retention_days: conv_default_retention_days(),
640 poll_interval_secs: conv_default_poll_interval(),
641 sources: ConversationsSources::default(),
642 filter: ConversationsFilter::default(),
643 compact: CompactConfig::default(),
644 ask: AskConfig::default(),
645 rollup: RollupConfig::default(),
646 }
647 }
648}
649
650fn conv_default_retention_days() -> u32 {
651 30
652}
653fn conv_default_poll_interval() -> u64 {
654 300
655}
656fn conv_truthy() -> bool {
657 true
658}
659fn conv_default_dedup() -> f64 {
660 0.85
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct CompactConfig {
665 #[serde(default = "conv_truthy")]
666 pub enabled_in_daemon: bool,
667 #[serde(default = "compact_default_max_days")]
668 pub max_days_per_run: u32,
669 #[serde(default = "compact_default_model")]
670 pub extractive_model: String,
671 #[serde(default = "compact_default_model")]
672 pub abstractive_model: String,
673 #[serde(default = "compact_default_ollama_endpoint")]
674 pub ollama_endpoint: String,
675 #[serde(default = "compact_default_max_spans")]
676 pub max_extractive_spans: u32,
677 #[serde(default = "compact_default_max_words")]
678 pub max_abstractive_words: u32,
679 #[serde(default = "compact_default_chunk_tokens")]
680 pub chunk_tokens: u32,
681 #[serde(default = "compact_default_history_retain")]
682 pub history_retain: u32,
683 #[serde(default = "compact_default_cron")]
684 pub daemon_cron: String,
685 #[serde(default)]
688 pub extractive_backend: Option<BackendConfig>,
689 #[serde(default)]
692 pub abstractive_backend: Option<BackendConfig>,
693}
694
695impl CompactConfig {
696 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
705 self.extractive_backend
706 .clone()
707 .unwrap_or_else(|| BackendConfig {
708 provider: "ollama".into(),
709 model: self.extractive_model.clone(),
710 endpoint: Some(self.ollama_endpoint.clone()),
711 api_key_env: None,
712 timeout_secs: Some(120),
713 })
714 }
715
716 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
719 self.abstractive_backend
720 .clone()
721 .unwrap_or_else(|| BackendConfig {
722 provider: "ollama".into(),
723 model: self.abstractive_model.clone(),
724 endpoint: Some(self.ollama_endpoint.clone()),
725 api_key_env: None,
726 timeout_secs: Some(120),
727 })
728 }
729}
730
731impl Default for CompactConfig {
732 fn default() -> Self {
733 Self {
734 enabled_in_daemon: true,
735 max_days_per_run: compact_default_max_days(),
736 extractive_model: compact_default_model(),
737 abstractive_model: compact_default_model(),
738 ollama_endpoint: compact_default_ollama_endpoint(),
739 max_extractive_spans: compact_default_max_spans(),
740 max_abstractive_words: compact_default_max_words(),
741 chunk_tokens: compact_default_chunk_tokens(),
742 history_retain: compact_default_history_retain(),
743 daemon_cron: compact_default_cron(),
744 extractive_backend: None,
745 abstractive_backend: None,
746 }
747 }
748}
749
750fn compact_default_max_days() -> u32 {
751 7
752}
753fn compact_default_model() -> String {
754 DEFAULT_LOCAL_LLM_MODEL.into()
755}
756fn compact_default_ollama_endpoint() -> String {
757 "http://localhost:11434".into()
758}
759fn compact_default_max_spans() -> u32 {
760 20
761}
762fn compact_default_max_words() -> u32 {
763 400
764}
765fn compact_default_chunk_tokens() -> u32 {
766 6000
767}
768fn compact_default_history_retain() -> u32 {
769 5
770}
771fn compact_default_cron() -> String {
772 "0 0 3 * * * *".into()
773}
774
775#[derive(Debug, Clone, Serialize, Deserialize)]
778pub struct RollupConfig {
779 #[serde(default = "rollup_default_enabled")]
780 pub enabled: bool,
781 #[serde(default = "rollup_default_max_weeks")]
782 pub max_weeks_per_run: u32,
783 #[serde(default = "rollup_default_max_months")]
784 pub max_months_per_run: u32,
785 #[serde(default = "rollup_default_max_spans_week")]
786 pub max_extractive_spans_per_week: u32,
787 #[serde(default = "rollup_default_max_words_week")]
788 pub max_abstractive_words_per_week: u32,
789 #[serde(default = "rollup_default_max_spans_month")]
790 pub max_extractive_spans_per_month: u32,
791 #[serde(default = "rollup_default_max_words_month")]
792 pub max_abstractive_words_per_month: u32,
793 #[serde(default = "rollup_default_week_mmr")]
794 pub week_mmr_threshold: f64,
795 #[serde(default = "rollup_default_month_mmr")]
796 pub month_mmr_threshold: f64,
797 #[serde(default = "compact_default_model")]
798 pub extractive_model: String,
799 #[serde(default = "compact_default_model")]
800 pub abstractive_model: String,
801 #[serde(default = "compact_default_ollama_endpoint")]
802 pub ollama_endpoint: String,
803}
804
805impl Default for RollupConfig {
806 fn default() -> Self {
807 Self {
808 enabled: rollup_default_enabled(),
809 max_weeks_per_run: rollup_default_max_weeks(),
810 max_months_per_run: rollup_default_max_months(),
811 max_extractive_spans_per_week: rollup_default_max_spans_week(),
812 max_abstractive_words_per_week: rollup_default_max_words_week(),
813 max_extractive_spans_per_month: rollup_default_max_spans_month(),
814 max_abstractive_words_per_month: rollup_default_max_words_month(),
815 week_mmr_threshold: rollup_default_week_mmr(),
816 month_mmr_threshold: rollup_default_month_mmr(),
817 extractive_model: compact_default_model(),
818 abstractive_model: compact_default_model(),
819 ollama_endpoint: compact_default_ollama_endpoint(),
820 }
821 }
822}
823
824fn rollup_default_enabled() -> bool {
825 true
826}
827fn rollup_default_max_weeks() -> u32 {
828 4
829}
830fn rollup_default_max_months() -> u32 {
831 2
832}
833fn rollup_default_max_spans_week() -> u32 {
834 20
835}
836fn rollup_default_max_words_week() -> u32 {
837 500
838}
839fn rollup_default_max_spans_month() -> u32 {
840 20
841}
842fn rollup_default_max_words_month() -> u32 {
843 700
844}
845fn rollup_default_week_mmr() -> f64 {
846 0.85
847}
848fn rollup_default_month_mmr() -> f64 {
849 0.82
850}
851
852#[derive(Debug, Clone, Serialize, Deserialize)]
853pub struct ConversationsSources {
854 #[serde(default = "conv_truthy")]
855 pub claude_code: bool,
856 #[serde(default = "conv_truthy")]
857 pub cursor: bool,
858 #[serde(default = "conv_truthy")]
859 pub gemini: bool,
860 #[serde(default)]
861 pub aider: AiderSourceConfig,
862}
863
864impl Default for ConversationsSources {
865 fn default() -> Self {
866 Self {
867 claude_code: true,
868 cursor: true,
869 gemini: true,
870 aider: AiderSourceConfig::default(),
871 }
872 }
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize)]
876pub struct AiderSourceConfig {
877 #[serde(default = "conv_truthy")]
878 pub enabled: bool,
879 #[serde(default)]
880 pub watched_dirs: Vec<String>,
881}
882
883impl Default for AiderSourceConfig {
884 fn default() -> Self {
885 Self {
886 enabled: true,
887 watched_dirs: Vec::new(),
888 }
889 }
890}
891
892#[derive(Debug, Clone, Serialize, Deserialize)]
893pub struct ConversationsFilter {
894 #[serde(default = "conv_default_dedup")]
895 pub dedup_threshold: f64,
896 #[serde(default = "conv_truthy")]
897 pub reject_heartbeat: bool,
898 #[serde(default = "conv_truthy")]
899 pub reject_system_restatement: bool,
900}
901
902impl Default for ConversationsFilter {
903 fn default() -> Self {
904 Self {
905 dedup_threshold: conv_default_dedup(),
906 reject_heartbeat: true,
907 reject_system_restatement: true,
908 }
909 }
910}
911
912#[cfg(test)]
913mod conversations_tests {
914 use super::*;
915
916 #[test]
917 fn conversations_section_defaults() {
918 let c = ConversationsConfig::default();
919 assert!(!c.enabled);
920 assert_eq!(c.retention_days, 30);
921 assert_eq!(c.poll_interval_secs, 300);
922 assert!(c.sources.claude_code);
923 assert!(c.sources.cursor);
924 assert!(c.sources.gemini);
925 assert!(c.sources.aider.enabled);
926 assert!(c.sources.aider.watched_dirs.is_empty());
927 assert_eq!(c.filter.dedup_threshold, 0.85);
928 assert!(c.filter.reject_heartbeat);
929 assert!(c.filter.reject_system_restatement);
930 }
931
932 #[test]
933 fn parse_from_yaml_with_overrides() {
934 let y = r#"
935conversations:
936 enabled: true
937 retention_days: 45
938 poll_interval_secs: 120
939 sources:
940 cursor: false
941 aider:
942 watched_dirs: ["~/Projects/a", "~/Projects/b"]
943 filter:
944 dedup_threshold: 0.9
945"#;
946 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
947 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
948 assert!(conv.enabled);
949 assert_eq!(conv.retention_days, 45);
950 assert_eq!(conv.poll_interval_secs, 120);
951 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
955 assert_eq!(conv.filter.dedup_threshold, 0.9);
956 assert!(conv.filter.reject_heartbeat); }
958
959 #[test]
960 fn missing_conversations_section_is_fine() {
961 let y = r#"
962# No conversations section at all
963foo: bar
964"#;
965 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
966 let conv: ConversationsConfig = v
968 .get("conversations")
969 .cloned()
970 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
971 .unwrap_or_default();
972 assert_eq!(conv.retention_days, 30);
973 }
974
975 #[test]
976 fn compact_config_defaults() {
977 let c = CompactConfig::default();
978 assert!(c.enabled_in_daemon);
979 assert_eq!(c.max_days_per_run, 7);
980 assert_eq!(c.extractive_model, "qwen3.5:4b");
981 assert_eq!(c.abstractive_model, "qwen3.5:4b");
982 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
983 assert_eq!(c.max_extractive_spans, 20);
984 assert_eq!(c.chunk_tokens, 6000);
985 assert_eq!(c.history_retain, 5);
986 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
987 }
988
989 #[test]
990 fn compact_parses_partial_overrides() {
991 let y = r#"
992conversations:
993 compact:
994 max_days_per_run: 3
995 extractive_model: qwen3:4b
996"#;
997 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
998 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
999 assert_eq!(conv.compact.max_days_per_run, 3);
1000 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1001 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
1004
1005 #[test]
1006 fn ask_config_defaults() {
1007 let c = AskConfig::default();
1008 assert_eq!(c.model, "qwen3.5:4b");
1009 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1010 assert_eq!(c.k_raw, 10);
1011 assert_eq!(c.escalation_threshold, 0.5);
1012 assert_eq!(c.mmr_threshold, 0.88);
1013 assert_eq!(c.max_context_tokens, 6000);
1014 assert_eq!(c.response_tokens, 1024);
1015 assert_eq!(c.timeout_secs, 120);
1016 assert_eq!(c.min_score, 0.35);
1017 }
1018
1019 #[test]
1020 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1021 let c = AskConfig::default();
1023 assert!(
1024 (c.mmr_threshold - 0.88).abs() < 1e-9,
1025 "expected 0.88, got {}",
1026 c.mmr_threshold
1027 );
1028 }
1029
1030 #[test]
1031 fn rollup_config_defaults() {
1032 let c = RollupConfig::default();
1033 assert!(c.enabled);
1034 assert_eq!(c.max_weeks_per_run, 4);
1035 assert_eq!(c.max_months_per_run, 2);
1036 assert_eq!(c.max_extractive_spans_per_week, 20);
1037 assert_eq!(c.max_abstractive_words_per_week, 500);
1038 assert_eq!(c.max_extractive_spans_per_month, 20);
1039 assert_eq!(c.max_abstractive_words_per_month, 700);
1040 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1041 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1042 assert_eq!(c.extractive_model, "qwen3.5:4b");
1043 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1044 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1045 }
1046
1047 #[test]
1048 fn rollup_config_plumbed_into_conversations_config() {
1049 let c = ConversationsConfig::default();
1050 assert!(c.rollup.enabled);
1051 }
1052
1053 #[test]
1054 fn ask_config_default_continue_history_turns_is_3() {
1055 let c = AskConfig::default();
1056 assert_eq!(c.continue_history_turns, 3);
1057 }
1058
1059 #[test]
1060 fn ask_config_default_compress_hits_enabled_is_true() {
1061 let c = AskConfig::default();
1062 assert!(c.compress_hits_enabled);
1063 }
1064
1065 #[test]
1066 fn ask_config_default_summarize_hits_enabled_is_true() {
1067 let c = AskConfig::default();
1068 assert!(c.summarize_hits_enabled);
1069 }
1070
1071 #[test]
1072 fn ask_config_default_summarize_model_is_none() {
1073 let c = AskConfig::default();
1074 assert!(c.summarize_model.is_none());
1075 }
1076
1077 #[test]
1078 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1079 let y = r#"
1080conversations:
1081 ask:
1082 summarize_hits_enabled: false
1083 summarize_model: qwen3:4b
1084"#;
1085 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1086 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1087 assert!(!conv.ask.summarize_hits_enabled);
1088 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1089 }
1090
1091 #[test]
1092 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1093 let y = r#"
1097conversations:
1098 ask:
1099 model: qwen3:14b
1100"#;
1101 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1102 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1103 assert!(conv.ask.summarize_hits_enabled);
1104 assert!(conv.ask.summarize_model.is_none());
1105 }
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111
1112 #[test]
1113 fn default_bundled_model_id_is_qwen35_2b() {
1114 assert_eq!(
1115 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1116 "Qwen3.5-2B-MLX-4bit"
1117 );
1118 }
1119
1120 #[test]
1121 fn nudge_config_defaults() {
1122 let c = NudgeConfig::default();
1123 assert!(c.enabled);
1124 assert_eq!(c.daily_cap, 3);
1125 assert_eq!(c.snooze_days, 7);
1126 assert_eq!(c.threshold, 3);
1127 }
1128
1129 #[test]
1130 fn config_has_nudge_section_with_defaults() {
1131 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1132 assert_eq!(c.nudge.daily_cap, 3);
1133 }
1134
1135 #[test]
1136 fn storage_config_default_is_lancedb() {
1137 let c = StorageConfig::default();
1138 assert_eq!(c.vector_backend, "lancedb");
1139 assert_eq!(c.qdrant_url, None);
1140 assert_eq!(c.qdrant_api_key_ref, None);
1141 }
1142
1143 #[test]
1144 fn sources_global_config_has_sensible_defaults() {
1145 let c = SourcesGlobalConfig::default();
1146 assert_eq!(c.poll_interval_secs, 600);
1147 assert_eq!(c.max_chunks_per_sync, 10_000);
1148 assert_eq!(c.max_parallel_sources, 3);
1149 assert_eq!(c.default_weight, 1.0);
1150 assert_eq!(c.embedding_batch_size, 32);
1151 }
1152
1153 #[test]
1154 fn config_default_has_storage_and_sources_global() {
1155 let c = Config::default();
1156 assert_eq!(c.storage.vector_backend, "lancedb");
1157 assert_eq!(c.sources_global.default_weight, 1.0);
1158 }
1159
1160 #[test]
1161 fn config_loads_yaml_without_new_fields() {
1162 let yaml = r#"
1165embedding:
1166 provider: ollama
1167 model: test-model
1168 dimensions: 512
1169 ollama_endpoint: http://localhost:11434
1170"#;
1171 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1172 assert_eq!(c.storage.vector_backend, "lancedb");
1173 assert_eq!(c.sources_global.max_parallel_sources, 3);
1174 }
1175
1176 #[test]
1177 fn llm_config_to_backend_config_anthropic_passthrough() {
1178 let cfg = LlmConfig {
1179 provider: "anthropic".into(),
1180 model: "claude-haiku-4-5".into(),
1181 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1182 openai_url: None,
1183 };
1184 let b = cfg.to_backend_config();
1185 assert_eq!(b.provider, "anthropic");
1186 assert_eq!(b.model, "claude-haiku-4-5");
1187 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1188 assert_eq!(b.endpoint, None);
1189 assert_eq!(b.timeout_secs, None);
1190 }
1191
1192 #[test]
1193 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1194 let cfg = LlmConfig {
1195 provider: "openai".into(),
1196 model: "gpt-4o-mini".into(),
1197 api_key_env: None,
1198 openai_url: Some("https://api.together.xyz/v1".into()),
1199 };
1200 let b = cfg.to_backend_config();
1201 assert_eq!(b.provider, "openai");
1202 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1203 assert_eq!(b.api_key_env, None); }
1205
1206 #[test]
1207 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1208 let cfg = LlmConfig {
1209 provider: "ollama".into(),
1210 model: "qwen3:14b".into(),
1211 api_key_env: None,
1212 openai_url: Some("http://192.168.1.10:11434".into()),
1213 };
1214 let b = cfg.to_backend_config();
1215 assert_eq!(b.provider, "ollama");
1216 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1217 }
1218
1219 #[test]
1220 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1221 let cfg = LlmConfig {
1225 provider: "custom-name".into(),
1226 model: "some-model".into(),
1227 api_key_env: Some("CUSTOM_KEY".into()),
1228 openai_url: Some("https://my-proxy.local/v1".into()),
1229 };
1230 let b = cfg.to_backend_config();
1231 assert_eq!(
1232 b.provider, "openai",
1233 "unknown provider + openai_url should alias to openai"
1234 );
1235 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1236 }
1237}
1238
1239#[cfg(test)]
1240mod backend_config_tests {
1241 use super::*;
1242
1243 #[test]
1244 fn default_is_ollama_qwen3() {
1245 let cfg = BackendConfig::default();
1246 assert_eq!(cfg.provider, "ollama");
1247 assert_eq!(cfg.model, "qwen3.5:4b");
1248 assert_eq!(cfg.endpoint, None);
1249 assert_eq!(cfg.api_key_env, None);
1250 assert_eq!(cfg.timeout_secs, None);
1251 }
1252
1253 #[test]
1254 fn deserializes_anthropic_full() {
1255 let yaml = "\
1256provider: anthropic
1257model: claude-haiku-4-5
1258api_key_env: ANTHROPIC_API_KEY
1259timeout_secs: 60
1260";
1261 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1262 assert_eq!(cfg.provider, "anthropic");
1263 assert_eq!(cfg.model, "claude-haiku-4-5");
1264 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1265 assert_eq!(cfg.timeout_secs, Some(60));
1266 assert_eq!(cfg.endpoint, None);
1267 }
1268
1269 #[test]
1270 fn deserializes_partial_fills_defaults() {
1271 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1272 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1273 assert_eq!(cfg.provider, "anthropic");
1274 assert_eq!(cfg.model, "claude-sonnet-4-6");
1275 assert_eq!(cfg.api_key_env, None);
1276 assert_eq!(cfg.timeout_secs, None);
1277 }
1278
1279 #[test]
1280 fn round_trips_through_yaml() {
1281 let original = BackendConfig {
1282 provider: "anthropic".into(),
1283 model: "claude-haiku-4-5".into(),
1284 endpoint: Some("https://api.anthropic.com".into()),
1285 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1286 timeout_secs: Some(60),
1287 };
1288 let yaml = serde_yaml::to_string(&original).unwrap();
1289 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1290 assert_eq!(parsed, original);
1291 }
1292
1293 #[test]
1294 fn skills_config_curation_gate_defaults_on() {
1295 let c = SkillsConfig::default();
1296 assert!(c.require_human_curation_before_stable);
1297 }
1298}
1299
1300#[derive(Debug, Clone, Serialize, Deserialize)]
1304#[serde(default)]
1305pub struct SkillsConfig {
1306 pub max_skills_in_prompt: usize,
1307 pub max_total_tokens: usize,
1308 pub priority_order: Vec<String>,
1309 pub adaptive: Option<AdaptiveSkillsConfig>,
1310
1311 #[serde(default = "default_require_human_curation")]
1315 pub require_human_curation_before_stable: bool,
1316
1317 #[serde(default)]
1321 pub lifecycle: SkillLifecycleConfig,
1322}
1323
1324fn default_require_human_curation() -> bool {
1325 true
1326}
1327
1328impl Default for SkillsConfig {
1329 fn default() -> Self {
1330 Self {
1331 max_skills_in_prompt: 5,
1332 max_total_tokens: 2000,
1333 priority_order: vec!["agent".into(), "global".into()],
1334 adaptive: Some(AdaptiveSkillsConfig::default()),
1335 require_human_curation_before_stable: default_require_human_curation(),
1336 lifecycle: SkillLifecycleConfig::default(),
1337 }
1338 }
1339}
1340
1341#[derive(Debug, Clone, Serialize, Deserialize)]
1347#[serde(default)]
1348pub struct SkillLifecycleConfig {
1349 pub promote_draft_uses: u64,
1351 pub promote_emerging_uses: u64,
1352 pub promote_emerging_success_rate: f64,
1353 pub promote_emerging_age_days: i64,
1354 pub promote_stable_uses: u64,
1355 pub promote_stable_success_rate: f64,
1356 pub promote_stable_age_days: i64,
1357
1358 pub demote_emerging_uses: u64,
1360 pub demote_emerging_success_rate: f64,
1361 pub demote_stable_uses: u64,
1362 pub demote_stable_success_rate: f64,
1363 pub deprecated_success_rate: f64,
1364 pub deprecated_no_success_days: i64,
1365
1366 pub auto_archive_confidence: f64,
1368 pub auto_archive_age_days: i64,
1369
1370 pub broken_workflow_streak: u32,
1375
1376 pub archive_destroy_grace_days: i64,
1381}
1382
1383impl Default for SkillLifecycleConfig {
1384 fn default() -> Self {
1385 Self {
1386 promote_draft_uses: 3,
1387 promote_emerging_uses: 10,
1388 promote_emerging_success_rate: 0.6,
1389 promote_emerging_age_days: 7,
1390 promote_stable_uses: 30,
1391 promote_stable_success_rate: 0.8,
1392 promote_stable_age_days: 30,
1393 demote_emerging_uses: 8,
1394 demote_emerging_success_rate: 0.55,
1395 demote_stable_uses: 25,
1396 demote_stable_success_rate: 0.75,
1397 deprecated_success_rate: 0.3,
1398 deprecated_no_success_days: 90,
1399 auto_archive_confidence: 0.10,
1400 auto_archive_age_days: 180,
1401 broken_workflow_streak: 3,
1402 archive_destroy_grace_days: 30,
1403 }
1404 }
1405}
1406
1407#[derive(Debug, Clone, Serialize, Deserialize)]
1408#[serde(default)]
1409pub struct AdaptiveSkillsConfig {
1410 pub context_fill_decay: f64,
1411 pub min_remaining_context_ratio: f64,
1412 pub recent_fire_boost_turns: usize,
1413 pub model_max_context_tokens: u64,
1417}
1418
1419impl Default for AdaptiveSkillsConfig {
1420 fn default() -> Self {
1421 Self {
1422 context_fill_decay: 1.5,
1423 min_remaining_context_ratio: 0.20,
1424 recent_fire_boost_turns: 5,
1425 model_max_context_tokens: 200_000,
1426 }
1427 }
1428}
1429
1430#[derive(Debug, Clone, Serialize, Deserialize)]
1433pub struct SleepCycleConfig {
1434 #[serde(default)]
1436 pub enabled: bool,
1437
1438 #[serde(default = "default_idle_threshold_minutes")]
1440 pub idle_threshold_minutes: u64,
1441
1442 #[serde(default = "default_agent_idle_minutes")]
1444 pub agent_idle_minutes: u64,
1445}
1446
1447fn default_idle_threshold_minutes() -> u64 {
1448 15
1449}
1450
1451fn default_agent_idle_minutes() -> u64 {
1452 5
1453}
1454
1455impl Default for SleepCycleConfig {
1456 fn default() -> Self {
1457 Self {
1458 enabled: false,
1459 idle_threshold_minutes: default_idle_threshold_minutes(),
1460 agent_idle_minutes: default_agent_idle_minutes(),
1461 }
1462 }
1463}
1464
1465#[derive(Debug, Clone, Serialize, Deserialize)]
1468pub struct NudgeConfig {
1469 #[serde(default = "default_nudge_enabled")]
1471 pub enabled: bool,
1472 #[serde(default = "default_nudge_daily_cap")]
1473 pub daily_cap: u32,
1474 #[serde(default = "default_nudge_snooze_days")]
1475 pub snooze_days: u32,
1476 #[serde(default = "default_nudge_threshold")]
1477 pub threshold: usize,
1478}
1479
1480fn default_nudge_enabled() -> bool {
1481 true
1482}
1483fn default_nudge_daily_cap() -> u32 {
1484 3
1485}
1486fn default_nudge_snooze_days() -> u32 {
1487 7
1488}
1489fn default_nudge_threshold() -> usize {
1490 3
1491}
1492
1493impl Default for NudgeConfig {
1494 fn default() -> Self {
1495 Self {
1496 enabled: true,
1497 daily_cap: default_nudge_daily_cap(),
1498 snooze_days: default_nudge_snooze_days(),
1499 threshold: default_nudge_threshold(),
1500 }
1501 }
1502}
1503
1504#[derive(Debug, Clone, Serialize, Deserialize)]
1508pub struct SessionCfg {
1509 #[serde(default = "default_capture_mode")]
1511 pub capture: String,
1512 #[serde(default = "default_retention_days")]
1514 pub retention_days: u32,
1515}
1516
1517impl Default for SessionCfg {
1518 fn default() -> Self {
1519 Self {
1520 capture: default_capture_mode(),
1521 retention_days: default_retention_days(),
1522 }
1523 }
1524}
1525
1526fn default_capture_mode() -> String {
1527 "ambient".to_string()
1528}
1529fn default_retention_days() -> u32 {
1530 14
1531}
1532
1533#[derive(Debug, Clone, Serialize, Deserialize)]
1535pub struct HarvestCfg {
1536 #[serde(default = "default_harvest_enabled")]
1538 pub auto_gate: bool,
1539 #[serde(default = "default_harvest_llm")]
1541 pub llm: String,
1542 #[serde(default = "default_min_events")]
1544 pub min_events: usize,
1545 #[serde(default = "default_min_user_turns")]
1546 pub min_user_turns: usize,
1547 #[serde(default = "default_min_duration_secs")]
1548 pub min_duration_secs: i64,
1549 #[serde(default = "default_idle_minutes")]
1551 pub idle_minutes: i64,
1552 #[serde(default = "default_max_llm_calls_per_day")]
1554 pub max_llm_calls_per_day: u32,
1555 #[serde(default = "default_max_extract_input_tokens")]
1556 pub max_extract_input_tokens: usize,
1557 #[serde(default = "default_harvest_enabled")]
1559 pub session_start_hint: bool,
1560 #[serde(default = "default_similarity_merge_threshold")]
1562 pub similarity_merge_threshold: f32,
1563}
1564
1565impl Default for HarvestCfg {
1566 fn default() -> Self {
1567 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1568 }
1569}
1570
1571fn default_harvest_enabled() -> bool {
1572 true
1573}
1574fn default_harvest_llm() -> String {
1575 "local-first".to_string()
1576}
1577fn default_min_events() -> usize {
1578 5
1579}
1580fn default_min_user_turns() -> usize {
1581 2
1582}
1583fn default_min_duration_secs() -> i64 {
1584 120
1585}
1586fn default_idle_minutes() -> i64 {
1587 30
1588}
1589fn default_max_llm_calls_per_day() -> u32 {
1590 10
1591}
1592fn default_max_extract_input_tokens() -> usize {
1593 12000
1594}
1595fn default_similarity_merge_threshold() -> f32 {
1596 0.6
1597}
1598
1599#[derive(Debug, Clone, Serialize, Deserialize)]
1602#[serde(default)]
1603pub struct CrossAgentConfig {
1604 #[serde(default = "default_half_life_days")]
1605 pub fitness_half_life_days: u32,
1606 #[serde(default = "default_fitness_floor")]
1607 pub fitness_floor: f64,
1608}
1609
1610fn default_half_life_days() -> u32 {
1611 7
1612}
1613fn default_fitness_floor() -> f64 {
1614 0.1
1615}
1616
1617impl Default for CrossAgentConfig {
1618 fn default() -> Self {
1619 Self {
1620 fitness_half_life_days: default_half_life_days(),
1621 fitness_floor: default_fitness_floor(),
1622 }
1623 }
1624}
1625
1626#[derive(Debug, Clone, Serialize, Deserialize)]
1629#[serde(default)]
1630pub struct SkillLlmConfig {
1631 #[serde(default = "default_per_call_token_cap")]
1633 pub per_call_token_cap: u32,
1634
1635 #[serde(default = "default_per_day_usd_cap")]
1637 pub per_day_usd_cap: f64,
1638
1639 #[serde(default = "default_cache_ttl_days")]
1641 pub cache_ttl_days: u32,
1642
1643 #[serde(default, skip_serializing_if = "Option::is_none")]
1645 pub model_ref: Option<String>,
1646}
1647
1648fn default_per_call_token_cap() -> u32 {
1649 1500
1650}
1651fn default_per_day_usd_cap() -> f64 {
1652 0.50
1653}
1654fn default_cache_ttl_days() -> u32 {
1655 30
1656}
1657
1658impl Default for SkillLlmConfig {
1659 fn default() -> Self {
1660 Self {
1661 per_call_token_cap: default_per_call_token_cap(),
1662 per_day_usd_cap: default_per_day_usd_cap(),
1663 cache_ttl_days: default_cache_ttl_days(),
1664 model_ref: None,
1665 }
1666 }
1667}
1668#[cfg(test)]
1669mod per_stage_backend_tests {
1670 use super::*;
1671
1672 #[test]
1673 fn legacy_compact_config_has_no_per_stage_overrides() {
1674 let yaml = "\
1675extractive_model: qwen3:14b
1676abstractive_model: qwen3:14b
1677ollama_endpoint: http://localhost:11434
1678";
1679 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1680 assert!(cfg.extractive_backend.is_none());
1681 assert!(cfg.abstractive_backend.is_none());
1682 assert_eq!(cfg.extractive_model, "qwen3:14b");
1683 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1684 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1685 }
1686
1687 #[test]
1688 fn legacy_ask_config_has_no_per_stage_overrides() {
1689 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1690 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1691 assert!(cfg.backend.is_none());
1692 assert!(cfg.rewriter_backend.is_none());
1693 assert_eq!(cfg.model, "qwen3:14b");
1694 }
1695
1696 #[test]
1697 fn compact_extractive_backend_override_parses() {
1698 let yaml = "\
1699extractive_backend:
1700 provider: anthropic
1701 model: claude-haiku-4-5
1702 api_key_env: ANTHROPIC_API_KEY
1703abstractive_model: qwen3:14b
1704";
1705 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1706 let extractive = cfg
1707 .extractive_backend
1708 .as_ref()
1709 .expect("override should parse");
1710 assert_eq!(extractive.provider, "anthropic");
1711 assert_eq!(extractive.model, "claude-haiku-4-5");
1712 assert!(cfg.abstractive_backend.is_none());
1713 }
1714
1715 #[test]
1716 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1717 let yaml = "\
1718backend:
1719 provider: anthropic
1720 model: claude-sonnet-4-6
1721 api_key_env: ANTHROPIC_API_KEY
1722rewriter_backend:
1723 provider: ollama
1724 model: llama3.2:3b
1725";
1726 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1727 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1728 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1729 }
1730
1731 #[test]
1732 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1733 let yaml = "\
1734extractive_model: qwen3:14b
1735ollama_endpoint: http://192.168.1.10:11434
1736";
1737 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1738 let synth = cfg.synthesize_extractive_backend();
1739 assert_eq!(synth.provider, "ollama");
1740 assert_eq!(synth.model, "qwen3:14b");
1741 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1742 assert_eq!(synth.api_key_env, None);
1743 }
1744
1745 #[test]
1746 fn synthesize_legacy_to_backend_config_for_ask() {
1747 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1748 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1749 let synth = cfg.synthesize_backend();
1750 assert_eq!(synth.provider, "ollama");
1751 assert_eq!(synth.model, "qwen3:14b");
1752 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1753 }
1754
1755 #[test]
1756 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1757 let yaml = "\
1766backend:
1767 provider: anthropic
1768 model: claude-sonnet-4-6
1769 api_key_env: ANTHROPIC_API_KEY
1770";
1771 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1772 let rewriter = cfg.synthesize_rewriter_backend();
1773 assert_eq!(rewriter.provider, "ollama");
1774 assert_eq!(rewriter.model, ask_default_model());
1775 assert_eq!(
1776 rewriter.timeout_secs,
1777 Some(ask_default_rewriter_timeout() as u64)
1778 );
1779 }
1780
1781 #[test]
1782 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1783 let cfg = AskConfig {
1784 timeout_secs: 45,
1785 ..AskConfig::default()
1786 };
1787 let b = cfg.synthesize_backend();
1788 assert_eq!(
1789 b.timeout_secs,
1790 Some(45),
1791 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1792 );
1793 }
1794
1795 #[test]
1796 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1797 let mut cfg = AskConfig {
1798 timeout_secs: 45,
1799 ..AskConfig::default()
1800 };
1801 cfg.backend = Some(BackendConfig {
1802 provider: "anthropic".into(),
1803 model: "claude-haiku-4-5".into(),
1804 endpoint: None,
1805 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1806 timeout_secs: Some(10),
1807 });
1808 let b = cfg.synthesize_backend();
1809 assert_eq!(
1810 b.timeout_secs,
1811 Some(10),
1812 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1813 );
1814 }
1815
1816 #[test]
1817 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1818 let cfg = AskConfig {
1819 timeout_secs: 120,
1820 rewriter_timeout_secs: 8,
1821 ..AskConfig::default()
1822 };
1823 let b = cfg.synthesize_rewriter_backend();
1824 assert_eq!(
1825 b.timeout_secs,
1826 Some(8),
1827 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1828 );
1829 }
1830
1831 #[test]
1832 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1833 let mut cfg = AskConfig {
1834 rewriter_timeout_secs: 8,
1835 ..AskConfig::default()
1836 };
1837 cfg.rewriter_backend = Some(BackendConfig {
1838 provider: "anthropic".into(),
1839 model: "claude-haiku-4-5".into(),
1840 endpoint: None,
1841 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1842 timeout_secs: Some(30),
1843 });
1844 let b = cfg.synthesize_rewriter_backend();
1845 assert_eq!(
1846 b.timeout_secs,
1847 Some(30),
1848 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1849 );
1850 }
1851
1852 #[test]
1853 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1854 let cfg = CompactConfig::default();
1857 let b = cfg.synthesize_extractive_backend();
1858 assert_eq!(
1859 b.timeout_secs,
1860 Some(120),
1861 "compact synthesis without per-stage override must produce 120s timeout"
1862 );
1863 }
1864
1865 #[test]
1866 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1867 let cfg = CompactConfig::default();
1868 let b = cfg.synthesize_abstractive_backend();
1869 assert_eq!(b.timeout_secs, Some(120));
1870 }
1871}
1872
1873#[cfg(test)]
1874mod skills_config_tests {
1875 use super::*;
1876
1877 #[test]
1878 fn empty_yaml_hydrates_defaults() {
1879 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1880 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1881 assert_eq!(cfg.skills.max_total_tokens, 2000);
1882 assert!(cfg.skills.adaptive.is_some());
1883 }
1884
1885 #[test]
1886 fn load_or_default_missing_file_returns_default() {
1887 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1888 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1889 }
1890}
1891
1892#[cfg(test)]
1893mod ambient_capture_cfg_tests {
1894 use super::*;
1895
1896 #[test]
1897 fn session_and_harvest_defaults() {
1898 let cfg: Config = serde_yaml::from_str("{}").unwrap();
1899 assert_eq!(cfg.session.capture, "ambient");
1900 assert_eq!(cfg.session.retention_days, 14);
1901 assert!(cfg.harvest.auto_gate);
1902 assert_eq!(cfg.harvest.llm, "local-first");
1903 assert_eq!(cfg.harvest.min_events, 5);
1904 assert_eq!(cfg.harvest.min_user_turns, 2);
1905 assert_eq!(cfg.harvest.min_duration_secs, 120);
1906 assert_eq!(cfg.harvest.idle_minutes, 30);
1907 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
1908 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
1909 assert!(cfg.harvest.session_start_hint);
1910 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
1911 }
1912
1913 #[test]
1914 fn session_capture_override_parses() {
1915 let cfg: Config =
1916 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
1917 assert_eq!(cfg.session.capture, "off");
1918 assert_eq!(cfg.session.retention_days, 3);
1919 }
1920}