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
12pub const DEFAULT_MAX_RETRIES: u32 = 1;
13pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
14pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
15pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
16pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
17
18fn default_max_retries() -> u32 {
19 DEFAULT_MAX_RETRIES
20}
21fn default_backoff_base_ms() -> u64 {
22 DEFAULT_BACKOFF_BASE_MS
23}
24fn default_cooldown_secs() -> u64 {
25 DEFAULT_COOLDOWN_SECS
26}
27fn default_smart_max_escalations() -> u32 {
28 DEFAULT_SMART_MAX_ESCALATIONS
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct SmartConfig {
37 #[serde(default = "default_true")]
38 pub enabled: bool,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub cheap: Option<String>,
41 #[serde(default = "default_smart_max_escalations")]
42 pub max_escalations: u32,
43}
44
45impl Default for SmartConfig {
46 fn default() -> Self {
47 Self {
48 enabled: true,
49 cheap: None,
50 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct ModelSwitchConfig {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub default: Option<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub fallback_chain: Vec<String>,
65 #[serde(default)]
66 pub retry: RetryConfig,
67 #[serde(default)]
68 pub routing: RoutingConfig,
69 #[serde(default)]
70 pub smart: SmartConfig,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct RetryConfig {
75 #[serde(default = "default_max_retries")]
76 pub max_retries: u32,
77 #[serde(default = "default_backoff_base_ms")]
78 pub backoff_base_ms: u64,
79 #[serde(default = "default_cooldown_secs")]
80 pub cooldown_secs: u64,
81}
82
83impl Default for RetryConfig {
84 fn default() -> Self {
85 Self {
86 max_retries: DEFAULT_MAX_RETRIES,
87 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
88 cooldown_secs: DEFAULT_COOLDOWN_SECS,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
94pub struct RoutingConfig {
95 #[serde(default)]
96 pub enabled: bool,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub cheap: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub frontier: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub threshold_input_tokens: Option<u32>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub smart: Option<SmartConfig>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109pub struct Config {
110 #[serde(default)]
111 pub embedding: EmbeddingConfig,
112
113 #[serde(default)]
114 pub llm: LlmConfig,
115
116 #[serde(default)]
117 pub models: ModelSwitchConfig,
118
119 #[serde(default)]
120 pub retrieval: RetrievalConfig,
121
122 #[serde(default)]
123 pub paths: PathConfig,
124
125 #[serde(default)]
126 pub server: ServerConfig,
127
128 #[serde(default)]
129 pub community: CommunityConfig,
130
131 #[serde(default)]
132 pub conversations: ConversationsConfig,
133
134 #[serde(default)]
135 pub sync: SyncConfig,
136
137 #[serde(default)]
139 pub storage: StorageConfig,
140
141 #[serde(default)]
142 pub sources_global: SourcesGlobalConfig,
143
144 #[serde(default)]
146 pub sleep_cycle: SleepCycleConfig,
147
148 #[serde(default)]
150 pub skills: SkillsConfig,
151
152 #[serde(default)]
154 pub skill_llm: SkillLlmConfig,
155
156 #[serde(default)]
158 pub cross_agent: CrossAgentConfig,
159
160 #[serde(default)]
162 pub nudge: NudgeConfig,
163
164 #[serde(default)]
166 pub mobile_relay: MobileRelayConfig,
167
168 #[serde(default)]
170 pub session: SessionCfg,
171
172 #[serde(default)]
173 pub harvest: HarvestCfg,
174
175 #[serde(default)]
177 pub cc_proxy: CcProxyConfig,
178
179 #[serde(default)]
181 pub cli: CliConfig,
182
183 #[serde(default)]
185 pub parallel_jobs: ParallelJobsConfig,
186
187 #[serde(default)]
189 pub fleet_run: FleetRunConfig,
190
191 #[serde(default)]
193 pub fleet: FleetConfig,
194}
195
196#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
202pub struct ParallelJobsConfig {
203 #[serde(default)]
206 pub targets: Vec<String>,
207}
208
209#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
217pub struct FleetRunConfig {
218 #[serde(default)]
220 pub agents: Vec<String>,
221 #[serde(default)]
223 pub fleets: Vec<String>,
224}
225
226#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
233pub struct FleetConfig {
234 #[serde(default)]
236 pub autorun: bool,
237}
238
239#[cfg(test)]
240mod fleet_config_tests {
241 use super::*;
242
243 #[test]
244 fn fleet_config_defaults_off_and_roundtrips() {
245 assert!(!FleetConfig::default().autorun);
246
247 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
248 assert!(cfg.fleet.autorun);
249
250 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
252 assert!(!cfg2.fleet.autorun);
253 }
254
255 #[test]
256 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
257 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
259 assert!(cfg.fleet_run.agents.is_empty());
260 assert!(cfg.fleet_run.fleets.is_empty());
261
262 let cfg2: Config =
263 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
264 .unwrap();
265 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
266 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
267 }
268}
269
270#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct CcProxyConfig {
282 #[serde(default = "default_cc_proxy_url")]
284 pub url: String,
285
286 #[serde(default = "default_true")]
289 pub enabled: bool,
290}
291
292fn default_cc_proxy_url() -> String {
293 "http://127.0.0.1:8088".to_string()
294}
295
296fn default_true() -> bool {
297 true
298}
299
300impl Default for CcProxyConfig {
301 fn default() -> Self {
302 Self {
303 url: default_cc_proxy_url(),
304 enabled: true,
305 }
306 }
307}
308
309#[derive(Debug, Clone, Serialize, Deserialize, Default)]
312pub struct CliConfig {
313 pub skin: Option<String>,
316}
317
318#[derive(Debug, Clone, Serialize, Deserialize, Default)]
321pub struct MobileRelayConfig {
322 #[serde(default, skip_serializing_if = "Option::is_none")]
325 pub relay_url: Option<String>,
326
327 #[serde(default, skip_serializing_if = "Option::is_none")]
330 pub api_key: Option<String>,
331}
332
333impl Config {
334 pub fn load_or_default(path: &std::path::Path) -> Self {
336 std::fs::read_to_string(path)
337 .ok()
338 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
339 .unwrap_or_default()
340 }
341}
342
343#[derive(Debug, Clone, Serialize, Deserialize, Default)]
344pub struct SyncConfig {
345 #[serde(default = "default_sync_method")]
347 pub method: String,
348
349 #[serde(default, skip_serializing_if = "Option::is_none")]
351 pub git_remote: Option<String>,
352
353 #[serde(default)]
355 pub auto: bool,
356
357 #[serde(default, skip_serializing_if = "Option::is_none")]
359 pub team_id: Option<String>,
360}
361
362fn default_sync_method() -> String {
363 "local".to_string()
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize)]
367pub struct ServerConfig {
368 #[serde(default = "default_server_url")]
370 pub url: String,
371}
372
373impl Default for ServerConfig {
374 fn default() -> Self {
375 Self {
376 url: default_server_url(),
377 }
378 }
379}
380
381#[derive(Debug, Clone, Serialize, Deserialize, Default)]
382pub struct CommunityConfig {
383 #[serde(default)]
385 pub enabled: bool,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize)]
389pub struct EmbeddingConfig {
390 #[serde(default = "default_embedding_provider")]
392 pub provider: String,
393
394 #[serde(default = "default_embedding_model")]
396 pub model: String,
397
398 #[serde(default = "default_dimensions")]
400 pub dimensions: usize,
401
402 #[serde(default = "default_ollama_endpoint")]
404 pub ollama_endpoint: String,
405
406 #[serde(default, skip_serializing_if = "Option::is_none")]
408 pub api_key_env: Option<String>,
409
410 #[serde(default, skip_serializing_if = "Option::is_none")]
413 pub api_key_ref: Option<String>,
414
415 #[serde(default, skip_serializing_if = "Option::is_none")]
417 pub openai_url: Option<String>,
418}
419
420impl Default for EmbeddingConfig {
421 fn default() -> Self {
422 Self {
423 provider: default_embedding_provider(),
424 model: default_embedding_model(),
425 dimensions: default_dimensions(),
426 ollama_endpoint: default_ollama_endpoint(),
427 api_key_env: None,
428 api_key_ref: None,
429 openai_url: None,
430 }
431 }
432}
433
434#[derive(Debug, Clone, Serialize, Deserialize)]
435pub struct LlmConfig {
436 #[serde(default = "default_llm_provider")]
438 pub provider: String,
439
440 #[serde(default = "default_llm_model")]
441 pub model: String,
442
443 #[serde(default, skip_serializing_if = "Option::is_none")]
445 pub api_key_env: Option<String>,
446
447 #[serde(default, skip_serializing_if = "Option::is_none")]
450 pub api_key_ref: Option<String>,
451
452 #[serde(default, skip_serializing_if = "Option::is_none")]
454 pub openai_url: Option<String>,
455}
456
457impl Default for LlmConfig {
458 fn default() -> Self {
459 Self {
460 provider: default_llm_provider(),
461 model: default_llm_model(),
462 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
463 api_key_ref: None,
464 openai_url: None,
465 }
466 }
467}
468
469impl LlmConfig {
470 pub fn to_backend_config(&self) -> BackendConfig {
483 let provider = match self.provider.as_str() {
484 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
485 _ if self.openai_url.is_some() => "openai".into(),
486 other => other.into(), };
488 BackendConfig {
489 provider,
490 model: self.model.clone(),
491 endpoint: self.openai_url.clone(),
492 api_key_env: self.api_key_env.clone(),
493 api_key_ref: self.api_key_ref.clone(),
494 timeout_secs: None,
495 }
496 }
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
510#[serde(default)]
511pub struct BackendConfig {
512 pub provider: String,
514 pub model: String,
516 pub endpoint: Option<String>,
519 pub api_key_env: Option<String>,
521 pub api_key_ref: Option<String>,
523 pub timeout_secs: Option<u64>,
525}
526
527impl Default for BackendConfig {
528 fn default() -> Self {
529 Self {
530 provider: "ollama".into(),
531 model: DEFAULT_LOCAL_LLM_MODEL.into(),
532 endpoint: None,
533 api_key_env: None,
534 api_key_ref: None,
535 timeout_secs: None,
536 }
537 }
538}
539
540#[derive(Debug, Clone, Serialize, Deserialize)]
541pub struct RetrievalConfig {
542 #[serde(default = "default_max_patterns")]
544 pub max_patterns: usize,
545
546 #[serde(default = "default_max_tokens")]
548 pub max_tokens: usize,
549
550 #[serde(default = "default_min_score")]
552 pub min_score: f64,
553
554 #[serde(default = "default_mmr_threshold")]
556 pub mmr_threshold: f64,
557}
558
559impl Default for RetrievalConfig {
560 fn default() -> Self {
561 Self {
562 max_patterns: default_max_patterns(),
563 max_tokens: default_max_tokens(),
564 min_score: default_min_score(),
565 mmr_threshold: default_mmr_threshold(),
566 }
567 }
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize)]
571pub struct PathConfig {
572 #[serde(default = "default_mur_dir")]
574 pub mur_dir: PathBuf,
575}
576
577impl Default for PathConfig {
578 fn default() -> Self {
579 Self {
580 mur_dir: default_mur_dir(),
581 }
582 }
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize)]
586pub struct StorageConfig {
587 #[serde(default = "default_vector_backend")]
589 pub vector_backend: String,
590
591 #[serde(default, skip_serializing_if = "Option::is_none")]
593 pub qdrant_url: Option<String>,
594
595 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub qdrant_api_key_ref: Option<String>,
598}
599
600impl Default for StorageConfig {
601 fn default() -> Self {
602 Self {
603 vector_backend: default_vector_backend(),
604 qdrant_url: None,
605 qdrant_api_key_ref: None,
606 }
607 }
608}
609
610fn default_vector_backend() -> String {
611 "lancedb".to_string()
612}
613
614#[derive(Debug, Clone, Serialize, Deserialize)]
615pub struct SourcesGlobalConfig {
616 #[serde(default = "default_poll_interval_secs")]
618 pub poll_interval_secs: u64,
619
620 #[serde(default = "default_max_chunks_per_sync")]
622 pub max_chunks_per_sync: usize,
623
624 #[serde(default = "default_max_parallel_sources")]
626 pub max_parallel_sources: usize,
627
628 #[serde(default = "default_source_weight")]
630 pub default_weight: f32,
631
632 #[serde(default = "default_embedding_batch_size")]
634 pub embedding_batch_size: usize,
635}
636
637impl Default for SourcesGlobalConfig {
638 fn default() -> Self {
639 Self {
640 poll_interval_secs: default_poll_interval_secs(),
641 max_chunks_per_sync: default_max_chunks_per_sync(),
642 max_parallel_sources: default_max_parallel_sources(),
643 default_weight: default_source_weight(),
644 embedding_batch_size: default_embedding_batch_size(),
645 }
646 }
647}
648
649fn default_poll_interval_secs() -> u64 {
650 600
651}
652fn default_max_chunks_per_sync() -> usize {
653 10_000
654}
655fn default_max_parallel_sources() -> usize {
656 3
657}
658fn default_source_weight() -> f32 {
659 1.0
660}
661fn default_embedding_batch_size() -> usize {
662 32
663}
664
665fn default_embedding_provider() -> String {
666 "ollama".to_string()
667}
668fn default_embedding_model() -> String {
669 "qwen3-embedding:0.6b".to_string()
670}
671fn default_dimensions() -> usize {
672 1024
673}
674fn default_ollama_endpoint() -> String {
675 "http://localhost:11434".to_string()
676}
677fn default_llm_provider() -> String {
678 "anthropic".to_string()
679}
680fn default_llm_model() -> String {
681 "claude-opus-4-6".to_string()
682}
683fn default_max_patterns() -> usize {
684 5
685}
686fn default_max_tokens() -> usize {
687 2000
688}
689fn default_min_score() -> f64 {
690 0.35
691}
692fn default_mmr_threshold() -> f64 {
693 0.85
694}
695fn default_mur_dir() -> PathBuf {
696 let home = std::env::var("HOME")
699 .map(PathBuf::from)
700 .unwrap_or_else(|_| PathBuf::from("/tmp"));
701 home.join(".mur")
702}
703fn default_server_url() -> String {
704 "https://mur-server.fly.dev".to_string()
705}
706
707#[derive(Debug, Clone, Serialize, Deserialize)]
710pub struct AskConfig {
711 #[serde(default = "ask_default_model")]
712 pub model: String,
713 #[serde(default = "compact_default_ollama_endpoint")]
714 pub ollama_endpoint: String,
715 #[serde(default = "ask_default_k_summary")]
716 pub k_summary: u32,
717 #[serde(default = "ask_default_k_raw")]
718 pub k_raw: u32,
719 #[serde(default = "ask_default_esc")]
720 pub escalation_threshold: f64,
721 #[serde(default = "ask_default_mmr")]
722 pub mmr_threshold: f64,
723 #[serde(default = "ask_default_max_ctx")]
724 pub max_context_tokens: u32,
725 #[serde(default = "ask_default_resp_tok")]
726 pub response_tokens: u32,
727 #[serde(default = "ask_default_timeout")]
728 pub timeout_secs: u32,
729 #[serde(default = "ask_default_min_score")]
730 pub min_score: f64,
731 #[serde(default = "ask_default_continue_history_turns")]
732 pub continue_history_turns: u32,
733 #[serde(default = "ask_default_rewriter_timeout")]
739 pub rewriter_timeout_secs: u32,
740 #[serde(default = "ask_default_compress_hits_enabled")]
741 pub compress_hits_enabled: bool,
742 #[serde(default = "ask_default_summarize_hits_enabled")]
743 pub summarize_hits_enabled: bool,
744 #[serde(default)]
745 pub summarize_model: Option<String>,
746 #[serde(default)]
749 pub backend: Option<BackendConfig>,
750 #[serde(default)]
754 pub rewriter_backend: Option<BackendConfig>,
755}
756
757impl AskConfig {
758 pub fn synthesize_backend(&self) -> BackendConfig {
768 self.backend.clone().unwrap_or_else(|| BackendConfig {
769 provider: "ollama".into(),
770 model: self.model.clone(),
771 endpoint: Some(self.ollama_endpoint.clone()),
772 api_key_env: None,
773 api_key_ref: None,
774 timeout_secs: Some(self.timeout_secs as u64),
775 })
776 }
777
778 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
788 self.rewriter_backend
789 .clone()
790 .unwrap_or_else(|| BackendConfig {
791 provider: "ollama".into(),
792 model: self.model.clone(),
793 endpoint: Some(self.ollama_endpoint.clone()),
794 api_key_env: None,
795 api_key_ref: None,
796 timeout_secs: Some(self.rewriter_timeout_secs as u64),
797 })
798 }
799}
800
801impl Default for AskConfig {
802 fn default() -> Self {
803 Self {
804 model: ask_default_model(),
805 ollama_endpoint: compact_default_ollama_endpoint(),
806 k_summary: ask_default_k_summary(),
807 k_raw: ask_default_k_raw(),
808 escalation_threshold: ask_default_esc(),
809 mmr_threshold: ask_default_mmr(),
810 max_context_tokens: ask_default_max_ctx(),
811 response_tokens: ask_default_resp_tok(),
812 timeout_secs: ask_default_timeout(),
813 min_score: ask_default_min_score(),
814 continue_history_turns: ask_default_continue_history_turns(),
815 rewriter_timeout_secs: ask_default_rewriter_timeout(),
816 compress_hits_enabled: ask_default_compress_hits_enabled(),
817 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
818 summarize_model: None,
819 backend: None,
820 rewriter_backend: None,
821 }
822 }
823}
824
825fn ask_default_model() -> String {
826 DEFAULT_LOCAL_LLM_MODEL.into()
827}
828fn ask_default_k_summary() -> u32 {
829 5
830}
831fn ask_default_k_raw() -> u32 {
832 10
833}
834fn ask_default_esc() -> f64 {
835 0.5
836}
837fn ask_default_mmr() -> f64 {
838 0.88
839}
840fn ask_default_max_ctx() -> u32 {
841 6000
842}
843fn ask_default_resp_tok() -> u32 {
844 1024
845}
846fn ask_default_timeout() -> u32 {
847 120
848}
849fn ask_default_min_score() -> f64 {
850 0.35
851}
852fn ask_default_rewriter_timeout() -> u32 {
853 8
854}
855fn ask_default_continue_history_turns() -> u32 {
856 3
857}
858fn ask_default_compress_hits_enabled() -> bool {
859 true
860}
861fn ask_default_summarize_hits_enabled() -> bool {
862 true
863}
864
865#[derive(Debug, Clone, Serialize, Deserialize)]
874pub struct ConversationsConfig {
875 #[serde(default)]
876 pub enabled: bool,
877 #[serde(default = "conv_default_retention_days")]
878 pub retention_days: u32,
879 #[serde(default = "conv_default_poll_interval")]
880 pub poll_interval_secs: u64,
881 #[serde(default)]
882 pub sources: ConversationsSources,
883 #[serde(default)]
884 pub filter: ConversationsFilter,
885 #[serde(default)]
886 pub compact: CompactConfig,
887 #[serde(default)]
888 pub ask: AskConfig,
889 #[serde(default)]
890 pub rollup: RollupConfig,
891}
892
893impl Default for ConversationsConfig {
894 fn default() -> Self {
895 Self {
896 enabled: false,
897 retention_days: conv_default_retention_days(),
898 poll_interval_secs: conv_default_poll_interval(),
899 sources: ConversationsSources::default(),
900 filter: ConversationsFilter::default(),
901 compact: CompactConfig::default(),
902 ask: AskConfig::default(),
903 rollup: RollupConfig::default(),
904 }
905 }
906}
907
908fn conv_default_retention_days() -> u32 {
909 30
910}
911fn conv_default_poll_interval() -> u64 {
912 300
913}
914fn conv_truthy() -> bool {
915 true
916}
917fn conv_default_dedup() -> f64 {
918 0.85
919}
920
921#[derive(Debug, Clone, Serialize, Deserialize)]
922pub struct CompactConfig {
923 #[serde(default = "conv_truthy")]
924 pub enabled_in_daemon: bool,
925 #[serde(default = "compact_default_max_days")]
926 pub max_days_per_run: u32,
927 #[serde(default = "compact_default_model")]
928 pub extractive_model: String,
929 #[serde(default = "compact_default_model")]
930 pub abstractive_model: String,
931 #[serde(default = "compact_default_ollama_endpoint")]
932 pub ollama_endpoint: String,
933 #[serde(default = "compact_default_max_spans")]
934 pub max_extractive_spans: u32,
935 #[serde(default = "compact_default_max_words")]
936 pub max_abstractive_words: u32,
937 #[serde(default = "compact_default_chunk_tokens")]
938 pub chunk_tokens: u32,
939 #[serde(default = "compact_default_history_retain")]
940 pub history_retain: u32,
941 #[serde(default = "compact_default_cron")]
942 pub daemon_cron: String,
943 #[serde(default)]
946 pub extractive_backend: Option<BackendConfig>,
947 #[serde(default)]
950 pub abstractive_backend: Option<BackendConfig>,
951}
952
953impl CompactConfig {
954 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
963 self.extractive_backend
964 .clone()
965 .unwrap_or_else(|| BackendConfig {
966 provider: "ollama".into(),
967 model: self.extractive_model.clone(),
968 endpoint: Some(self.ollama_endpoint.clone()),
969 api_key_env: None,
970 api_key_ref: None,
971 timeout_secs: Some(120),
972 })
973 }
974
975 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
978 self.abstractive_backend
979 .clone()
980 .unwrap_or_else(|| BackendConfig {
981 provider: "ollama".into(),
982 model: self.abstractive_model.clone(),
983 endpoint: Some(self.ollama_endpoint.clone()),
984 api_key_env: None,
985 api_key_ref: None,
986 timeout_secs: Some(120),
987 })
988 }
989}
990
991impl Default for CompactConfig {
992 fn default() -> Self {
993 Self {
994 enabled_in_daemon: true,
995 max_days_per_run: compact_default_max_days(),
996 extractive_model: compact_default_model(),
997 abstractive_model: compact_default_model(),
998 ollama_endpoint: compact_default_ollama_endpoint(),
999 max_extractive_spans: compact_default_max_spans(),
1000 max_abstractive_words: compact_default_max_words(),
1001 chunk_tokens: compact_default_chunk_tokens(),
1002 history_retain: compact_default_history_retain(),
1003 daemon_cron: compact_default_cron(),
1004 extractive_backend: None,
1005 abstractive_backend: None,
1006 }
1007 }
1008}
1009
1010fn compact_default_max_days() -> u32 {
1011 7
1012}
1013fn compact_default_model() -> String {
1014 DEFAULT_LOCAL_LLM_MODEL.into()
1015}
1016fn compact_default_ollama_endpoint() -> String {
1017 "http://localhost:11434".into()
1018}
1019fn compact_default_max_spans() -> u32 {
1020 20
1021}
1022fn compact_default_max_words() -> u32 {
1023 400
1024}
1025fn compact_default_chunk_tokens() -> u32 {
1026 6000
1027}
1028fn compact_default_history_retain() -> u32 {
1029 5
1030}
1031fn compact_default_cron() -> String {
1032 "0 0 3 * * * *".into()
1033}
1034
1035#[derive(Debug, Clone, Serialize, Deserialize)]
1038pub struct RollupConfig {
1039 #[serde(default = "rollup_default_enabled")]
1040 pub enabled: bool,
1041 #[serde(default = "rollup_default_max_weeks")]
1042 pub max_weeks_per_run: u32,
1043 #[serde(default = "rollup_default_max_months")]
1044 pub max_months_per_run: u32,
1045 #[serde(default = "rollup_default_max_spans_week")]
1046 pub max_extractive_spans_per_week: u32,
1047 #[serde(default = "rollup_default_max_words_week")]
1048 pub max_abstractive_words_per_week: u32,
1049 #[serde(default = "rollup_default_max_spans_month")]
1050 pub max_extractive_spans_per_month: u32,
1051 #[serde(default = "rollup_default_max_words_month")]
1052 pub max_abstractive_words_per_month: u32,
1053 #[serde(default = "rollup_default_week_mmr")]
1054 pub week_mmr_threshold: f64,
1055 #[serde(default = "rollup_default_month_mmr")]
1056 pub month_mmr_threshold: f64,
1057 #[serde(default = "compact_default_model")]
1058 pub extractive_model: String,
1059 #[serde(default = "compact_default_model")]
1060 pub abstractive_model: String,
1061 #[serde(default = "compact_default_ollama_endpoint")]
1062 pub ollama_endpoint: String,
1063}
1064
1065impl Default for RollupConfig {
1066 fn default() -> Self {
1067 Self {
1068 enabled: rollup_default_enabled(),
1069 max_weeks_per_run: rollup_default_max_weeks(),
1070 max_months_per_run: rollup_default_max_months(),
1071 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1072 max_abstractive_words_per_week: rollup_default_max_words_week(),
1073 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1074 max_abstractive_words_per_month: rollup_default_max_words_month(),
1075 week_mmr_threshold: rollup_default_week_mmr(),
1076 month_mmr_threshold: rollup_default_month_mmr(),
1077 extractive_model: compact_default_model(),
1078 abstractive_model: compact_default_model(),
1079 ollama_endpoint: compact_default_ollama_endpoint(),
1080 }
1081 }
1082}
1083
1084fn rollup_default_enabled() -> bool {
1085 true
1086}
1087fn rollup_default_max_weeks() -> u32 {
1088 4
1089}
1090fn rollup_default_max_months() -> u32 {
1091 2
1092}
1093fn rollup_default_max_spans_week() -> u32 {
1094 20
1095}
1096fn rollup_default_max_words_week() -> u32 {
1097 500
1098}
1099fn rollup_default_max_spans_month() -> u32 {
1100 20
1101}
1102fn rollup_default_max_words_month() -> u32 {
1103 700
1104}
1105fn rollup_default_week_mmr() -> f64 {
1106 0.85
1107}
1108fn rollup_default_month_mmr() -> f64 {
1109 0.82
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1113pub struct ConversationsSources {
1114 #[serde(default = "conv_truthy")]
1115 pub claude_code: bool,
1116 #[serde(default = "conv_truthy")]
1117 pub cursor: bool,
1118 #[serde(default = "conv_truthy")]
1119 pub gemini: bool,
1120 #[serde(default)]
1121 pub aider: AiderSourceConfig,
1122}
1123
1124impl Default for ConversationsSources {
1125 fn default() -> Self {
1126 Self {
1127 claude_code: true,
1128 cursor: true,
1129 gemini: true,
1130 aider: AiderSourceConfig::default(),
1131 }
1132 }
1133}
1134
1135#[derive(Debug, Clone, Serialize, Deserialize)]
1136pub struct AiderSourceConfig {
1137 #[serde(default = "conv_truthy")]
1138 pub enabled: bool,
1139 #[serde(default)]
1140 pub watched_dirs: Vec<String>,
1141}
1142
1143impl Default for AiderSourceConfig {
1144 fn default() -> Self {
1145 Self {
1146 enabled: true,
1147 watched_dirs: Vec::new(),
1148 }
1149 }
1150}
1151
1152#[derive(Debug, Clone, Serialize, Deserialize)]
1153pub struct ConversationsFilter {
1154 #[serde(default = "conv_default_dedup")]
1155 pub dedup_threshold: f64,
1156 #[serde(default = "conv_truthy")]
1157 pub reject_heartbeat: bool,
1158 #[serde(default = "conv_truthy")]
1159 pub reject_system_restatement: bool,
1160}
1161
1162impl Default for ConversationsFilter {
1163 fn default() -> Self {
1164 Self {
1165 dedup_threshold: conv_default_dedup(),
1166 reject_heartbeat: true,
1167 reject_system_restatement: true,
1168 }
1169 }
1170}
1171
1172#[cfg(test)]
1173mod conversations_tests {
1174 use super::*;
1175
1176 #[test]
1177 fn conversations_section_defaults() {
1178 let c = ConversationsConfig::default();
1179 assert!(!c.enabled);
1180 assert_eq!(c.retention_days, 30);
1181 assert_eq!(c.poll_interval_secs, 300);
1182 assert!(c.sources.claude_code);
1183 assert!(c.sources.cursor);
1184 assert!(c.sources.gemini);
1185 assert!(c.sources.aider.enabled);
1186 assert!(c.sources.aider.watched_dirs.is_empty());
1187 assert_eq!(c.filter.dedup_threshold, 0.85);
1188 assert!(c.filter.reject_heartbeat);
1189 assert!(c.filter.reject_system_restatement);
1190 }
1191
1192 #[test]
1193 fn parse_from_yaml_with_overrides() {
1194 let y = r#"
1195conversations:
1196 enabled: true
1197 retention_days: 45
1198 poll_interval_secs: 120
1199 sources:
1200 cursor: false
1201 aider:
1202 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1203 filter:
1204 dedup_threshold: 0.9
1205"#;
1206 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1207 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1208 assert!(conv.enabled);
1209 assert_eq!(conv.retention_days, 45);
1210 assert_eq!(conv.poll_interval_secs, 120);
1211 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1215 assert_eq!(conv.filter.dedup_threshold, 0.9);
1216 assert!(conv.filter.reject_heartbeat); }
1218
1219 #[test]
1220 fn missing_conversations_section_is_fine() {
1221 let y = r#"
1222# No conversations section at all
1223foo: bar
1224"#;
1225 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1226 let conv: ConversationsConfig = v
1228 .get("conversations")
1229 .cloned()
1230 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1231 .unwrap_or_default();
1232 assert_eq!(conv.retention_days, 30);
1233 }
1234
1235 #[test]
1236 fn compact_config_defaults() {
1237 let c = CompactConfig::default();
1238 assert!(c.enabled_in_daemon);
1239 assert_eq!(c.max_days_per_run, 7);
1240 assert_eq!(c.extractive_model, "qwen3.5:4b");
1241 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1242 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1243 assert_eq!(c.max_extractive_spans, 20);
1244 assert_eq!(c.chunk_tokens, 6000);
1245 assert_eq!(c.history_retain, 5);
1246 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1247 }
1248
1249 #[test]
1250 fn compact_parses_partial_overrides() {
1251 let y = r#"
1252conversations:
1253 compact:
1254 max_days_per_run: 3
1255 extractive_model: qwen3:4b
1256"#;
1257 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1258 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1259 assert_eq!(conv.compact.max_days_per_run, 3);
1260 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1261 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
1264
1265 #[test]
1266 fn ask_config_defaults() {
1267 let c = AskConfig::default();
1268 assert_eq!(c.model, "qwen3.5:4b");
1269 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1270 assert_eq!(c.k_raw, 10);
1271 assert_eq!(c.escalation_threshold, 0.5);
1272 assert_eq!(c.mmr_threshold, 0.88);
1273 assert_eq!(c.max_context_tokens, 6000);
1274 assert_eq!(c.response_tokens, 1024);
1275 assert_eq!(c.timeout_secs, 120);
1276 assert_eq!(c.min_score, 0.35);
1277 }
1278
1279 #[test]
1280 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1281 let c = AskConfig::default();
1283 assert!(
1284 (c.mmr_threshold - 0.88).abs() < 1e-9,
1285 "expected 0.88, got {}",
1286 c.mmr_threshold
1287 );
1288 }
1289
1290 #[test]
1291 fn rollup_config_defaults() {
1292 let c = RollupConfig::default();
1293 assert!(c.enabled);
1294 assert_eq!(c.max_weeks_per_run, 4);
1295 assert_eq!(c.max_months_per_run, 2);
1296 assert_eq!(c.max_extractive_spans_per_week, 20);
1297 assert_eq!(c.max_abstractive_words_per_week, 500);
1298 assert_eq!(c.max_extractive_spans_per_month, 20);
1299 assert_eq!(c.max_abstractive_words_per_month, 700);
1300 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1301 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1302 assert_eq!(c.extractive_model, "qwen3.5:4b");
1303 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1304 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1305 }
1306
1307 #[test]
1308 fn rollup_config_plumbed_into_conversations_config() {
1309 let c = ConversationsConfig::default();
1310 assert!(c.rollup.enabled);
1311 }
1312
1313 #[test]
1314 fn ask_config_default_continue_history_turns_is_3() {
1315 let c = AskConfig::default();
1316 assert_eq!(c.continue_history_turns, 3);
1317 }
1318
1319 #[test]
1320 fn ask_config_default_compress_hits_enabled_is_true() {
1321 let c = AskConfig::default();
1322 assert!(c.compress_hits_enabled);
1323 }
1324
1325 #[test]
1326 fn ask_config_default_summarize_hits_enabled_is_true() {
1327 let c = AskConfig::default();
1328 assert!(c.summarize_hits_enabled);
1329 }
1330
1331 #[test]
1332 fn ask_config_default_summarize_model_is_none() {
1333 let c = AskConfig::default();
1334 assert!(c.summarize_model.is_none());
1335 }
1336
1337 #[test]
1338 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1339 let y = r#"
1340conversations:
1341 ask:
1342 summarize_hits_enabled: false
1343 summarize_model: qwen3:4b
1344"#;
1345 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1346 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1347 assert!(!conv.ask.summarize_hits_enabled);
1348 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1349 }
1350
1351 #[test]
1352 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1353 let y = r#"
1357conversations:
1358 ask:
1359 model: qwen3:14b
1360"#;
1361 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1362 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1363 assert!(conv.ask.summarize_hits_enabled);
1364 assert!(conv.ask.summarize_model.is_none());
1365 }
1366}
1367
1368#[cfg(test)]
1369mod tests {
1370 use super::*;
1371
1372 #[test]
1373 fn default_bundled_model_id_is_qwen35_2b() {
1374 assert_eq!(
1375 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1376 "Qwen3.5-2B-MLX-4bit"
1377 );
1378 }
1379
1380 #[test]
1381 fn nudge_config_defaults() {
1382 let c = NudgeConfig::default();
1383 assert!(c.enabled);
1384 assert_eq!(c.daily_cap, 3);
1385 assert_eq!(c.snooze_days, 7);
1386 assert_eq!(c.threshold, 3);
1387 }
1388
1389 #[test]
1390 fn config_has_nudge_section_with_defaults() {
1391 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1392 assert_eq!(c.nudge.daily_cap, 3);
1393 }
1394
1395 #[test]
1396 fn storage_config_default_is_lancedb() {
1397 let c = StorageConfig::default();
1398 assert_eq!(c.vector_backend, "lancedb");
1399 assert_eq!(c.qdrant_url, None);
1400 assert_eq!(c.qdrant_api_key_ref, None);
1401 }
1402
1403 #[test]
1404 fn sources_global_config_has_sensible_defaults() {
1405 let c = SourcesGlobalConfig::default();
1406 assert_eq!(c.poll_interval_secs, 600);
1407 assert_eq!(c.max_chunks_per_sync, 10_000);
1408 assert_eq!(c.max_parallel_sources, 3);
1409 assert_eq!(c.default_weight, 1.0);
1410 assert_eq!(c.embedding_batch_size, 32);
1411 }
1412
1413 #[test]
1414 fn config_default_has_storage_and_sources_global() {
1415 let c = Config::default();
1416 assert_eq!(c.storage.vector_backend, "lancedb");
1417 assert_eq!(c.sources_global.default_weight, 1.0);
1418 }
1419
1420 #[test]
1421 fn config_loads_yaml_without_new_fields() {
1422 let yaml = r#"
1425embedding:
1426 provider: ollama
1427 model: test-model
1428 dimensions: 512
1429 ollama_endpoint: http://localhost:11434
1430"#;
1431 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1432 assert_eq!(c.storage.vector_backend, "lancedb");
1433 assert_eq!(c.sources_global.max_parallel_sources, 3);
1434 }
1435
1436 #[test]
1437 fn llm_config_to_backend_config_anthropic_passthrough() {
1438 let cfg = LlmConfig {
1439 provider: "anthropic".into(),
1440 model: "claude-haiku-4-5".into(),
1441 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1442 api_key_ref: None,
1443 openai_url: None,
1444 };
1445 let b = cfg.to_backend_config();
1446 assert_eq!(b.provider, "anthropic");
1447 assert_eq!(b.model, "claude-haiku-4-5");
1448 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1449 assert_eq!(b.endpoint, None);
1450 assert_eq!(b.timeout_secs, None);
1451 }
1452
1453 #[test]
1454 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1455 let cfg = LlmConfig {
1456 provider: "openai".into(),
1457 model: "gpt-4o-mini".into(),
1458 api_key_env: None,
1459 api_key_ref: None,
1460 openai_url: Some("https://api.together.xyz/v1".into()),
1461 };
1462 let b = cfg.to_backend_config();
1463 assert_eq!(b.provider, "openai");
1464 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1465 assert_eq!(b.api_key_env, None); }
1467
1468 #[test]
1469 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1470 let cfg = LlmConfig {
1471 provider: "ollama".into(),
1472 model: "qwen3:14b".into(),
1473 api_key_env: None,
1474 api_key_ref: None,
1475 openai_url: Some("http://192.168.1.10:11434".into()),
1476 };
1477 let b = cfg.to_backend_config();
1478 assert_eq!(b.provider, "ollama");
1479 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1480 }
1481
1482 #[test]
1483 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1484 let cfg = LlmConfig {
1488 provider: "custom-name".into(),
1489 model: "some-model".into(),
1490 api_key_env: Some("CUSTOM_KEY".into()),
1491 api_key_ref: None,
1492 openai_url: Some("https://my-proxy.local/v1".into()),
1493 };
1494 let b = cfg.to_backend_config();
1495 assert_eq!(
1496 b.provider, "openai",
1497 "unknown provider + openai_url should alias to openai"
1498 );
1499 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1500 }
1501
1502 #[test]
1503 fn api_key_ref_roundtrips_and_defaults_none() {
1504 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1506 assert_eq!(b.api_key_ref, None);
1507 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1508 assert_eq!(l.api_key_ref, None);
1509 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1510 assert_eq!(e.api_key_ref, None);
1511
1512 let mut l2 = LlmConfig::default();
1514 l2.api_key_ref = Some("keychain:mur/anthropic".into());
1515 let y = serde_yaml_ng::to_string(&l2).unwrap();
1516 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1517 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1518 assert_eq!(
1519 l3.to_backend_config().api_key_ref.as_deref(),
1520 Some("keychain:mur/anthropic")
1521 );
1522 }
1523}
1524
1525#[cfg(test)]
1526mod backend_config_tests {
1527 use super::*;
1528
1529 #[test]
1530 fn default_is_ollama_qwen3() {
1531 let cfg = BackendConfig::default();
1532 assert_eq!(cfg.provider, "ollama");
1533 assert_eq!(cfg.model, "qwen3.5:4b");
1534 assert_eq!(cfg.endpoint, None);
1535 assert_eq!(cfg.api_key_env, None);
1536 assert_eq!(cfg.timeout_secs, None);
1537 }
1538
1539 #[test]
1540 fn deserializes_anthropic_full() {
1541 let yaml = "\
1542provider: anthropic
1543model: claude-haiku-4-5
1544api_key_env: ANTHROPIC_API_KEY
1545timeout_secs: 60
1546";
1547 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1548 assert_eq!(cfg.provider, "anthropic");
1549 assert_eq!(cfg.model, "claude-haiku-4-5");
1550 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1551 assert_eq!(cfg.timeout_secs, Some(60));
1552 assert_eq!(cfg.endpoint, None);
1553 }
1554
1555 #[test]
1556 fn deserializes_partial_fills_defaults() {
1557 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1558 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1559 assert_eq!(cfg.provider, "anthropic");
1560 assert_eq!(cfg.model, "claude-sonnet-4-6");
1561 assert_eq!(cfg.api_key_env, None);
1562 assert_eq!(cfg.timeout_secs, None);
1563 }
1564
1565 #[test]
1566 fn round_trips_through_yaml() {
1567 let original = BackendConfig {
1568 provider: "anthropic".into(),
1569 model: "claude-haiku-4-5".into(),
1570 endpoint: Some("https://api.anthropic.com".into()),
1571 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1572 api_key_ref: None,
1573 timeout_secs: Some(60),
1574 };
1575 let yaml = serde_yaml::to_string(&original).unwrap();
1576 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1577 assert_eq!(parsed, original);
1578 }
1579
1580 #[test]
1581 fn skills_config_curation_gate_defaults_on() {
1582 let c = SkillsConfig::default();
1583 assert!(c.require_human_curation_before_stable);
1584 }
1585}
1586
1587#[derive(Debug, Clone, Serialize, Deserialize)]
1591#[serde(default)]
1592pub struct SkillsConfig {
1593 pub max_skills_in_prompt: usize,
1594 pub max_total_tokens: usize,
1595 pub priority_order: Vec<String>,
1596 pub adaptive: Option<AdaptiveSkillsConfig>,
1597
1598 #[serde(default = "default_require_human_curation")]
1602 pub require_human_curation_before_stable: bool,
1603
1604 #[serde(default)]
1608 pub lifecycle: SkillLifecycleConfig,
1609
1610 #[serde(default = "default_auto_upgrade")]
1615 pub auto_upgrade: bool,
1616}
1617
1618fn default_require_human_curation() -> bool {
1619 true
1620}
1621
1622fn default_auto_upgrade() -> bool {
1623 true
1624}
1625
1626impl Default for SkillsConfig {
1627 fn default() -> Self {
1628 Self {
1629 max_skills_in_prompt: 5,
1630 max_total_tokens: 2000,
1631 priority_order: vec!["agent".into(), "global".into()],
1632 adaptive: Some(AdaptiveSkillsConfig::default()),
1633 require_human_curation_before_stable: default_require_human_curation(),
1634 lifecycle: SkillLifecycleConfig::default(),
1635 auto_upgrade: default_auto_upgrade(),
1636 }
1637 }
1638}
1639
1640#[derive(Debug, Clone, Serialize, Deserialize)]
1646#[serde(default)]
1647pub struct SkillLifecycleConfig {
1648 pub promote_draft_uses: u64,
1650 pub promote_emerging_uses: u64,
1651 pub promote_emerging_success_rate: f64,
1652 pub promote_emerging_age_days: i64,
1653 pub promote_stable_uses: u64,
1654 pub promote_stable_success_rate: f64,
1655 pub promote_stable_age_days: i64,
1656
1657 pub demote_emerging_uses: u64,
1659 pub demote_emerging_success_rate: f64,
1660 pub demote_stable_uses: u64,
1661 pub demote_stable_success_rate: f64,
1662 pub deprecated_success_rate: f64,
1663 pub deprecated_no_success_days: i64,
1664
1665 pub auto_archive_confidence: f64,
1667 pub auto_archive_age_days: i64,
1668
1669 pub broken_workflow_streak: u32,
1674
1675 pub archive_destroy_grace_days: i64,
1680}
1681
1682impl Default for SkillLifecycleConfig {
1683 fn default() -> Self {
1684 Self {
1685 promote_draft_uses: 3,
1686 promote_emerging_uses: 10,
1687 promote_emerging_success_rate: 0.6,
1688 promote_emerging_age_days: 7,
1689 promote_stable_uses: 30,
1690 promote_stable_success_rate: 0.8,
1691 promote_stable_age_days: 30,
1692 demote_emerging_uses: 8,
1693 demote_emerging_success_rate: 0.55,
1694 demote_stable_uses: 25,
1695 demote_stable_success_rate: 0.75,
1696 deprecated_success_rate: 0.3,
1697 deprecated_no_success_days: 90,
1698 auto_archive_confidence: 0.10,
1699 auto_archive_age_days: 180,
1700 broken_workflow_streak: 3,
1701 archive_destroy_grace_days: 30,
1702 }
1703 }
1704}
1705
1706#[derive(Debug, Clone, Serialize, Deserialize)]
1707#[serde(default)]
1708pub struct AdaptiveSkillsConfig {
1709 pub context_fill_decay: f64,
1710 pub min_remaining_context_ratio: f64,
1711 pub recent_fire_boost_turns: usize,
1712 pub model_max_context_tokens: u64,
1716}
1717
1718impl Default for AdaptiveSkillsConfig {
1719 fn default() -> Self {
1720 Self {
1721 context_fill_decay: 1.5,
1722 min_remaining_context_ratio: 0.20,
1723 recent_fire_boost_turns: 5,
1724 model_max_context_tokens: 200_000,
1725 }
1726 }
1727}
1728
1729#[derive(Debug, Clone, Serialize, Deserialize)]
1732pub struct SleepCycleConfig {
1733 #[serde(default)]
1735 pub enabled: bool,
1736
1737 #[serde(default = "default_idle_threshold_minutes")]
1739 pub idle_threshold_minutes: u64,
1740
1741 #[serde(default = "default_agent_idle_minutes")]
1743 pub agent_idle_minutes: u64,
1744}
1745
1746fn default_idle_threshold_minutes() -> u64 {
1747 15
1748}
1749
1750fn default_agent_idle_minutes() -> u64 {
1751 5
1752}
1753
1754impl Default for SleepCycleConfig {
1755 fn default() -> Self {
1756 Self {
1757 enabled: false,
1758 idle_threshold_minutes: default_idle_threshold_minutes(),
1759 agent_idle_minutes: default_agent_idle_minutes(),
1760 }
1761 }
1762}
1763
1764#[derive(Debug, Clone, Serialize, Deserialize)]
1767pub struct NudgeConfig {
1768 #[serde(default = "default_nudge_enabled")]
1770 pub enabled: bool,
1771 #[serde(default = "default_nudge_daily_cap")]
1772 pub daily_cap: u32,
1773 #[serde(default = "default_nudge_snooze_days")]
1774 pub snooze_days: u32,
1775 #[serde(default = "default_nudge_threshold")]
1776 pub threshold: usize,
1777}
1778
1779fn default_nudge_enabled() -> bool {
1780 true
1781}
1782fn default_nudge_daily_cap() -> u32 {
1783 3
1784}
1785fn default_nudge_snooze_days() -> u32 {
1786 7
1787}
1788fn default_nudge_threshold() -> usize {
1789 3
1790}
1791
1792impl Default for NudgeConfig {
1793 fn default() -> Self {
1794 Self {
1795 enabled: true,
1796 daily_cap: default_nudge_daily_cap(),
1797 snooze_days: default_nudge_snooze_days(),
1798 threshold: default_nudge_threshold(),
1799 }
1800 }
1801}
1802
1803#[derive(Debug, Clone, Serialize, Deserialize)]
1807pub struct SessionCfg {
1808 #[serde(default = "default_capture_mode")]
1810 pub capture: String,
1811 #[serde(default = "default_retention_days")]
1813 pub retention_days: u32,
1814}
1815
1816impl Default for SessionCfg {
1817 fn default() -> Self {
1818 Self {
1819 capture: default_capture_mode(),
1820 retention_days: default_retention_days(),
1821 }
1822 }
1823}
1824
1825fn default_capture_mode() -> String {
1826 "ambient".to_string()
1827}
1828fn default_retention_days() -> u32 {
1829 14
1830}
1831
1832#[derive(Debug, Clone, Serialize, Deserialize)]
1834pub struct HarvestCfg {
1835 #[serde(default = "default_harvest_enabled")]
1837 pub auto_gate: bool,
1838 #[serde(default = "default_harvest_llm")]
1840 pub llm: String,
1841 #[serde(default = "default_min_events")]
1843 pub min_events: usize,
1844 #[serde(default = "default_min_user_turns")]
1845 pub min_user_turns: usize,
1846 #[serde(default = "default_min_duration_secs")]
1847 pub min_duration_secs: i64,
1848 #[serde(default = "default_idle_minutes")]
1850 pub idle_minutes: i64,
1851 #[serde(default = "default_max_llm_calls_per_day")]
1853 pub max_llm_calls_per_day: u32,
1854 #[serde(default = "default_max_extract_input_tokens")]
1855 pub max_extract_input_tokens: usize,
1856 #[serde(default = "default_harvest_enabled")]
1858 pub session_start_hint: bool,
1859 #[serde(default = "default_similarity_merge_threshold")]
1861 pub similarity_merge_threshold: f32,
1862}
1863
1864impl Default for HarvestCfg {
1865 fn default() -> Self {
1866 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1867 }
1868}
1869
1870fn default_harvest_enabled() -> bool {
1871 true
1872}
1873fn default_harvest_llm() -> String {
1874 "local-first".to_string()
1875}
1876fn default_min_events() -> usize {
1877 5
1878}
1879fn default_min_user_turns() -> usize {
1880 2
1881}
1882fn default_min_duration_secs() -> i64 {
1883 120
1884}
1885fn default_idle_minutes() -> i64 {
1886 30
1887}
1888fn default_max_llm_calls_per_day() -> u32 {
1889 10
1890}
1891fn default_max_extract_input_tokens() -> usize {
1892 12000
1893}
1894fn default_similarity_merge_threshold() -> f32 {
1895 0.6
1896}
1897
1898#[derive(Debug, Clone, Serialize, Deserialize)]
1901#[serde(default)]
1902pub struct CrossAgentConfig {
1903 #[serde(default = "default_half_life_days")]
1904 pub fitness_half_life_days: u32,
1905 #[serde(default = "default_fitness_floor")]
1906 pub fitness_floor: f64,
1907}
1908
1909fn default_half_life_days() -> u32 {
1910 7
1911}
1912fn default_fitness_floor() -> f64 {
1913 0.1
1914}
1915
1916impl Default for CrossAgentConfig {
1917 fn default() -> Self {
1918 Self {
1919 fitness_half_life_days: default_half_life_days(),
1920 fitness_floor: default_fitness_floor(),
1921 }
1922 }
1923}
1924
1925#[derive(Debug, Clone, Serialize, Deserialize)]
1928#[serde(default)]
1929pub struct SkillLlmConfig {
1930 #[serde(default = "default_per_call_token_cap")]
1932 pub per_call_token_cap: u32,
1933
1934 #[serde(default = "default_per_day_usd_cap")]
1936 pub per_day_usd_cap: f64,
1937
1938 #[serde(default = "default_cache_ttl_days")]
1940 pub cache_ttl_days: u32,
1941
1942 #[serde(default, skip_serializing_if = "Option::is_none")]
1944 pub model_ref: Option<String>,
1945}
1946
1947fn default_per_call_token_cap() -> u32 {
1948 1500
1949}
1950fn default_per_day_usd_cap() -> f64 {
1951 0.50
1952}
1953fn default_cache_ttl_days() -> u32 {
1954 30
1955}
1956
1957impl Default for SkillLlmConfig {
1958 fn default() -> Self {
1959 Self {
1960 per_call_token_cap: default_per_call_token_cap(),
1961 per_day_usd_cap: default_per_day_usd_cap(),
1962 cache_ttl_days: default_cache_ttl_days(),
1963 model_ref: None,
1964 }
1965 }
1966}
1967#[cfg(test)]
1968mod per_stage_backend_tests {
1969 use super::*;
1970
1971 #[test]
1972 fn legacy_compact_config_has_no_per_stage_overrides() {
1973 let yaml = "\
1974extractive_model: qwen3:14b
1975abstractive_model: qwen3:14b
1976ollama_endpoint: http://localhost:11434
1977";
1978 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1979 assert!(cfg.extractive_backend.is_none());
1980 assert!(cfg.abstractive_backend.is_none());
1981 assert_eq!(cfg.extractive_model, "qwen3:14b");
1982 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1983 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1984 }
1985
1986 #[test]
1987 fn legacy_ask_config_has_no_per_stage_overrides() {
1988 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1989 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1990 assert!(cfg.backend.is_none());
1991 assert!(cfg.rewriter_backend.is_none());
1992 assert_eq!(cfg.model, "qwen3:14b");
1993 }
1994
1995 #[test]
1996 fn compact_extractive_backend_override_parses() {
1997 let yaml = "\
1998extractive_backend:
1999 provider: anthropic
2000 model: claude-haiku-4-5
2001 api_key_env: ANTHROPIC_API_KEY
2002abstractive_model: qwen3:14b
2003";
2004 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2005 let extractive = cfg
2006 .extractive_backend
2007 .as_ref()
2008 .expect("override should parse");
2009 assert_eq!(extractive.provider, "anthropic");
2010 assert_eq!(extractive.model, "claude-haiku-4-5");
2011 assert!(cfg.abstractive_backend.is_none());
2012 }
2013
2014 #[test]
2015 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2016 let yaml = "\
2017backend:
2018 provider: anthropic
2019 model: claude-sonnet-4-6
2020 api_key_env: ANTHROPIC_API_KEY
2021rewriter_backend:
2022 provider: ollama
2023 model: llama3.2:3b
2024";
2025 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2026 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2027 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2028 }
2029
2030 #[test]
2031 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
2032 let yaml = "\
2033extractive_model: qwen3:14b
2034ollama_endpoint: http://192.168.1.10:11434
2035";
2036 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2037 let synth = cfg.synthesize_extractive_backend();
2038 assert_eq!(synth.provider, "ollama");
2039 assert_eq!(synth.model, "qwen3:14b");
2040 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
2041 assert_eq!(synth.api_key_env, None);
2042 }
2043
2044 #[test]
2045 fn synthesize_legacy_to_backend_config_for_ask() {
2046 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
2047 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2048 let synth = cfg.synthesize_backend();
2049 assert_eq!(synth.provider, "ollama");
2050 assert_eq!(synth.model, "qwen3:14b");
2051 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
2052 }
2053
2054 #[test]
2055 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
2056 let yaml = "\
2065backend:
2066 provider: anthropic
2067 model: claude-sonnet-4-6
2068 api_key_env: ANTHROPIC_API_KEY
2069";
2070 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2071 let rewriter = cfg.synthesize_rewriter_backend();
2072 assert_eq!(rewriter.provider, "ollama");
2073 assert_eq!(rewriter.model, ask_default_model());
2074 assert_eq!(
2075 rewriter.timeout_secs,
2076 Some(ask_default_rewriter_timeout() as u64)
2077 );
2078 }
2079
2080 #[test]
2081 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
2082 let cfg = AskConfig {
2083 timeout_secs: 45,
2084 ..AskConfig::default()
2085 };
2086 let b = cfg.synthesize_backend();
2087 assert_eq!(
2088 b.timeout_secs,
2089 Some(45),
2090 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
2091 );
2092 }
2093
2094 #[test]
2095 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
2096 let mut cfg = AskConfig {
2097 timeout_secs: 45,
2098 ..AskConfig::default()
2099 };
2100 cfg.backend = Some(BackendConfig {
2101 provider: "anthropic".into(),
2102 model: "claude-haiku-4-5".into(),
2103 endpoint: None,
2104 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2105 api_key_ref: None,
2106 timeout_secs: Some(10),
2107 });
2108 let b = cfg.synthesize_backend();
2109 assert_eq!(
2110 b.timeout_secs,
2111 Some(10),
2112 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
2113 );
2114 }
2115
2116 #[test]
2117 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
2118 let cfg = AskConfig {
2119 timeout_secs: 120,
2120 rewriter_timeout_secs: 8,
2121 ..AskConfig::default()
2122 };
2123 let b = cfg.synthesize_rewriter_backend();
2124 assert_eq!(
2125 b.timeout_secs,
2126 Some(8),
2127 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
2128 );
2129 }
2130
2131 #[test]
2132 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
2133 let mut cfg = AskConfig {
2134 rewriter_timeout_secs: 8,
2135 ..AskConfig::default()
2136 };
2137 cfg.rewriter_backend = Some(BackendConfig {
2138 provider: "anthropic".into(),
2139 model: "claude-haiku-4-5".into(),
2140 endpoint: None,
2141 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2142 api_key_ref: None,
2143 timeout_secs: Some(30),
2144 });
2145 let b = cfg.synthesize_rewriter_backend();
2146 assert_eq!(
2147 b.timeout_secs,
2148 Some(30),
2149 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
2150 );
2151 }
2152
2153 #[test]
2154 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
2155 let cfg = CompactConfig::default();
2158 let b = cfg.synthesize_extractive_backend();
2159 assert_eq!(
2160 b.timeout_secs,
2161 Some(120),
2162 "compact synthesis without per-stage override must produce 120s timeout"
2163 );
2164 }
2165
2166 #[test]
2167 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
2168 let cfg = CompactConfig::default();
2169 let b = cfg.synthesize_abstractive_backend();
2170 assert_eq!(b.timeout_secs, Some(120));
2171 }
2172}
2173
2174#[cfg(test)]
2175mod skills_config_tests {
2176 use super::*;
2177
2178 #[test]
2179 fn empty_yaml_hydrates_defaults() {
2180 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2181 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2182 assert_eq!(cfg.skills.max_total_tokens, 2000);
2183 assert!(cfg.skills.adaptive.is_some());
2184 }
2185
2186 #[test]
2187 fn load_or_default_missing_file_returns_default() {
2188 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2189 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2190 }
2191}
2192
2193#[cfg(test)]
2194mod ambient_capture_cfg_tests {
2195 use super::*;
2196
2197 #[test]
2198 fn session_and_harvest_defaults() {
2199 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2200 assert_eq!(cfg.session.capture, "ambient");
2201 assert_eq!(cfg.session.retention_days, 14);
2202 assert!(cfg.harvest.auto_gate);
2203 assert_eq!(cfg.harvest.llm, "local-first");
2204 assert_eq!(cfg.harvest.min_events, 5);
2205 assert_eq!(cfg.harvest.min_user_turns, 2);
2206 assert_eq!(cfg.harvest.min_duration_secs, 120);
2207 assert_eq!(cfg.harvest.idle_minutes, 30);
2208 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2209 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2210 assert!(cfg.harvest.session_start_hint);
2211 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2212 }
2213
2214 #[test]
2215 fn session_capture_override_parses() {
2216 let cfg: Config =
2217 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2218 assert_eq!(cfg.session.capture, "off");
2219 assert_eq!(cfg.session.retention_days, 3);
2220 }
2221}
2222
2223#[cfg(test)]
2224mod cc_proxy_cfg_tests {
2225 use super::*;
2226
2227 #[test]
2228 fn defaults_to_local_cc_proxy_enabled() {
2229 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2230 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2231 assert!(cfg.cc_proxy.enabled);
2232 }
2233
2234 #[test]
2235 fn url_and_enabled_override_parse() {
2236 let cfg: Config =
2237 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2238 .unwrap();
2239 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2240 assert!(!cfg.cc_proxy.enabled);
2241 }
2242
2243 #[test]
2244 fn partial_section_keeps_other_default() {
2245 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2247 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2248 assert!(!cfg.cc_proxy.enabled);
2249 }
2250}
2251
2252#[cfg(test)]
2253mod model_switch_config_tests {
2254 use super::*;
2255
2256 #[test]
2257 fn model_switch_config_defaults_and_omitted_block() {
2258 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2260 assert_eq!(cfg.models.default, None);
2261 assert!(cfg.models.fallback_chain.is_empty());
2262 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2263 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2264 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2265 assert!(!cfg.models.routing.enabled);
2266
2267 let yaml = "models:\n default: claude_sonnet\n fallback_chain: [claude_sonnet, deepseek_v4_pro]\n routing:\n enabled: true\n cheap: deepseek_v4_flash\n frontier: claude_opus\n threshold_input_tokens: 1500\n";
2269 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2270 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2271 assert_eq!(
2272 cfg.models.fallback_chain,
2273 vec!["claude_sonnet", "deepseek_v4_pro"]
2274 );
2275 assert!(cfg.models.routing.enabled);
2276 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2277 }
2278
2279 #[test]
2280 fn smart_config_defaults_on_with_autopick() {
2281 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2282 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2285 cfg.models.smart.max_escalations,
2286 DEFAULT_SMART_MAX_ESCALATIONS
2287 );
2288 }
2289}