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 open_items: OpenItemsConfig,
194
195 #[serde(default)]
197 pub fleet: FleetConfig,
198}
199
200#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
206pub struct ParallelJobsConfig {
207 #[serde(default)]
210 pub targets: Vec<String>,
211}
212
213#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
221pub struct FleetRunConfig {
222 #[serde(default)]
224 pub agents: Vec<String>,
225 #[serde(default)]
227 pub fleets: Vec<String>,
228}
229
230#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
237pub struct OpenItemsConfig {
238 #[serde(default)]
241 pub muted: Vec<String>,
242}
243
244#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
251pub struct FleetConfig {
252 #[serde(default)]
254 pub autorun: bool,
255}
256
257#[cfg(test)]
258mod fleet_config_tests {
259 use super::*;
260
261 #[test]
262 fn fleet_config_defaults_off_and_roundtrips() {
263 assert!(!FleetConfig::default().autorun);
264
265 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
266 assert!(cfg.fleet.autorun);
267
268 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
270 assert!(!cfg2.fleet.autorun);
271 }
272
273 #[test]
274 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
275 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
277 assert!(cfg.fleet_run.agents.is_empty());
278 assert!(cfg.fleet_run.fleets.is_empty());
279
280 let cfg2: Config =
281 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
282 .unwrap();
283 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
284 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
285 }
286}
287
288#[derive(Debug, Clone, Serialize, Deserialize)]
299pub struct CcProxyConfig {
300 #[serde(default = "default_cc_proxy_url")]
302 pub url: String,
303
304 #[serde(default = "default_true")]
307 pub enabled: bool,
308}
309
310fn default_cc_proxy_url() -> String {
311 "http://127.0.0.1:8088".to_string()
312}
313
314fn default_true() -> bool {
315 true
316}
317
318impl Default for CcProxyConfig {
319 fn default() -> Self {
320 Self {
321 url: default_cc_proxy_url(),
322 enabled: true,
323 }
324 }
325}
326
327#[derive(Debug, Clone, Serialize, Deserialize, Default)]
330pub struct CliConfig {
331 pub skin: Option<String>,
334}
335
336#[derive(Debug, Clone, Serialize, Deserialize, Default)]
339pub struct MobileRelayConfig {
340 #[serde(default, skip_serializing_if = "Option::is_none")]
343 pub relay_url: Option<String>,
344
345 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub api_key: Option<String>,
349}
350
351impl Config {
352 pub fn load_or_default(path: &std::path::Path) -> Self {
354 std::fs::read_to_string(path)
355 .ok()
356 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
357 .unwrap_or_default()
358 }
359}
360
361#[derive(Debug, Clone, Serialize, Deserialize, Default)]
362pub struct SyncConfig {
363 #[serde(default = "default_sync_method")]
365 pub method: String,
366
367 #[serde(default, skip_serializing_if = "Option::is_none")]
369 pub git_remote: Option<String>,
370
371 #[serde(default)]
373 pub auto: bool,
374
375 #[serde(default, skip_serializing_if = "Option::is_none")]
377 pub team_id: Option<String>,
378}
379
380fn default_sync_method() -> String {
381 "local".to_string()
382}
383
384#[derive(Debug, Clone, Serialize, Deserialize)]
385pub struct ServerConfig {
386 #[serde(default = "default_server_url")]
388 pub url: String,
389}
390
391impl Default for ServerConfig {
392 fn default() -> Self {
393 Self {
394 url: default_server_url(),
395 }
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize, Default)]
400pub struct CommunityConfig {
401 #[serde(default)]
403 pub enabled: bool,
404}
405
406#[derive(Debug, Clone, Serialize, Deserialize)]
407pub struct EmbeddingConfig {
408 #[serde(default = "default_embedding_provider")]
410 pub provider: String,
411
412 #[serde(default = "default_embedding_model")]
414 pub model: String,
415
416 #[serde(default = "default_dimensions")]
418 pub dimensions: usize,
419
420 #[serde(default = "default_ollama_endpoint")]
422 pub ollama_endpoint: String,
423
424 #[serde(default, skip_serializing_if = "Option::is_none")]
426 pub api_key_env: Option<String>,
427
428 #[serde(default, skip_serializing_if = "Option::is_none")]
431 pub api_key_ref: Option<String>,
432
433 #[serde(default, skip_serializing_if = "Option::is_none")]
435 pub openai_url: Option<String>,
436}
437
438impl Default for EmbeddingConfig {
439 fn default() -> Self {
440 Self {
441 provider: default_embedding_provider(),
442 model: default_embedding_model(),
443 dimensions: default_dimensions(),
444 ollama_endpoint: default_ollama_endpoint(),
445 api_key_env: None,
446 api_key_ref: None,
447 openai_url: None,
448 }
449 }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct LlmConfig {
454 #[serde(default = "default_llm_provider")]
456 pub provider: String,
457
458 #[serde(default = "default_llm_model")]
459 pub model: String,
460
461 #[serde(default, skip_serializing_if = "Option::is_none")]
463 pub api_key_env: Option<String>,
464
465 #[serde(default, skip_serializing_if = "Option::is_none")]
468 pub api_key_ref: Option<String>,
469
470 #[serde(default, skip_serializing_if = "Option::is_none")]
472 pub openai_url: Option<String>,
473}
474
475impl Default for LlmConfig {
476 fn default() -> Self {
477 Self {
478 provider: default_llm_provider(),
479 model: default_llm_model(),
480 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
481 api_key_ref: None,
482 openai_url: None,
483 }
484 }
485}
486
487impl LlmConfig {
488 pub fn to_backend_config(&self) -> BackendConfig {
501 let provider = match self.provider.as_str() {
502 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
503 _ if self.openai_url.is_some() => "openai".into(),
504 other => other.into(), };
506 BackendConfig {
507 provider,
508 model: self.model.clone(),
509 endpoint: self.openai_url.clone(),
510 api_key_env: self.api_key_env.clone(),
511 api_key_ref: self.api_key_ref.clone(),
512 timeout_secs: None,
513 }
514 }
515}
516
517#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
528#[serde(default)]
529pub struct BackendConfig {
530 pub provider: String,
532 pub model: String,
534 pub endpoint: Option<String>,
537 pub api_key_env: Option<String>,
539 pub api_key_ref: Option<String>,
541 pub timeout_secs: Option<u64>,
543}
544
545impl Default for BackendConfig {
546 fn default() -> Self {
547 Self {
548 provider: "ollama".into(),
549 model: DEFAULT_LOCAL_LLM_MODEL.into(),
550 endpoint: None,
551 api_key_env: None,
552 api_key_ref: None,
553 timeout_secs: None,
554 }
555 }
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
559pub struct RetrievalConfig {
560 #[serde(default = "default_max_patterns")]
562 pub max_patterns: usize,
563
564 #[serde(default = "default_max_tokens")]
566 pub max_tokens: usize,
567
568 #[serde(default = "default_min_score")]
570 pub min_score: f64,
571
572 #[serde(default = "default_mmr_threshold")]
574 pub mmr_threshold: f64,
575}
576
577impl Default for RetrievalConfig {
578 fn default() -> Self {
579 Self {
580 max_patterns: default_max_patterns(),
581 max_tokens: default_max_tokens(),
582 min_score: default_min_score(),
583 mmr_threshold: default_mmr_threshold(),
584 }
585 }
586}
587
588#[derive(Debug, Clone, Serialize, Deserialize)]
589pub struct PathConfig {
590 #[serde(default = "default_mur_dir")]
592 pub mur_dir: PathBuf,
593}
594
595impl Default for PathConfig {
596 fn default() -> Self {
597 Self {
598 mur_dir: default_mur_dir(),
599 }
600 }
601}
602
603#[derive(Debug, Clone, Serialize, Deserialize)]
604pub struct StorageConfig {
605 #[serde(default = "default_vector_backend")]
607 pub vector_backend: String,
608
609 #[serde(default, skip_serializing_if = "Option::is_none")]
611 pub qdrant_url: Option<String>,
612
613 #[serde(default, skip_serializing_if = "Option::is_none")]
615 pub qdrant_api_key_ref: Option<String>,
616}
617
618impl Default for StorageConfig {
619 fn default() -> Self {
620 Self {
621 vector_backend: default_vector_backend(),
622 qdrant_url: None,
623 qdrant_api_key_ref: None,
624 }
625 }
626}
627
628fn default_vector_backend() -> String {
629 "lancedb".to_string()
630}
631
632#[derive(Debug, Clone, Serialize, Deserialize)]
633pub struct SourcesGlobalConfig {
634 #[serde(default = "default_poll_interval_secs")]
636 pub poll_interval_secs: u64,
637
638 #[serde(default = "default_max_chunks_per_sync")]
640 pub max_chunks_per_sync: usize,
641
642 #[serde(default = "default_max_parallel_sources")]
644 pub max_parallel_sources: usize,
645
646 #[serde(default = "default_source_weight")]
648 pub default_weight: f32,
649
650 #[serde(default = "default_embedding_batch_size")]
652 pub embedding_batch_size: usize,
653}
654
655impl Default for SourcesGlobalConfig {
656 fn default() -> Self {
657 Self {
658 poll_interval_secs: default_poll_interval_secs(),
659 max_chunks_per_sync: default_max_chunks_per_sync(),
660 max_parallel_sources: default_max_parallel_sources(),
661 default_weight: default_source_weight(),
662 embedding_batch_size: default_embedding_batch_size(),
663 }
664 }
665}
666
667fn default_poll_interval_secs() -> u64 {
668 600
669}
670fn default_max_chunks_per_sync() -> usize {
671 10_000
672}
673fn default_max_parallel_sources() -> usize {
674 3
675}
676fn default_source_weight() -> f32 {
677 1.0
678}
679fn default_embedding_batch_size() -> usize {
680 32
681}
682
683fn default_embedding_provider() -> String {
684 "ollama".to_string()
685}
686fn default_embedding_model() -> String {
687 "qwen3-embedding:0.6b".to_string()
688}
689fn default_dimensions() -> usize {
690 1024
691}
692fn default_ollama_endpoint() -> String {
693 "http://localhost:11434".to_string()
694}
695fn default_llm_provider() -> String {
696 "anthropic".to_string()
697}
698fn default_llm_model() -> String {
699 "claude-opus-5".to_string()
700}
701fn default_max_patterns() -> usize {
702 5
703}
704fn default_max_tokens() -> usize {
705 2000
706}
707fn default_min_score() -> f64 {
708 0.35
709}
710fn default_mmr_threshold() -> f64 {
711 0.85
712}
713fn default_mur_dir() -> PathBuf {
714 let home = std::env::var("HOME")
717 .map(PathBuf::from)
718 .unwrap_or_else(|_| PathBuf::from("/tmp"));
719 home.join(".mur")
720}
721fn default_server_url() -> String {
722 "https://mur-server.fly.dev".to_string()
723}
724
725#[derive(Debug, Clone, Serialize, Deserialize)]
728pub struct AskConfig {
729 #[serde(default = "ask_default_model")]
730 pub model: String,
731 #[serde(default = "compact_default_ollama_endpoint")]
732 pub ollama_endpoint: String,
733 #[serde(default = "ask_default_k_summary")]
734 pub k_summary: u32,
735 #[serde(default = "ask_default_k_raw")]
736 pub k_raw: u32,
737 #[serde(default = "ask_default_esc")]
738 pub escalation_threshold: f64,
739 #[serde(default = "ask_default_mmr")]
740 pub mmr_threshold: f64,
741 #[serde(default = "ask_default_max_ctx")]
742 pub max_context_tokens: u32,
743 #[serde(default = "ask_default_resp_tok")]
744 pub response_tokens: u32,
745 #[serde(default = "ask_default_timeout")]
746 pub timeout_secs: u32,
747 #[serde(default = "ask_default_min_score")]
748 pub min_score: f64,
749 #[serde(default = "ask_default_continue_history_turns")]
750 pub continue_history_turns: u32,
751 #[serde(default = "ask_default_rewriter_timeout")]
757 pub rewriter_timeout_secs: u32,
758 #[serde(default = "ask_default_compress_hits_enabled")]
759 pub compress_hits_enabled: bool,
760 #[serde(default = "ask_default_summarize_hits_enabled")]
761 pub summarize_hits_enabled: bool,
762 #[serde(default)]
763 pub summarize_model: Option<String>,
764 #[serde(default)]
767 pub backend: Option<BackendConfig>,
768 #[serde(default)]
772 pub rewriter_backend: Option<BackendConfig>,
773}
774
775impl AskConfig {
776 pub fn synthesize_backend(&self) -> BackendConfig {
786 self.backend.clone().unwrap_or_else(|| BackendConfig {
787 provider: "ollama".into(),
788 model: self.model.clone(),
789 endpoint: Some(self.ollama_endpoint.clone()),
790 api_key_env: None,
791 api_key_ref: None,
792 timeout_secs: Some(self.timeout_secs as u64),
793 })
794 }
795
796 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
806 self.rewriter_backend
807 .clone()
808 .unwrap_or_else(|| BackendConfig {
809 provider: "ollama".into(),
810 model: self.model.clone(),
811 endpoint: Some(self.ollama_endpoint.clone()),
812 api_key_env: None,
813 api_key_ref: None,
814 timeout_secs: Some(self.rewriter_timeout_secs as u64),
815 })
816 }
817}
818
819impl Default for AskConfig {
820 fn default() -> Self {
821 Self {
822 model: ask_default_model(),
823 ollama_endpoint: compact_default_ollama_endpoint(),
824 k_summary: ask_default_k_summary(),
825 k_raw: ask_default_k_raw(),
826 escalation_threshold: ask_default_esc(),
827 mmr_threshold: ask_default_mmr(),
828 max_context_tokens: ask_default_max_ctx(),
829 response_tokens: ask_default_resp_tok(),
830 timeout_secs: ask_default_timeout(),
831 min_score: ask_default_min_score(),
832 continue_history_turns: ask_default_continue_history_turns(),
833 rewriter_timeout_secs: ask_default_rewriter_timeout(),
834 compress_hits_enabled: ask_default_compress_hits_enabled(),
835 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
836 summarize_model: None,
837 backend: None,
838 rewriter_backend: None,
839 }
840 }
841}
842
843fn ask_default_model() -> String {
844 DEFAULT_LOCAL_LLM_MODEL.into()
845}
846fn ask_default_k_summary() -> u32 {
847 5
848}
849fn ask_default_k_raw() -> u32 {
850 10
851}
852fn ask_default_esc() -> f64 {
853 0.5
854}
855fn ask_default_mmr() -> f64 {
856 0.88
857}
858fn ask_default_max_ctx() -> u32 {
859 6000
860}
861fn ask_default_resp_tok() -> u32 {
862 1024
863}
864fn ask_default_timeout() -> u32 {
865 120
866}
867fn ask_default_min_score() -> f64 {
868 0.35
869}
870fn ask_default_rewriter_timeout() -> u32 {
871 8
872}
873fn ask_default_continue_history_turns() -> u32 {
874 3
875}
876fn ask_default_compress_hits_enabled() -> bool {
877 true
878}
879fn ask_default_summarize_hits_enabled() -> bool {
880 true
881}
882
883#[derive(Debug, Clone, Serialize, Deserialize)]
892pub struct ConversationsConfig {
893 #[serde(default)]
894 pub enabled: bool,
895 #[serde(default = "conv_default_retention_days")]
896 pub retention_days: u32,
897 #[serde(default = "conv_default_poll_interval")]
898 pub poll_interval_secs: u64,
899 #[serde(default)]
900 pub sources: ConversationsSources,
901 #[serde(default)]
902 pub filter: ConversationsFilter,
903 #[serde(default)]
904 pub compact: CompactConfig,
905 #[serde(default)]
906 pub ask: AskConfig,
907 #[serde(default)]
908 pub rollup: RollupConfig,
909}
910
911impl Default for ConversationsConfig {
912 fn default() -> Self {
913 Self {
914 enabled: false,
915 retention_days: conv_default_retention_days(),
916 poll_interval_secs: conv_default_poll_interval(),
917 sources: ConversationsSources::default(),
918 filter: ConversationsFilter::default(),
919 compact: CompactConfig::default(),
920 ask: AskConfig::default(),
921 rollup: RollupConfig::default(),
922 }
923 }
924}
925
926fn conv_default_retention_days() -> u32 {
927 30
928}
929fn conv_default_poll_interval() -> u64 {
930 300
931}
932fn conv_truthy() -> bool {
933 true
934}
935fn conv_default_dedup() -> f64 {
936 0.85
937}
938
939#[derive(Debug, Clone, Serialize, Deserialize)]
940pub struct CompactConfig {
941 #[serde(default = "conv_truthy")]
942 pub enabled_in_daemon: bool,
943 #[serde(default = "compact_default_max_days")]
944 pub max_days_per_run: u32,
945 #[serde(default = "compact_default_model")]
946 pub extractive_model: String,
947 #[serde(default = "compact_default_model")]
948 pub abstractive_model: String,
949 #[serde(default = "compact_default_ollama_endpoint")]
950 pub ollama_endpoint: String,
951 #[serde(default = "compact_default_max_spans")]
952 pub max_extractive_spans: u32,
953 #[serde(default = "compact_default_max_words")]
954 pub max_abstractive_words: u32,
955 #[serde(default = "compact_default_chunk_tokens")]
956 pub chunk_tokens: u32,
957 #[serde(default = "compact_default_history_retain")]
958 pub history_retain: u32,
959 #[serde(default = "compact_default_cron")]
960 pub daemon_cron: String,
961 #[serde(default)]
964 pub extractive_backend: Option<BackendConfig>,
965 #[serde(default)]
968 pub abstractive_backend: Option<BackendConfig>,
969}
970
971impl CompactConfig {
972 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
981 self.extractive_backend
982 .clone()
983 .unwrap_or_else(|| BackendConfig {
984 provider: "ollama".into(),
985 model: self.extractive_model.clone(),
986 endpoint: Some(self.ollama_endpoint.clone()),
987 api_key_env: None,
988 api_key_ref: None,
989 timeout_secs: Some(120),
990 })
991 }
992
993 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
996 self.abstractive_backend
997 .clone()
998 .unwrap_or_else(|| BackendConfig {
999 provider: "ollama".into(),
1000 model: self.abstractive_model.clone(),
1001 endpoint: Some(self.ollama_endpoint.clone()),
1002 api_key_env: None,
1003 api_key_ref: None,
1004 timeout_secs: Some(120),
1005 })
1006 }
1007}
1008
1009impl Default for CompactConfig {
1010 fn default() -> Self {
1011 Self {
1012 enabled_in_daemon: true,
1013 max_days_per_run: compact_default_max_days(),
1014 extractive_model: compact_default_model(),
1015 abstractive_model: compact_default_model(),
1016 ollama_endpoint: compact_default_ollama_endpoint(),
1017 max_extractive_spans: compact_default_max_spans(),
1018 max_abstractive_words: compact_default_max_words(),
1019 chunk_tokens: compact_default_chunk_tokens(),
1020 history_retain: compact_default_history_retain(),
1021 daemon_cron: compact_default_cron(),
1022 extractive_backend: None,
1023 abstractive_backend: None,
1024 }
1025 }
1026}
1027
1028fn compact_default_max_days() -> u32 {
1029 7
1030}
1031fn compact_default_model() -> String {
1032 DEFAULT_LOCAL_LLM_MODEL.into()
1033}
1034fn compact_default_ollama_endpoint() -> String {
1035 "http://localhost:11434".into()
1036}
1037fn compact_default_max_spans() -> u32 {
1038 20
1039}
1040fn compact_default_max_words() -> u32 {
1041 400
1042}
1043fn compact_default_chunk_tokens() -> u32 {
1044 6000
1045}
1046fn compact_default_history_retain() -> u32 {
1047 5
1048}
1049fn compact_default_cron() -> String {
1050 "0 0 3 * * * *".into()
1051}
1052
1053#[derive(Debug, Clone, Serialize, Deserialize)]
1056pub struct RollupConfig {
1057 #[serde(default = "rollup_default_enabled")]
1058 pub enabled: bool,
1059 #[serde(default = "rollup_default_max_weeks")]
1060 pub max_weeks_per_run: u32,
1061 #[serde(default = "rollup_default_max_months")]
1062 pub max_months_per_run: u32,
1063 #[serde(default = "rollup_default_max_spans_week")]
1064 pub max_extractive_spans_per_week: u32,
1065 #[serde(default = "rollup_default_max_words_week")]
1066 pub max_abstractive_words_per_week: u32,
1067 #[serde(default = "rollup_default_max_spans_month")]
1068 pub max_extractive_spans_per_month: u32,
1069 #[serde(default = "rollup_default_max_words_month")]
1070 pub max_abstractive_words_per_month: u32,
1071 #[serde(default = "rollup_default_week_mmr")]
1072 pub week_mmr_threshold: f64,
1073 #[serde(default = "rollup_default_month_mmr")]
1074 pub month_mmr_threshold: f64,
1075 #[serde(default = "compact_default_model")]
1076 pub extractive_model: String,
1077 #[serde(default = "compact_default_model")]
1078 pub abstractive_model: String,
1079 #[serde(default = "compact_default_ollama_endpoint")]
1080 pub ollama_endpoint: String,
1081}
1082
1083impl Default for RollupConfig {
1084 fn default() -> Self {
1085 Self {
1086 enabled: rollup_default_enabled(),
1087 max_weeks_per_run: rollup_default_max_weeks(),
1088 max_months_per_run: rollup_default_max_months(),
1089 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1090 max_abstractive_words_per_week: rollup_default_max_words_week(),
1091 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1092 max_abstractive_words_per_month: rollup_default_max_words_month(),
1093 week_mmr_threshold: rollup_default_week_mmr(),
1094 month_mmr_threshold: rollup_default_month_mmr(),
1095 extractive_model: compact_default_model(),
1096 abstractive_model: compact_default_model(),
1097 ollama_endpoint: compact_default_ollama_endpoint(),
1098 }
1099 }
1100}
1101
1102fn rollup_default_enabled() -> bool {
1103 true
1104}
1105fn rollup_default_max_weeks() -> u32 {
1106 4
1107}
1108fn rollup_default_max_months() -> u32 {
1109 2
1110}
1111fn rollup_default_max_spans_week() -> u32 {
1112 20
1113}
1114fn rollup_default_max_words_week() -> u32 {
1115 500
1116}
1117fn rollup_default_max_spans_month() -> u32 {
1118 20
1119}
1120fn rollup_default_max_words_month() -> u32 {
1121 700
1122}
1123fn rollup_default_week_mmr() -> f64 {
1124 0.85
1125}
1126fn rollup_default_month_mmr() -> f64 {
1127 0.82
1128}
1129
1130#[derive(Debug, Clone, Serialize, Deserialize)]
1131pub struct ConversationsSources {
1132 #[serde(default = "conv_truthy")]
1133 pub claude_code: bool,
1134 #[serde(default = "conv_truthy")]
1135 pub cursor: bool,
1136 #[serde(default = "conv_truthy")]
1137 pub gemini: bool,
1138 #[serde(default)]
1139 pub aider: AiderSourceConfig,
1140}
1141
1142impl Default for ConversationsSources {
1143 fn default() -> Self {
1144 Self {
1145 claude_code: true,
1146 cursor: true,
1147 gemini: true,
1148 aider: AiderSourceConfig::default(),
1149 }
1150 }
1151}
1152
1153#[derive(Debug, Clone, Serialize, Deserialize)]
1154pub struct AiderSourceConfig {
1155 #[serde(default = "conv_truthy")]
1156 pub enabled: bool,
1157 #[serde(default)]
1158 pub watched_dirs: Vec<String>,
1159}
1160
1161impl Default for AiderSourceConfig {
1162 fn default() -> Self {
1163 Self {
1164 enabled: true,
1165 watched_dirs: Vec::new(),
1166 }
1167 }
1168}
1169
1170#[derive(Debug, Clone, Serialize, Deserialize)]
1171pub struct ConversationsFilter {
1172 #[serde(default = "conv_default_dedup")]
1173 pub dedup_threshold: f64,
1174 #[serde(default = "conv_truthy")]
1175 pub reject_heartbeat: bool,
1176 #[serde(default = "conv_truthy")]
1177 pub reject_system_restatement: bool,
1178}
1179
1180impl Default for ConversationsFilter {
1181 fn default() -> Self {
1182 Self {
1183 dedup_threshold: conv_default_dedup(),
1184 reject_heartbeat: true,
1185 reject_system_restatement: true,
1186 }
1187 }
1188}
1189
1190#[cfg(test)]
1191mod conversations_tests {
1192 use super::*;
1193
1194 #[test]
1195 fn conversations_section_defaults() {
1196 let c = ConversationsConfig::default();
1197 assert!(!c.enabled);
1198 assert_eq!(c.retention_days, 30);
1199 assert_eq!(c.poll_interval_secs, 300);
1200 assert!(c.sources.claude_code);
1201 assert!(c.sources.cursor);
1202 assert!(c.sources.gemini);
1203 assert!(c.sources.aider.enabled);
1204 assert!(c.sources.aider.watched_dirs.is_empty());
1205 assert_eq!(c.filter.dedup_threshold, 0.85);
1206 assert!(c.filter.reject_heartbeat);
1207 assert!(c.filter.reject_system_restatement);
1208 }
1209
1210 #[test]
1211 fn parse_from_yaml_with_overrides() {
1212 let y = r#"
1213conversations:
1214 enabled: true
1215 retention_days: 45
1216 poll_interval_secs: 120
1217 sources:
1218 cursor: false
1219 aider:
1220 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1221 filter:
1222 dedup_threshold: 0.9
1223"#;
1224 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1225 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1226 assert!(conv.enabled);
1227 assert_eq!(conv.retention_days, 45);
1228 assert_eq!(conv.poll_interval_secs, 120);
1229 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1233 assert_eq!(conv.filter.dedup_threshold, 0.9);
1234 assert!(conv.filter.reject_heartbeat); }
1236
1237 #[test]
1238 fn missing_conversations_section_is_fine() {
1239 let y = r#"
1240# No conversations section at all
1241foo: bar
1242"#;
1243 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1244 let conv: ConversationsConfig = v
1246 .get("conversations")
1247 .cloned()
1248 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1249 .unwrap_or_default();
1250 assert_eq!(conv.retention_days, 30);
1251 }
1252
1253 #[test]
1254 fn compact_config_defaults() {
1255 let c = CompactConfig::default();
1256 assert!(c.enabled_in_daemon);
1257 assert_eq!(c.max_days_per_run, 7);
1258 assert_eq!(c.extractive_model, "qwen3.5:4b");
1259 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1260 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1261 assert_eq!(c.max_extractive_spans, 20);
1262 assert_eq!(c.chunk_tokens, 6000);
1263 assert_eq!(c.history_retain, 5);
1264 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1265 }
1266
1267 #[test]
1268 fn compact_parses_partial_overrides() {
1269 let y = r#"
1270conversations:
1271 compact:
1272 max_days_per_run: 3
1273 extractive_model: qwen3:4b
1274"#;
1275 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1276 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1277 assert_eq!(conv.compact.max_days_per_run, 3);
1278 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1279 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
1282
1283 #[test]
1284 fn ask_config_defaults() {
1285 let c = AskConfig::default();
1286 assert_eq!(c.model, "qwen3.5:4b");
1287 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1288 assert_eq!(c.k_raw, 10);
1289 assert_eq!(c.escalation_threshold, 0.5);
1290 assert_eq!(c.mmr_threshold, 0.88);
1291 assert_eq!(c.max_context_tokens, 6000);
1292 assert_eq!(c.response_tokens, 1024);
1293 assert_eq!(c.timeout_secs, 120);
1294 assert_eq!(c.min_score, 0.35);
1295 }
1296
1297 #[test]
1298 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1299 let c = AskConfig::default();
1301 assert!(
1302 (c.mmr_threshold - 0.88).abs() < 1e-9,
1303 "expected 0.88, got {}",
1304 c.mmr_threshold
1305 );
1306 }
1307
1308 #[test]
1309 fn rollup_config_defaults() {
1310 let c = RollupConfig::default();
1311 assert!(c.enabled);
1312 assert_eq!(c.max_weeks_per_run, 4);
1313 assert_eq!(c.max_months_per_run, 2);
1314 assert_eq!(c.max_extractive_spans_per_week, 20);
1315 assert_eq!(c.max_abstractive_words_per_week, 500);
1316 assert_eq!(c.max_extractive_spans_per_month, 20);
1317 assert_eq!(c.max_abstractive_words_per_month, 700);
1318 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1319 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1320 assert_eq!(c.extractive_model, "qwen3.5:4b");
1321 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1322 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1323 }
1324
1325 #[test]
1326 fn rollup_config_plumbed_into_conversations_config() {
1327 let c = ConversationsConfig::default();
1328 assert!(c.rollup.enabled);
1329 }
1330
1331 #[test]
1332 fn ask_config_default_continue_history_turns_is_3() {
1333 let c = AskConfig::default();
1334 assert_eq!(c.continue_history_turns, 3);
1335 }
1336
1337 #[test]
1338 fn ask_config_default_compress_hits_enabled_is_true() {
1339 let c = AskConfig::default();
1340 assert!(c.compress_hits_enabled);
1341 }
1342
1343 #[test]
1344 fn ask_config_default_summarize_hits_enabled_is_true() {
1345 let c = AskConfig::default();
1346 assert!(c.summarize_hits_enabled);
1347 }
1348
1349 #[test]
1350 fn ask_config_default_summarize_model_is_none() {
1351 let c = AskConfig::default();
1352 assert!(c.summarize_model.is_none());
1353 }
1354
1355 #[test]
1356 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1357 let y = r#"
1358conversations:
1359 ask:
1360 summarize_hits_enabled: false
1361 summarize_model: qwen3:4b
1362"#;
1363 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1364 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1365 assert!(!conv.ask.summarize_hits_enabled);
1366 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1367 }
1368
1369 #[test]
1370 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1371 let y = r#"
1375conversations:
1376 ask:
1377 model: qwen3:14b
1378"#;
1379 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1380 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1381 assert!(conv.ask.summarize_hits_enabled);
1382 assert!(conv.ask.summarize_model.is_none());
1383 }
1384}
1385
1386#[cfg(test)]
1387mod tests {
1388 use super::*;
1389
1390 #[test]
1391 fn default_bundled_model_id_is_qwen35_2b() {
1392 assert_eq!(
1393 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1394 "Qwen3.5-2B-MLX-4bit"
1395 );
1396 }
1397
1398 #[test]
1399 fn nudge_config_defaults() {
1400 let c = NudgeConfig::default();
1401 assert!(c.enabled);
1402 assert_eq!(c.daily_cap, 3);
1403 assert_eq!(c.snooze_days, 7);
1404 assert_eq!(c.threshold, 3);
1405 }
1406
1407 #[test]
1408 fn config_has_nudge_section_with_defaults() {
1409 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1410 assert_eq!(c.nudge.daily_cap, 3);
1411 }
1412
1413 #[test]
1414 fn storage_config_default_is_lancedb() {
1415 let c = StorageConfig::default();
1416 assert_eq!(c.vector_backend, "lancedb");
1417 assert_eq!(c.qdrant_url, None);
1418 assert_eq!(c.qdrant_api_key_ref, None);
1419 }
1420
1421 #[test]
1422 fn sources_global_config_has_sensible_defaults() {
1423 let c = SourcesGlobalConfig::default();
1424 assert_eq!(c.poll_interval_secs, 600);
1425 assert_eq!(c.max_chunks_per_sync, 10_000);
1426 assert_eq!(c.max_parallel_sources, 3);
1427 assert_eq!(c.default_weight, 1.0);
1428 assert_eq!(c.embedding_batch_size, 32);
1429 }
1430
1431 #[test]
1432 fn config_default_has_storage_and_sources_global() {
1433 let c = Config::default();
1434 assert_eq!(c.storage.vector_backend, "lancedb");
1435 assert_eq!(c.sources_global.default_weight, 1.0);
1436 }
1437
1438 #[test]
1439 fn config_loads_yaml_without_new_fields() {
1440 let yaml = r#"
1443embedding:
1444 provider: ollama
1445 model: test-model
1446 dimensions: 512
1447 ollama_endpoint: http://localhost:11434
1448"#;
1449 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1450 assert_eq!(c.storage.vector_backend, "lancedb");
1451 assert_eq!(c.sources_global.max_parallel_sources, 3);
1452 }
1453
1454 #[test]
1455 fn llm_config_to_backend_config_anthropic_passthrough() {
1456 let cfg = LlmConfig {
1457 provider: "anthropic".into(),
1458 model: "claude-haiku-4-5".into(),
1459 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1460 api_key_ref: None,
1461 openai_url: None,
1462 };
1463 let b = cfg.to_backend_config();
1464 assert_eq!(b.provider, "anthropic");
1465 assert_eq!(b.model, "claude-haiku-4-5");
1466 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1467 assert_eq!(b.endpoint, None);
1468 assert_eq!(b.timeout_secs, None);
1469 }
1470
1471 #[test]
1472 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1473 let cfg = LlmConfig {
1474 provider: "openai".into(),
1475 model: "gpt-4o-mini".into(),
1476 api_key_env: None,
1477 api_key_ref: None,
1478 openai_url: Some("https://api.together.xyz/v1".into()),
1479 };
1480 let b = cfg.to_backend_config();
1481 assert_eq!(b.provider, "openai");
1482 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1483 assert_eq!(b.api_key_env, None); }
1485
1486 #[test]
1487 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1488 let cfg = LlmConfig {
1489 provider: "ollama".into(),
1490 model: "qwen3:14b".into(),
1491 api_key_env: None,
1492 api_key_ref: None,
1493 openai_url: Some("http://192.168.1.10:11434".into()),
1494 };
1495 let b = cfg.to_backend_config();
1496 assert_eq!(b.provider, "ollama");
1497 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1498 }
1499
1500 #[test]
1501 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1502 let cfg = LlmConfig {
1506 provider: "custom-name".into(),
1507 model: "some-model".into(),
1508 api_key_env: Some("CUSTOM_KEY".into()),
1509 api_key_ref: None,
1510 openai_url: Some("https://my-proxy.local/v1".into()),
1511 };
1512 let b = cfg.to_backend_config();
1513 assert_eq!(
1514 b.provider, "openai",
1515 "unknown provider + openai_url should alias to openai"
1516 );
1517 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1518 }
1519
1520 #[test]
1521 fn api_key_ref_roundtrips_and_defaults_none() {
1522 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1524 assert_eq!(b.api_key_ref, None);
1525 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1526 assert_eq!(l.api_key_ref, None);
1527 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1528 assert_eq!(e.api_key_ref, None);
1529
1530 let mut l2 = LlmConfig::default();
1532 l2.api_key_ref = Some("keychain:mur/anthropic".into());
1533 let y = serde_yaml_ng::to_string(&l2).unwrap();
1534 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1535 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1536 assert_eq!(
1537 l3.to_backend_config().api_key_ref.as_deref(),
1538 Some("keychain:mur/anthropic")
1539 );
1540 }
1541
1542 #[test]
1543 fn open_items_muted_parses_and_defaults_empty() {
1544 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
1545 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
1546
1547 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
1548 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
1549 }
1550
1551 #[test]
1554 fn unreadable_config_yields_no_mutes() {
1555 let tmp = tempfile::tempdir().unwrap();
1556 let path = tmp.path().join("config.yaml");
1557 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
1558 let cfg = Config::load_or_default(&path);
1559 assert!(
1560 cfg.open_items.muted.is_empty(),
1561 "a broken config must hide nothing"
1562 );
1563
1564 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
1566 assert!(missing.open_items.muted.is_empty());
1567 }
1568}
1569
1570#[cfg(test)]
1571mod backend_config_tests {
1572 use super::*;
1573
1574 #[test]
1575 fn default_is_ollama_qwen3() {
1576 let cfg = BackendConfig::default();
1577 assert_eq!(cfg.provider, "ollama");
1578 assert_eq!(cfg.model, "qwen3.5:4b");
1579 assert_eq!(cfg.endpoint, None);
1580 assert_eq!(cfg.api_key_env, None);
1581 assert_eq!(cfg.timeout_secs, None);
1582 }
1583
1584 #[test]
1585 fn deserializes_anthropic_full() {
1586 let yaml = "\
1587provider: anthropic
1588model: claude-haiku-4-5
1589api_key_env: ANTHROPIC_API_KEY
1590timeout_secs: 60
1591";
1592 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1593 assert_eq!(cfg.provider, "anthropic");
1594 assert_eq!(cfg.model, "claude-haiku-4-5");
1595 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1596 assert_eq!(cfg.timeout_secs, Some(60));
1597 assert_eq!(cfg.endpoint, None);
1598 }
1599
1600 #[test]
1601 fn deserializes_partial_fills_defaults() {
1602 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
1603 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1604 assert_eq!(cfg.provider, "anthropic");
1605 assert_eq!(cfg.model, "claude-sonnet-5");
1606 assert_eq!(cfg.api_key_env, None);
1607 assert_eq!(cfg.timeout_secs, None);
1608 }
1609
1610 #[test]
1611 fn round_trips_through_yaml() {
1612 let original = BackendConfig {
1613 provider: "anthropic".into(),
1614 model: "claude-haiku-4-5".into(),
1615 endpoint: Some("https://api.anthropic.com".into()),
1616 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1617 api_key_ref: None,
1618 timeout_secs: Some(60),
1619 };
1620 let yaml = serde_yaml::to_string(&original).unwrap();
1621 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1622 assert_eq!(parsed, original);
1623 }
1624
1625 #[test]
1626 fn skills_config_curation_gate_defaults_on() {
1627 let c = SkillsConfig::default();
1628 assert!(c.require_human_curation_before_stable);
1629 }
1630}
1631
1632#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1640#[serde(rename_all = "lowercase")]
1641pub enum DevDisciplineIndex {
1642 #[default]
1644 Auto,
1645 Always,
1647 Never,
1649}
1650
1651#[derive(Debug, Clone, Serialize, Deserialize)]
1652#[serde(default)]
1653pub struct SkillsConfig {
1654 pub max_skills_in_prompt: usize,
1655 pub max_total_tokens: usize,
1656 pub priority_order: Vec<String>,
1657 pub adaptive: Option<AdaptiveSkillsConfig>,
1658
1659 #[serde(default = "default_require_human_curation")]
1663 pub require_human_curation_before_stable: bool,
1664
1665 #[serde(default)]
1669 pub lifecycle: SkillLifecycleConfig,
1670
1671 #[serde(default = "default_auto_upgrade")]
1676 pub auto_upgrade: bool,
1677
1678 #[serde(default)]
1680 pub dev_discipline_index: DevDisciplineIndex,
1681}
1682
1683fn default_require_human_curation() -> bool {
1684 true
1685}
1686
1687fn default_auto_upgrade() -> bool {
1688 true
1689}
1690
1691impl Default for SkillsConfig {
1692 fn default() -> Self {
1693 Self {
1694 max_skills_in_prompt: 5,
1695 max_total_tokens: 2000,
1696 priority_order: vec!["agent".into(), "global".into()],
1697 adaptive: Some(AdaptiveSkillsConfig::default()),
1698 require_human_curation_before_stable: default_require_human_curation(),
1699 lifecycle: SkillLifecycleConfig::default(),
1700 auto_upgrade: default_auto_upgrade(),
1701 dev_discipline_index: DevDisciplineIndex::default(),
1702 }
1703 }
1704}
1705
1706#[derive(Debug, Clone, Serialize, Deserialize)]
1712#[serde(default)]
1713pub struct SkillLifecycleConfig {
1714 pub promote_draft_uses: u64,
1716 pub promote_emerging_uses: u64,
1717 pub promote_emerging_success_rate: f64,
1718 pub promote_emerging_age_days: i64,
1719 pub promote_stable_uses: u64,
1720 pub promote_stable_success_rate: f64,
1721 pub promote_stable_age_days: i64,
1722
1723 pub demote_emerging_uses: u64,
1725 pub demote_emerging_success_rate: f64,
1726 pub demote_stable_uses: u64,
1727 pub demote_stable_success_rate: f64,
1728 pub deprecated_success_rate: f64,
1729 pub deprecated_no_success_days: i64,
1730
1731 pub auto_archive_confidence: f64,
1733 pub auto_archive_age_days: i64,
1734
1735 pub broken_workflow_streak: u32,
1740
1741 pub archive_destroy_grace_days: i64,
1746}
1747
1748impl Default for SkillLifecycleConfig {
1749 fn default() -> Self {
1750 Self {
1751 promote_draft_uses: 3,
1752 promote_emerging_uses: 10,
1753 promote_emerging_success_rate: 0.6,
1754 promote_emerging_age_days: 7,
1755 promote_stable_uses: 30,
1756 promote_stable_success_rate: 0.8,
1757 promote_stable_age_days: 30,
1758 demote_emerging_uses: 8,
1759 demote_emerging_success_rate: 0.55,
1760 demote_stable_uses: 25,
1761 demote_stable_success_rate: 0.75,
1762 deprecated_success_rate: 0.3,
1763 deprecated_no_success_days: 90,
1764 auto_archive_confidence: 0.10,
1765 auto_archive_age_days: 180,
1766 broken_workflow_streak: 3,
1767 archive_destroy_grace_days: 30,
1768 }
1769 }
1770}
1771
1772#[derive(Debug, Clone, Serialize, Deserialize)]
1773#[serde(default)]
1774pub struct AdaptiveSkillsConfig {
1775 pub context_fill_decay: f64,
1776 pub min_remaining_context_ratio: f64,
1777 pub recent_fire_boost_turns: usize,
1778 pub model_max_context_tokens: u64,
1782}
1783
1784impl Default for AdaptiveSkillsConfig {
1785 fn default() -> Self {
1786 Self {
1787 context_fill_decay: 1.5,
1788 min_remaining_context_ratio: 0.20,
1789 recent_fire_boost_turns: 5,
1790 model_max_context_tokens: 200_000,
1791 }
1792 }
1793}
1794
1795#[derive(Debug, Clone, Serialize, Deserialize)]
1798pub struct SleepCycleConfig {
1799 #[serde(default)]
1801 pub enabled: bool,
1802
1803 #[serde(default = "default_idle_threshold_minutes")]
1805 pub idle_threshold_minutes: u64,
1806
1807 #[serde(default = "default_agent_idle_minutes")]
1809 pub agent_idle_minutes: u64,
1810}
1811
1812fn default_idle_threshold_minutes() -> u64 {
1813 15
1814}
1815
1816fn default_agent_idle_minutes() -> u64 {
1817 5
1818}
1819
1820impl Default for SleepCycleConfig {
1821 fn default() -> Self {
1822 Self {
1823 enabled: false,
1824 idle_threshold_minutes: default_idle_threshold_minutes(),
1825 agent_idle_minutes: default_agent_idle_minutes(),
1826 }
1827 }
1828}
1829
1830#[derive(Debug, Clone, Serialize, Deserialize)]
1833pub struct NudgeConfig {
1834 #[serde(default = "default_nudge_enabled")]
1836 pub enabled: bool,
1837 #[serde(default = "default_nudge_daily_cap")]
1838 pub daily_cap: u32,
1839 #[serde(default = "default_nudge_snooze_days")]
1840 pub snooze_days: u32,
1841 #[serde(default = "default_nudge_threshold")]
1842 pub threshold: usize,
1843}
1844
1845fn default_nudge_enabled() -> bool {
1846 true
1847}
1848fn default_nudge_daily_cap() -> u32 {
1849 3
1850}
1851fn default_nudge_snooze_days() -> u32 {
1852 7
1853}
1854fn default_nudge_threshold() -> usize {
1855 3
1856}
1857
1858impl Default for NudgeConfig {
1859 fn default() -> Self {
1860 Self {
1861 enabled: true,
1862 daily_cap: default_nudge_daily_cap(),
1863 snooze_days: default_nudge_snooze_days(),
1864 threshold: default_nudge_threshold(),
1865 }
1866 }
1867}
1868
1869#[derive(Debug, Clone, Serialize, Deserialize)]
1873pub struct SessionCfg {
1874 #[serde(default = "default_capture_mode")]
1876 pub capture: String,
1877 #[serde(default = "default_retention_days")]
1879 pub retention_days: u32,
1880}
1881
1882impl Default for SessionCfg {
1883 fn default() -> Self {
1884 Self {
1885 capture: default_capture_mode(),
1886 retention_days: default_retention_days(),
1887 }
1888 }
1889}
1890
1891fn default_capture_mode() -> String {
1892 "ambient".to_string()
1893}
1894fn default_retention_days() -> u32 {
1895 14
1896}
1897
1898#[derive(Debug, Clone, Serialize, Deserialize)]
1900pub struct HarvestCfg {
1901 #[serde(default = "default_harvest_enabled")]
1903 pub auto_gate: bool,
1904 #[serde(default = "default_harvest_llm")]
1906 pub llm: String,
1907 #[serde(default = "default_min_events")]
1909 pub min_events: usize,
1910 #[serde(default = "default_min_user_turns")]
1911 pub min_user_turns: usize,
1912 #[serde(default = "default_min_duration_secs")]
1913 pub min_duration_secs: i64,
1914 #[serde(default = "default_idle_minutes")]
1916 pub idle_minutes: i64,
1917 #[serde(default = "default_max_steps")]
1920 pub max_steps: usize,
1921 #[serde(default = "default_max_duration_secs")]
1922 pub max_duration_secs: i64,
1923 #[serde(default = "default_max_llm_calls_per_day")]
1925 pub max_llm_calls_per_day: u32,
1926 #[serde(default = "default_max_extract_input_tokens")]
1927 pub max_extract_input_tokens: usize,
1928 #[serde(default = "default_harvest_enabled")]
1930 pub session_start_hint: bool,
1931 #[serde(default = "default_similarity_merge_threshold")]
1934 pub similarity_merge_threshold: f32,
1935 #[serde(default = "default_min_occurrences")]
1939 pub min_occurrences: usize,
1940}
1941
1942impl Default for HarvestCfg {
1943 fn default() -> Self {
1944 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1945 }
1946}
1947
1948fn default_harvest_enabled() -> bool {
1949 true
1950}
1951fn default_harvest_llm() -> String {
1952 "local-first".to_string()
1953}
1954fn default_min_events() -> usize {
1955 5
1956}
1957fn default_min_user_turns() -> usize {
1958 2
1959}
1960fn default_min_duration_secs() -> i64 {
1961 120
1962}
1963fn default_idle_minutes() -> i64 {
1964 30
1965}
1966fn default_max_steps() -> usize {
1970 20
1971}
1972fn default_max_duration_secs() -> i64 {
1975 1800
1976}
1977fn default_max_llm_calls_per_day() -> u32 {
1978 10
1979}
1980fn default_max_extract_input_tokens() -> usize {
1981 12000
1982}
1983fn default_similarity_merge_threshold() -> f32 {
1984 0.6
1985}
1986fn default_min_occurrences() -> usize {
1989 2
1990}
1991
1992#[derive(Debug, Clone, Serialize, Deserialize)]
1995#[serde(default)]
1996pub struct CrossAgentConfig {
1997 #[serde(default = "default_half_life_days")]
1998 pub fitness_half_life_days: u32,
1999 #[serde(default = "default_fitness_floor")]
2000 pub fitness_floor: f64,
2001}
2002
2003fn default_half_life_days() -> u32 {
2004 7
2005}
2006fn default_fitness_floor() -> f64 {
2007 0.1
2008}
2009
2010impl Default for CrossAgentConfig {
2011 fn default() -> Self {
2012 Self {
2013 fitness_half_life_days: default_half_life_days(),
2014 fitness_floor: default_fitness_floor(),
2015 }
2016 }
2017}
2018
2019#[derive(Debug, Clone, Serialize, Deserialize)]
2022#[serde(default)]
2023pub struct SkillLlmConfig {
2024 #[serde(default = "default_per_call_token_cap")]
2026 pub per_call_token_cap: u32,
2027
2028 #[serde(default = "default_per_day_usd_cap")]
2030 pub per_day_usd_cap: f64,
2031
2032 #[serde(default = "default_cache_ttl_days")]
2034 pub cache_ttl_days: u32,
2035
2036 #[serde(default, skip_serializing_if = "Option::is_none")]
2038 pub model_ref: Option<String>,
2039}
2040
2041fn default_per_call_token_cap() -> u32 {
2042 1500
2043}
2044fn default_per_day_usd_cap() -> f64 {
2045 0.50
2046}
2047fn default_cache_ttl_days() -> u32 {
2048 30
2049}
2050
2051impl Default for SkillLlmConfig {
2052 fn default() -> Self {
2053 Self {
2054 per_call_token_cap: default_per_call_token_cap(),
2055 per_day_usd_cap: default_per_day_usd_cap(),
2056 cache_ttl_days: default_cache_ttl_days(),
2057 model_ref: None,
2058 }
2059 }
2060}
2061#[cfg(test)]
2062mod per_stage_backend_tests {
2063 use super::*;
2064
2065 #[test]
2066 fn legacy_compact_config_has_no_per_stage_overrides() {
2067 let yaml = "\
2068extractive_model: qwen3:14b
2069abstractive_model: qwen3:14b
2070ollama_endpoint: http://localhost:11434
2071";
2072 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2073 assert!(cfg.extractive_backend.is_none());
2074 assert!(cfg.abstractive_backend.is_none());
2075 assert_eq!(cfg.extractive_model, "qwen3:14b");
2076 assert_eq!(cfg.abstractive_model, "qwen3:14b");
2077 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
2078 }
2079
2080 #[test]
2081 fn legacy_ask_config_has_no_per_stage_overrides() {
2082 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
2083 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2084 assert!(cfg.backend.is_none());
2085 assert!(cfg.rewriter_backend.is_none());
2086 assert_eq!(cfg.model, "qwen3:14b");
2087 }
2088
2089 #[test]
2090 fn compact_extractive_backend_override_parses() {
2091 let yaml = "\
2092extractive_backend:
2093 provider: anthropic
2094 model: claude-haiku-4-5
2095 api_key_env: ANTHROPIC_API_KEY
2096abstractive_model: qwen3:14b
2097";
2098 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2099 let extractive = cfg
2100 .extractive_backend
2101 .as_ref()
2102 .expect("override should parse");
2103 assert_eq!(extractive.provider, "anthropic");
2104 assert_eq!(extractive.model, "claude-haiku-4-5");
2105 assert!(cfg.abstractive_backend.is_none());
2106 }
2107
2108 #[test]
2109 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2110 let yaml = "\
2111backend:
2112 provider: anthropic
2113 model: claude-sonnet-5
2114 api_key_env: ANTHROPIC_API_KEY
2115rewriter_backend:
2116 provider: ollama
2117 model: llama3.2:3b
2118";
2119 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2120 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2121 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2122 }
2123
2124 #[test]
2125 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
2126 let yaml = "\
2127extractive_model: qwen3:14b
2128ollama_endpoint: http://192.168.1.10:11434
2129";
2130 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2131 let synth = cfg.synthesize_extractive_backend();
2132 assert_eq!(synth.provider, "ollama");
2133 assert_eq!(synth.model, "qwen3:14b");
2134 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
2135 assert_eq!(synth.api_key_env, None);
2136 }
2137
2138 #[test]
2139 fn synthesize_legacy_to_backend_config_for_ask() {
2140 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
2141 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2142 let synth = cfg.synthesize_backend();
2143 assert_eq!(synth.provider, "ollama");
2144 assert_eq!(synth.model, "qwen3:14b");
2145 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
2146 }
2147
2148 #[test]
2149 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
2150 let yaml = "\
2159backend:
2160 provider: anthropic
2161 model: claude-sonnet-5
2162 api_key_env: ANTHROPIC_API_KEY
2163";
2164 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2165 let rewriter = cfg.synthesize_rewriter_backend();
2166 assert_eq!(rewriter.provider, "ollama");
2167 assert_eq!(rewriter.model, ask_default_model());
2168 assert_eq!(
2169 rewriter.timeout_secs,
2170 Some(ask_default_rewriter_timeout() as u64)
2171 );
2172 }
2173
2174 #[test]
2175 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
2176 let cfg = AskConfig {
2177 timeout_secs: 45,
2178 ..AskConfig::default()
2179 };
2180 let b = cfg.synthesize_backend();
2181 assert_eq!(
2182 b.timeout_secs,
2183 Some(45),
2184 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
2185 );
2186 }
2187
2188 #[test]
2189 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
2190 let mut cfg = AskConfig {
2191 timeout_secs: 45,
2192 ..AskConfig::default()
2193 };
2194 cfg.backend = Some(BackendConfig {
2195 provider: "anthropic".into(),
2196 model: "claude-haiku-4-5".into(),
2197 endpoint: None,
2198 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2199 api_key_ref: None,
2200 timeout_secs: Some(10),
2201 });
2202 let b = cfg.synthesize_backend();
2203 assert_eq!(
2204 b.timeout_secs,
2205 Some(10),
2206 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
2207 );
2208 }
2209
2210 #[test]
2211 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
2212 let cfg = AskConfig {
2213 timeout_secs: 120,
2214 rewriter_timeout_secs: 8,
2215 ..AskConfig::default()
2216 };
2217 let b = cfg.synthesize_rewriter_backend();
2218 assert_eq!(
2219 b.timeout_secs,
2220 Some(8),
2221 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
2222 );
2223 }
2224
2225 #[test]
2226 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
2227 let mut cfg = AskConfig {
2228 rewriter_timeout_secs: 8,
2229 ..AskConfig::default()
2230 };
2231 cfg.rewriter_backend = Some(BackendConfig {
2232 provider: "anthropic".into(),
2233 model: "claude-haiku-4-5".into(),
2234 endpoint: None,
2235 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2236 api_key_ref: None,
2237 timeout_secs: Some(30),
2238 });
2239 let b = cfg.synthesize_rewriter_backend();
2240 assert_eq!(
2241 b.timeout_secs,
2242 Some(30),
2243 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
2244 );
2245 }
2246
2247 #[test]
2248 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
2249 let cfg = CompactConfig::default();
2252 let b = cfg.synthesize_extractive_backend();
2253 assert_eq!(
2254 b.timeout_secs,
2255 Some(120),
2256 "compact synthesis without per-stage override must produce 120s timeout"
2257 );
2258 }
2259
2260 #[test]
2261 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
2262 let cfg = CompactConfig::default();
2263 let b = cfg.synthesize_abstractive_backend();
2264 assert_eq!(b.timeout_secs, Some(120));
2265 }
2266}
2267
2268#[cfg(test)]
2269mod skills_config_tests {
2270 use super::*;
2271
2272 #[test]
2273 fn empty_yaml_hydrates_defaults() {
2274 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2275 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2276 assert_eq!(cfg.skills.max_total_tokens, 2000);
2277 assert!(cfg.skills.adaptive.is_some());
2278 }
2279
2280 #[test]
2281 fn load_or_default_missing_file_returns_default() {
2282 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2283 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2284 }
2285
2286 #[test]
2287 fn dev_discipline_index_defaults_auto_and_parses() {
2288 use crate::config::DevDisciplineIndex;
2289 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2290 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2291 let cfg: Config =
2292 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2293 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2294 let cfg: Config =
2295 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2296 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2297 }
2298}
2299
2300#[cfg(test)]
2301mod ambient_capture_cfg_tests {
2302 use super::*;
2303
2304 #[test]
2305 fn session_and_harvest_defaults() {
2306 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2307 assert_eq!(cfg.session.capture, "ambient");
2308 assert_eq!(cfg.session.retention_days, 14);
2309 assert!(cfg.harvest.auto_gate);
2310 assert_eq!(cfg.harvest.llm, "local-first");
2311 assert_eq!(cfg.harvest.min_events, 5);
2312 assert_eq!(cfg.harvest.min_user_turns, 2);
2313 assert_eq!(cfg.harvest.min_duration_secs, 120);
2314 assert_eq!(cfg.harvest.idle_minutes, 30);
2315 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2316 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2317 assert!(cfg.harvest.session_start_hint);
2318 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2319 }
2320
2321 #[test]
2322 fn session_capture_override_parses() {
2323 let cfg: Config =
2324 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2325 assert_eq!(cfg.session.capture, "off");
2326 assert_eq!(cfg.session.retention_days, 3);
2327 }
2328}
2329
2330#[cfg(test)]
2331mod cc_proxy_cfg_tests {
2332 use super::*;
2333
2334 #[test]
2335 fn defaults_to_local_cc_proxy_enabled() {
2336 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2337 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2338 assert!(cfg.cc_proxy.enabled);
2339 }
2340
2341 #[test]
2342 fn url_and_enabled_override_parse() {
2343 let cfg: Config =
2344 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2345 .unwrap();
2346 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2347 assert!(!cfg.cc_proxy.enabled);
2348 }
2349
2350 #[test]
2351 fn partial_section_keeps_other_default() {
2352 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2354 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2355 assert!(!cfg.cc_proxy.enabled);
2356 }
2357}
2358
2359#[cfg(test)]
2360mod model_switch_config_tests {
2361 use super::*;
2362
2363 #[test]
2364 fn model_switch_config_defaults_and_omitted_block() {
2365 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2367 assert_eq!(cfg.models.default, None);
2368 assert!(cfg.models.fallback_chain.is_empty());
2369 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2370 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2371 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2372 assert!(!cfg.models.routing.enabled);
2373
2374 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";
2376 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2377 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2378 assert_eq!(
2379 cfg.models.fallback_chain,
2380 vec!["claude_sonnet", "deepseek_v4_pro"]
2381 );
2382 assert!(cfg.models.routing.enabled);
2383 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2384 }
2385
2386 #[test]
2387 fn smart_config_defaults_on_with_autopick() {
2388 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2389 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2392 cfg.models.smart.max_escalations,
2393 DEFAULT_SMART_MAX_ESCALATIONS
2394 );
2395 }
2396}