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, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1595#[serde(rename_all = "lowercase")]
1596pub enum DevDisciplineIndex {
1597 #[default]
1599 Auto,
1600 Always,
1602 Never,
1604}
1605
1606#[derive(Debug, Clone, Serialize, Deserialize)]
1607#[serde(default)]
1608pub struct SkillsConfig {
1609 pub max_skills_in_prompt: usize,
1610 pub max_total_tokens: usize,
1611 pub priority_order: Vec<String>,
1612 pub adaptive: Option<AdaptiveSkillsConfig>,
1613
1614 #[serde(default = "default_require_human_curation")]
1618 pub require_human_curation_before_stable: bool,
1619
1620 #[serde(default)]
1624 pub lifecycle: SkillLifecycleConfig,
1625
1626 #[serde(default = "default_auto_upgrade")]
1631 pub auto_upgrade: bool,
1632
1633 #[serde(default)]
1635 pub dev_discipline_index: DevDisciplineIndex,
1636}
1637
1638fn default_require_human_curation() -> bool {
1639 true
1640}
1641
1642fn default_auto_upgrade() -> bool {
1643 true
1644}
1645
1646impl Default for SkillsConfig {
1647 fn default() -> Self {
1648 Self {
1649 max_skills_in_prompt: 5,
1650 max_total_tokens: 2000,
1651 priority_order: vec!["agent".into(), "global".into()],
1652 adaptive: Some(AdaptiveSkillsConfig::default()),
1653 require_human_curation_before_stable: default_require_human_curation(),
1654 lifecycle: SkillLifecycleConfig::default(),
1655 auto_upgrade: default_auto_upgrade(),
1656 dev_discipline_index: DevDisciplineIndex::default(),
1657 }
1658 }
1659}
1660
1661#[derive(Debug, Clone, Serialize, Deserialize)]
1667#[serde(default)]
1668pub struct SkillLifecycleConfig {
1669 pub promote_draft_uses: u64,
1671 pub promote_emerging_uses: u64,
1672 pub promote_emerging_success_rate: f64,
1673 pub promote_emerging_age_days: i64,
1674 pub promote_stable_uses: u64,
1675 pub promote_stable_success_rate: f64,
1676 pub promote_stable_age_days: i64,
1677
1678 pub demote_emerging_uses: u64,
1680 pub demote_emerging_success_rate: f64,
1681 pub demote_stable_uses: u64,
1682 pub demote_stable_success_rate: f64,
1683 pub deprecated_success_rate: f64,
1684 pub deprecated_no_success_days: i64,
1685
1686 pub auto_archive_confidence: f64,
1688 pub auto_archive_age_days: i64,
1689
1690 pub broken_workflow_streak: u32,
1695
1696 pub archive_destroy_grace_days: i64,
1701}
1702
1703impl Default for SkillLifecycleConfig {
1704 fn default() -> Self {
1705 Self {
1706 promote_draft_uses: 3,
1707 promote_emerging_uses: 10,
1708 promote_emerging_success_rate: 0.6,
1709 promote_emerging_age_days: 7,
1710 promote_stable_uses: 30,
1711 promote_stable_success_rate: 0.8,
1712 promote_stable_age_days: 30,
1713 demote_emerging_uses: 8,
1714 demote_emerging_success_rate: 0.55,
1715 demote_stable_uses: 25,
1716 demote_stable_success_rate: 0.75,
1717 deprecated_success_rate: 0.3,
1718 deprecated_no_success_days: 90,
1719 auto_archive_confidence: 0.10,
1720 auto_archive_age_days: 180,
1721 broken_workflow_streak: 3,
1722 archive_destroy_grace_days: 30,
1723 }
1724 }
1725}
1726
1727#[derive(Debug, Clone, Serialize, Deserialize)]
1728#[serde(default)]
1729pub struct AdaptiveSkillsConfig {
1730 pub context_fill_decay: f64,
1731 pub min_remaining_context_ratio: f64,
1732 pub recent_fire_boost_turns: usize,
1733 pub model_max_context_tokens: u64,
1737}
1738
1739impl Default for AdaptiveSkillsConfig {
1740 fn default() -> Self {
1741 Self {
1742 context_fill_decay: 1.5,
1743 min_remaining_context_ratio: 0.20,
1744 recent_fire_boost_turns: 5,
1745 model_max_context_tokens: 200_000,
1746 }
1747 }
1748}
1749
1750#[derive(Debug, Clone, Serialize, Deserialize)]
1753pub struct SleepCycleConfig {
1754 #[serde(default)]
1756 pub enabled: bool,
1757
1758 #[serde(default = "default_idle_threshold_minutes")]
1760 pub idle_threshold_minutes: u64,
1761
1762 #[serde(default = "default_agent_idle_minutes")]
1764 pub agent_idle_minutes: u64,
1765}
1766
1767fn default_idle_threshold_minutes() -> u64 {
1768 15
1769}
1770
1771fn default_agent_idle_minutes() -> u64 {
1772 5
1773}
1774
1775impl Default for SleepCycleConfig {
1776 fn default() -> Self {
1777 Self {
1778 enabled: false,
1779 idle_threshold_minutes: default_idle_threshold_minutes(),
1780 agent_idle_minutes: default_agent_idle_minutes(),
1781 }
1782 }
1783}
1784
1785#[derive(Debug, Clone, Serialize, Deserialize)]
1788pub struct NudgeConfig {
1789 #[serde(default = "default_nudge_enabled")]
1791 pub enabled: bool,
1792 #[serde(default = "default_nudge_daily_cap")]
1793 pub daily_cap: u32,
1794 #[serde(default = "default_nudge_snooze_days")]
1795 pub snooze_days: u32,
1796 #[serde(default = "default_nudge_threshold")]
1797 pub threshold: usize,
1798}
1799
1800fn default_nudge_enabled() -> bool {
1801 true
1802}
1803fn default_nudge_daily_cap() -> u32 {
1804 3
1805}
1806fn default_nudge_snooze_days() -> u32 {
1807 7
1808}
1809fn default_nudge_threshold() -> usize {
1810 3
1811}
1812
1813impl Default for NudgeConfig {
1814 fn default() -> Self {
1815 Self {
1816 enabled: true,
1817 daily_cap: default_nudge_daily_cap(),
1818 snooze_days: default_nudge_snooze_days(),
1819 threshold: default_nudge_threshold(),
1820 }
1821 }
1822}
1823
1824#[derive(Debug, Clone, Serialize, Deserialize)]
1828pub struct SessionCfg {
1829 #[serde(default = "default_capture_mode")]
1831 pub capture: String,
1832 #[serde(default = "default_retention_days")]
1834 pub retention_days: u32,
1835}
1836
1837impl Default for SessionCfg {
1838 fn default() -> Self {
1839 Self {
1840 capture: default_capture_mode(),
1841 retention_days: default_retention_days(),
1842 }
1843 }
1844}
1845
1846fn default_capture_mode() -> String {
1847 "ambient".to_string()
1848}
1849fn default_retention_days() -> u32 {
1850 14
1851}
1852
1853#[derive(Debug, Clone, Serialize, Deserialize)]
1855pub struct HarvestCfg {
1856 #[serde(default = "default_harvest_enabled")]
1858 pub auto_gate: bool,
1859 #[serde(default = "default_harvest_llm")]
1861 pub llm: String,
1862 #[serde(default = "default_min_events")]
1864 pub min_events: usize,
1865 #[serde(default = "default_min_user_turns")]
1866 pub min_user_turns: usize,
1867 #[serde(default = "default_min_duration_secs")]
1868 pub min_duration_secs: i64,
1869 #[serde(default = "default_idle_minutes")]
1871 pub idle_minutes: i64,
1872 #[serde(default = "default_max_llm_calls_per_day")]
1874 pub max_llm_calls_per_day: u32,
1875 #[serde(default = "default_max_extract_input_tokens")]
1876 pub max_extract_input_tokens: usize,
1877 #[serde(default = "default_harvest_enabled")]
1879 pub session_start_hint: bool,
1880 #[serde(default = "default_similarity_merge_threshold")]
1882 pub similarity_merge_threshold: f32,
1883}
1884
1885impl Default for HarvestCfg {
1886 fn default() -> Self {
1887 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1888 }
1889}
1890
1891fn default_harvest_enabled() -> bool {
1892 true
1893}
1894fn default_harvest_llm() -> String {
1895 "local-first".to_string()
1896}
1897fn default_min_events() -> usize {
1898 5
1899}
1900fn default_min_user_turns() -> usize {
1901 2
1902}
1903fn default_min_duration_secs() -> i64 {
1904 120
1905}
1906fn default_idle_minutes() -> i64 {
1907 30
1908}
1909fn default_max_llm_calls_per_day() -> u32 {
1910 10
1911}
1912fn default_max_extract_input_tokens() -> usize {
1913 12000
1914}
1915fn default_similarity_merge_threshold() -> f32 {
1916 0.6
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1922#[serde(default)]
1923pub struct CrossAgentConfig {
1924 #[serde(default = "default_half_life_days")]
1925 pub fitness_half_life_days: u32,
1926 #[serde(default = "default_fitness_floor")]
1927 pub fitness_floor: f64,
1928}
1929
1930fn default_half_life_days() -> u32 {
1931 7
1932}
1933fn default_fitness_floor() -> f64 {
1934 0.1
1935}
1936
1937impl Default for CrossAgentConfig {
1938 fn default() -> Self {
1939 Self {
1940 fitness_half_life_days: default_half_life_days(),
1941 fitness_floor: default_fitness_floor(),
1942 }
1943 }
1944}
1945
1946#[derive(Debug, Clone, Serialize, Deserialize)]
1949#[serde(default)]
1950pub struct SkillLlmConfig {
1951 #[serde(default = "default_per_call_token_cap")]
1953 pub per_call_token_cap: u32,
1954
1955 #[serde(default = "default_per_day_usd_cap")]
1957 pub per_day_usd_cap: f64,
1958
1959 #[serde(default = "default_cache_ttl_days")]
1961 pub cache_ttl_days: u32,
1962
1963 #[serde(default, skip_serializing_if = "Option::is_none")]
1965 pub model_ref: Option<String>,
1966}
1967
1968fn default_per_call_token_cap() -> u32 {
1969 1500
1970}
1971fn default_per_day_usd_cap() -> f64 {
1972 0.50
1973}
1974fn default_cache_ttl_days() -> u32 {
1975 30
1976}
1977
1978impl Default for SkillLlmConfig {
1979 fn default() -> Self {
1980 Self {
1981 per_call_token_cap: default_per_call_token_cap(),
1982 per_day_usd_cap: default_per_day_usd_cap(),
1983 cache_ttl_days: default_cache_ttl_days(),
1984 model_ref: None,
1985 }
1986 }
1987}
1988#[cfg(test)]
1989mod per_stage_backend_tests {
1990 use super::*;
1991
1992 #[test]
1993 fn legacy_compact_config_has_no_per_stage_overrides() {
1994 let yaml = "\
1995extractive_model: qwen3:14b
1996abstractive_model: qwen3:14b
1997ollama_endpoint: http://localhost:11434
1998";
1999 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2000 assert!(cfg.extractive_backend.is_none());
2001 assert!(cfg.abstractive_backend.is_none());
2002 assert_eq!(cfg.extractive_model, "qwen3:14b");
2003 assert_eq!(cfg.abstractive_model, "qwen3:14b");
2004 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
2005 }
2006
2007 #[test]
2008 fn legacy_ask_config_has_no_per_stage_overrides() {
2009 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
2010 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2011 assert!(cfg.backend.is_none());
2012 assert!(cfg.rewriter_backend.is_none());
2013 assert_eq!(cfg.model, "qwen3:14b");
2014 }
2015
2016 #[test]
2017 fn compact_extractive_backend_override_parses() {
2018 let yaml = "\
2019extractive_backend:
2020 provider: anthropic
2021 model: claude-haiku-4-5
2022 api_key_env: ANTHROPIC_API_KEY
2023abstractive_model: qwen3:14b
2024";
2025 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2026 let extractive = cfg
2027 .extractive_backend
2028 .as_ref()
2029 .expect("override should parse");
2030 assert_eq!(extractive.provider, "anthropic");
2031 assert_eq!(extractive.model, "claude-haiku-4-5");
2032 assert!(cfg.abstractive_backend.is_none());
2033 }
2034
2035 #[test]
2036 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2037 let yaml = "\
2038backend:
2039 provider: anthropic
2040 model: claude-sonnet-4-6
2041 api_key_env: ANTHROPIC_API_KEY
2042rewriter_backend:
2043 provider: ollama
2044 model: llama3.2:3b
2045";
2046 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2047 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2048 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2049 }
2050
2051 #[test]
2052 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
2053 let yaml = "\
2054extractive_model: qwen3:14b
2055ollama_endpoint: http://192.168.1.10:11434
2056";
2057 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2058 let synth = cfg.synthesize_extractive_backend();
2059 assert_eq!(synth.provider, "ollama");
2060 assert_eq!(synth.model, "qwen3:14b");
2061 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
2062 assert_eq!(synth.api_key_env, None);
2063 }
2064
2065 #[test]
2066 fn synthesize_legacy_to_backend_config_for_ask() {
2067 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
2068 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2069 let synth = cfg.synthesize_backend();
2070 assert_eq!(synth.provider, "ollama");
2071 assert_eq!(synth.model, "qwen3:14b");
2072 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
2073 }
2074
2075 #[test]
2076 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
2077 let yaml = "\
2086backend:
2087 provider: anthropic
2088 model: claude-sonnet-4-6
2089 api_key_env: ANTHROPIC_API_KEY
2090";
2091 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2092 let rewriter = cfg.synthesize_rewriter_backend();
2093 assert_eq!(rewriter.provider, "ollama");
2094 assert_eq!(rewriter.model, ask_default_model());
2095 assert_eq!(
2096 rewriter.timeout_secs,
2097 Some(ask_default_rewriter_timeout() as u64)
2098 );
2099 }
2100
2101 #[test]
2102 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
2103 let cfg = AskConfig {
2104 timeout_secs: 45,
2105 ..AskConfig::default()
2106 };
2107 let b = cfg.synthesize_backend();
2108 assert_eq!(
2109 b.timeout_secs,
2110 Some(45),
2111 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
2112 );
2113 }
2114
2115 #[test]
2116 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
2117 let mut cfg = AskConfig {
2118 timeout_secs: 45,
2119 ..AskConfig::default()
2120 };
2121 cfg.backend = Some(BackendConfig {
2122 provider: "anthropic".into(),
2123 model: "claude-haiku-4-5".into(),
2124 endpoint: None,
2125 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2126 api_key_ref: None,
2127 timeout_secs: Some(10),
2128 });
2129 let b = cfg.synthesize_backend();
2130 assert_eq!(
2131 b.timeout_secs,
2132 Some(10),
2133 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
2134 );
2135 }
2136
2137 #[test]
2138 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
2139 let cfg = AskConfig {
2140 timeout_secs: 120,
2141 rewriter_timeout_secs: 8,
2142 ..AskConfig::default()
2143 };
2144 let b = cfg.synthesize_rewriter_backend();
2145 assert_eq!(
2146 b.timeout_secs,
2147 Some(8),
2148 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
2149 );
2150 }
2151
2152 #[test]
2153 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
2154 let mut cfg = AskConfig {
2155 rewriter_timeout_secs: 8,
2156 ..AskConfig::default()
2157 };
2158 cfg.rewriter_backend = Some(BackendConfig {
2159 provider: "anthropic".into(),
2160 model: "claude-haiku-4-5".into(),
2161 endpoint: None,
2162 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2163 api_key_ref: None,
2164 timeout_secs: Some(30),
2165 });
2166 let b = cfg.synthesize_rewriter_backend();
2167 assert_eq!(
2168 b.timeout_secs,
2169 Some(30),
2170 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
2171 );
2172 }
2173
2174 #[test]
2175 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
2176 let cfg = CompactConfig::default();
2179 let b = cfg.synthesize_extractive_backend();
2180 assert_eq!(
2181 b.timeout_secs,
2182 Some(120),
2183 "compact synthesis without per-stage override must produce 120s timeout"
2184 );
2185 }
2186
2187 #[test]
2188 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
2189 let cfg = CompactConfig::default();
2190 let b = cfg.synthesize_abstractive_backend();
2191 assert_eq!(b.timeout_secs, Some(120));
2192 }
2193}
2194
2195#[cfg(test)]
2196mod skills_config_tests {
2197 use super::*;
2198
2199 #[test]
2200 fn empty_yaml_hydrates_defaults() {
2201 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2202 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2203 assert_eq!(cfg.skills.max_total_tokens, 2000);
2204 assert!(cfg.skills.adaptive.is_some());
2205 }
2206
2207 #[test]
2208 fn load_or_default_missing_file_returns_default() {
2209 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2210 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2211 }
2212
2213 #[test]
2214 fn dev_discipline_index_defaults_auto_and_parses() {
2215 use crate::config::DevDisciplineIndex;
2216 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2217 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2218 let cfg: Config =
2219 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2220 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2221 let cfg: Config =
2222 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2223 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2224 }
2225}
2226
2227#[cfg(test)]
2228mod ambient_capture_cfg_tests {
2229 use super::*;
2230
2231 #[test]
2232 fn session_and_harvest_defaults() {
2233 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2234 assert_eq!(cfg.session.capture, "ambient");
2235 assert_eq!(cfg.session.retention_days, 14);
2236 assert!(cfg.harvest.auto_gate);
2237 assert_eq!(cfg.harvest.llm, "local-first");
2238 assert_eq!(cfg.harvest.min_events, 5);
2239 assert_eq!(cfg.harvest.min_user_turns, 2);
2240 assert_eq!(cfg.harvest.min_duration_secs, 120);
2241 assert_eq!(cfg.harvest.idle_minutes, 30);
2242 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2243 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2244 assert!(cfg.harvest.session_start_hint);
2245 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2246 }
2247
2248 #[test]
2249 fn session_capture_override_parses() {
2250 let cfg: Config =
2251 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2252 assert_eq!(cfg.session.capture, "off");
2253 assert_eq!(cfg.session.retention_days, 3);
2254 }
2255}
2256
2257#[cfg(test)]
2258mod cc_proxy_cfg_tests {
2259 use super::*;
2260
2261 #[test]
2262 fn defaults_to_local_cc_proxy_enabled() {
2263 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2264 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2265 assert!(cfg.cc_proxy.enabled);
2266 }
2267
2268 #[test]
2269 fn url_and_enabled_override_parse() {
2270 let cfg: Config =
2271 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2272 .unwrap();
2273 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2274 assert!(!cfg.cc_proxy.enabled);
2275 }
2276
2277 #[test]
2278 fn partial_section_keeps_other_default() {
2279 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2281 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2282 assert!(!cfg.cc_proxy.enabled);
2283 }
2284}
2285
2286#[cfg(test)]
2287mod model_switch_config_tests {
2288 use super::*;
2289
2290 #[test]
2291 fn model_switch_config_defaults_and_omitted_block() {
2292 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2294 assert_eq!(cfg.models.default, None);
2295 assert!(cfg.models.fallback_chain.is_empty());
2296 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2297 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2298 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2299 assert!(!cfg.models.routing.enabled);
2300
2301 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";
2303 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2304 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2305 assert_eq!(
2306 cfg.models.fallback_chain,
2307 vec!["claude_sonnet", "deepseek_v4_pro"]
2308 );
2309 assert!(cfg.models.routing.enabled);
2310 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2311 }
2312
2313 #[test]
2314 fn smart_config_defaults_on_with_autopick() {
2315 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2316 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2319 cfg.models.smart.max_escalations,
2320 DEFAULT_SMART_MAX_ESCALATIONS
2321 );
2322 }
2323}