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}
356
357impl Default for MemoryConfig {
358 fn default() -> Self {
359 Self {
360 capture: CaptureMode::AutoAnnounce,
361 }
362 }
363}
364
365#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
367#[serde(rename_all = "snake_case")]
368pub enum CaptureMode {
369 Ask,
371 AutoAnnounce,
373 Off,
375}
376
377#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
385pub struct FleetRunConfig {
386 #[serde(default)]
388 pub agents: Vec<String>,
389 #[serde(default)]
391 pub fleets: Vec<String>,
392}
393
394#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
401pub struct OpenItemsConfig {
402 #[serde(default)]
405 pub muted: Vec<String>,
406}
407
408#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
415pub struct FleetConfig {
416 #[serde(default)]
418 pub autorun: bool,
419}
420
421#[cfg(test)]
422mod fleet_config_tests {
423 use super::*;
424
425 #[test]
426 fn fleet_config_defaults_off_and_roundtrips() {
427 assert!(!FleetConfig::default().autorun);
428
429 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
430 assert!(cfg.fleet.autorun);
431
432 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
434 assert!(!cfg2.fleet.autorun);
435 }
436
437 #[test]
438 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
439 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
441 assert!(cfg.fleet_run.agents.is_empty());
442 assert!(cfg.fleet_run.fleets.is_empty());
443
444 let cfg2: Config =
445 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
446 .unwrap();
447 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
448 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
449 }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
463pub struct CcProxyConfig {
464 #[serde(default = "default_cc_proxy_url")]
466 pub url: String,
467
468 #[serde(default = "default_true")]
471 pub enabled: bool,
472}
473
474fn default_cc_proxy_url() -> String {
475 "http://127.0.0.1:8088".to_string()
476}
477
478fn default_true() -> bool {
479 true
480}
481
482impl Default for CcProxyConfig {
483 fn default() -> Self {
484 Self {
485 url: default_cc_proxy_url(),
486 enabled: true,
487 }
488 }
489}
490
491#[derive(Debug, Clone, Serialize, Deserialize, Default)]
494pub struct CliConfig {
495 pub skin: Option<String>,
498}
499
500#[derive(Debug, Clone, Serialize, Deserialize, Default)]
503pub struct MobileRelayConfig {
504 #[serde(default, skip_serializing_if = "Option::is_none")]
507 pub relay_url: Option<String>,
508
509 #[serde(default, skip_serializing_if = "Option::is_none")]
512 pub api_key: Option<String>,
513}
514
515impl Config {
516 pub fn load_or_default(path: &std::path::Path) -> Self {
521 let Ok(text) = std::fs::read_to_string(path) else {
522 return Self::default();
523 };
524 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
525 let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
526 cfg.sanitize();
527 cfg
528 }
529
530 fn sanitize(&mut self) {
535 if self.runs.heartbeat_interval_secs == 0 {
539 self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
540 }
541 if self.capture.rotate_at_mb == 0 {
543 self.capture.rotate_at_mb = default_rotate_at_mb();
544 }
545 if self.runs.heartbeat_stale_after_intervals == 0 {
546 self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
547 }
548 }
549}
550
551#[derive(Debug, Clone, Serialize, Deserialize, Default)]
552pub struct SyncConfig {
553 #[serde(default = "default_sync_method")]
555 pub method: String,
556
557 #[serde(default, skip_serializing_if = "Option::is_none")]
559 pub git_remote: Option<String>,
560
561 #[serde(default)]
563 pub auto: bool,
564
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub team_id: Option<String>,
568}
569
570fn default_sync_method() -> String {
571 "local".to_string()
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
575pub struct ServerConfig {
576 #[serde(default = "default_server_url")]
578 pub url: String,
579}
580
581impl Default for ServerConfig {
582 fn default() -> Self {
583 Self {
584 url: default_server_url(),
585 }
586 }
587}
588
589#[derive(Debug, Clone, Serialize, Deserialize, Default)]
590pub struct CommunityConfig {
591 #[serde(default)]
593 pub enabled: bool,
594}
595
596#[derive(Debug, Clone, Serialize, Deserialize)]
597pub struct EmbeddingConfig {
598 #[serde(default = "default_embedding_provider")]
600 pub provider: String,
601
602 #[serde(default = "default_embedding_model")]
604 pub model: String,
605
606 #[serde(default = "default_dimensions")]
608 pub dimensions: usize,
609
610 #[serde(default, skip_serializing_if = "Option::is_none")]
614 pub ollama_endpoint: Option<String>,
615
616 #[serde(default, skip_serializing_if = "Option::is_none")]
618 pub api_key_env: Option<String>,
619
620 #[serde(default, skip_serializing_if = "Option::is_none")]
623 pub api_key_ref: Option<String>,
624
625 #[serde(default, skip_serializing_if = "Option::is_none")]
627 pub openai_url: Option<String>,
628}
629
630impl Default for EmbeddingConfig {
631 fn default() -> Self {
632 Self {
633 provider: default_embedding_provider(),
634 model: default_embedding_model(),
635 dimensions: default_dimensions(),
636 ollama_endpoint: Some(default_ollama_endpoint()),
637 api_key_env: None,
638 api_key_ref: None,
639 openai_url: None,
640 }
641 }
642}
643
644#[derive(Debug, Clone, Serialize, Deserialize)]
645pub struct LlmConfig {
646 #[serde(default = "default_llm_provider")]
648 pub provider: String,
649
650 #[serde(default = "default_llm_model")]
651 pub model: String,
652
653 #[serde(default, skip_serializing_if = "Option::is_none")]
655 pub api_key_env: Option<String>,
656
657 #[serde(default, skip_serializing_if = "Option::is_none")]
660 pub api_key_ref: Option<String>,
661
662 #[serde(default, skip_serializing_if = "Option::is_none")]
664 pub openai_url: Option<String>,
665}
666
667impl Default for LlmConfig {
668 fn default() -> Self {
669 Self {
670 provider: default_llm_provider(),
671 model: default_llm_model(),
672 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
673 api_key_ref: None,
674 openai_url: None,
675 }
676 }
677}
678
679impl LlmConfig {
680 pub fn to_backend_config(&self) -> BackendConfig {
693 let provider = match self.provider.as_str() {
694 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
695 _ if self.openai_url.is_some() => "openai".into(),
696 other => other.into(), };
698 BackendConfig {
699 provider,
700 model: self.model.clone(),
701 endpoint: self.openai_url.clone(),
702 api_key_env: self.api_key_env.clone(),
703 api_key_ref: self.api_key_ref.clone(),
704 timeout_secs: None,
705 }
706 }
707}
708
709#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
720#[serde(default)]
721pub struct BackendConfig {
722 pub provider: String,
724 pub model: String,
726 pub endpoint: Option<String>,
729 pub api_key_env: Option<String>,
731 pub api_key_ref: Option<String>,
733 pub timeout_secs: Option<u64>,
735}
736
737impl Default for BackendConfig {
738 fn default() -> Self {
739 Self {
740 provider: "ollama".into(),
741 model: DEFAULT_LOCAL_LLM_MODEL.into(),
742 endpoint: None,
743 api_key_env: None,
744 api_key_ref: None,
745 timeout_secs: None,
746 }
747 }
748}
749
750#[derive(Debug, Clone, Serialize, Deserialize)]
751pub struct RetrievalConfig {
752 #[serde(default = "default_max_patterns")]
754 pub max_patterns: usize,
755
756 #[serde(default = "default_max_tokens")]
758 pub max_tokens: usize,
759
760 #[serde(default = "default_min_score")]
762 pub min_score: f64,
763
764 #[serde(default = "default_mmr_threshold")]
766 pub mmr_threshold: f64,
767
768 #[serde(default = "default_reserved_note_slots")]
771 pub reserved_note_slots: usize,
772}
773
774impl Default for RetrievalConfig {
775 fn default() -> Self {
776 Self {
777 max_patterns: default_max_patterns(),
778 max_tokens: default_max_tokens(),
779 min_score: default_min_score(),
780 mmr_threshold: default_mmr_threshold(),
781 reserved_note_slots: default_reserved_note_slots(),
782 }
783 }
784}
785
786fn default_reserved_note_slots() -> usize {
787 1
788}
789
790#[derive(Debug, Clone, Serialize, Deserialize)]
791pub struct PathConfig {
792 #[serde(default = "default_mur_dir")]
794 pub mur_dir: PathBuf,
795}
796
797impl Default for PathConfig {
798 fn default() -> Self {
799 Self {
800 mur_dir: default_mur_dir(),
801 }
802 }
803}
804
805#[derive(Debug, Clone, Serialize, Deserialize)]
806pub struct StorageConfig {
807 #[serde(default = "default_vector_backend")]
809 pub vector_backend: String,
810
811 #[serde(default, skip_serializing_if = "Option::is_none")]
813 pub qdrant_url: Option<String>,
814
815 #[serde(default, skip_serializing_if = "Option::is_none")]
817 pub qdrant_api_key_ref: Option<String>,
818}
819
820impl Default for StorageConfig {
821 fn default() -> Self {
822 Self {
823 vector_backend: default_vector_backend(),
824 qdrant_url: None,
825 qdrant_api_key_ref: None,
826 }
827 }
828}
829
830fn default_vector_backend() -> String {
831 "lancedb".to_string()
832}
833
834#[derive(Debug, Clone, Serialize, Deserialize)]
835pub struct SourcesGlobalConfig {
836 #[serde(default = "default_poll_interval_secs")]
838 pub poll_interval_secs: u64,
839
840 #[serde(default = "default_max_chunks_per_sync")]
842 pub max_chunks_per_sync: usize,
843
844 #[serde(default = "default_max_parallel_sources")]
846 pub max_parallel_sources: usize,
847
848 #[serde(default = "default_source_weight")]
850 pub default_weight: f32,
851
852 #[serde(default = "default_embedding_batch_size")]
854 pub embedding_batch_size: usize,
855}
856
857impl Default for SourcesGlobalConfig {
858 fn default() -> Self {
859 Self {
860 poll_interval_secs: default_poll_interval_secs(),
861 max_chunks_per_sync: default_max_chunks_per_sync(),
862 max_parallel_sources: default_max_parallel_sources(),
863 default_weight: default_source_weight(),
864 embedding_batch_size: default_embedding_batch_size(),
865 }
866 }
867}
868
869fn default_poll_interval_secs() -> u64 {
870 600
871}
872fn default_max_chunks_per_sync() -> usize {
873 10_000
874}
875fn default_max_parallel_sources() -> usize {
876 3
877}
878fn default_source_weight() -> f32 {
879 1.0
880}
881fn default_embedding_batch_size() -> usize {
882 32
883}
884
885fn default_embedding_provider() -> String {
886 "ollama".to_string()
887}
888fn default_embedding_model() -> String {
889 "qwen3-embedding:0.6b".to_string()
890}
891fn default_dimensions() -> usize {
892 1024
893}
894fn default_ollama_endpoint() -> String {
895 DEFAULT_OLLAMA_ENDPOINT.to_string()
896}
897fn default_llm_provider() -> String {
898 "anthropic".to_string()
899}
900fn default_llm_model() -> String {
901 "claude-opus-5".to_string()
902}
903fn default_max_patterns() -> usize {
904 5
905}
906fn default_max_tokens() -> usize {
907 2000
908}
909fn default_min_score() -> f64 {
910 0.35
911}
912fn default_mmr_threshold() -> f64 {
913 0.85
914}
915fn default_mur_dir() -> PathBuf {
916 let home = std::env::var("HOME")
919 .map(PathBuf::from)
920 .unwrap_or_else(|_| PathBuf::from("/tmp"));
921 home.join(".mur")
922}
923fn default_server_url() -> String {
924 "https://mur-server.fly.dev".to_string()
925}
926
927#[derive(Debug, Clone, Serialize, Deserialize)]
930pub struct AskConfig {
931 #[serde(default = "ask_default_k_summary")]
932 pub k_summary: u32,
933 #[serde(default = "ask_default_k_raw")]
934 pub k_raw: u32,
935 #[serde(default = "ask_default_esc")]
936 pub escalation_threshold: f64,
937 #[serde(default = "ask_default_mmr")]
938 pub mmr_threshold: f64,
939 #[serde(default = "ask_default_max_ctx")]
940 pub max_context_tokens: u32,
941 #[serde(default = "ask_default_resp_tok")]
942 pub response_tokens: u32,
943 #[serde(default = "ask_default_timeout")]
944 pub timeout_secs: u32,
945 #[serde(default = "ask_default_min_score")]
946 pub min_score: f64,
947 #[serde(default = "ask_default_continue_history_turns")]
948 pub continue_history_turns: u32,
949 #[serde(default = "ask_default_rewriter_timeout")]
955 pub rewriter_timeout_secs: u32,
956 #[serde(default = "ask_default_compress_hits_enabled")]
957 pub compress_hits_enabled: bool,
958 #[serde(default = "ask_default_summarize_hits_enabled")]
959 pub summarize_hits_enabled: bool,
960 #[serde(default)]
961 pub summarize_model: Option<String>,
962 #[serde(default)]
965 pub backend: Option<BackendConfig>,
966 #[serde(default)]
970 pub rewriter_backend: Option<BackendConfig>,
971}
972
973impl AskConfig {
974 pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
979 self.backend.clone().unwrap_or_else(|| BackendConfig {
980 timeout_secs: Some(self.timeout_secs as u64),
981 ..llm.to_backend_config()
982 })
983 }
984
985 pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
995 self.rewriter_backend
996 .clone()
997 .unwrap_or_else(|| BackendConfig {
998 timeout_secs: Some(self.rewriter_timeout_secs as u64),
999 ..self
1000 .backend
1001 .clone()
1002 .unwrap_or_else(|| llm.to_backend_config())
1003 })
1004 }
1005}
1006
1007impl Default for AskConfig {
1008 fn default() -> Self {
1009 Self {
1010 k_summary: ask_default_k_summary(),
1011 k_raw: ask_default_k_raw(),
1012 escalation_threshold: ask_default_esc(),
1013 mmr_threshold: ask_default_mmr(),
1014 max_context_tokens: ask_default_max_ctx(),
1015 response_tokens: ask_default_resp_tok(),
1016 timeout_secs: ask_default_timeout(),
1017 min_score: ask_default_min_score(),
1018 continue_history_turns: ask_default_continue_history_turns(),
1019 rewriter_timeout_secs: ask_default_rewriter_timeout(),
1020 compress_hits_enabled: ask_default_compress_hits_enabled(),
1021 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
1022 summarize_model: None,
1023 backend: None,
1024 rewriter_backend: None,
1025 }
1026 }
1027}
1028
1029fn ask_default_k_summary() -> u32 {
1030 5
1031}
1032fn ask_default_k_raw() -> u32 {
1033 10
1034}
1035fn ask_default_esc() -> f64 {
1036 0.5
1037}
1038fn ask_default_mmr() -> f64 {
1039 0.88
1040}
1041fn ask_default_max_ctx() -> u32 {
1042 6000
1043}
1044fn ask_default_resp_tok() -> u32 {
1045 1024
1046}
1047fn ask_default_timeout() -> u32 {
1048 120
1049}
1050fn ask_default_min_score() -> f64 {
1051 0.35
1052}
1053fn ask_default_rewriter_timeout() -> u32 {
1054 8
1055}
1056fn ask_default_continue_history_turns() -> u32 {
1057 3
1058}
1059fn ask_default_compress_hits_enabled() -> bool {
1060 true
1061}
1062fn ask_default_summarize_hits_enabled() -> bool {
1063 true
1064}
1065
1066#[derive(Debug, Clone, Serialize, Deserialize)]
1075pub struct ConversationsConfig {
1076 #[serde(default)]
1077 pub enabled: bool,
1078 #[serde(default = "conv_default_retention_days")]
1079 pub retention_days: u32,
1080 #[serde(default = "conv_default_poll_interval")]
1081 pub poll_interval_secs: u64,
1082 #[serde(default)]
1083 pub sources: ConversationsSources,
1084 #[serde(default)]
1085 pub filter: ConversationsFilter,
1086 #[serde(default)]
1087 pub compact: CompactConfig,
1088 #[serde(default)]
1089 pub ask: AskConfig,
1090 #[serde(default)]
1091 pub rollup: RollupConfig,
1092}
1093
1094impl Default for ConversationsConfig {
1095 fn default() -> Self {
1096 Self {
1097 enabled: false,
1098 retention_days: conv_default_retention_days(),
1099 poll_interval_secs: conv_default_poll_interval(),
1100 sources: ConversationsSources::default(),
1101 filter: ConversationsFilter::default(),
1102 compact: CompactConfig::default(),
1103 ask: AskConfig::default(),
1104 rollup: RollupConfig::default(),
1105 }
1106 }
1107}
1108
1109fn conv_default_retention_days() -> u32 {
1110 30
1111}
1112fn conv_default_poll_interval() -> u64 {
1113 300
1114}
1115fn conv_truthy() -> bool {
1116 true
1117}
1118fn conv_default_dedup() -> f64 {
1119 0.85
1120}
1121
1122#[derive(Debug, Clone, Serialize, Deserialize)]
1123pub struct CompactConfig {
1124 #[serde(default = "conv_truthy")]
1125 pub enabled_in_daemon: bool,
1126 #[serde(default = "compact_default_max_days")]
1127 pub max_days_per_run: u32,
1128 #[serde(default = "compact_default_max_spans")]
1129 pub max_extractive_spans: u32,
1130 #[serde(default = "compact_default_max_words")]
1131 pub max_abstractive_words: u32,
1132 #[serde(default = "compact_default_chunk_tokens")]
1133 pub chunk_tokens: u32,
1134 #[serde(default = "compact_default_history_retain")]
1135 pub history_retain: u32,
1136 #[serde(default = "compact_default_cron")]
1137 pub daemon_cron: String,
1138 #[serde(default)]
1141 pub extractive_backend: Option<BackendConfig>,
1142 #[serde(default)]
1145 pub abstractive_backend: Option<BackendConfig>,
1146}
1147
1148impl CompactConfig {
1149 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1154 self.extractive_backend
1155 .clone()
1156 .unwrap_or_else(|| BackendConfig {
1157 timeout_secs: Some(120),
1158 ..llm.to_backend_config()
1159 })
1160 }
1161
1162 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1165 self.abstractive_backend
1166 .clone()
1167 .unwrap_or_else(|| BackendConfig {
1168 timeout_secs: Some(120),
1169 ..llm.to_backend_config()
1170 })
1171 }
1172}
1173
1174impl Default for CompactConfig {
1175 fn default() -> Self {
1176 Self {
1177 enabled_in_daemon: true,
1178 max_days_per_run: compact_default_max_days(),
1179 max_extractive_spans: compact_default_max_spans(),
1180 max_abstractive_words: compact_default_max_words(),
1181 chunk_tokens: compact_default_chunk_tokens(),
1182 history_retain: compact_default_history_retain(),
1183 daemon_cron: compact_default_cron(),
1184 extractive_backend: None,
1185 abstractive_backend: None,
1186 }
1187 }
1188}
1189
1190fn compact_default_max_days() -> u32 {
1191 7
1192}
1193fn compact_default_max_spans() -> u32 {
1194 20
1195}
1196fn compact_default_max_words() -> u32 {
1197 400
1198}
1199fn compact_default_chunk_tokens() -> u32 {
1200 6000
1201}
1202fn compact_default_history_retain() -> u32 {
1203 5
1204}
1205fn compact_default_cron() -> String {
1206 "0 0 3 * * * *".into()
1207}
1208
1209#[derive(Debug, Clone, Serialize, Deserialize)]
1212pub struct RollupConfig {
1213 #[serde(default = "rollup_default_enabled")]
1214 pub enabled: bool,
1215 #[serde(default = "rollup_default_max_weeks")]
1216 pub max_weeks_per_run: u32,
1217 #[serde(default = "rollup_default_max_months")]
1218 pub max_months_per_run: u32,
1219 #[serde(default = "rollup_default_max_spans_week")]
1220 pub max_extractive_spans_per_week: u32,
1221 #[serde(default = "rollup_default_max_words_week")]
1222 pub max_abstractive_words_per_week: u32,
1223 #[serde(default = "rollup_default_max_spans_month")]
1224 pub max_extractive_spans_per_month: u32,
1225 #[serde(default = "rollup_default_max_words_month")]
1226 pub max_abstractive_words_per_month: u32,
1227 #[serde(default = "rollup_default_week_mmr")]
1228 pub week_mmr_threshold: f64,
1229 #[serde(default = "rollup_default_month_mmr")]
1230 pub month_mmr_threshold: f64,
1231 #[serde(default)]
1234 pub extractive_backend: Option<BackendConfig>,
1235 #[serde(default)]
1238 pub abstractive_backend: Option<BackendConfig>,
1239}
1240
1241impl Default for RollupConfig {
1242 fn default() -> Self {
1243 Self {
1244 enabled: rollup_default_enabled(),
1245 max_weeks_per_run: rollup_default_max_weeks(),
1246 max_months_per_run: rollup_default_max_months(),
1247 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1248 max_abstractive_words_per_week: rollup_default_max_words_week(),
1249 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1250 max_abstractive_words_per_month: rollup_default_max_words_month(),
1251 week_mmr_threshold: rollup_default_week_mmr(),
1252 month_mmr_threshold: rollup_default_month_mmr(),
1253 extractive_backend: None,
1254 abstractive_backend: None,
1255 }
1256 }
1257}
1258
1259impl RollupConfig {
1260 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1264 self.extractive_backend
1265 .clone()
1266 .unwrap_or_else(|| BackendConfig {
1267 timeout_secs: Some(120),
1268 ..llm.to_backend_config()
1269 })
1270 }
1271
1272 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1274 self.abstractive_backend
1275 .clone()
1276 .unwrap_or_else(|| BackendConfig {
1277 timeout_secs: Some(120),
1278 ..llm.to_backend_config()
1279 })
1280 }
1281}
1282
1283fn rollup_default_enabled() -> bool {
1284 true
1285}
1286fn rollup_default_max_weeks() -> u32 {
1287 4
1288}
1289fn rollup_default_max_months() -> u32 {
1290 2
1291}
1292fn rollup_default_max_spans_week() -> u32 {
1293 20
1294}
1295fn rollup_default_max_words_week() -> u32 {
1296 500
1297}
1298fn rollup_default_max_spans_month() -> u32 {
1299 20
1300}
1301fn rollup_default_max_words_month() -> u32 {
1302 700
1303}
1304fn rollup_default_week_mmr() -> f64 {
1305 0.85
1306}
1307fn rollup_default_month_mmr() -> f64 {
1308 0.82
1309}
1310
1311#[derive(Debug, Clone, Serialize, Deserialize)]
1312pub struct ConversationsSources {
1313 #[serde(default = "conv_truthy")]
1314 pub claude_code: bool,
1315 #[serde(default = "conv_truthy")]
1316 pub cursor: bool,
1317 #[serde(default = "conv_truthy")]
1318 pub gemini: bool,
1319 #[serde(default)]
1320 pub aider: AiderSourceConfig,
1321}
1322
1323impl Default for ConversationsSources {
1324 fn default() -> Self {
1325 Self {
1326 claude_code: true,
1327 cursor: true,
1328 gemini: true,
1329 aider: AiderSourceConfig::default(),
1330 }
1331 }
1332}
1333
1334#[derive(Debug, Clone, Serialize, Deserialize)]
1335pub struct AiderSourceConfig {
1336 #[serde(default = "conv_truthy")]
1337 pub enabled: bool,
1338 #[serde(default)]
1339 pub watched_dirs: Vec<String>,
1340}
1341
1342impl Default for AiderSourceConfig {
1343 fn default() -> Self {
1344 Self {
1345 enabled: true,
1346 watched_dirs: Vec::new(),
1347 }
1348 }
1349}
1350
1351#[derive(Debug, Clone, Serialize, Deserialize)]
1352pub struct ConversationsFilter {
1353 #[serde(default = "conv_default_dedup")]
1354 pub dedup_threshold: f64,
1355 #[serde(default = "conv_truthy")]
1356 pub reject_heartbeat: bool,
1357 #[serde(default = "conv_truthy")]
1358 pub reject_system_restatement: bool,
1359}
1360
1361impl Default for ConversationsFilter {
1362 fn default() -> Self {
1363 Self {
1364 dedup_threshold: conv_default_dedup(),
1365 reject_heartbeat: true,
1366 reject_system_restatement: true,
1367 }
1368 }
1369}
1370
1371#[cfg(test)]
1372mod conversations_tests {
1373 use super::*;
1374
1375 #[test]
1376 fn conversations_section_defaults() {
1377 let c = ConversationsConfig::default();
1378 assert!(!c.enabled);
1379 assert_eq!(c.retention_days, 30);
1380 assert_eq!(c.poll_interval_secs, 300);
1381 assert!(c.sources.claude_code);
1382 assert!(c.sources.cursor);
1383 assert!(c.sources.gemini);
1384 assert!(c.sources.aider.enabled);
1385 assert!(c.sources.aider.watched_dirs.is_empty());
1386 assert_eq!(c.filter.dedup_threshold, 0.85);
1387 assert!(c.filter.reject_heartbeat);
1388 assert!(c.filter.reject_system_restatement);
1389 }
1390
1391 #[test]
1392 fn parse_from_yaml_with_overrides() {
1393 let y = r#"
1394conversations:
1395 enabled: true
1396 retention_days: 45
1397 poll_interval_secs: 120
1398 sources:
1399 cursor: false
1400 aider:
1401 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1402 filter:
1403 dedup_threshold: 0.9
1404"#;
1405 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1406 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1407 assert!(conv.enabled);
1408 assert_eq!(conv.retention_days, 45);
1409 assert_eq!(conv.poll_interval_secs, 120);
1410 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1414 assert_eq!(conv.filter.dedup_threshold, 0.9);
1415 assert!(conv.filter.reject_heartbeat); }
1417
1418 #[test]
1419 fn missing_conversations_section_is_fine() {
1420 let y = r#"
1421# No conversations section at all
1422foo: bar
1423"#;
1424 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1425 let conv: ConversationsConfig = v
1427 .get("conversations")
1428 .cloned()
1429 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1430 .unwrap_or_default();
1431 assert_eq!(conv.retention_days, 30);
1432 }
1433
1434 #[test]
1435 fn compact_config_defaults() {
1436 let c = CompactConfig::default();
1437 assert!(c.enabled_in_daemon);
1438 assert_eq!(c.max_days_per_run, 7);
1439 assert_eq!(c.max_extractive_spans, 20);
1440 assert_eq!(c.chunk_tokens, 6000);
1441 assert_eq!(c.history_retain, 5);
1442 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1443 }
1444
1445 #[test]
1446 fn compact_parses_partial_overrides() {
1447 let y = r#"
1448conversations:
1449 compact:
1450 max_days_per_run: 3
1451 extractive_backend:
1452 provider: anthropic
1453 model: claude-haiku-4-5
1454"#;
1455 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1456 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1457 assert_eq!(conv.compact.max_days_per_run, 3);
1458 assert_eq!(
1459 conv.compact.extractive_backend.as_ref().unwrap().model,
1460 "claude-haiku-4-5"
1461 );
1462 assert!(conv.compact.enabled_in_daemon); assert!(conv.compact.abstractive_backend.is_none()); }
1465
1466 #[test]
1467 fn ask_config_defaults() {
1468 let c = AskConfig::default();
1469 assert_eq!(c.k_raw, 10);
1470 assert_eq!(c.escalation_threshold, 0.5);
1471 assert_eq!(c.mmr_threshold, 0.88);
1472 assert_eq!(c.max_context_tokens, 6000);
1473 assert_eq!(c.response_tokens, 1024);
1474 assert_eq!(c.timeout_secs, 120);
1475 assert_eq!(c.min_score, 0.35);
1476 }
1477
1478 #[test]
1479 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1480 let c = AskConfig::default();
1482 assert!(
1483 (c.mmr_threshold - 0.88).abs() < 1e-9,
1484 "expected 0.88, got {}",
1485 c.mmr_threshold
1486 );
1487 }
1488
1489 #[test]
1490 fn rollup_config_defaults() {
1491 let c = RollupConfig::default();
1492 assert!(c.enabled);
1493 assert_eq!(c.max_weeks_per_run, 4);
1494 assert_eq!(c.max_months_per_run, 2);
1495 assert_eq!(c.max_extractive_spans_per_week, 20);
1496 assert_eq!(c.max_abstractive_words_per_week, 500);
1497 assert_eq!(c.max_extractive_spans_per_month, 20);
1498 assert_eq!(c.max_abstractive_words_per_month, 700);
1499 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1500 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1501 }
1502
1503 #[test]
1504 fn rollup_config_plumbed_into_conversations_config() {
1505 let c = ConversationsConfig::default();
1506 assert!(c.rollup.enabled);
1507 }
1508
1509 #[test]
1510 fn ask_config_default_continue_history_turns_is_3() {
1511 let c = AskConfig::default();
1512 assert_eq!(c.continue_history_turns, 3);
1513 }
1514
1515 #[test]
1516 fn ask_config_default_compress_hits_enabled_is_true() {
1517 let c = AskConfig::default();
1518 assert!(c.compress_hits_enabled);
1519 }
1520
1521 #[test]
1522 fn ask_config_default_summarize_hits_enabled_is_true() {
1523 let c = AskConfig::default();
1524 assert!(c.summarize_hits_enabled);
1525 }
1526
1527 #[test]
1528 fn ask_config_default_summarize_model_is_none() {
1529 let c = AskConfig::default();
1530 assert!(c.summarize_model.is_none());
1531 }
1532
1533 #[test]
1534 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1535 let y = r#"
1536conversations:
1537 ask:
1538 summarize_hits_enabled: false
1539 summarize_model: qwen3:4b
1540"#;
1541 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1542 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1543 assert!(!conv.ask.summarize_hits_enabled);
1544 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1545 }
1546
1547 #[test]
1548 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1549 let y = r#"
1553conversations:
1554 ask:
1555 min_score: 0.4
1556"#;
1557 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1558 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1559 assert!(conv.ask.summarize_hits_enabled);
1560 assert!(conv.ask.summarize_model.is_none());
1561 }
1562}
1563
1564#[cfg(test)]
1565mod tests {
1566 #[test]
1567 fn update_defaults_apply_when_block_absent_and_fields_parse() {
1568 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1569 assert_eq!(c.update, super::UpdateConfig::default());
1570 assert!(c.update.codesign_identity.is_none());
1571 assert!(c.update.restart_exclude.is_empty());
1572
1573 let c: super::Config = serde_yaml_ng::from_str(
1574 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
1575 )
1576 .unwrap();
1577 assert_eq!(
1578 c.update.codesign_identity.as_deref(),
1579 Some("Developer ID Application: X (TEAM)")
1580 );
1581 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
1582 }
1583
1584 #[test]
1585 fn federation_snapshot_defaults_apply_when_block_absent() {
1586 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1587 assert_eq!(c.federation_snapshot.poll_secs, 30);
1588 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1589 assert_eq!(
1590 c.federation_snapshot.min_lifecycle,
1591 crate::skill::stats::LifecycleState::Stable
1592 );
1593 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1595 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
1596 assert_eq!(c.memory.capture, super::CaptureMode::Off);
1597 }
1598
1599 use super::*;
1600
1601 #[test]
1602 fn default_bundled_model_id_is_qwen35_2b() {
1603 assert_eq!(
1604 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1605 "Qwen3.5-2B-MLX-4bit"
1606 );
1607 }
1608
1609 #[test]
1610 fn nudge_config_defaults() {
1611 let c = NudgeConfig::default();
1612 assert!(c.enabled);
1613 assert_eq!(c.daily_cap, 3);
1614 assert_eq!(c.snooze_days, 7);
1615 assert_eq!(c.threshold, 3);
1616 }
1617
1618 #[test]
1619 fn config_has_nudge_section_with_defaults() {
1620 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1621 assert_eq!(c.nudge.daily_cap, 3);
1622 }
1623
1624 #[test]
1625 fn storage_config_default_is_lancedb() {
1626 let c = StorageConfig::default();
1627 assert_eq!(c.vector_backend, "lancedb");
1628 assert_eq!(c.qdrant_url, None);
1629 assert_eq!(c.qdrant_api_key_ref, None);
1630 }
1631
1632 #[test]
1633 fn sources_global_config_has_sensible_defaults() {
1634 let c = SourcesGlobalConfig::default();
1635 assert_eq!(c.poll_interval_secs, 600);
1636 assert_eq!(c.max_chunks_per_sync, 10_000);
1637 assert_eq!(c.max_parallel_sources, 3);
1638 assert_eq!(c.default_weight, 1.0);
1639 assert_eq!(c.embedding_batch_size, 32);
1640 }
1641
1642 #[test]
1643 fn config_default_has_storage_and_sources_global() {
1644 let c = Config::default();
1645 assert_eq!(c.storage.vector_backend, "lancedb");
1646 assert_eq!(c.sources_global.default_weight, 1.0);
1647 }
1648
1649 #[test]
1650 fn config_loads_yaml_without_new_fields() {
1651 let yaml = r#"
1654embedding:
1655 provider: ollama
1656 model: test-model
1657 dimensions: 512
1658 ollama_endpoint: http://localhost:11434
1659"#;
1660 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1661 assert_eq!(c.storage.vector_backend, "lancedb");
1662 assert_eq!(c.sources_global.max_parallel_sources, 3);
1663 }
1664
1665 #[test]
1666 fn llm_config_to_backend_config_anthropic_passthrough() {
1667 let cfg = LlmConfig {
1668 provider: "anthropic".into(),
1669 model: "claude-haiku-4-5".into(),
1670 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1671 api_key_ref: None,
1672 openai_url: None,
1673 };
1674 let b = cfg.to_backend_config();
1675 assert_eq!(b.provider, "anthropic");
1676 assert_eq!(b.model, "claude-haiku-4-5");
1677 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1678 assert_eq!(b.endpoint, None);
1679 assert_eq!(b.timeout_secs, None);
1680 }
1681
1682 #[test]
1683 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1684 let cfg = LlmConfig {
1685 provider: "openai".into(),
1686 model: "gpt-4o-mini".into(),
1687 api_key_env: None,
1688 api_key_ref: None,
1689 openai_url: Some("https://api.together.xyz/v1".into()),
1690 };
1691 let b = cfg.to_backend_config();
1692 assert_eq!(b.provider, "openai");
1693 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1694 assert_eq!(b.api_key_env, None); }
1696
1697 #[test]
1698 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1699 let cfg = LlmConfig {
1700 provider: "ollama".into(),
1701 model: "qwen3:14b".into(),
1702 api_key_env: None,
1703 api_key_ref: None,
1704 openai_url: Some("http://192.168.1.10:11434".into()),
1705 };
1706 let b = cfg.to_backend_config();
1707 assert_eq!(b.provider, "ollama");
1708 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1709 }
1710
1711 #[test]
1712 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1713 let cfg = LlmConfig {
1717 provider: "custom-name".into(),
1718 model: "some-model".into(),
1719 api_key_env: Some("CUSTOM_KEY".into()),
1720 api_key_ref: None,
1721 openai_url: Some("https://my-proxy.local/v1".into()),
1722 };
1723 let b = cfg.to_backend_config();
1724 assert_eq!(
1725 b.provider, "openai",
1726 "unknown provider + openai_url should alias to openai"
1727 );
1728 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1729 }
1730
1731 #[test]
1732 fn api_key_ref_roundtrips_and_defaults_none() {
1733 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1735 assert_eq!(b.api_key_ref, None);
1736 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1737 assert_eq!(l.api_key_ref, None);
1738 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1739 assert_eq!(e.api_key_ref, None);
1740
1741 let l2 = LlmConfig {
1743 api_key_ref: Some("keychain:mur/anthropic".into()),
1744 ..Default::default()
1745 };
1746 let y = serde_yaml_ng::to_string(&l2).unwrap();
1747 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1748 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1749 assert_eq!(
1750 l3.to_backend_config().api_key_ref.as_deref(),
1751 Some("keychain:mur/anthropic")
1752 );
1753 }
1754
1755 #[test]
1756 fn open_items_muted_parses_and_defaults_empty() {
1757 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
1758 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
1759
1760 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
1761 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
1762 }
1763
1764 #[test]
1767 fn unreadable_config_yields_no_mutes() {
1768 let tmp = tempfile::tempdir().unwrap();
1769 let path = tmp.path().join("config.yaml");
1770 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
1771 let cfg = Config::load_or_default(&path);
1772 assert!(
1773 cfg.open_items.muted.is_empty(),
1774 "a broken config must hide nothing"
1775 );
1776
1777 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
1779 assert!(missing.open_items.muted.is_empty());
1780 }
1781
1782 #[test]
1783 fn rollup_config_accepts_backend_overrides() {
1784 let yaml = r#"
1785enabled: true
1786extractive_backend:
1787 provider: openai
1788 model: Qwen3.5-4B-MLX-4bit
1789 endpoint: http://127.0.0.1:8000/v1
1790"#;
1791 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1792 let b = c.extractive_backend.expect("override present");
1793 assert_eq!(b.provider, "openai");
1794 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
1795 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
1796 assert!(c.abstractive_backend.is_none());
1797 }
1798
1799 #[test]
1800 fn legacy_conversation_fields_are_gone_from_serialized_output() {
1801 let cfg = Config::default();
1802 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
1808 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
1809 assert!(
1810 !yaml.contains(key),
1811 "legacy key {key} still serialized:\n{yaml}"
1812 );
1813 }
1814 }
1815
1816 #[test]
1817 fn embedding_ollama_endpoint_is_omitted_when_unset() {
1818 let mut cfg = Config::default();
1819 cfg.embedding.provider = "omlx".into();
1820 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
1821 cfg.embedding.ollama_endpoint = None;
1822 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
1823 assert!(
1824 !yaml.contains("ollama_endpoint"),
1825 "dead field re-emitted:\n{yaml}"
1826 );
1827 }
1828
1829 #[test]
1830 fn embedding_ollama_endpoint_still_round_trips_when_set() {
1831 let yaml =
1832 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
1833 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1834 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
1835 }
1836}
1837
1838#[cfg(test)]
1839mod backend_config_tests {
1840 use super::*;
1841
1842 #[test]
1843 fn default_is_ollama_qwen3() {
1844 let cfg = BackendConfig::default();
1845 assert_eq!(cfg.provider, "ollama");
1846 assert_eq!(cfg.model, "qwen3.5:4b");
1847 assert_eq!(cfg.endpoint, None);
1848 assert_eq!(cfg.api_key_env, None);
1849 assert_eq!(cfg.timeout_secs, None);
1850 }
1851
1852 #[test]
1853 fn deserializes_anthropic_full() {
1854 let yaml = "\
1855provider: anthropic
1856model: claude-haiku-4-5
1857api_key_env: ANTHROPIC_API_KEY
1858timeout_secs: 60
1859";
1860 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1861 assert_eq!(cfg.provider, "anthropic");
1862 assert_eq!(cfg.model, "claude-haiku-4-5");
1863 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1864 assert_eq!(cfg.timeout_secs, Some(60));
1865 assert_eq!(cfg.endpoint, None);
1866 }
1867
1868 #[test]
1869 fn deserializes_partial_fills_defaults() {
1870 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
1871 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1872 assert_eq!(cfg.provider, "anthropic");
1873 assert_eq!(cfg.model, "claude-sonnet-5");
1874 assert_eq!(cfg.api_key_env, None);
1875 assert_eq!(cfg.timeout_secs, None);
1876 }
1877
1878 #[test]
1879 fn round_trips_through_yaml() {
1880 let original = BackendConfig {
1881 provider: "anthropic".into(),
1882 model: "claude-haiku-4-5".into(),
1883 endpoint: Some("https://api.anthropic.com".into()),
1884 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1885 api_key_ref: None,
1886 timeout_secs: Some(60),
1887 };
1888 let yaml = serde_yaml::to_string(&original).unwrap();
1889 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1890 assert_eq!(parsed, original);
1891 }
1892
1893 #[test]
1894 fn skills_config_curation_gate_defaults_on() {
1895 let c = SkillsConfig::default();
1896 assert!(c.require_human_curation_before_stable);
1897 }
1898}
1899
1900#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1908#[serde(rename_all = "lowercase")]
1909pub enum DevDisciplineIndex {
1910 #[default]
1912 Auto,
1913 Always,
1915 Never,
1917}
1918
1919#[derive(Debug, Clone, Serialize, Deserialize)]
1920#[serde(default)]
1921pub struct SkillsConfig {
1922 pub max_skills_in_prompt: usize,
1923 pub max_total_tokens: usize,
1924 pub priority_order: Vec<String>,
1925 pub adaptive: Option<AdaptiveSkillsConfig>,
1926
1927 #[serde(default = "default_require_human_curation")]
1931 pub require_human_curation_before_stable: bool,
1932
1933 #[serde(default)]
1937 pub lifecycle: SkillLifecycleConfig,
1938
1939 #[serde(default = "default_auto_upgrade")]
1944 pub auto_upgrade: bool,
1945
1946 #[serde(default)]
1948 pub dev_discipline_index: DevDisciplineIndex,
1949}
1950
1951fn default_require_human_curation() -> bool {
1952 true
1953}
1954
1955fn default_auto_upgrade() -> bool {
1956 true
1957}
1958
1959impl Default for SkillsConfig {
1960 fn default() -> Self {
1961 Self {
1962 max_skills_in_prompt: 5,
1963 max_total_tokens: 2000,
1964 priority_order: vec!["agent".into(), "global".into()],
1965 adaptive: Some(AdaptiveSkillsConfig::default()),
1966 require_human_curation_before_stable: default_require_human_curation(),
1967 lifecycle: SkillLifecycleConfig::default(),
1968 auto_upgrade: default_auto_upgrade(),
1969 dev_discipline_index: DevDisciplineIndex::default(),
1970 }
1971 }
1972}
1973
1974#[derive(Debug, Clone, Serialize, Deserialize)]
1980#[serde(default)]
1981pub struct SkillLifecycleConfig {
1982 pub note_rule_half_life_factor: f64,
1986 pub note_fact_half_life_factor: f64,
1989 pub promote_draft_uses: u64,
1991 pub promote_emerging_uses: u64,
1992 pub promote_emerging_success_rate: f64,
1993 pub promote_emerging_age_days: i64,
1994 pub promote_stable_uses: u64,
1995 pub promote_stable_success_rate: f64,
1996 pub promote_stable_age_days: i64,
1997
1998 pub demote_emerging_uses: u64,
2000 pub demote_emerging_success_rate: f64,
2001 pub demote_stable_uses: u64,
2002 pub demote_stable_success_rate: f64,
2003 pub deprecated_success_rate: f64,
2004 pub deprecated_no_success_days: i64,
2005
2006 pub auto_archive_confidence: f64,
2008 pub auto_archive_age_days: i64,
2009
2010 pub broken_workflow_streak: u32,
2015
2016 pub archive_destroy_grace_days: i64,
2021}
2022
2023impl Default for SkillLifecycleConfig {
2024 fn default() -> Self {
2025 Self {
2026 promote_draft_uses: 3,
2027 promote_emerging_uses: 10,
2028 promote_emerging_success_rate: 0.6,
2029 promote_emerging_age_days: 7,
2030 promote_stable_uses: 30,
2031 promote_stable_success_rate: 0.8,
2032 promote_stable_age_days: 30,
2033 demote_emerging_uses: 8,
2034 demote_emerging_success_rate: 0.55,
2035 demote_stable_uses: 25,
2036 demote_stable_success_rate: 0.75,
2037 deprecated_success_rate: 0.3,
2038 deprecated_no_success_days: 90,
2039 auto_archive_confidence: 0.10,
2040 auto_archive_age_days: 180,
2041 broken_workflow_streak: 3,
2042 archive_destroy_grace_days: 30,
2043 note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
2044 note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
2045 }
2046 }
2047}
2048
2049#[derive(Debug, Clone, Serialize, Deserialize)]
2050#[serde(default)]
2051pub struct AdaptiveSkillsConfig {
2052 pub context_fill_decay: f64,
2053 pub min_remaining_context_ratio: f64,
2054 pub recent_fire_boost_turns: usize,
2055 pub model_max_context_tokens: u64,
2059}
2060
2061impl Default for AdaptiveSkillsConfig {
2062 fn default() -> Self {
2063 Self {
2064 context_fill_decay: 1.5,
2065 min_remaining_context_ratio: 0.20,
2066 recent_fire_boost_turns: 5,
2067 model_max_context_tokens: 200_000,
2068 }
2069 }
2070}
2071
2072#[derive(Debug, Clone, Serialize, Deserialize)]
2075pub struct SleepCycleConfig {
2076 #[serde(default)]
2078 pub enabled: bool,
2079
2080 #[serde(default = "default_idle_threshold_minutes")]
2082 pub idle_threshold_minutes: u64,
2083
2084 #[serde(default = "default_agent_idle_minutes")]
2086 pub agent_idle_minutes: u64,
2087}
2088
2089fn default_idle_threshold_minutes() -> u64 {
2090 15
2091}
2092
2093fn default_agent_idle_minutes() -> u64 {
2094 5
2095}
2096
2097impl Default for SleepCycleConfig {
2098 fn default() -> Self {
2099 Self {
2100 enabled: false,
2101 idle_threshold_minutes: default_idle_threshold_minutes(),
2102 agent_idle_minutes: default_agent_idle_minutes(),
2103 }
2104 }
2105}
2106
2107#[derive(Debug, Clone, Serialize, Deserialize)]
2110pub struct NudgeConfig {
2111 #[serde(default = "default_nudge_enabled")]
2113 pub enabled: bool,
2114 #[serde(default = "default_nudge_daily_cap")]
2115 pub daily_cap: u32,
2116 #[serde(default = "default_nudge_snooze_days")]
2117 pub snooze_days: u32,
2118 #[serde(default = "default_nudge_threshold")]
2119 pub threshold: usize,
2120}
2121
2122fn default_nudge_enabled() -> bool {
2123 true
2124}
2125fn default_nudge_daily_cap() -> u32 {
2126 3
2127}
2128fn default_nudge_snooze_days() -> u32 {
2129 7
2130}
2131fn default_nudge_threshold() -> usize {
2132 3
2133}
2134
2135impl Default for NudgeConfig {
2136 fn default() -> Self {
2137 Self {
2138 enabled: true,
2139 daily_cap: default_nudge_daily_cap(),
2140 snooze_days: default_nudge_snooze_days(),
2141 threshold: default_nudge_threshold(),
2142 }
2143 }
2144}
2145
2146#[derive(Debug, Clone, Serialize, Deserialize)]
2150pub struct SessionCfg {
2151 #[serde(default = "default_capture_mode")]
2153 pub capture: String,
2154 #[serde(default = "default_retention_days")]
2156 pub retention_days: u32,
2157}
2158
2159impl Default for SessionCfg {
2160 fn default() -> Self {
2161 Self {
2162 capture: default_capture_mode(),
2163 retention_days: default_retention_days(),
2164 }
2165 }
2166}
2167
2168fn default_capture_mode() -> String {
2169 "ambient".to_string()
2170}
2171fn default_retention_days() -> u32 {
2172 14
2173}
2174
2175#[derive(Debug, Clone, Serialize, Deserialize)]
2177pub struct HarvestCfg {
2178 #[serde(default = "default_harvest_enabled")]
2180 pub auto_gate: bool,
2181 #[serde(default = "default_harvest_llm")]
2183 pub llm: String,
2184 #[serde(default = "default_min_events")]
2186 pub min_events: usize,
2187 #[serde(default = "default_min_user_turns")]
2188 pub min_user_turns: usize,
2189 #[serde(default = "default_min_duration_secs")]
2190 pub min_duration_secs: i64,
2191 #[serde(default = "default_idle_minutes")]
2193 pub idle_minutes: i64,
2194 #[serde(default = "default_max_steps")]
2197 pub max_steps: usize,
2198 #[serde(default = "default_max_duration_secs")]
2199 pub max_duration_secs: i64,
2200 #[serde(default = "default_max_llm_calls_per_day")]
2202 pub max_llm_calls_per_day: u32,
2203 #[serde(default = "default_max_extract_input_tokens")]
2204 pub max_extract_input_tokens: usize,
2205 #[serde(default = "default_harvest_enabled")]
2207 pub session_start_hint: bool,
2208 #[serde(default = "default_similarity_merge_threshold")]
2211 pub similarity_merge_threshold: f32,
2212 #[serde(default = "default_min_occurrences")]
2216 pub min_occurrences: usize,
2217}
2218
2219impl Default for HarvestCfg {
2220 fn default() -> Self {
2221 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2222 }
2223}
2224
2225fn default_harvest_enabled() -> bool {
2226 true
2227}
2228fn default_harvest_llm() -> String {
2229 "local-first".to_string()
2230}
2231fn default_min_events() -> usize {
2232 5
2233}
2234fn default_min_user_turns() -> usize {
2235 2
2236}
2237fn default_min_duration_secs() -> i64 {
2238 120
2239}
2240fn default_idle_minutes() -> i64 {
2241 30
2242}
2243fn default_max_steps() -> usize {
2247 20
2248}
2249fn default_max_duration_secs() -> i64 {
2252 1800
2253}
2254fn default_max_llm_calls_per_day() -> u32 {
2255 10
2256}
2257fn default_max_extract_input_tokens() -> usize {
2258 12000
2259}
2260fn default_similarity_merge_threshold() -> f32 {
2261 0.6
2262}
2263fn default_min_occurrences() -> usize {
2266 2
2267}
2268
2269#[derive(Debug, Clone, Serialize, Deserialize)]
2272#[serde(default)]
2273pub struct CrossAgentConfig {
2274 #[serde(default = "default_half_life_days")]
2275 pub fitness_half_life_days: u32,
2276 #[serde(default = "default_fitness_floor")]
2277 pub fitness_floor: f64,
2278}
2279
2280fn default_half_life_days() -> u32 {
2281 7
2282}
2283fn default_fitness_floor() -> f64 {
2284 0.1
2285}
2286
2287impl Default for CrossAgentConfig {
2288 fn default() -> Self {
2289 Self {
2290 fitness_half_life_days: default_half_life_days(),
2291 fitness_floor: default_fitness_floor(),
2292 }
2293 }
2294}
2295
2296#[derive(Debug, Clone, Serialize, Deserialize)]
2299#[serde(default)]
2300pub struct SkillLlmConfig {
2301 #[serde(default = "default_per_call_token_cap")]
2303 pub per_call_token_cap: u32,
2304
2305 #[serde(default = "default_per_day_usd_cap")]
2307 pub per_day_usd_cap: f64,
2308
2309 #[serde(default = "default_cache_ttl_days")]
2311 pub cache_ttl_days: u32,
2312
2313 #[serde(default, skip_serializing_if = "Option::is_none")]
2315 pub model_ref: Option<String>,
2316}
2317
2318fn default_per_call_token_cap() -> u32 {
2319 1500
2320}
2321fn default_per_day_usd_cap() -> f64 {
2322 0.50
2323}
2324fn default_cache_ttl_days() -> u32 {
2325 30
2326}
2327
2328impl Default for SkillLlmConfig {
2329 fn default() -> Self {
2330 Self {
2331 per_call_token_cap: default_per_call_token_cap(),
2332 per_day_usd_cap: default_per_day_usd_cap(),
2333 cache_ttl_days: default_cache_ttl_days(),
2334 model_ref: None,
2335 }
2336 }
2337}
2338#[cfg(test)]
2339mod per_stage_backend_tests {
2340 use super::*;
2341
2342 #[test]
2343 fn compact_extractive_backend_override_parses() {
2344 let yaml = "\
2345extractive_backend:
2346 provider: anthropic
2347 model: claude-haiku-4-5
2348 api_key_env: ANTHROPIC_API_KEY
2349";
2350 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2351 let extractive = cfg
2352 .extractive_backend
2353 .as_ref()
2354 .expect("override should parse");
2355 assert_eq!(extractive.provider, "anthropic");
2356 assert_eq!(extractive.model, "claude-haiku-4-5");
2357 assert!(cfg.abstractive_backend.is_none());
2358 }
2359
2360 #[test]
2361 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2362 let yaml = "\
2363backend:
2364 provider: anthropic
2365 model: claude-sonnet-5
2366 api_key_env: ANTHROPIC_API_KEY
2367rewriter_backend:
2368 provider: ollama
2369 model: llama3.2:3b
2370";
2371 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2372 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2373 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2374 }
2375
2376 #[test]
2377 fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2378 let cfg = AskConfig {
2389 backend: Some(BackendConfig {
2390 provider: "anthropic".into(),
2391 model: "claude-sonnet-5".into(),
2392 endpoint: None,
2393 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2394 api_key_ref: None,
2395 timeout_secs: None,
2396 }),
2397 ..Default::default()
2398 };
2399 let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2400 assert_eq!(rewriter.provider, "anthropic");
2401 assert_eq!(rewriter.model, "claude-sonnet-5");
2402 assert_eq!(
2403 rewriter.timeout_secs,
2404 Some(cfg.rewriter_timeout_secs as u64),
2405 "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2406 );
2407 }
2408
2409 #[test]
2410 fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2411 let mut cfg = AskConfig {
2412 rewriter_timeout_secs: 8,
2413 ..AskConfig::default()
2414 };
2415 cfg.rewriter_backend = Some(BackendConfig {
2416 provider: "anthropic".into(),
2417 model: "claude-haiku-4-5".into(),
2418 endpoint: None,
2419 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2420 api_key_ref: None,
2421 timeout_secs: Some(30),
2422 });
2423 let b = cfg.effective_rewriter_backend(&omlx_llm());
2424 assert_eq!(
2425 b.timeout_secs,
2426 Some(30),
2427 "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2428 );
2429 }
2430
2431 fn omlx_llm() -> LlmConfig {
2432 LlmConfig {
2433 provider: "omlx".into(),
2434 model: "Qwen3.5-4B-MLX-4bit".into(),
2435 api_key_env: None,
2436 api_key_ref: Some("env:OMLX_API_KEY".into()),
2437 openai_url: Some("http://127.0.0.1:8000/v1".into()),
2438 }
2439 }
2440
2441 #[test]
2442 fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2443 let ask = AskConfig::default();
2444 let b = ask.effective_backend(&omlx_llm());
2445 assert_eq!(b.provider, "openai");
2446 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2447 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2448 assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2449 assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2451 }
2452
2453 #[test]
2454 fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2455 let ask = AskConfig::default();
2456 let b = ask.effective_rewriter_backend(&omlx_llm());
2457 assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2458 assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2459 }
2460
2461 #[test]
2462 fn explicit_override_wins_over_the_smart_slot() {
2463 let ask = AskConfig {
2464 backend: Some(BackendConfig {
2465 provider: "anthropic".into(),
2466 model: "claude-haiku-4-5".into(),
2467 endpoint: None,
2468 api_key_env: None,
2469 api_key_ref: None,
2470 timeout_secs: Some(42),
2471 }),
2472 ..Default::default()
2473 };
2474 let b = ask.effective_backend(&omlx_llm());
2475 assert_eq!(b.provider, "anthropic");
2476 assert_eq!(b.timeout_secs, Some(42));
2477 }
2478
2479 #[test]
2480 fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2481 let llm = omlx_llm();
2482 for b in [
2483 CompactConfig::default().effective_extractive_backend(&llm),
2484 CompactConfig::default().effective_abstractive_backend(&llm),
2485 RollupConfig::default().effective_extractive_backend(&llm),
2486 RollupConfig::default().effective_abstractive_backend(&llm),
2487 ] {
2488 assert_eq!(b.provider, "openai");
2489 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2490 assert_eq!(b.timeout_secs, Some(120));
2491 }
2492 }
2493
2494 #[test]
2495 fn rollup_override_is_honored() {
2496 let r = RollupConfig {
2497 abstractive_backend: Some(BackendConfig {
2498 provider: "ollama".into(),
2499 model: "qwen3:4b".into(),
2500 endpoint: Some("http://box.local:11434".into()),
2501 api_key_env: None,
2502 api_key_ref: None,
2503 timeout_secs: None,
2504 }),
2505 ..Default::default()
2506 };
2507 let b = r.effective_abstractive_backend(&omlx_llm());
2508 assert_eq!(b.provider, "ollama");
2509 assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2510 }
2511}
2512
2513#[cfg(test)]
2514mod skills_config_tests {
2515 use super::*;
2516
2517 #[test]
2518 fn empty_yaml_hydrates_defaults() {
2519 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2520 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2521 assert_eq!(cfg.skills.max_total_tokens, 2000);
2522 assert!(cfg.skills.adaptive.is_some());
2523 }
2524
2525 #[test]
2526 fn load_or_default_missing_file_returns_default() {
2527 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2528 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2529 }
2530
2531 #[test]
2532 fn dev_discipline_index_defaults_auto_and_parses() {
2533 use crate::config::DevDisciplineIndex;
2534 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2535 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2536 let cfg: Config =
2537 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2538 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2539 let cfg: Config =
2540 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2541 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2542 }
2543}
2544
2545#[cfg(test)]
2546mod runs_config_tests {
2547 use super::*;
2548
2549 #[test]
2555 fn zero_heartbeat_values_clamp_to_defaults_at_load() {
2556 let tmp = tempfile::tempdir().unwrap();
2557 let path = tmp.path().join("config.yaml");
2558 std::fs::write(
2559 &path,
2560 "runs:\n heartbeat_interval_secs: 0\n heartbeat_stale_after_intervals: 0\n",
2561 )
2562 .unwrap();
2563
2564 let cfg = Config::load_or_default(&path);
2565 assert_eq!(
2566 cfg.runs.heartbeat_interval_secs, 10,
2567 "a zero interval must load as the default, not 0"
2568 );
2569 assert_eq!(
2570 cfg.runs.heartbeat_stale_after_intervals, 3,
2571 "a zero interval count must load as the default, not 0"
2572 );
2573 }
2574
2575 #[test]
2577 fn positive_heartbeat_values_survive_the_load() {
2578 let tmp = tempfile::tempdir().unwrap();
2579 let path = tmp.path().join("config.yaml");
2580 std::fs::write(
2581 &path,
2582 "runs:\n heartbeat_interval_secs: 60\n heartbeat_stale_after_intervals: 2\n",
2583 )
2584 .unwrap();
2585
2586 let cfg = Config::load_or_default(&path);
2587 assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
2588 assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
2589 }
2590}
2591
2592#[cfg(test)]
2593mod ambient_capture_cfg_tests {
2594 use super::*;
2595
2596 #[test]
2597 fn session_and_harvest_defaults() {
2598 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2599 assert_eq!(cfg.session.capture, "ambient");
2600 assert_eq!(cfg.session.retention_days, 14);
2601 assert!(cfg.harvest.auto_gate);
2602 assert_eq!(cfg.harvest.llm, "local-first");
2603 assert_eq!(cfg.harvest.min_events, 5);
2604 assert_eq!(cfg.harvest.min_user_turns, 2);
2605 assert_eq!(cfg.harvest.min_duration_secs, 120);
2606 assert_eq!(cfg.harvest.idle_minutes, 30);
2607 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2608 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2609 assert!(cfg.harvest.session_start_hint);
2610 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2611 }
2612
2613 #[test]
2614 fn session_capture_override_parses() {
2615 let cfg: Config =
2616 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2617 assert_eq!(cfg.session.capture, "off");
2618 assert_eq!(cfg.session.retention_days, 3);
2619 }
2620}
2621
2622#[cfg(test)]
2623mod cc_proxy_cfg_tests {
2624 use super::*;
2625
2626 #[test]
2627 fn defaults_to_local_cc_proxy_enabled() {
2628 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2629 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2630 assert!(cfg.cc_proxy.enabled);
2631 }
2632
2633 #[test]
2634 fn url_and_enabled_override_parse() {
2635 let cfg: Config =
2636 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2637 .unwrap();
2638 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2639 assert!(!cfg.cc_proxy.enabled);
2640 }
2641
2642 #[test]
2643 fn partial_section_keeps_other_default() {
2644 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2646 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2647 assert!(!cfg.cc_proxy.enabled);
2648 }
2649}
2650
2651#[cfg(test)]
2652mod model_switch_config_tests {
2653 use super::*;
2654
2655 #[test]
2656 fn model_switch_config_defaults_and_omitted_block() {
2657 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2659 assert_eq!(cfg.models.default, None);
2660 assert!(cfg.models.fallback_chain.is_empty());
2661 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2662 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2663 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2664 assert!(!cfg.models.routing.enabled);
2665
2666 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";
2668 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2669 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2670 assert_eq!(
2671 cfg.models.fallback_chain,
2672 vec!["claude_sonnet", "deepseek_v4_pro"]
2673 );
2674 assert!(cfg.models.routing.enabled);
2675 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2676 }
2677
2678 #[test]
2679 fn smart_config_defaults_on_with_autopick() {
2680 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2681 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2684 cfg.models.smart.max_escalations,
2685 DEFAULT_SMART_MAX_ESCALATIONS
2686 );
2687 }
2688}