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
18pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
23
24fn default_max_retries() -> u32 {
25 DEFAULT_MAX_RETRIES
26}
27fn default_backoff_base_ms() -> u64 {
28 DEFAULT_BACKOFF_BASE_MS
29}
30fn default_cooldown_secs() -> u64 {
31 DEFAULT_COOLDOWN_SECS
32}
33fn default_smart_max_escalations() -> u32 {
34 DEFAULT_SMART_MAX_ESCALATIONS
35}
36
37#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
42pub struct SmartConfig {
43 #[serde(default = "default_true")]
44 pub enabled: bool,
45 #[serde(default, skip_serializing_if = "Option::is_none")]
46 pub cheap: Option<String>,
47 #[serde(default = "default_smart_max_escalations")]
48 pub max_escalations: u32,
49}
50
51impl Default for SmartConfig {
52 fn default() -> Self {
53 Self {
54 enabled: true,
55 cheap: None,
56 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
57 }
58 }
59}
60
61#[derive(Debug, Clone, Serialize, Deserialize, Default)]
64pub struct ModelSwitchConfig {
65 #[serde(default, skip_serializing_if = "Option::is_none")]
67 pub default: Option<String>,
68 #[serde(default, skip_serializing_if = "Vec::is_empty")]
70 pub fallback_chain: Vec<String>,
71 #[serde(default)]
72 pub retry: RetryConfig,
73 #[serde(default)]
74 pub routing: RoutingConfig,
75 #[serde(default)]
76 pub smart: SmartConfig,
77}
78
79#[derive(Debug, Clone, Serialize, Deserialize)]
80pub struct RetryConfig {
81 #[serde(default = "default_max_retries")]
82 pub max_retries: u32,
83 #[serde(default = "default_backoff_base_ms")]
84 pub backoff_base_ms: u64,
85 #[serde(default = "default_cooldown_secs")]
86 pub cooldown_secs: u64,
87}
88
89impl Default for RetryConfig {
90 fn default() -> Self {
91 Self {
92 max_retries: DEFAULT_MAX_RETRIES,
93 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
94 cooldown_secs: DEFAULT_COOLDOWN_SECS,
95 }
96 }
97}
98
99#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
100pub struct RoutingConfig {
101 #[serde(default)]
102 pub enabled: bool,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub cheap: Option<String>,
105 #[serde(default, skip_serializing_if = "Option::is_none")]
106 pub frontier: Option<String>,
107 #[serde(default, skip_serializing_if = "Option::is_none")]
108 pub threshold_input_tokens: Option<u32>,
109 #[serde(default, skip_serializing_if = "Option::is_none")]
110 pub smart: Option<SmartConfig>,
111}
112
113#[derive(Debug, Clone, Serialize, Deserialize, Default)]
115pub struct Config {
116 #[serde(default)]
117 pub embedding: EmbeddingConfig,
118
119 #[serde(default)]
120 pub llm: LlmConfig,
121
122 #[serde(default)]
123 pub models: ModelSwitchConfig,
124
125 #[serde(default)]
126 pub retrieval: RetrievalConfig,
127
128 #[serde(default)]
129 pub paths: PathConfig,
130
131 #[serde(default)]
132 pub server: ServerConfig,
133
134 #[serde(default)]
135 pub community: CommunityConfig,
136
137 #[serde(default)]
138 pub conversations: ConversationsConfig,
139
140 #[serde(default)]
141 pub sync: SyncConfig,
142
143 #[serde(default)]
145 pub storage: StorageConfig,
146
147 #[serde(default)]
148 pub sources_global: SourcesGlobalConfig,
149
150 #[serde(default)]
152 pub sleep_cycle: SleepCycleConfig,
153
154 #[serde(default)]
156 pub skills: SkillsConfig,
157
158 #[serde(default)]
160 pub skill_llm: SkillLlmConfig,
161
162 #[serde(default)]
164 pub cross_agent: CrossAgentConfig,
165
166 #[serde(default)]
168 pub nudge: NudgeConfig,
169
170 #[serde(default)]
172 pub mobile_relay: MobileRelayConfig,
173
174 #[serde(default)]
176 pub session: SessionCfg,
177
178 #[serde(default)]
179 pub harvest: HarvestCfg,
180
181 #[serde(default)]
183 pub cc_proxy: CcProxyConfig,
184
185 #[serde(default)]
187 pub cli: CliConfig,
188
189 #[serde(default)]
191 pub parallel_jobs: ParallelJobsConfig,
192
193 #[serde(default)]
195 pub federation_snapshot: SnapshotConfig,
196
197 #[serde(default)]
199 pub memory: MemoryConfig,
200
201 #[serde(default)]
203 pub fleet_run: FleetRunConfig,
204
205 #[serde(default)]
207 pub open_items: OpenItemsConfig,
208
209 #[serde(default)]
211 pub fleet: FleetConfig,
212
213 #[serde(default)]
215 pub update: UpdateConfig,
216
217 #[serde(default)]
219 pub runs: RunsConfig,
220
221 #[serde(default)]
223 pub capture: CaptureConfig,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
234pub struct CaptureConfig {
235 #[serde(default = "default_rotate_at_mb")]
238 pub rotate_at_mb: u64,
239 #[serde(default = "default_keep_generations")]
242 pub keep_generations: u32,
243}
244
245fn default_rotate_at_mb() -> u64 {
246 64
247}
248
249fn default_keep_generations() -> u32 {
250 5
251}
252
253impl Default for CaptureConfig {
254 fn default() -> Self {
255 Self {
256 rotate_at_mb: default_rotate_at_mb(),
257 keep_generations: default_keep_generations(),
258 }
259 }
260}
261
262#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
265pub struct UpdateConfig {
266 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub codesign_identity: Option<String>,
273 #[serde(default, skip_serializing_if = "Vec::is_empty")]
275 pub restart_exclude: Vec<String>,
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
281pub struct RunsConfig {
282 #[serde(default = "default_heartbeat_interval_secs")]
284 pub heartbeat_interval_secs: u64,
285 #[serde(default = "default_heartbeat_stale_after_intervals")]
289 pub heartbeat_stale_after_intervals: u32,
290}
291
292fn default_heartbeat_interval_secs() -> u64 {
293 10
294}
295
296fn default_heartbeat_stale_after_intervals() -> u32 {
297 3
298}
299
300impl Default for RunsConfig {
301 fn default() -> Self {
302 Self {
303 heartbeat_interval_secs: default_heartbeat_interval_secs(),
304 heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
305 }
306 }
307}
308
309#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
315pub struct ParallelJobsConfig {
316 #[serde(default)]
319 pub targets: Vec<String>,
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
328#[serde(default)]
329pub struct SnapshotConfig {
330 pub poll_secs: u64,
332 pub request_max_age_secs: u64,
334 pub min_lifecycle: crate::skill::stats::LifecycleState,
336}
337
338impl Default for SnapshotConfig {
339 fn default() -> Self {
340 Self {
341 poll_secs: 30,
342 request_max_age_secs: 600,
343 min_lifecycle: crate::skill::stats::LifecycleState::Stable,
344 }
345 }
346}
347
348#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
352#[serde(default)]
353pub struct MemoryConfig {
354 pub capture: CaptureMode,
355 #[serde(default = "default_memory_in_prompt")]
360 pub max_in_prompt: usize,
361 #[serde(default = "default_memory_chars")]
363 pub max_chars: usize,
364}
365
366fn default_memory_in_prompt() -> usize {
367 20
368}
369
370fn default_memory_chars() -> usize {
371 1500
372}
373
374impl Default for MemoryConfig {
375 fn default() -> Self {
376 Self {
377 capture: CaptureMode::AutoAnnounce,
378 max_in_prompt: default_memory_in_prompt(),
379 max_chars: default_memory_chars(),
380 }
381 }
382}
383
384#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
386#[serde(rename_all = "snake_case")]
387pub enum CaptureMode {
388 Ask,
390 AutoAnnounce,
392 Off,
394}
395
396#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
404pub struct FleetRunConfig {
405 #[serde(default)]
407 pub agents: Vec<String>,
408 #[serde(default)]
410 pub fleets: Vec<String>,
411}
412
413#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
420pub struct OpenItemsConfig {
421 #[serde(default)]
424 pub muted: Vec<String>,
425}
426
427#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
434pub struct FleetConfig {
435 #[serde(default)]
437 pub autorun: bool,
438}
439
440#[cfg(test)]
441mod fleet_config_tests {
442 use super::*;
443
444 #[test]
445 fn fleet_config_defaults_off_and_roundtrips() {
446 assert!(!FleetConfig::default().autorun);
447
448 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
449 assert!(cfg.fleet.autorun);
450
451 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
453 assert!(!cfg2.fleet.autorun);
454 }
455
456 #[test]
457 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
458 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
460 assert!(cfg.fleet_run.agents.is_empty());
461 assert!(cfg.fleet_run.fleets.is_empty());
462
463 let cfg2: Config =
464 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
465 .unwrap();
466 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
467 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
468 }
469}
470
471#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct CcProxyConfig {
483 #[serde(default = "default_cc_proxy_url")]
485 pub url: String,
486
487 #[serde(default = "default_true")]
490 pub enabled: bool,
491}
492
493fn default_cc_proxy_url() -> String {
494 "http://127.0.0.1:8088".to_string()
495}
496
497fn default_true() -> bool {
498 true
499}
500
501impl Default for CcProxyConfig {
502 fn default() -> Self {
503 Self {
504 url: default_cc_proxy_url(),
505 enabled: true,
506 }
507 }
508}
509
510#[derive(Debug, Clone, Serialize, Deserialize, Default)]
513pub struct CliConfig {
514 pub skin: Option<String>,
517}
518
519#[derive(Debug, Clone, Serialize, Deserialize, Default)]
522pub struct MobileRelayConfig {
523 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub relay_url: Option<String>,
527
528 #[serde(default, skip_serializing_if = "Option::is_none")]
531 pub api_key: Option<String>,
532}
533
534impl Config {
535 pub fn load_or_default(path: &std::path::Path) -> Self {
540 let Ok(text) = std::fs::read_to_string(path) else {
541 return Self::default();
542 };
543 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
544 let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
545 cfg.sanitize();
546 cfg
547 }
548
549 fn sanitize(&mut self) {
554 if self.runs.heartbeat_interval_secs == 0 {
558 self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
559 }
560 if self.capture.rotate_at_mb == 0 {
562 self.capture.rotate_at_mb = default_rotate_at_mb();
563 }
564 if self.runs.heartbeat_stale_after_intervals == 0 {
565 self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
566 }
567 }
568}
569
570#[derive(Debug, Clone, Serialize, Deserialize, Default)]
571pub struct SyncConfig {
572 #[serde(default = "default_sync_method")]
574 pub method: String,
575
576 #[serde(default, skip_serializing_if = "Option::is_none")]
578 pub git_remote: Option<String>,
579
580 #[serde(default)]
582 pub auto: bool,
583
584 #[serde(default, skip_serializing_if = "Option::is_none")]
586 pub team_id: Option<String>,
587}
588
589fn default_sync_method() -> String {
590 "local".to_string()
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
594pub struct ServerConfig {
595 #[serde(default = "default_server_url")]
597 pub url: String,
598}
599
600impl Default for ServerConfig {
601 fn default() -> Self {
602 Self {
603 url: default_server_url(),
604 }
605 }
606}
607
608#[derive(Debug, Clone, Serialize, Deserialize, Default)]
609pub struct CommunityConfig {
610 #[serde(default)]
612 pub enabled: bool,
613}
614
615#[derive(Debug, Clone, Serialize, Deserialize)]
616pub struct EmbeddingConfig {
617 #[serde(default = "default_embedding_provider")]
619 pub provider: String,
620
621 #[serde(default = "default_embedding_model")]
623 pub model: String,
624
625 #[serde(default = "default_dimensions")]
627 pub dimensions: usize,
628
629 #[serde(default, skip_serializing_if = "Option::is_none")]
633 pub ollama_endpoint: Option<String>,
634
635 #[serde(default, skip_serializing_if = "Option::is_none")]
637 pub api_key_env: Option<String>,
638
639 #[serde(default, skip_serializing_if = "Option::is_none")]
642 pub api_key_ref: Option<String>,
643
644 #[serde(default, skip_serializing_if = "Option::is_none")]
646 pub openai_url: Option<String>,
647}
648
649impl Default for EmbeddingConfig {
650 fn default() -> Self {
651 Self {
652 provider: default_embedding_provider(),
653 model: default_embedding_model(),
654 dimensions: default_dimensions(),
655 ollama_endpoint: Some(default_ollama_endpoint()),
656 api_key_env: None,
657 api_key_ref: None,
658 openai_url: None,
659 }
660 }
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
664pub struct LlmConfig {
665 #[serde(default = "default_llm_provider")]
667 pub provider: String,
668
669 #[serde(default = "default_llm_model")]
670 pub model: String,
671
672 #[serde(default, skip_serializing_if = "Option::is_none")]
674 pub api_key_env: Option<String>,
675
676 #[serde(default, skip_serializing_if = "Option::is_none")]
679 pub api_key_ref: Option<String>,
680
681 #[serde(default, skip_serializing_if = "Option::is_none")]
683 pub openai_url: Option<String>,
684}
685
686impl Default for LlmConfig {
687 fn default() -> Self {
688 Self {
689 provider: default_llm_provider(),
690 model: default_llm_model(),
691 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
692 api_key_ref: None,
693 openai_url: None,
694 }
695 }
696}
697
698impl LlmConfig {
699 pub fn to_backend_config(&self) -> BackendConfig {
712 let provider = match self.provider.as_str() {
713 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
714 _ if self.openai_url.is_some() => "openai".into(),
715 other => other.into(), };
717 BackendConfig {
718 provider,
719 model: self.model.clone(),
720 endpoint: self.openai_url.clone(),
721 api_key_env: self.api_key_env.clone(),
722 api_key_ref: self.api_key_ref.clone(),
723 timeout_secs: None,
724 }
725 }
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
739#[serde(default)]
740pub struct BackendConfig {
741 pub provider: String,
743 pub model: String,
745 pub endpoint: Option<String>,
748 pub api_key_env: Option<String>,
750 pub api_key_ref: Option<String>,
752 pub timeout_secs: Option<u64>,
754}
755
756impl Default for BackendConfig {
757 fn default() -> Self {
758 Self {
759 provider: "ollama".into(),
760 model: DEFAULT_LOCAL_LLM_MODEL.into(),
761 endpoint: None,
762 api_key_env: None,
763 api_key_ref: None,
764 timeout_secs: None,
765 }
766 }
767}
768
769#[derive(Debug, Clone, Serialize, Deserialize)]
770pub struct RetrievalConfig {
771 #[serde(default = "default_max_patterns")]
773 pub max_patterns: usize,
774
775 #[serde(default = "default_max_tokens")]
777 pub max_tokens: usize,
778
779 #[serde(default = "default_min_score")]
781 pub min_score: f64,
782
783 #[serde(default = "default_mmr_threshold")]
785 pub mmr_threshold: f64,
786
787 #[serde(default = "default_reserved_note_slots")]
790 pub reserved_note_slots: usize,
791}
792
793impl Default for RetrievalConfig {
794 fn default() -> Self {
795 Self {
796 max_patterns: default_max_patterns(),
797 max_tokens: default_max_tokens(),
798 min_score: default_min_score(),
799 mmr_threshold: default_mmr_threshold(),
800 reserved_note_slots: default_reserved_note_slots(),
801 }
802 }
803}
804
805fn default_reserved_note_slots() -> usize {
806 1
807}
808
809#[derive(Debug, Clone, Serialize, Deserialize)]
810pub struct PathConfig {
811 #[serde(default = "default_mur_dir")]
813 pub mur_dir: PathBuf,
814}
815
816impl Default for PathConfig {
817 fn default() -> Self {
818 Self {
819 mur_dir: default_mur_dir(),
820 }
821 }
822}
823
824#[derive(Debug, Clone, Serialize, Deserialize)]
825pub struct StorageConfig {
826 #[serde(default = "default_vector_backend")]
828 pub vector_backend: String,
829
830 #[serde(default, skip_serializing_if = "Option::is_none")]
832 pub qdrant_url: Option<String>,
833
834 #[serde(default, skip_serializing_if = "Option::is_none")]
836 pub qdrant_api_key_ref: Option<String>,
837}
838
839impl Default for StorageConfig {
840 fn default() -> Self {
841 Self {
842 vector_backend: default_vector_backend(),
843 qdrant_url: None,
844 qdrant_api_key_ref: None,
845 }
846 }
847}
848
849fn default_vector_backend() -> String {
850 "lancedb".to_string()
851}
852
853#[derive(Debug, Clone, Serialize, Deserialize)]
854pub struct SourcesGlobalConfig {
855 #[serde(default = "default_poll_interval_secs")]
857 pub poll_interval_secs: u64,
858
859 #[serde(default = "default_max_chunks_per_sync")]
861 pub max_chunks_per_sync: usize,
862
863 #[serde(default = "default_max_parallel_sources")]
865 pub max_parallel_sources: usize,
866
867 #[serde(default = "default_source_weight")]
869 pub default_weight: f32,
870
871 #[serde(default = "default_embedding_batch_size")]
873 pub embedding_batch_size: usize,
874}
875
876impl Default for SourcesGlobalConfig {
877 fn default() -> Self {
878 Self {
879 poll_interval_secs: default_poll_interval_secs(),
880 max_chunks_per_sync: default_max_chunks_per_sync(),
881 max_parallel_sources: default_max_parallel_sources(),
882 default_weight: default_source_weight(),
883 embedding_batch_size: default_embedding_batch_size(),
884 }
885 }
886}
887
888fn default_poll_interval_secs() -> u64 {
889 600
890}
891fn default_max_chunks_per_sync() -> usize {
892 10_000
893}
894fn default_max_parallel_sources() -> usize {
895 3
896}
897fn default_source_weight() -> f32 {
898 1.0
899}
900fn default_embedding_batch_size() -> usize {
901 32
902}
903
904fn default_embedding_provider() -> String {
905 "ollama".to_string()
906}
907fn default_embedding_model() -> String {
908 "qwen3-embedding:0.6b".to_string()
909}
910fn default_dimensions() -> usize {
911 1024
912}
913fn default_ollama_endpoint() -> String {
914 DEFAULT_OLLAMA_ENDPOINT.to_string()
915}
916fn default_llm_provider() -> String {
917 "anthropic".to_string()
918}
919fn default_llm_model() -> String {
920 "claude-opus-5".to_string()
921}
922fn default_max_patterns() -> usize {
923 5
924}
925fn default_max_tokens() -> usize {
926 2000
927}
928fn default_min_score() -> f64 {
929 0.35
930}
931fn default_mmr_threshold() -> f64 {
932 0.85
933}
934fn default_mur_dir() -> PathBuf {
935 let home = std::env::var("HOME")
938 .map(PathBuf::from)
939 .unwrap_or_else(|_| PathBuf::from("/tmp"));
940 home.join(".mur")
941}
942fn default_server_url() -> String {
943 "https://mur-server.fly.dev".to_string()
944}
945
946#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct AskConfig {
950 #[serde(default = "ask_default_k_summary")]
951 pub k_summary: u32,
952 #[serde(default = "ask_default_k_raw")]
953 pub k_raw: u32,
954 #[serde(default = "ask_default_esc")]
955 pub escalation_threshold: f64,
956 #[serde(default = "ask_default_mmr")]
957 pub mmr_threshold: f64,
958 #[serde(default = "ask_default_max_ctx")]
959 pub max_context_tokens: u32,
960 #[serde(default = "ask_default_resp_tok")]
961 pub response_tokens: u32,
962 #[serde(default = "ask_default_timeout")]
963 pub timeout_secs: u32,
964 #[serde(default = "ask_default_min_score")]
965 pub min_score: f64,
966 #[serde(default = "ask_default_continue_history_turns")]
967 pub continue_history_turns: u32,
968 #[serde(default = "ask_default_rewriter_timeout")]
974 pub rewriter_timeout_secs: u32,
975 #[serde(default = "ask_default_compress_hits_enabled")]
976 pub compress_hits_enabled: bool,
977 #[serde(default = "ask_default_summarize_hits_enabled")]
978 pub summarize_hits_enabled: bool,
979 #[serde(default)]
980 pub summarize_model: Option<String>,
981 #[serde(default)]
984 pub backend: Option<BackendConfig>,
985 #[serde(default)]
989 pub rewriter_backend: Option<BackendConfig>,
990}
991
992impl AskConfig {
993 pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
998 self.backend.clone().unwrap_or_else(|| BackendConfig {
999 timeout_secs: Some(self.timeout_secs as u64),
1000 ..llm.to_backend_config()
1001 })
1002 }
1003
1004 pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
1014 self.rewriter_backend
1015 .clone()
1016 .unwrap_or_else(|| BackendConfig {
1017 timeout_secs: Some(self.rewriter_timeout_secs as u64),
1018 ..self
1019 .backend
1020 .clone()
1021 .unwrap_or_else(|| llm.to_backend_config())
1022 })
1023 }
1024}
1025
1026impl Default for AskConfig {
1027 fn default() -> Self {
1028 Self {
1029 k_summary: ask_default_k_summary(),
1030 k_raw: ask_default_k_raw(),
1031 escalation_threshold: ask_default_esc(),
1032 mmr_threshold: ask_default_mmr(),
1033 max_context_tokens: ask_default_max_ctx(),
1034 response_tokens: ask_default_resp_tok(),
1035 timeout_secs: ask_default_timeout(),
1036 min_score: ask_default_min_score(),
1037 continue_history_turns: ask_default_continue_history_turns(),
1038 rewriter_timeout_secs: ask_default_rewriter_timeout(),
1039 compress_hits_enabled: ask_default_compress_hits_enabled(),
1040 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
1041 summarize_model: None,
1042 backend: None,
1043 rewriter_backend: None,
1044 }
1045 }
1046}
1047
1048fn ask_default_k_summary() -> u32 {
1049 5
1050}
1051fn ask_default_k_raw() -> u32 {
1052 10
1053}
1054fn ask_default_esc() -> f64 {
1055 0.5
1056}
1057fn ask_default_mmr() -> f64 {
1058 0.88
1059}
1060fn ask_default_max_ctx() -> u32 {
1061 6000
1062}
1063fn ask_default_resp_tok() -> u32 {
1064 1024
1065}
1066fn ask_default_timeout() -> u32 {
1067 120
1068}
1069fn ask_default_min_score() -> f64 {
1070 0.35
1071}
1072fn ask_default_rewriter_timeout() -> u32 {
1073 8
1074}
1075fn ask_default_continue_history_turns() -> u32 {
1076 3
1077}
1078fn ask_default_compress_hits_enabled() -> bool {
1079 true
1080}
1081fn ask_default_summarize_hits_enabled() -> bool {
1082 true
1083}
1084
1085#[derive(Debug, Clone, Serialize, Deserialize)]
1094pub struct ConversationsConfig {
1095 #[serde(default)]
1096 pub enabled: bool,
1097 #[serde(default = "conv_default_retention_days")]
1098 pub retention_days: u32,
1099 #[serde(default = "conv_default_poll_interval")]
1100 pub poll_interval_secs: u64,
1101 #[serde(default)]
1102 pub sources: ConversationsSources,
1103 #[serde(default)]
1104 pub filter: ConversationsFilter,
1105 #[serde(default)]
1106 pub compact: CompactConfig,
1107 #[serde(default)]
1108 pub ask: AskConfig,
1109 #[serde(default)]
1110 pub rollup: RollupConfig,
1111}
1112
1113impl Default for ConversationsConfig {
1114 fn default() -> Self {
1115 Self {
1116 enabled: false,
1117 retention_days: conv_default_retention_days(),
1118 poll_interval_secs: conv_default_poll_interval(),
1119 sources: ConversationsSources::default(),
1120 filter: ConversationsFilter::default(),
1121 compact: CompactConfig::default(),
1122 ask: AskConfig::default(),
1123 rollup: RollupConfig::default(),
1124 }
1125 }
1126}
1127
1128fn conv_default_retention_days() -> u32 {
1129 30
1130}
1131fn conv_default_poll_interval() -> u64 {
1132 300
1133}
1134fn conv_truthy() -> bool {
1135 true
1136}
1137fn conv_default_dedup() -> f64 {
1138 0.85
1139}
1140
1141#[derive(Debug, Clone, Serialize, Deserialize)]
1142pub struct CompactConfig {
1143 #[serde(default = "conv_truthy")]
1144 pub enabled_in_daemon: bool,
1145 #[serde(default = "compact_default_max_days")]
1146 pub max_days_per_run: u32,
1147 #[serde(default = "compact_default_max_spans")]
1148 pub max_extractive_spans: u32,
1149 #[serde(default = "compact_default_max_words")]
1150 pub max_abstractive_words: u32,
1151 #[serde(default = "compact_default_chunk_tokens")]
1152 pub chunk_tokens: u32,
1153 #[serde(default = "compact_default_history_retain")]
1154 pub history_retain: u32,
1155 #[serde(default = "compact_default_cron")]
1156 pub daemon_cron: String,
1157 #[serde(default)]
1160 pub extractive_backend: Option<BackendConfig>,
1161 #[serde(default)]
1164 pub abstractive_backend: Option<BackendConfig>,
1165}
1166
1167impl CompactConfig {
1168 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1173 self.extractive_backend
1174 .clone()
1175 .unwrap_or_else(|| BackendConfig {
1176 timeout_secs: Some(120),
1177 ..llm.to_backend_config()
1178 })
1179 }
1180
1181 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1184 self.abstractive_backend
1185 .clone()
1186 .unwrap_or_else(|| BackendConfig {
1187 timeout_secs: Some(120),
1188 ..llm.to_backend_config()
1189 })
1190 }
1191}
1192
1193impl Default for CompactConfig {
1194 fn default() -> Self {
1195 Self {
1196 enabled_in_daemon: true,
1197 max_days_per_run: compact_default_max_days(),
1198 max_extractive_spans: compact_default_max_spans(),
1199 max_abstractive_words: compact_default_max_words(),
1200 chunk_tokens: compact_default_chunk_tokens(),
1201 history_retain: compact_default_history_retain(),
1202 daemon_cron: compact_default_cron(),
1203 extractive_backend: None,
1204 abstractive_backend: None,
1205 }
1206 }
1207}
1208
1209fn compact_default_max_days() -> u32 {
1210 7
1211}
1212fn compact_default_max_spans() -> u32 {
1213 20
1214}
1215fn compact_default_max_words() -> u32 {
1216 400
1217}
1218fn compact_default_chunk_tokens() -> u32 {
1219 6000
1220}
1221fn compact_default_history_retain() -> u32 {
1222 5
1223}
1224fn compact_default_cron() -> String {
1225 "0 0 3 * * * *".into()
1226}
1227
1228#[derive(Debug, Clone, Serialize, Deserialize)]
1231pub struct RollupConfig {
1232 #[serde(default = "rollup_default_enabled")]
1233 pub enabled: bool,
1234 #[serde(default = "rollup_default_max_weeks")]
1235 pub max_weeks_per_run: u32,
1236 #[serde(default = "rollup_default_max_months")]
1237 pub max_months_per_run: u32,
1238 #[serde(default = "rollup_default_max_spans_week")]
1239 pub max_extractive_spans_per_week: u32,
1240 #[serde(default = "rollup_default_max_words_week")]
1241 pub max_abstractive_words_per_week: u32,
1242 #[serde(default = "rollup_default_max_spans_month")]
1243 pub max_extractive_spans_per_month: u32,
1244 #[serde(default = "rollup_default_max_words_month")]
1245 pub max_abstractive_words_per_month: u32,
1246 #[serde(default = "rollup_default_week_mmr")]
1247 pub week_mmr_threshold: f64,
1248 #[serde(default = "rollup_default_month_mmr")]
1249 pub month_mmr_threshold: f64,
1250 #[serde(default)]
1253 pub extractive_backend: Option<BackendConfig>,
1254 #[serde(default)]
1257 pub abstractive_backend: Option<BackendConfig>,
1258}
1259
1260impl Default for RollupConfig {
1261 fn default() -> Self {
1262 Self {
1263 enabled: rollup_default_enabled(),
1264 max_weeks_per_run: rollup_default_max_weeks(),
1265 max_months_per_run: rollup_default_max_months(),
1266 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1267 max_abstractive_words_per_week: rollup_default_max_words_week(),
1268 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1269 max_abstractive_words_per_month: rollup_default_max_words_month(),
1270 week_mmr_threshold: rollup_default_week_mmr(),
1271 month_mmr_threshold: rollup_default_month_mmr(),
1272 extractive_backend: None,
1273 abstractive_backend: None,
1274 }
1275 }
1276}
1277
1278impl RollupConfig {
1279 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1283 self.extractive_backend
1284 .clone()
1285 .unwrap_or_else(|| BackendConfig {
1286 timeout_secs: Some(120),
1287 ..llm.to_backend_config()
1288 })
1289 }
1290
1291 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1293 self.abstractive_backend
1294 .clone()
1295 .unwrap_or_else(|| BackendConfig {
1296 timeout_secs: Some(120),
1297 ..llm.to_backend_config()
1298 })
1299 }
1300}
1301
1302fn rollup_default_enabled() -> bool {
1303 true
1304}
1305fn rollup_default_max_weeks() -> u32 {
1306 4
1307}
1308fn rollup_default_max_months() -> u32 {
1309 2
1310}
1311fn rollup_default_max_spans_week() -> u32 {
1312 20
1313}
1314fn rollup_default_max_words_week() -> u32 {
1315 500
1316}
1317fn rollup_default_max_spans_month() -> u32 {
1318 20
1319}
1320fn rollup_default_max_words_month() -> u32 {
1321 700
1322}
1323fn rollup_default_week_mmr() -> f64 {
1324 0.85
1325}
1326fn rollup_default_month_mmr() -> f64 {
1327 0.82
1328}
1329
1330#[derive(Debug, Clone, Serialize, Deserialize)]
1331pub struct ConversationsSources {
1332 #[serde(default = "conv_truthy")]
1333 pub claude_code: bool,
1334 #[serde(default = "conv_truthy")]
1335 pub cursor: bool,
1336 #[serde(default = "conv_truthy")]
1337 pub gemini: bool,
1338 #[serde(default)]
1339 pub aider: AiderSourceConfig,
1340}
1341
1342impl Default for ConversationsSources {
1343 fn default() -> Self {
1344 Self {
1345 claude_code: true,
1346 cursor: true,
1347 gemini: true,
1348 aider: AiderSourceConfig::default(),
1349 }
1350 }
1351}
1352
1353#[derive(Debug, Clone, Serialize, Deserialize)]
1354pub struct AiderSourceConfig {
1355 #[serde(default = "conv_truthy")]
1356 pub enabled: bool,
1357 #[serde(default)]
1358 pub watched_dirs: Vec<String>,
1359}
1360
1361impl Default for AiderSourceConfig {
1362 fn default() -> Self {
1363 Self {
1364 enabled: true,
1365 watched_dirs: Vec::new(),
1366 }
1367 }
1368}
1369
1370#[derive(Debug, Clone, Serialize, Deserialize)]
1371pub struct ConversationsFilter {
1372 #[serde(default = "conv_default_dedup")]
1373 pub dedup_threshold: f64,
1374 #[serde(default = "conv_truthy")]
1375 pub reject_heartbeat: bool,
1376 #[serde(default = "conv_truthy")]
1377 pub reject_system_restatement: bool,
1378}
1379
1380impl Default for ConversationsFilter {
1381 fn default() -> Self {
1382 Self {
1383 dedup_threshold: conv_default_dedup(),
1384 reject_heartbeat: true,
1385 reject_system_restatement: true,
1386 }
1387 }
1388}
1389
1390#[cfg(test)]
1391mod conversations_tests {
1392 use super::*;
1393
1394 #[test]
1395 fn conversations_section_defaults() {
1396 let c = ConversationsConfig::default();
1397 assert!(!c.enabled);
1398 assert_eq!(c.retention_days, 30);
1399 assert_eq!(c.poll_interval_secs, 300);
1400 assert!(c.sources.claude_code);
1401 assert!(c.sources.cursor);
1402 assert!(c.sources.gemini);
1403 assert!(c.sources.aider.enabled);
1404 assert!(c.sources.aider.watched_dirs.is_empty());
1405 assert_eq!(c.filter.dedup_threshold, 0.85);
1406 assert!(c.filter.reject_heartbeat);
1407 assert!(c.filter.reject_system_restatement);
1408 }
1409
1410 #[test]
1411 fn parse_from_yaml_with_overrides() {
1412 let y = r#"
1413conversations:
1414 enabled: true
1415 retention_days: 45
1416 poll_interval_secs: 120
1417 sources:
1418 cursor: false
1419 aider:
1420 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1421 filter:
1422 dedup_threshold: 0.9
1423"#;
1424 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1425 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1426 assert!(conv.enabled);
1427 assert_eq!(conv.retention_days, 45);
1428 assert_eq!(conv.poll_interval_secs, 120);
1429 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1433 assert_eq!(conv.filter.dedup_threshold, 0.9);
1434 assert!(conv.filter.reject_heartbeat); }
1436
1437 #[test]
1438 fn missing_conversations_section_is_fine() {
1439 let y = r#"
1440# No conversations section at all
1441foo: bar
1442"#;
1443 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1444 let conv: ConversationsConfig = v
1446 .get("conversations")
1447 .cloned()
1448 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1449 .unwrap_or_default();
1450 assert_eq!(conv.retention_days, 30);
1451 }
1452
1453 #[test]
1454 fn compact_config_defaults() {
1455 let c = CompactConfig::default();
1456 assert!(c.enabled_in_daemon);
1457 assert_eq!(c.max_days_per_run, 7);
1458 assert_eq!(c.max_extractive_spans, 20);
1459 assert_eq!(c.chunk_tokens, 6000);
1460 assert_eq!(c.history_retain, 5);
1461 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1462 }
1463
1464 #[test]
1465 fn compact_parses_partial_overrides() {
1466 let y = r#"
1467conversations:
1468 compact:
1469 max_days_per_run: 3
1470 extractive_backend:
1471 provider: anthropic
1472 model: claude-haiku-4-5
1473"#;
1474 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1475 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1476 assert_eq!(conv.compact.max_days_per_run, 3);
1477 assert_eq!(
1478 conv.compact.extractive_backend.as_ref().unwrap().model,
1479 "claude-haiku-4-5"
1480 );
1481 assert!(conv.compact.enabled_in_daemon); assert!(conv.compact.abstractive_backend.is_none()); }
1484
1485 #[test]
1486 fn ask_config_defaults() {
1487 let c = AskConfig::default();
1488 assert_eq!(c.k_raw, 10);
1489 assert_eq!(c.escalation_threshold, 0.5);
1490 assert_eq!(c.mmr_threshold, 0.88);
1491 assert_eq!(c.max_context_tokens, 6000);
1492 assert_eq!(c.response_tokens, 1024);
1493 assert_eq!(c.timeout_secs, 120);
1494 assert_eq!(c.min_score, 0.35);
1495 }
1496
1497 #[test]
1498 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1499 let c = AskConfig::default();
1501 assert!(
1502 (c.mmr_threshold - 0.88).abs() < 1e-9,
1503 "expected 0.88, got {}",
1504 c.mmr_threshold
1505 );
1506 }
1507
1508 #[test]
1509 fn rollup_config_defaults() {
1510 let c = RollupConfig::default();
1511 assert!(c.enabled);
1512 assert_eq!(c.max_weeks_per_run, 4);
1513 assert_eq!(c.max_months_per_run, 2);
1514 assert_eq!(c.max_extractive_spans_per_week, 20);
1515 assert_eq!(c.max_abstractive_words_per_week, 500);
1516 assert_eq!(c.max_extractive_spans_per_month, 20);
1517 assert_eq!(c.max_abstractive_words_per_month, 700);
1518 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1519 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1520 }
1521
1522 #[test]
1523 fn rollup_config_plumbed_into_conversations_config() {
1524 let c = ConversationsConfig::default();
1525 assert!(c.rollup.enabled);
1526 }
1527
1528 #[test]
1529 fn ask_config_default_continue_history_turns_is_3() {
1530 let c = AskConfig::default();
1531 assert_eq!(c.continue_history_turns, 3);
1532 }
1533
1534 #[test]
1535 fn ask_config_default_compress_hits_enabled_is_true() {
1536 let c = AskConfig::default();
1537 assert!(c.compress_hits_enabled);
1538 }
1539
1540 #[test]
1541 fn ask_config_default_summarize_hits_enabled_is_true() {
1542 let c = AskConfig::default();
1543 assert!(c.summarize_hits_enabled);
1544 }
1545
1546 #[test]
1547 fn ask_config_default_summarize_model_is_none() {
1548 let c = AskConfig::default();
1549 assert!(c.summarize_model.is_none());
1550 }
1551
1552 #[test]
1553 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1554 let y = r#"
1555conversations:
1556 ask:
1557 summarize_hits_enabled: false
1558 summarize_model: qwen3:4b
1559"#;
1560 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1561 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1562 assert!(!conv.ask.summarize_hits_enabled);
1563 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1564 }
1565
1566 #[test]
1567 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1568 let y = r#"
1572conversations:
1573 ask:
1574 min_score: 0.4
1575"#;
1576 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1577 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1578 assert!(conv.ask.summarize_hits_enabled);
1579 assert!(conv.ask.summarize_model.is_none());
1580 }
1581}
1582
1583#[cfg(test)]
1584mod tests {
1585 #[test]
1586 fn update_defaults_apply_when_block_absent_and_fields_parse() {
1587 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1588 assert_eq!(c.update, super::UpdateConfig::default());
1589 assert!(c.update.codesign_identity.is_none());
1590 assert!(c.update.restart_exclude.is_empty());
1591
1592 let c: super::Config = serde_yaml_ng::from_str(
1593 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
1594 )
1595 .unwrap();
1596 assert_eq!(
1597 c.update.codesign_identity.as_deref(),
1598 Some("Developer ID Application: X (TEAM)")
1599 );
1600 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
1601 }
1602
1603 #[test]
1604 fn federation_snapshot_defaults_apply_when_block_absent() {
1605 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1606 assert_eq!(c.federation_snapshot.poll_secs, 30);
1607 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1608 assert_eq!(
1609 c.federation_snapshot.min_lifecycle,
1610 crate::skill::stats::LifecycleState::Stable
1611 );
1612 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1614 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
1615 assert_eq!(c.memory.capture, super::CaptureMode::Off);
1616 }
1617
1618 use super::*;
1619
1620 #[test]
1621 fn default_bundled_model_id_is_qwen35_2b() {
1622 assert_eq!(
1623 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1624 "Qwen3.5-2B-MLX-4bit"
1625 );
1626 }
1627
1628 #[test]
1629 fn nudge_config_defaults() {
1630 let c = NudgeConfig::default();
1631 assert!(c.enabled);
1632 assert_eq!(c.daily_cap, 3);
1633 assert_eq!(c.snooze_days, 7);
1634 assert_eq!(c.threshold, 3);
1635 }
1636
1637 #[test]
1638 fn config_has_nudge_section_with_defaults() {
1639 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1640 assert_eq!(c.nudge.daily_cap, 3);
1641 }
1642
1643 #[test]
1644 fn storage_config_default_is_lancedb() {
1645 let c = StorageConfig::default();
1646 assert_eq!(c.vector_backend, "lancedb");
1647 assert_eq!(c.qdrant_url, None);
1648 assert_eq!(c.qdrant_api_key_ref, None);
1649 }
1650
1651 #[test]
1652 fn sources_global_config_has_sensible_defaults() {
1653 let c = SourcesGlobalConfig::default();
1654 assert_eq!(c.poll_interval_secs, 600);
1655 assert_eq!(c.max_chunks_per_sync, 10_000);
1656 assert_eq!(c.max_parallel_sources, 3);
1657 assert_eq!(c.default_weight, 1.0);
1658 assert_eq!(c.embedding_batch_size, 32);
1659 }
1660
1661 #[test]
1662 fn config_default_has_storage_and_sources_global() {
1663 let c = Config::default();
1664 assert_eq!(c.storage.vector_backend, "lancedb");
1665 assert_eq!(c.sources_global.default_weight, 1.0);
1666 }
1667
1668 #[test]
1669 fn config_loads_yaml_without_new_fields() {
1670 let yaml = r#"
1673embedding:
1674 provider: ollama
1675 model: test-model
1676 dimensions: 512
1677 ollama_endpoint: http://localhost:11434
1678"#;
1679 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1680 assert_eq!(c.storage.vector_backend, "lancedb");
1681 assert_eq!(c.sources_global.max_parallel_sources, 3);
1682 }
1683
1684 #[test]
1685 fn llm_config_to_backend_config_anthropic_passthrough() {
1686 let cfg = LlmConfig {
1687 provider: "anthropic".into(),
1688 model: "claude-haiku-4-5".into(),
1689 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1690 api_key_ref: None,
1691 openai_url: None,
1692 };
1693 let b = cfg.to_backend_config();
1694 assert_eq!(b.provider, "anthropic");
1695 assert_eq!(b.model, "claude-haiku-4-5");
1696 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1697 assert_eq!(b.endpoint, None);
1698 assert_eq!(b.timeout_secs, None);
1699 }
1700
1701 #[test]
1702 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1703 let cfg = LlmConfig {
1704 provider: "openai".into(),
1705 model: "gpt-4o-mini".into(),
1706 api_key_env: None,
1707 api_key_ref: None,
1708 openai_url: Some("https://api.together.xyz/v1".into()),
1709 };
1710 let b = cfg.to_backend_config();
1711 assert_eq!(b.provider, "openai");
1712 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1713 assert_eq!(b.api_key_env, None); }
1715
1716 #[test]
1717 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1718 let cfg = LlmConfig {
1719 provider: "ollama".into(),
1720 model: "qwen3:14b".into(),
1721 api_key_env: None,
1722 api_key_ref: None,
1723 openai_url: Some("http://192.168.1.10:11434".into()),
1724 };
1725 let b = cfg.to_backend_config();
1726 assert_eq!(b.provider, "ollama");
1727 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1728 }
1729
1730 #[test]
1731 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1732 let cfg = LlmConfig {
1736 provider: "custom-name".into(),
1737 model: "some-model".into(),
1738 api_key_env: Some("CUSTOM_KEY".into()),
1739 api_key_ref: None,
1740 openai_url: Some("https://my-proxy.local/v1".into()),
1741 };
1742 let b = cfg.to_backend_config();
1743 assert_eq!(
1744 b.provider, "openai",
1745 "unknown provider + openai_url should alias to openai"
1746 );
1747 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1748 }
1749
1750 #[test]
1751 fn api_key_ref_roundtrips_and_defaults_none() {
1752 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1754 assert_eq!(b.api_key_ref, None);
1755 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1756 assert_eq!(l.api_key_ref, None);
1757 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1758 assert_eq!(e.api_key_ref, None);
1759
1760 let l2 = LlmConfig {
1762 api_key_ref: Some("keychain:mur/anthropic".into()),
1763 ..Default::default()
1764 };
1765 let y = serde_yaml_ng::to_string(&l2).unwrap();
1766 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1767 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1768 assert_eq!(
1769 l3.to_backend_config().api_key_ref.as_deref(),
1770 Some("keychain:mur/anthropic")
1771 );
1772 }
1773
1774 #[test]
1775 fn open_items_muted_parses_and_defaults_empty() {
1776 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
1777 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
1778
1779 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
1780 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
1781 }
1782
1783 #[test]
1786 fn unreadable_config_yields_no_mutes() {
1787 let tmp = tempfile::tempdir().unwrap();
1788 let path = tmp.path().join("config.yaml");
1789 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
1790 let cfg = Config::load_or_default(&path);
1791 assert!(
1792 cfg.open_items.muted.is_empty(),
1793 "a broken config must hide nothing"
1794 );
1795
1796 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
1798 assert!(missing.open_items.muted.is_empty());
1799 }
1800
1801 #[test]
1802 fn rollup_config_accepts_backend_overrides() {
1803 let yaml = r#"
1804enabled: true
1805extractive_backend:
1806 provider: openai
1807 model: Qwen3.5-4B-MLX-4bit
1808 endpoint: http://127.0.0.1:8000/v1
1809"#;
1810 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1811 let b = c.extractive_backend.expect("override present");
1812 assert_eq!(b.provider, "openai");
1813 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
1814 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
1815 assert!(c.abstractive_backend.is_none());
1816 }
1817
1818 #[test]
1819 fn legacy_conversation_fields_are_gone_from_serialized_output() {
1820 let cfg = Config::default();
1821 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
1827 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
1828 assert!(
1829 !yaml.contains(key),
1830 "legacy key {key} still serialized:\n{yaml}"
1831 );
1832 }
1833 }
1834
1835 #[test]
1836 fn embedding_ollama_endpoint_is_omitted_when_unset() {
1837 let mut cfg = Config::default();
1838 cfg.embedding.provider = "omlx".into();
1839 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
1840 cfg.embedding.ollama_endpoint = None;
1841 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
1842 assert!(
1843 !yaml.contains("ollama_endpoint"),
1844 "dead field re-emitted:\n{yaml}"
1845 );
1846 }
1847
1848 #[test]
1849 fn embedding_ollama_endpoint_still_round_trips_when_set() {
1850 let yaml =
1851 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
1852 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1853 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
1854 }
1855}
1856
1857#[cfg(test)]
1858mod backend_config_tests {
1859 use super::*;
1860
1861 #[test]
1862 fn default_is_ollama_qwen3() {
1863 let cfg = BackendConfig::default();
1864 assert_eq!(cfg.provider, "ollama");
1865 assert_eq!(cfg.model, "qwen3.5:4b");
1866 assert_eq!(cfg.endpoint, None);
1867 assert_eq!(cfg.api_key_env, None);
1868 assert_eq!(cfg.timeout_secs, None);
1869 }
1870
1871 #[test]
1872 fn deserializes_anthropic_full() {
1873 let yaml = "\
1874provider: anthropic
1875model: claude-haiku-4-5
1876api_key_env: ANTHROPIC_API_KEY
1877timeout_secs: 60
1878";
1879 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1880 assert_eq!(cfg.provider, "anthropic");
1881 assert_eq!(cfg.model, "claude-haiku-4-5");
1882 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1883 assert_eq!(cfg.timeout_secs, Some(60));
1884 assert_eq!(cfg.endpoint, None);
1885 }
1886
1887 #[test]
1888 fn deserializes_partial_fills_defaults() {
1889 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
1890 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1891 assert_eq!(cfg.provider, "anthropic");
1892 assert_eq!(cfg.model, "claude-sonnet-5");
1893 assert_eq!(cfg.api_key_env, None);
1894 assert_eq!(cfg.timeout_secs, None);
1895 }
1896
1897 #[test]
1898 fn round_trips_through_yaml() {
1899 let original = BackendConfig {
1900 provider: "anthropic".into(),
1901 model: "claude-haiku-4-5".into(),
1902 endpoint: Some("https://api.anthropic.com".into()),
1903 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1904 api_key_ref: None,
1905 timeout_secs: Some(60),
1906 };
1907 let yaml = serde_yaml::to_string(&original).unwrap();
1908 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1909 assert_eq!(parsed, original);
1910 }
1911
1912 #[test]
1913 fn skills_config_curation_gate_defaults_on() {
1914 let c = SkillsConfig::default();
1915 assert!(c.require_human_curation_before_stable);
1916 }
1917}
1918
1919#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1927#[serde(rename_all = "lowercase")]
1928pub enum DevDisciplineIndex {
1929 #[default]
1931 Auto,
1932 Always,
1934 Never,
1936}
1937
1938#[derive(Debug, Clone, Serialize, Deserialize)]
1939#[serde(default)]
1940pub struct SkillsConfig {
1941 pub max_skills_in_prompt: usize,
1942 pub max_total_tokens: usize,
1943 pub priority_order: Vec<String>,
1944 pub adaptive: Option<AdaptiveSkillsConfig>,
1945
1946 #[serde(default = "default_require_human_curation")]
1950 pub require_human_curation_before_stable: bool,
1951
1952 #[serde(default)]
1956 pub lifecycle: SkillLifecycleConfig,
1957
1958 #[serde(default = "default_auto_upgrade")]
1963 pub auto_upgrade: bool,
1964
1965 #[serde(default)]
1967 pub dev_discipline_index: DevDisciplineIndex,
1968}
1969
1970fn default_require_human_curation() -> bool {
1971 true
1972}
1973
1974fn default_auto_upgrade() -> bool {
1975 true
1976}
1977
1978impl Default for SkillsConfig {
1979 fn default() -> Self {
1980 Self {
1981 max_skills_in_prompt: 5,
1982 max_total_tokens: 2000,
1983 priority_order: vec!["agent".into(), "global".into()],
1984 adaptive: Some(AdaptiveSkillsConfig::default()),
1985 require_human_curation_before_stable: default_require_human_curation(),
1986 lifecycle: SkillLifecycleConfig::default(),
1987 auto_upgrade: default_auto_upgrade(),
1988 dev_discipline_index: DevDisciplineIndex::default(),
1989 }
1990 }
1991}
1992
1993#[derive(Debug, Clone, Serialize, Deserialize)]
1999#[serde(default)]
2000pub struct SkillLifecycleConfig {
2001 pub note_rule_half_life_factor: f64,
2005 pub note_fact_half_life_factor: f64,
2008 pub promote_draft_uses: u64,
2010 pub promote_emerging_uses: u64,
2011 pub promote_emerging_success_rate: f64,
2012 pub promote_emerging_age_days: i64,
2013 pub promote_stable_uses: u64,
2014 pub promote_stable_success_rate: f64,
2015 pub promote_stable_age_days: i64,
2016
2017 pub demote_emerging_uses: u64,
2019 pub demote_emerging_success_rate: f64,
2020 pub demote_stable_uses: u64,
2021 pub demote_stable_success_rate: f64,
2022 pub deprecated_success_rate: f64,
2023 pub deprecated_no_success_days: i64,
2024
2025 pub auto_archive_confidence: f64,
2027 pub auto_archive_age_days: i64,
2028
2029 pub broken_workflow_streak: u32,
2034
2035 pub archive_destroy_grace_days: i64,
2040}
2041
2042impl Default for SkillLifecycleConfig {
2043 fn default() -> Self {
2044 Self {
2045 promote_draft_uses: 3,
2046 promote_emerging_uses: 10,
2047 promote_emerging_success_rate: 0.6,
2048 promote_emerging_age_days: 7,
2049 promote_stable_uses: 30,
2050 promote_stable_success_rate: 0.8,
2051 promote_stable_age_days: 30,
2052 demote_emerging_uses: 8,
2053 demote_emerging_success_rate: 0.55,
2054 demote_stable_uses: 25,
2055 demote_stable_success_rate: 0.75,
2056 deprecated_success_rate: 0.3,
2057 deprecated_no_success_days: 90,
2058 auto_archive_confidence: 0.10,
2059 auto_archive_age_days: 180,
2060 broken_workflow_streak: 3,
2061 archive_destroy_grace_days: 30,
2062 note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
2063 note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
2064 }
2065 }
2066}
2067
2068#[derive(Debug, Clone, Serialize, Deserialize)]
2069#[serde(default)]
2070pub struct AdaptiveSkillsConfig {
2071 pub context_fill_decay: f64,
2072 pub min_remaining_context_ratio: f64,
2073 pub recent_fire_boost_turns: usize,
2074 pub model_max_context_tokens: u64,
2078}
2079
2080impl Default for AdaptiveSkillsConfig {
2081 fn default() -> Self {
2082 Self {
2083 context_fill_decay: 1.5,
2084 min_remaining_context_ratio: 0.20,
2085 recent_fire_boost_turns: 5,
2086 model_max_context_tokens: 200_000,
2087 }
2088 }
2089}
2090
2091#[derive(Debug, Clone, Serialize, Deserialize)]
2094pub struct SleepCycleConfig {
2095 #[serde(default)]
2097 pub enabled: bool,
2098
2099 #[serde(default = "default_idle_threshold_minutes")]
2101 pub idle_threshold_minutes: u64,
2102
2103 #[serde(default = "default_agent_idle_minutes")]
2105 pub agent_idle_minutes: u64,
2106}
2107
2108fn default_idle_threshold_minutes() -> u64 {
2109 15
2110}
2111
2112fn default_agent_idle_minutes() -> u64 {
2113 5
2114}
2115
2116impl Default for SleepCycleConfig {
2117 fn default() -> Self {
2118 Self {
2119 enabled: false,
2120 idle_threshold_minutes: default_idle_threshold_minutes(),
2121 agent_idle_minutes: default_agent_idle_minutes(),
2122 }
2123 }
2124}
2125
2126#[derive(Debug, Clone, Serialize, Deserialize)]
2129pub struct NudgeConfig {
2130 #[serde(default = "default_nudge_enabled")]
2132 pub enabled: bool,
2133 #[serde(default = "default_nudge_daily_cap")]
2134 pub daily_cap: u32,
2135 #[serde(default = "default_nudge_snooze_days")]
2136 pub snooze_days: u32,
2137 #[serde(default = "default_nudge_threshold")]
2138 pub threshold: usize,
2139}
2140
2141fn default_nudge_enabled() -> bool {
2142 true
2143}
2144fn default_nudge_daily_cap() -> u32 {
2145 3
2146}
2147fn default_nudge_snooze_days() -> u32 {
2148 7
2149}
2150fn default_nudge_threshold() -> usize {
2151 3
2152}
2153
2154impl Default for NudgeConfig {
2155 fn default() -> Self {
2156 Self {
2157 enabled: true,
2158 daily_cap: default_nudge_daily_cap(),
2159 snooze_days: default_nudge_snooze_days(),
2160 threshold: default_nudge_threshold(),
2161 }
2162 }
2163}
2164
2165#[derive(Debug, Clone, Serialize, Deserialize)]
2169pub struct SessionCfg {
2170 #[serde(default = "default_capture_mode")]
2172 pub capture: String,
2173 #[serde(default = "default_retention_days")]
2175 pub retention_days: u32,
2176}
2177
2178impl Default for SessionCfg {
2179 fn default() -> Self {
2180 Self {
2181 capture: default_capture_mode(),
2182 retention_days: default_retention_days(),
2183 }
2184 }
2185}
2186
2187fn default_capture_mode() -> String {
2188 "ambient".to_string()
2189}
2190fn default_retention_days() -> u32 {
2191 14
2192}
2193
2194#[derive(Debug, Clone, Serialize, Deserialize)]
2196pub struct HarvestCfg {
2197 #[serde(default = "default_harvest_enabled")]
2199 pub auto_gate: bool,
2200 #[serde(default = "default_harvest_llm")]
2202 pub llm: String,
2203 #[serde(default = "default_min_events")]
2205 pub min_events: usize,
2206 #[serde(default = "default_min_user_turns")]
2207 pub min_user_turns: usize,
2208 #[serde(default = "default_min_duration_secs")]
2209 pub min_duration_secs: i64,
2210 #[serde(default = "default_idle_minutes")]
2212 pub idle_minutes: i64,
2213 #[serde(default = "default_max_steps")]
2216 pub max_steps: usize,
2217 #[serde(default = "default_max_duration_secs")]
2218 pub max_duration_secs: i64,
2219 #[serde(default = "default_max_llm_calls_per_day")]
2221 pub max_llm_calls_per_day: u32,
2222 #[serde(default = "default_max_extract_input_tokens")]
2223 pub max_extract_input_tokens: usize,
2224 #[serde(default = "default_harvest_enabled")]
2226 pub session_start_hint: bool,
2227 #[serde(default = "default_similarity_merge_threshold")]
2230 pub similarity_merge_threshold: f32,
2231 #[serde(default = "default_min_occurrences")]
2235 pub min_occurrences: usize,
2236}
2237
2238impl Default for HarvestCfg {
2239 fn default() -> Self {
2240 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2241 }
2242}
2243
2244fn default_harvest_enabled() -> bool {
2245 true
2246}
2247fn default_harvest_llm() -> String {
2248 "local-first".to_string()
2249}
2250fn default_min_events() -> usize {
2251 5
2252}
2253fn default_min_user_turns() -> usize {
2254 2
2255}
2256fn default_min_duration_secs() -> i64 {
2257 120
2258}
2259fn default_idle_minutes() -> i64 {
2260 30
2261}
2262fn default_max_steps() -> usize {
2266 20
2267}
2268fn default_max_duration_secs() -> i64 {
2271 1800
2272}
2273fn default_max_llm_calls_per_day() -> u32 {
2274 10
2275}
2276fn default_max_extract_input_tokens() -> usize {
2277 12000
2278}
2279fn default_similarity_merge_threshold() -> f32 {
2280 0.6
2281}
2282fn default_min_occurrences() -> usize {
2285 2
2286}
2287
2288#[derive(Debug, Clone, Serialize, Deserialize)]
2291#[serde(default)]
2292pub struct CrossAgentConfig {
2293 #[serde(default = "default_half_life_days")]
2294 pub fitness_half_life_days: u32,
2295 #[serde(default = "default_fitness_floor")]
2296 pub fitness_floor: f64,
2297}
2298
2299fn default_half_life_days() -> u32 {
2300 7
2301}
2302fn default_fitness_floor() -> f64 {
2303 0.1
2304}
2305
2306impl Default for CrossAgentConfig {
2307 fn default() -> Self {
2308 Self {
2309 fitness_half_life_days: default_half_life_days(),
2310 fitness_floor: default_fitness_floor(),
2311 }
2312 }
2313}
2314
2315#[derive(Debug, Clone, Serialize, Deserialize)]
2318#[serde(default)]
2319pub struct SkillLlmConfig {
2320 #[serde(default = "default_per_call_token_cap")]
2322 pub per_call_token_cap: u32,
2323
2324 #[serde(default = "default_per_day_usd_cap")]
2326 pub per_day_usd_cap: f64,
2327
2328 #[serde(default = "default_cache_ttl_days")]
2330 pub cache_ttl_days: u32,
2331
2332 #[serde(default, skip_serializing_if = "Option::is_none")]
2334 pub model_ref: Option<String>,
2335}
2336
2337fn default_per_call_token_cap() -> u32 {
2338 1500
2339}
2340fn default_per_day_usd_cap() -> f64 {
2341 0.50
2342}
2343fn default_cache_ttl_days() -> u32 {
2344 30
2345}
2346
2347impl Default for SkillLlmConfig {
2348 fn default() -> Self {
2349 Self {
2350 per_call_token_cap: default_per_call_token_cap(),
2351 per_day_usd_cap: default_per_day_usd_cap(),
2352 cache_ttl_days: default_cache_ttl_days(),
2353 model_ref: None,
2354 }
2355 }
2356}
2357#[cfg(test)]
2358mod per_stage_backend_tests {
2359 use super::*;
2360
2361 #[test]
2362 fn compact_extractive_backend_override_parses() {
2363 let yaml = "\
2364extractive_backend:
2365 provider: anthropic
2366 model: claude-haiku-4-5
2367 api_key_env: ANTHROPIC_API_KEY
2368";
2369 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2370 let extractive = cfg
2371 .extractive_backend
2372 .as_ref()
2373 .expect("override should parse");
2374 assert_eq!(extractive.provider, "anthropic");
2375 assert_eq!(extractive.model, "claude-haiku-4-5");
2376 assert!(cfg.abstractive_backend.is_none());
2377 }
2378
2379 #[test]
2380 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2381 let yaml = "\
2382backend:
2383 provider: anthropic
2384 model: claude-sonnet-5
2385 api_key_env: ANTHROPIC_API_KEY
2386rewriter_backend:
2387 provider: ollama
2388 model: llama3.2:3b
2389";
2390 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2391 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2392 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2393 }
2394
2395 #[test]
2396 fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2397 let cfg = AskConfig {
2408 backend: Some(BackendConfig {
2409 provider: "anthropic".into(),
2410 model: "claude-sonnet-5".into(),
2411 endpoint: None,
2412 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2413 api_key_ref: None,
2414 timeout_secs: None,
2415 }),
2416 ..Default::default()
2417 };
2418 let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2419 assert_eq!(rewriter.provider, "anthropic");
2420 assert_eq!(rewriter.model, "claude-sonnet-5");
2421 assert_eq!(
2422 rewriter.timeout_secs,
2423 Some(cfg.rewriter_timeout_secs as u64),
2424 "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2425 );
2426 }
2427
2428 #[test]
2429 fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2430 let mut cfg = AskConfig {
2431 rewriter_timeout_secs: 8,
2432 ..AskConfig::default()
2433 };
2434 cfg.rewriter_backend = Some(BackendConfig {
2435 provider: "anthropic".into(),
2436 model: "claude-haiku-4-5".into(),
2437 endpoint: None,
2438 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2439 api_key_ref: None,
2440 timeout_secs: Some(30),
2441 });
2442 let b = cfg.effective_rewriter_backend(&omlx_llm());
2443 assert_eq!(
2444 b.timeout_secs,
2445 Some(30),
2446 "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2447 );
2448 }
2449
2450 fn omlx_llm() -> LlmConfig {
2451 LlmConfig {
2452 provider: "omlx".into(),
2453 model: "Qwen3.5-4B-MLX-4bit".into(),
2454 api_key_env: None,
2455 api_key_ref: Some("env:OMLX_API_KEY".into()),
2456 openai_url: Some("http://127.0.0.1:8000/v1".into()),
2457 }
2458 }
2459
2460 #[test]
2461 fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2462 let ask = AskConfig::default();
2463 let b = ask.effective_backend(&omlx_llm());
2464 assert_eq!(b.provider, "openai");
2465 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2466 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2467 assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2468 assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2470 }
2471
2472 #[test]
2473 fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2474 let ask = AskConfig::default();
2475 let b = ask.effective_rewriter_backend(&omlx_llm());
2476 assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2477 assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2478 }
2479
2480 #[test]
2481 fn explicit_override_wins_over_the_smart_slot() {
2482 let ask = AskConfig {
2483 backend: Some(BackendConfig {
2484 provider: "anthropic".into(),
2485 model: "claude-haiku-4-5".into(),
2486 endpoint: None,
2487 api_key_env: None,
2488 api_key_ref: None,
2489 timeout_secs: Some(42),
2490 }),
2491 ..Default::default()
2492 };
2493 let b = ask.effective_backend(&omlx_llm());
2494 assert_eq!(b.provider, "anthropic");
2495 assert_eq!(b.timeout_secs, Some(42));
2496 }
2497
2498 #[test]
2499 fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2500 let llm = omlx_llm();
2501 for b in [
2502 CompactConfig::default().effective_extractive_backend(&llm),
2503 CompactConfig::default().effective_abstractive_backend(&llm),
2504 RollupConfig::default().effective_extractive_backend(&llm),
2505 RollupConfig::default().effective_abstractive_backend(&llm),
2506 ] {
2507 assert_eq!(b.provider, "openai");
2508 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2509 assert_eq!(b.timeout_secs, Some(120));
2510 }
2511 }
2512
2513 #[test]
2514 fn rollup_override_is_honored() {
2515 let r = RollupConfig {
2516 abstractive_backend: Some(BackendConfig {
2517 provider: "ollama".into(),
2518 model: "qwen3:4b".into(),
2519 endpoint: Some("http://box.local:11434".into()),
2520 api_key_env: None,
2521 api_key_ref: None,
2522 timeout_secs: None,
2523 }),
2524 ..Default::default()
2525 };
2526 let b = r.effective_abstractive_backend(&omlx_llm());
2527 assert_eq!(b.provider, "ollama");
2528 assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2529 }
2530}
2531
2532#[cfg(test)]
2533mod skills_config_tests {
2534 use super::*;
2535
2536 #[test]
2537 fn empty_yaml_hydrates_defaults() {
2538 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2539 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2540 assert_eq!(cfg.skills.max_total_tokens, 2000);
2541 assert!(cfg.skills.adaptive.is_some());
2542 }
2543
2544 #[test]
2545 fn load_or_default_missing_file_returns_default() {
2546 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2547 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2548 }
2549
2550 #[test]
2551 fn dev_discipline_index_defaults_auto_and_parses() {
2552 use crate::config::DevDisciplineIndex;
2553 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2554 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2555 let cfg: Config =
2556 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2557 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2558 let cfg: Config =
2559 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2560 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2561 }
2562}
2563
2564#[cfg(test)]
2565mod runs_config_tests {
2566 use super::*;
2567
2568 #[test]
2574 fn zero_heartbeat_values_clamp_to_defaults_at_load() {
2575 let tmp = tempfile::tempdir().unwrap();
2576 let path = tmp.path().join("config.yaml");
2577 std::fs::write(
2578 &path,
2579 "runs:\n heartbeat_interval_secs: 0\n heartbeat_stale_after_intervals: 0\n",
2580 )
2581 .unwrap();
2582
2583 let cfg = Config::load_or_default(&path);
2584 assert_eq!(
2585 cfg.runs.heartbeat_interval_secs, 10,
2586 "a zero interval must load as the default, not 0"
2587 );
2588 assert_eq!(
2589 cfg.runs.heartbeat_stale_after_intervals, 3,
2590 "a zero interval count must load as the default, not 0"
2591 );
2592 }
2593
2594 #[test]
2596 fn positive_heartbeat_values_survive_the_load() {
2597 let tmp = tempfile::tempdir().unwrap();
2598 let path = tmp.path().join("config.yaml");
2599 std::fs::write(
2600 &path,
2601 "runs:\n heartbeat_interval_secs: 60\n heartbeat_stale_after_intervals: 2\n",
2602 )
2603 .unwrap();
2604
2605 let cfg = Config::load_or_default(&path);
2606 assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
2607 assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
2608 }
2609}
2610
2611#[cfg(test)]
2612mod ambient_capture_cfg_tests {
2613 use super::*;
2614
2615 #[test]
2616 fn session_and_harvest_defaults() {
2617 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2618 assert_eq!(cfg.session.capture, "ambient");
2619 assert_eq!(cfg.session.retention_days, 14);
2620 assert!(cfg.harvest.auto_gate);
2621 assert_eq!(cfg.harvest.llm, "local-first");
2622 assert_eq!(cfg.harvest.min_events, 5);
2623 assert_eq!(cfg.harvest.min_user_turns, 2);
2624 assert_eq!(cfg.harvest.min_duration_secs, 120);
2625 assert_eq!(cfg.harvest.idle_minutes, 30);
2626 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2627 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2628 assert!(cfg.harvest.session_start_hint);
2629 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2630 }
2631
2632 #[test]
2633 fn session_capture_override_parses() {
2634 let cfg: Config =
2635 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2636 assert_eq!(cfg.session.capture, "off");
2637 assert_eq!(cfg.session.retention_days, 3);
2638 }
2639}
2640
2641#[cfg(test)]
2642mod cc_proxy_cfg_tests {
2643 use super::*;
2644
2645 #[test]
2646 fn defaults_to_local_cc_proxy_enabled() {
2647 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2648 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2649 assert!(cfg.cc_proxy.enabled);
2650 }
2651
2652 #[test]
2653 fn url_and_enabled_override_parse() {
2654 let cfg: Config =
2655 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2656 .unwrap();
2657 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2658 assert!(!cfg.cc_proxy.enabled);
2659 }
2660
2661 #[test]
2662 fn partial_section_keeps_other_default() {
2663 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2665 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2666 assert!(!cfg.cc_proxy.enabled);
2667 }
2668}
2669
2670#[cfg(test)]
2671mod model_switch_config_tests {
2672 use super::*;
2673
2674 #[test]
2675 fn model_switch_config_defaults_and_omitted_block() {
2676 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2678 assert_eq!(cfg.models.default, None);
2679 assert!(cfg.models.fallback_chain.is_empty());
2680 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2681 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2682 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2683 assert!(!cfg.models.routing.enabled);
2684
2685 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";
2687 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2688 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2689 assert_eq!(
2690 cfg.models.fallback_chain,
2691 vec!["claude_sonnet", "deepseek_v4_pro"]
2692 );
2693 assert!(cfg.models.routing.enabled);
2694 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2695 }
2696
2697 #[test]
2698 fn smart_config_defaults_on_with_autopick() {
2699 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2700 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2703 cfg.models.smart.max_escalations,
2704 DEFAULT_SMART_MAX_ESCALATIONS
2705 );
2706 }
2707}