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