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_SMART_ENABLED: bool = false;
22
23pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
28
29fn default_max_retries() -> u32 {
30 DEFAULT_MAX_RETRIES
31}
32fn default_backoff_base_ms() -> u64 {
33 DEFAULT_BACKOFF_BASE_MS
34}
35fn default_cooldown_secs() -> u64 {
36 DEFAULT_COOLDOWN_SECS
37}
38fn default_smart_max_escalations() -> u32 {
39 DEFAULT_SMART_MAX_ESCALATIONS
40}
41fn default_smart_enabled() -> bool {
42 DEFAULT_SMART_ENABLED
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct SmartConfig {
52 #[serde(default = "default_smart_enabled")]
53 pub enabled: bool,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub cheap: Option<String>,
56 #[serde(default = "default_smart_max_escalations")]
57 pub max_escalations: u32,
58}
59
60impl Default for SmartConfig {
61 fn default() -> Self {
62 Self {
63 enabled: DEFAULT_SMART_ENABLED,
64 cheap: None,
65 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct ModelSwitchConfig {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub default: Option<String>,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub fallback_chain: Vec<String>,
80 #[serde(default)]
81 pub retry: RetryConfig,
82 #[serde(default)]
83 pub routing: RoutingConfig,
84 #[serde(default)]
85 pub smart: SmartConfig,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct RetryConfig {
90 #[serde(default = "default_max_retries")]
91 pub max_retries: u32,
92 #[serde(default = "default_backoff_base_ms")]
93 pub backoff_base_ms: u64,
94 #[serde(default = "default_cooldown_secs")]
95 pub cooldown_secs: u64,
96}
97
98impl Default for RetryConfig {
99 fn default() -> Self {
100 Self {
101 max_retries: DEFAULT_MAX_RETRIES,
102 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
103 cooldown_secs: DEFAULT_COOLDOWN_SECS,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
109pub struct RoutingConfig {
110 #[serde(default)]
111 pub enabled: bool,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub cheap: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub frontier: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub threshold_input_tokens: Option<u32>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
125pub struct SmartOverride {
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub enabled: Option<bool>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub cheap: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub max_escalations: Option<u32>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
137pub struct RoutingOverride {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub enabled: Option<bool>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub cheap: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub frontier: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub threshold_input_tokens: Option<u32>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub smart: Option<SmartOverride>,
152}
153
154impl SmartConfig {
155 pub fn merged(&self, ov: Option<&SmartOverride>) -> SmartConfig {
157 let Some(o) = ov else { return self.clone() };
158 SmartConfig {
159 enabled: o.enabled.unwrap_or(self.enabled),
160 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
161 max_escalations: o.max_escalations.unwrap_or(self.max_escalations),
162 }
163 }
164}
165
166impl RoutingConfig {
167 pub fn merged(&self, ov: Option<&RoutingOverride>) -> RoutingConfig {
169 let Some(o) = ov else { return self.clone() };
170 RoutingConfig {
171 enabled: o.enabled.unwrap_or(self.enabled),
172 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
173 frontier: o.frontier.clone().or_else(|| self.frontier.clone()),
174 threshold_input_tokens: o.threshold_input_tokens.or(self.threshold_input_tokens),
175 }
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct Config {
182 #[serde(default)]
183 pub embedding: EmbeddingConfig,
184
185 #[serde(default)]
186 pub llm: LlmConfig,
187
188 #[serde(default)]
189 pub models: ModelSwitchConfig,
190
191 #[serde(default)]
192 pub retrieval: RetrievalConfig,
193
194 #[serde(default)]
195 pub paths: PathConfig,
196
197 #[serde(default)]
198 pub server: ServerConfig,
199
200 #[serde(default)]
201 pub community: CommunityConfig,
202
203 #[serde(default)]
204 pub conversations: ConversationsConfig,
205
206 #[serde(default)]
207 pub sync: SyncConfig,
208
209 #[serde(default)]
211 pub storage: StorageConfig,
212
213 #[serde(default)]
214 pub sources_global: SourcesGlobalConfig,
215
216 #[serde(default)]
218 pub sleep_cycle: SleepCycleConfig,
219
220 #[serde(default)]
222 pub skills: SkillsConfig,
223
224 #[serde(default)]
226 pub skill_llm: SkillLlmConfig,
227
228 #[serde(default)]
230 pub cross_agent: CrossAgentConfig,
231
232 #[serde(default)]
234 pub nudge: NudgeConfig,
235
236 #[serde(default)]
238 pub mobile_relay: MobileRelayConfig,
239
240 #[serde(default)]
242 pub session: SessionCfg,
243
244 #[serde(default)]
245 pub harvest: HarvestCfg,
246
247 #[serde(default)]
249 pub cc_proxy: CcProxyConfig,
250
251 #[serde(default)]
253 pub cli: CliConfig,
254
255 #[serde(default)]
257 pub parallel_jobs: ParallelJobsConfig,
258
259 #[serde(default)]
261 pub federation_snapshot: SnapshotConfig,
262
263 #[serde(default)]
265 pub memory: MemoryConfig,
266
267 #[serde(default)]
269 pub fleet_run: FleetRunConfig,
270
271 #[serde(default)]
273 pub open_items: OpenItemsConfig,
274
275 #[serde(default)]
277 pub fleet: FleetConfig,
278
279 #[serde(default)]
281 pub update: UpdateConfig,
282
283 #[serde(default)]
285 pub runs: RunsConfig,
286
287 #[serde(default)]
289 pub capture: CaptureConfig,
290}
291
292#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
300pub struct CaptureConfig {
301 #[serde(default = "default_rotate_at_mb")]
304 pub rotate_at_mb: u64,
305 #[serde(default = "default_keep_generations")]
308 pub keep_generations: u32,
309}
310
311fn default_rotate_at_mb() -> u64 {
312 64
313}
314
315fn default_keep_generations() -> u32 {
316 5
317}
318
319impl Default for CaptureConfig {
320 fn default() -> Self {
321 Self {
322 rotate_at_mb: default_rotate_at_mb(),
323 keep_generations: default_keep_generations(),
324 }
325 }
326}
327
328#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
331pub struct UpdateConfig {
332 #[serde(default, skip_serializing_if = "Option::is_none")]
338 pub codesign_identity: Option<String>,
339 #[serde(default, skip_serializing_if = "Vec::is_empty")]
341 pub restart_exclude: Vec<String>,
342}
343
344#[derive(Debug, Clone, Serialize, Deserialize)]
347pub struct RunsConfig {
348 #[serde(default = "default_heartbeat_interval_secs")]
350 pub heartbeat_interval_secs: u64,
351 #[serde(default = "default_heartbeat_stale_after_intervals")]
355 pub heartbeat_stale_after_intervals: u32,
356}
357
358fn default_heartbeat_interval_secs() -> u64 {
359 10
360}
361
362fn default_heartbeat_stale_after_intervals() -> u32 {
363 3
364}
365
366impl Default for RunsConfig {
367 fn default() -> Self {
368 Self {
369 heartbeat_interval_secs: default_heartbeat_interval_secs(),
370 heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
371 }
372 }
373}
374
375#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
381pub struct ParallelJobsConfig {
382 #[serde(default)]
385 pub targets: Vec<String>,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
394#[serde(default)]
395pub struct SnapshotConfig {
396 pub poll_secs: u64,
398 pub request_max_age_secs: u64,
400 pub min_lifecycle: crate::skill::stats::LifecycleState,
402}
403
404impl Default for SnapshotConfig {
405 fn default() -> Self {
406 Self {
407 poll_secs: 30,
408 request_max_age_secs: 600,
409 min_lifecycle: crate::skill::stats::LifecycleState::Stable,
410 }
411 }
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
418#[serde(default)]
419pub struct MemoryConfig {
420 pub capture: CaptureMode,
421 #[serde(default = "default_memory_in_prompt")]
426 pub max_in_prompt: usize,
427 #[serde(default = "default_memory_chars")]
429 pub max_chars: usize,
430}
431
432fn default_memory_in_prompt() -> usize {
433 20
434}
435
436fn default_memory_chars() -> usize {
437 1500
438}
439
440impl Default for MemoryConfig {
441 fn default() -> Self {
442 Self {
443 capture: CaptureMode::AutoAnnounce,
444 max_in_prompt: default_memory_in_prompt(),
445 max_chars: default_memory_chars(),
446 }
447 }
448}
449
450#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
452#[serde(rename_all = "snake_case")]
453pub enum CaptureMode {
454 Ask,
456 AutoAnnounce,
458 Off,
460}
461
462#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
470pub struct FleetRunConfig {
471 #[serde(default)]
473 pub agents: Vec<String>,
474 #[serde(default)]
476 pub fleets: Vec<String>,
477}
478
479#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
486pub struct OpenItemsConfig {
487 #[serde(default)]
490 pub muted: Vec<String>,
491}
492
493#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
500pub struct FleetConfig {
501 #[serde(default)]
503 pub autorun: bool,
504}
505
506#[cfg(test)]
507mod fleet_config_tests {
508 use super::*;
509
510 #[test]
511 fn fleet_config_defaults_off_and_roundtrips() {
512 assert!(!FleetConfig::default().autorun);
513
514 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
515 assert!(cfg.fleet.autorun);
516
517 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
519 assert!(!cfg2.fleet.autorun);
520 }
521
522 #[test]
523 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
524 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
526 assert!(cfg.fleet_run.agents.is_empty());
527 assert!(cfg.fleet_run.fleets.is_empty());
528
529 let cfg2: Config =
530 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
531 .unwrap();
532 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
533 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
534 }
535}
536
537#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct CcProxyConfig {
549 #[serde(default = "default_cc_proxy_url")]
551 pub url: String,
552
553 #[serde(default = "default_true")]
556 pub enabled: bool,
557}
558
559fn default_cc_proxy_url() -> String {
560 "http://127.0.0.1:8088".to_string()
561}
562
563fn default_true() -> bool {
564 true
565}
566
567impl Default for CcProxyConfig {
568 fn default() -> Self {
569 Self {
570 url: default_cc_proxy_url(),
571 enabled: true,
572 }
573 }
574}
575
576#[derive(Debug, Clone, Serialize, Deserialize, Default)]
579pub struct CliConfig {
580 pub skin: Option<String>,
583}
584
585#[derive(Debug, Clone, Serialize, Deserialize, Default)]
588pub struct MobileRelayConfig {
589 #[serde(default, skip_serializing_if = "Option::is_none")]
592 pub relay_url: Option<String>,
593
594 #[serde(default, skip_serializing_if = "Option::is_none")]
597 pub api_key: Option<String>,
598}
599
600impl Config {
601 pub fn load_or_default(path: &std::path::Path) -> Self {
606 let Ok(text) = std::fs::read_to_string(path) else {
607 return Self::default();
608 };
609 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
610 let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
611 cfg.sanitize();
612 cfg
613 }
614
615 fn sanitize(&mut self) {
620 if self.runs.heartbeat_interval_secs == 0 {
624 self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
625 }
626 if self.capture.rotate_at_mb == 0 {
628 self.capture.rotate_at_mb = default_rotate_at_mb();
629 }
630 if self.runs.heartbeat_stale_after_intervals == 0 {
631 self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
632 }
633 }
634}
635
636#[derive(Debug, Clone, Serialize, Deserialize, Default)]
637pub struct SyncConfig {
638 #[serde(default = "default_sync_method")]
640 pub method: String,
641
642 #[serde(default, skip_serializing_if = "Option::is_none")]
644 pub git_remote: Option<String>,
645
646 #[serde(default)]
648 pub auto: bool,
649
650 #[serde(default, skip_serializing_if = "Option::is_none")]
652 pub team_id: Option<String>,
653}
654
655fn default_sync_method() -> String {
656 "local".to_string()
657}
658
659#[derive(Debug, Clone, Serialize, Deserialize)]
660pub struct ServerConfig {
661 #[serde(default = "default_server_url")]
663 pub url: String,
664}
665
666impl Default for ServerConfig {
667 fn default() -> Self {
668 Self {
669 url: default_server_url(),
670 }
671 }
672}
673
674#[derive(Debug, Clone, Serialize, Deserialize, Default)]
675pub struct CommunityConfig {
676 #[serde(default)]
678 pub enabled: bool,
679}
680
681#[derive(Debug, Clone, Serialize, Deserialize)]
682pub struct EmbeddingConfig {
683 #[serde(default = "default_embedding_provider")]
685 pub provider: String,
686
687 #[serde(default = "default_embedding_model")]
689 pub model: String,
690
691 #[serde(default = "default_dimensions")]
693 pub dimensions: usize,
694
695 #[serde(default, skip_serializing_if = "Option::is_none")]
699 pub ollama_endpoint: Option<String>,
700
701 #[serde(default, skip_serializing_if = "Option::is_none")]
703 pub api_key_env: Option<String>,
704
705 #[serde(default, skip_serializing_if = "Option::is_none")]
708 pub api_key_ref: Option<String>,
709
710 #[serde(default, skip_serializing_if = "Option::is_none")]
712 pub openai_url: Option<String>,
713}
714
715impl Default for EmbeddingConfig {
716 fn default() -> Self {
717 Self {
718 provider: default_embedding_provider(),
719 model: default_embedding_model(),
720 dimensions: default_dimensions(),
721 ollama_endpoint: Some(default_ollama_endpoint()),
722 api_key_env: None,
723 api_key_ref: None,
724 openai_url: None,
725 }
726 }
727}
728
729#[derive(Debug, Clone, Serialize, Deserialize)]
730pub struct LlmConfig {
731 #[serde(default = "default_llm_provider")]
733 pub provider: String,
734
735 #[serde(default = "default_llm_model")]
736 pub model: String,
737
738 #[serde(default, skip_serializing_if = "Option::is_none")]
740 pub api_key_env: Option<String>,
741
742 #[serde(default, skip_serializing_if = "Option::is_none")]
745 pub api_key_ref: Option<String>,
746
747 #[serde(default, skip_serializing_if = "Option::is_none")]
749 pub openai_url: Option<String>,
750}
751
752impl Default for LlmConfig {
753 fn default() -> Self {
754 Self {
755 provider: default_llm_provider(),
756 model: default_llm_model(),
757 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
758 api_key_ref: None,
759 openai_url: None,
760 }
761 }
762}
763
764impl LlmConfig {
765 pub fn to_backend_config(&self) -> BackendConfig {
778 let provider = match self.provider.as_str() {
779 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
780 _ if self.openai_url.is_some() => "openai".into(),
781 other => other.into(), };
783 BackendConfig {
784 provider,
785 model: self.model.clone(),
786 endpoint: self.openai_url.clone(),
787 api_key_env: self.api_key_env.clone(),
788 api_key_ref: self.api_key_ref.clone(),
789 timeout_secs: None,
790 }
791 }
792}
793
794#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
805#[serde(default)]
806pub struct BackendConfig {
807 pub provider: String,
809 pub model: String,
811 pub endpoint: Option<String>,
814 pub api_key_env: Option<String>,
816 pub api_key_ref: Option<String>,
818 pub timeout_secs: Option<u64>,
820}
821
822impl Default for BackendConfig {
823 fn default() -> Self {
824 Self {
825 provider: "ollama".into(),
826 model: DEFAULT_LOCAL_LLM_MODEL.into(),
827 endpoint: None,
828 api_key_env: None,
829 api_key_ref: None,
830 timeout_secs: None,
831 }
832 }
833}
834
835#[derive(Debug, Clone, Serialize, Deserialize)]
836pub struct RetrievalConfig {
837 #[serde(default = "default_max_patterns")]
839 pub max_patterns: usize,
840
841 #[serde(default = "default_max_tokens")]
843 pub max_tokens: usize,
844
845 #[serde(default = "default_min_score")]
847 pub min_score: f64,
848
849 #[serde(default = "default_mmr_threshold")]
851 pub mmr_threshold: f64,
852
853 #[serde(default = "default_reserved_note_slots")]
856 pub reserved_note_slots: usize,
857}
858
859impl Default for RetrievalConfig {
860 fn default() -> Self {
861 Self {
862 max_patterns: default_max_patterns(),
863 max_tokens: default_max_tokens(),
864 min_score: default_min_score(),
865 mmr_threshold: default_mmr_threshold(),
866 reserved_note_slots: default_reserved_note_slots(),
867 }
868 }
869}
870
871fn default_reserved_note_slots() -> usize {
872 1
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize)]
876pub struct PathConfig {
877 #[serde(default = "default_mur_dir")]
879 pub mur_dir: PathBuf,
880}
881
882impl Default for PathConfig {
883 fn default() -> Self {
884 Self {
885 mur_dir: default_mur_dir(),
886 }
887 }
888}
889
890#[derive(Debug, Clone, Serialize, Deserialize)]
891pub struct StorageConfig {
892 #[serde(default = "default_vector_backend")]
894 pub vector_backend: String,
895
896 #[serde(default, skip_serializing_if = "Option::is_none")]
898 pub qdrant_url: Option<String>,
899
900 #[serde(default, skip_serializing_if = "Option::is_none")]
902 pub qdrant_api_key_ref: Option<String>,
903}
904
905impl Default for StorageConfig {
906 fn default() -> Self {
907 Self {
908 vector_backend: default_vector_backend(),
909 qdrant_url: None,
910 qdrant_api_key_ref: None,
911 }
912 }
913}
914
915fn default_vector_backend() -> String {
916 "lancedb".to_string()
917}
918
919#[derive(Debug, Clone, Serialize, Deserialize)]
920pub struct SourcesGlobalConfig {
921 #[serde(default = "default_poll_interval_secs")]
923 pub poll_interval_secs: u64,
924
925 #[serde(default = "default_max_chunks_per_sync")]
927 pub max_chunks_per_sync: usize,
928
929 #[serde(default = "default_max_parallel_sources")]
931 pub max_parallel_sources: usize,
932
933 #[serde(default = "default_source_weight")]
935 pub default_weight: f32,
936
937 #[serde(default = "default_embedding_batch_size")]
939 pub embedding_batch_size: usize,
940}
941
942impl Default for SourcesGlobalConfig {
943 fn default() -> Self {
944 Self {
945 poll_interval_secs: default_poll_interval_secs(),
946 max_chunks_per_sync: default_max_chunks_per_sync(),
947 max_parallel_sources: default_max_parallel_sources(),
948 default_weight: default_source_weight(),
949 embedding_batch_size: default_embedding_batch_size(),
950 }
951 }
952}
953
954fn default_poll_interval_secs() -> u64 {
955 600
956}
957fn default_max_chunks_per_sync() -> usize {
958 10_000
959}
960fn default_max_parallel_sources() -> usize {
961 3
962}
963fn default_source_weight() -> f32 {
964 1.0
965}
966fn default_embedding_batch_size() -> usize {
967 32
968}
969
970fn default_embedding_provider() -> String {
971 "ollama".to_string()
972}
973fn default_embedding_model() -> String {
974 "qwen3-embedding:0.6b".to_string()
975}
976fn default_dimensions() -> usize {
977 1024
978}
979fn default_ollama_endpoint() -> String {
980 DEFAULT_OLLAMA_ENDPOINT.to_string()
981}
982fn default_llm_provider() -> String {
983 "anthropic".to_string()
984}
985fn default_llm_model() -> String {
986 "claude-opus-5".to_string()
987}
988fn default_max_patterns() -> usize {
989 5
990}
991fn default_max_tokens() -> usize {
992 2000
993}
994fn default_min_score() -> f64 {
995 0.35
996}
997fn default_mmr_threshold() -> f64 {
998 0.85
999}
1000fn default_mur_dir() -> PathBuf {
1001 let home = std::env::var("HOME")
1004 .map(PathBuf::from)
1005 .unwrap_or_else(|_| PathBuf::from("/tmp"));
1006 home.join(".mur")
1007}
1008fn default_server_url() -> String {
1009 "https://mur-server.fly.dev".to_string()
1010}
1011
1012#[derive(Debug, Clone, Serialize, Deserialize)]
1015pub struct AskConfig {
1016 #[serde(default = "ask_default_k_summary")]
1017 pub k_summary: u32,
1018 #[serde(default = "ask_default_k_raw")]
1019 pub k_raw: u32,
1020 #[serde(default = "ask_default_esc")]
1021 pub escalation_threshold: f64,
1022 #[serde(default = "ask_default_mmr")]
1023 pub mmr_threshold: f64,
1024 #[serde(default = "ask_default_max_ctx")]
1025 pub max_context_tokens: u32,
1026 #[serde(default = "ask_default_resp_tok")]
1027 pub response_tokens: u32,
1028 #[serde(default = "ask_default_timeout")]
1029 pub timeout_secs: u32,
1030 #[serde(default = "ask_default_min_score")]
1031 pub min_score: f64,
1032 #[serde(default = "ask_default_continue_history_turns")]
1033 pub continue_history_turns: u32,
1034 #[serde(default = "ask_default_rewriter_timeout")]
1040 pub rewriter_timeout_secs: u32,
1041 #[serde(default = "ask_default_compress_hits_enabled")]
1042 pub compress_hits_enabled: bool,
1043 #[serde(default = "ask_default_summarize_hits_enabled")]
1044 pub summarize_hits_enabled: bool,
1045 #[serde(default)]
1046 pub summarize_model: Option<String>,
1047 #[serde(default)]
1050 pub backend: Option<BackendConfig>,
1051 #[serde(default)]
1055 pub rewriter_backend: Option<BackendConfig>,
1056}
1057
1058impl AskConfig {
1059 pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
1064 self.backend.clone().unwrap_or_else(|| BackendConfig {
1065 timeout_secs: Some(self.timeout_secs as u64),
1066 ..llm.to_backend_config()
1067 })
1068 }
1069
1070 pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
1080 self.rewriter_backend
1081 .clone()
1082 .unwrap_or_else(|| BackendConfig {
1083 timeout_secs: Some(self.rewriter_timeout_secs as u64),
1084 ..self
1085 .backend
1086 .clone()
1087 .unwrap_or_else(|| llm.to_backend_config())
1088 })
1089 }
1090}
1091
1092impl Default for AskConfig {
1093 fn default() -> Self {
1094 Self {
1095 k_summary: ask_default_k_summary(),
1096 k_raw: ask_default_k_raw(),
1097 escalation_threshold: ask_default_esc(),
1098 mmr_threshold: ask_default_mmr(),
1099 max_context_tokens: ask_default_max_ctx(),
1100 response_tokens: ask_default_resp_tok(),
1101 timeout_secs: ask_default_timeout(),
1102 min_score: ask_default_min_score(),
1103 continue_history_turns: ask_default_continue_history_turns(),
1104 rewriter_timeout_secs: ask_default_rewriter_timeout(),
1105 compress_hits_enabled: ask_default_compress_hits_enabled(),
1106 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
1107 summarize_model: None,
1108 backend: None,
1109 rewriter_backend: None,
1110 }
1111 }
1112}
1113
1114fn ask_default_k_summary() -> u32 {
1115 5
1116}
1117fn ask_default_k_raw() -> u32 {
1118 10
1119}
1120fn ask_default_esc() -> f64 {
1121 0.5
1122}
1123fn ask_default_mmr() -> f64 {
1124 0.88
1125}
1126fn ask_default_max_ctx() -> u32 {
1127 6000
1128}
1129fn ask_default_resp_tok() -> u32 {
1130 1024
1131}
1132fn ask_default_timeout() -> u32 {
1133 120
1134}
1135fn ask_default_min_score() -> f64 {
1136 0.35
1137}
1138fn ask_default_rewriter_timeout() -> u32 {
1139 8
1140}
1141fn ask_default_continue_history_turns() -> u32 {
1142 3
1143}
1144fn ask_default_compress_hits_enabled() -> bool {
1145 true
1146}
1147fn ask_default_summarize_hits_enabled() -> bool {
1148 true
1149}
1150
1151#[derive(Debug, Clone, Serialize, Deserialize)]
1160pub struct ConversationsConfig {
1161 #[serde(default)]
1162 pub enabled: bool,
1163 #[serde(default = "conv_default_retention_days")]
1164 pub retention_days: u32,
1165 #[serde(default = "conv_default_poll_interval")]
1166 pub poll_interval_secs: u64,
1167 #[serde(default)]
1168 pub sources: ConversationsSources,
1169 #[serde(default)]
1170 pub filter: ConversationsFilter,
1171 #[serde(default)]
1172 pub compact: CompactConfig,
1173 #[serde(default)]
1174 pub ask: AskConfig,
1175 #[serde(default)]
1176 pub rollup: RollupConfig,
1177}
1178
1179impl Default for ConversationsConfig {
1180 fn default() -> Self {
1181 Self {
1182 enabled: false,
1183 retention_days: conv_default_retention_days(),
1184 poll_interval_secs: conv_default_poll_interval(),
1185 sources: ConversationsSources::default(),
1186 filter: ConversationsFilter::default(),
1187 compact: CompactConfig::default(),
1188 ask: AskConfig::default(),
1189 rollup: RollupConfig::default(),
1190 }
1191 }
1192}
1193
1194fn conv_default_retention_days() -> u32 {
1195 30
1196}
1197fn conv_default_poll_interval() -> u64 {
1198 300
1199}
1200fn conv_truthy() -> bool {
1201 true
1202}
1203fn conv_default_dedup() -> f64 {
1204 0.85
1205}
1206
1207#[derive(Debug, Clone, Serialize, Deserialize)]
1208pub struct CompactConfig {
1209 #[serde(default = "conv_truthy")]
1210 pub enabled_in_daemon: bool,
1211 #[serde(default = "compact_default_max_days")]
1212 pub max_days_per_run: u32,
1213 #[serde(default = "compact_default_max_spans")]
1214 pub max_extractive_spans: u32,
1215 #[serde(default = "compact_default_max_words")]
1216 pub max_abstractive_words: u32,
1217 #[serde(default = "compact_default_chunk_tokens")]
1218 pub chunk_tokens: u32,
1219 #[serde(default = "compact_default_history_retain")]
1220 pub history_retain: u32,
1221 #[serde(default = "compact_default_cron")]
1222 pub daemon_cron: String,
1223 #[serde(default)]
1226 pub extractive_backend: Option<BackendConfig>,
1227 #[serde(default)]
1230 pub abstractive_backend: Option<BackendConfig>,
1231}
1232
1233impl CompactConfig {
1234 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1239 self.extractive_backend
1240 .clone()
1241 .unwrap_or_else(|| BackendConfig {
1242 timeout_secs: Some(120),
1243 ..llm.to_backend_config()
1244 })
1245 }
1246
1247 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1250 self.abstractive_backend
1251 .clone()
1252 .unwrap_or_else(|| BackendConfig {
1253 timeout_secs: Some(120),
1254 ..llm.to_backend_config()
1255 })
1256 }
1257}
1258
1259impl Default for CompactConfig {
1260 fn default() -> Self {
1261 Self {
1262 enabled_in_daemon: true,
1263 max_days_per_run: compact_default_max_days(),
1264 max_extractive_spans: compact_default_max_spans(),
1265 max_abstractive_words: compact_default_max_words(),
1266 chunk_tokens: compact_default_chunk_tokens(),
1267 history_retain: compact_default_history_retain(),
1268 daemon_cron: compact_default_cron(),
1269 extractive_backend: None,
1270 abstractive_backend: None,
1271 }
1272 }
1273}
1274
1275fn compact_default_max_days() -> u32 {
1276 7
1277}
1278fn compact_default_max_spans() -> u32 {
1279 20
1280}
1281fn compact_default_max_words() -> u32 {
1282 400
1283}
1284fn compact_default_chunk_tokens() -> u32 {
1285 6000
1286}
1287fn compact_default_history_retain() -> u32 {
1288 5
1289}
1290fn compact_default_cron() -> String {
1291 "0 0 3 * * * *".into()
1292}
1293
1294#[derive(Debug, Clone, Serialize, Deserialize)]
1297pub struct RollupConfig {
1298 #[serde(default = "rollup_default_enabled")]
1299 pub enabled: bool,
1300 #[serde(default = "rollup_default_max_weeks")]
1301 pub max_weeks_per_run: u32,
1302 #[serde(default = "rollup_default_max_months")]
1303 pub max_months_per_run: u32,
1304 #[serde(default = "rollup_default_max_spans_week")]
1305 pub max_extractive_spans_per_week: u32,
1306 #[serde(default = "rollup_default_max_words_week")]
1307 pub max_abstractive_words_per_week: u32,
1308 #[serde(default = "rollup_default_max_spans_month")]
1309 pub max_extractive_spans_per_month: u32,
1310 #[serde(default = "rollup_default_max_words_month")]
1311 pub max_abstractive_words_per_month: u32,
1312 #[serde(default = "rollup_default_week_mmr")]
1313 pub week_mmr_threshold: f64,
1314 #[serde(default = "rollup_default_month_mmr")]
1315 pub month_mmr_threshold: f64,
1316 #[serde(default)]
1319 pub extractive_backend: Option<BackendConfig>,
1320 #[serde(default)]
1323 pub abstractive_backend: Option<BackendConfig>,
1324}
1325
1326impl Default for RollupConfig {
1327 fn default() -> Self {
1328 Self {
1329 enabled: rollup_default_enabled(),
1330 max_weeks_per_run: rollup_default_max_weeks(),
1331 max_months_per_run: rollup_default_max_months(),
1332 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1333 max_abstractive_words_per_week: rollup_default_max_words_week(),
1334 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1335 max_abstractive_words_per_month: rollup_default_max_words_month(),
1336 week_mmr_threshold: rollup_default_week_mmr(),
1337 month_mmr_threshold: rollup_default_month_mmr(),
1338 extractive_backend: None,
1339 abstractive_backend: None,
1340 }
1341 }
1342}
1343
1344impl RollupConfig {
1345 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1349 self.extractive_backend
1350 .clone()
1351 .unwrap_or_else(|| BackendConfig {
1352 timeout_secs: Some(120),
1353 ..llm.to_backend_config()
1354 })
1355 }
1356
1357 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1359 self.abstractive_backend
1360 .clone()
1361 .unwrap_or_else(|| BackendConfig {
1362 timeout_secs: Some(120),
1363 ..llm.to_backend_config()
1364 })
1365 }
1366}
1367
1368fn rollup_default_enabled() -> bool {
1369 true
1370}
1371fn rollup_default_max_weeks() -> u32 {
1372 4
1373}
1374fn rollup_default_max_months() -> u32 {
1375 2
1376}
1377fn rollup_default_max_spans_week() -> u32 {
1378 20
1379}
1380fn rollup_default_max_words_week() -> u32 {
1381 500
1382}
1383fn rollup_default_max_spans_month() -> u32 {
1384 20
1385}
1386fn rollup_default_max_words_month() -> u32 {
1387 700
1388}
1389fn rollup_default_week_mmr() -> f64 {
1390 0.85
1391}
1392fn rollup_default_month_mmr() -> f64 {
1393 0.82
1394}
1395
1396#[derive(Debug, Clone, Serialize, Deserialize)]
1397pub struct ConversationsSources {
1398 #[serde(default = "conv_truthy")]
1399 pub claude_code: bool,
1400 #[serde(default = "conv_truthy")]
1401 pub cursor: bool,
1402 #[serde(default = "conv_truthy")]
1403 pub gemini: bool,
1404 #[serde(default)]
1405 pub aider: AiderSourceConfig,
1406}
1407
1408impl Default for ConversationsSources {
1409 fn default() -> Self {
1410 Self {
1411 claude_code: true,
1412 cursor: true,
1413 gemini: true,
1414 aider: AiderSourceConfig::default(),
1415 }
1416 }
1417}
1418
1419#[derive(Debug, Clone, Serialize, Deserialize)]
1420pub struct AiderSourceConfig {
1421 #[serde(default = "conv_truthy")]
1422 pub enabled: bool,
1423 #[serde(default)]
1424 pub watched_dirs: Vec<String>,
1425}
1426
1427impl Default for AiderSourceConfig {
1428 fn default() -> Self {
1429 Self {
1430 enabled: true,
1431 watched_dirs: Vec::new(),
1432 }
1433 }
1434}
1435
1436#[derive(Debug, Clone, Serialize, Deserialize)]
1437pub struct ConversationsFilter {
1438 #[serde(default = "conv_default_dedup")]
1439 pub dedup_threshold: f64,
1440 #[serde(default = "conv_truthy")]
1441 pub reject_heartbeat: bool,
1442 #[serde(default = "conv_truthy")]
1443 pub reject_system_restatement: bool,
1444}
1445
1446impl Default for ConversationsFilter {
1447 fn default() -> Self {
1448 Self {
1449 dedup_threshold: conv_default_dedup(),
1450 reject_heartbeat: true,
1451 reject_system_restatement: true,
1452 }
1453 }
1454}
1455
1456#[cfg(test)]
1457mod conversations_tests {
1458 use super::*;
1459
1460 #[test]
1461 fn conversations_section_defaults() {
1462 let c = ConversationsConfig::default();
1463 assert!(!c.enabled);
1464 assert_eq!(c.retention_days, 30);
1465 assert_eq!(c.poll_interval_secs, 300);
1466 assert!(c.sources.claude_code);
1467 assert!(c.sources.cursor);
1468 assert!(c.sources.gemini);
1469 assert!(c.sources.aider.enabled);
1470 assert!(c.sources.aider.watched_dirs.is_empty());
1471 assert_eq!(c.filter.dedup_threshold, 0.85);
1472 assert!(c.filter.reject_heartbeat);
1473 assert!(c.filter.reject_system_restatement);
1474 }
1475
1476 #[test]
1477 fn parse_from_yaml_with_overrides() {
1478 let y = r#"
1479conversations:
1480 enabled: true
1481 retention_days: 45
1482 poll_interval_secs: 120
1483 sources:
1484 cursor: false
1485 aider:
1486 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1487 filter:
1488 dedup_threshold: 0.9
1489"#;
1490 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1491 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1492 assert!(conv.enabled);
1493 assert_eq!(conv.retention_days, 45);
1494 assert_eq!(conv.poll_interval_secs, 120);
1495 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1499 assert_eq!(conv.filter.dedup_threshold, 0.9);
1500 assert!(conv.filter.reject_heartbeat); }
1502
1503 #[test]
1504 fn missing_conversations_section_is_fine() {
1505 let y = r#"
1506# No conversations section at all
1507foo: bar
1508"#;
1509 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1510 let conv: ConversationsConfig = v
1512 .get("conversations")
1513 .cloned()
1514 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1515 .unwrap_or_default();
1516 assert_eq!(conv.retention_days, 30);
1517 }
1518
1519 #[test]
1520 fn compact_config_defaults() {
1521 let c = CompactConfig::default();
1522 assert!(c.enabled_in_daemon);
1523 assert_eq!(c.max_days_per_run, 7);
1524 assert_eq!(c.max_extractive_spans, 20);
1525 assert_eq!(c.chunk_tokens, 6000);
1526 assert_eq!(c.history_retain, 5);
1527 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1528 }
1529
1530 #[test]
1531 fn compact_parses_partial_overrides() {
1532 let y = r#"
1533conversations:
1534 compact:
1535 max_days_per_run: 3
1536 extractive_backend:
1537 provider: anthropic
1538 model: claude-haiku-4-5
1539"#;
1540 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1541 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1542 assert_eq!(conv.compact.max_days_per_run, 3);
1543 assert_eq!(
1544 conv.compact.extractive_backend.as_ref().unwrap().model,
1545 "claude-haiku-4-5"
1546 );
1547 assert!(conv.compact.enabled_in_daemon); assert!(conv.compact.abstractive_backend.is_none()); }
1550
1551 #[test]
1552 fn ask_config_defaults() {
1553 let c = AskConfig::default();
1554 assert_eq!(c.k_raw, 10);
1555 assert_eq!(c.escalation_threshold, 0.5);
1556 assert_eq!(c.mmr_threshold, 0.88);
1557 assert_eq!(c.max_context_tokens, 6000);
1558 assert_eq!(c.response_tokens, 1024);
1559 assert_eq!(c.timeout_secs, 120);
1560 assert_eq!(c.min_score, 0.35);
1561 }
1562
1563 #[test]
1564 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1565 let c = AskConfig::default();
1567 assert!(
1568 (c.mmr_threshold - 0.88).abs() < 1e-9,
1569 "expected 0.88, got {}",
1570 c.mmr_threshold
1571 );
1572 }
1573
1574 #[test]
1575 fn rollup_config_defaults() {
1576 let c = RollupConfig::default();
1577 assert!(c.enabled);
1578 assert_eq!(c.max_weeks_per_run, 4);
1579 assert_eq!(c.max_months_per_run, 2);
1580 assert_eq!(c.max_extractive_spans_per_week, 20);
1581 assert_eq!(c.max_abstractive_words_per_week, 500);
1582 assert_eq!(c.max_extractive_spans_per_month, 20);
1583 assert_eq!(c.max_abstractive_words_per_month, 700);
1584 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1585 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1586 }
1587
1588 #[test]
1589 fn rollup_config_plumbed_into_conversations_config() {
1590 let c = ConversationsConfig::default();
1591 assert!(c.rollup.enabled);
1592 }
1593
1594 #[test]
1595 fn ask_config_default_continue_history_turns_is_3() {
1596 let c = AskConfig::default();
1597 assert_eq!(c.continue_history_turns, 3);
1598 }
1599
1600 #[test]
1601 fn ask_config_default_compress_hits_enabled_is_true() {
1602 let c = AskConfig::default();
1603 assert!(c.compress_hits_enabled);
1604 }
1605
1606 #[test]
1607 fn ask_config_default_summarize_hits_enabled_is_true() {
1608 let c = AskConfig::default();
1609 assert!(c.summarize_hits_enabled);
1610 }
1611
1612 #[test]
1613 fn ask_config_default_summarize_model_is_none() {
1614 let c = AskConfig::default();
1615 assert!(c.summarize_model.is_none());
1616 }
1617
1618 #[test]
1619 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1620 let y = r#"
1621conversations:
1622 ask:
1623 summarize_hits_enabled: false
1624 summarize_model: qwen3:4b
1625"#;
1626 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1627 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1628 assert!(!conv.ask.summarize_hits_enabled);
1629 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1630 }
1631
1632 #[test]
1633 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1634 let y = r#"
1638conversations:
1639 ask:
1640 min_score: 0.4
1641"#;
1642 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1643 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1644 assert!(conv.ask.summarize_hits_enabled);
1645 assert!(conv.ask.summarize_model.is_none());
1646 }
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 #[test]
1652 fn update_defaults_apply_when_block_absent_and_fields_parse() {
1653 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1654 assert_eq!(c.update, super::UpdateConfig::default());
1655 assert!(c.update.codesign_identity.is_none());
1656 assert!(c.update.restart_exclude.is_empty());
1657
1658 let c: super::Config = serde_yaml_ng::from_str(
1659 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
1660 )
1661 .unwrap();
1662 assert_eq!(
1663 c.update.codesign_identity.as_deref(),
1664 Some("Developer ID Application: X (TEAM)")
1665 );
1666 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
1667 }
1668
1669 #[test]
1670 fn federation_snapshot_defaults_apply_when_block_absent() {
1671 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1672 assert_eq!(c.federation_snapshot.poll_secs, 30);
1673 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1674 assert_eq!(
1675 c.federation_snapshot.min_lifecycle,
1676 crate::skill::stats::LifecycleState::Stable
1677 );
1678 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1680 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
1681 assert_eq!(c.memory.capture, super::CaptureMode::Off);
1682 }
1683
1684 use super::*;
1685
1686 #[test]
1687 fn default_bundled_model_id_is_qwen35_2b() {
1688 assert_eq!(
1689 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1690 "Qwen3.5-2B-MLX-4bit"
1691 );
1692 }
1693
1694 #[test]
1695 fn nudge_config_defaults() {
1696 let c = NudgeConfig::default();
1697 assert!(c.enabled);
1698 assert_eq!(c.daily_cap, 3);
1699 assert_eq!(c.snooze_days, 7);
1700 assert_eq!(c.threshold, 3);
1701 }
1702
1703 #[test]
1704 fn config_has_nudge_section_with_defaults() {
1705 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1706 assert_eq!(c.nudge.daily_cap, 3);
1707 }
1708
1709 #[test]
1710 fn storage_config_default_is_lancedb() {
1711 let c = StorageConfig::default();
1712 assert_eq!(c.vector_backend, "lancedb");
1713 assert_eq!(c.qdrant_url, None);
1714 assert_eq!(c.qdrant_api_key_ref, None);
1715 }
1716
1717 #[test]
1718 fn sources_global_config_has_sensible_defaults() {
1719 let c = SourcesGlobalConfig::default();
1720 assert_eq!(c.poll_interval_secs, 600);
1721 assert_eq!(c.max_chunks_per_sync, 10_000);
1722 assert_eq!(c.max_parallel_sources, 3);
1723 assert_eq!(c.default_weight, 1.0);
1724 assert_eq!(c.embedding_batch_size, 32);
1725 }
1726
1727 #[test]
1728 fn config_default_has_storage_and_sources_global() {
1729 let c = Config::default();
1730 assert_eq!(c.storage.vector_backend, "lancedb");
1731 assert_eq!(c.sources_global.default_weight, 1.0);
1732 }
1733
1734 #[test]
1735 fn config_loads_yaml_without_new_fields() {
1736 let yaml = r#"
1739embedding:
1740 provider: ollama
1741 model: test-model
1742 dimensions: 512
1743 ollama_endpoint: http://localhost:11434
1744"#;
1745 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1746 assert_eq!(c.storage.vector_backend, "lancedb");
1747 assert_eq!(c.sources_global.max_parallel_sources, 3);
1748 }
1749
1750 #[test]
1751 fn llm_config_to_backend_config_anthropic_passthrough() {
1752 let cfg = LlmConfig {
1753 provider: "anthropic".into(),
1754 model: "claude-haiku-4-5".into(),
1755 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1756 api_key_ref: None,
1757 openai_url: None,
1758 };
1759 let b = cfg.to_backend_config();
1760 assert_eq!(b.provider, "anthropic");
1761 assert_eq!(b.model, "claude-haiku-4-5");
1762 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1763 assert_eq!(b.endpoint, None);
1764 assert_eq!(b.timeout_secs, None);
1765 }
1766
1767 #[test]
1768 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1769 let cfg = LlmConfig {
1770 provider: "openai".into(),
1771 model: "gpt-4o-mini".into(),
1772 api_key_env: None,
1773 api_key_ref: None,
1774 openai_url: Some("https://api.together.xyz/v1".into()),
1775 };
1776 let b = cfg.to_backend_config();
1777 assert_eq!(b.provider, "openai");
1778 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1779 assert_eq!(b.api_key_env, None); }
1781
1782 #[test]
1783 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1784 let cfg = LlmConfig {
1785 provider: "ollama".into(),
1786 model: "qwen3:14b".into(),
1787 api_key_env: None,
1788 api_key_ref: None,
1789 openai_url: Some("http://192.168.1.10:11434".into()),
1790 };
1791 let b = cfg.to_backend_config();
1792 assert_eq!(b.provider, "ollama");
1793 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1794 }
1795
1796 #[test]
1797 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1798 let cfg = LlmConfig {
1802 provider: "custom-name".into(),
1803 model: "some-model".into(),
1804 api_key_env: Some("CUSTOM_KEY".into()),
1805 api_key_ref: None,
1806 openai_url: Some("https://my-proxy.local/v1".into()),
1807 };
1808 let b = cfg.to_backend_config();
1809 assert_eq!(
1810 b.provider, "openai",
1811 "unknown provider + openai_url should alias to openai"
1812 );
1813 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1814 }
1815
1816 #[test]
1817 fn api_key_ref_roundtrips_and_defaults_none() {
1818 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1820 assert_eq!(b.api_key_ref, None);
1821 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1822 assert_eq!(l.api_key_ref, None);
1823 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1824 assert_eq!(e.api_key_ref, None);
1825
1826 let l2 = LlmConfig {
1828 api_key_ref: Some("keychain:mur/anthropic".into()),
1829 ..Default::default()
1830 };
1831 let y = serde_yaml_ng::to_string(&l2).unwrap();
1832 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1833 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1834 assert_eq!(
1835 l3.to_backend_config().api_key_ref.as_deref(),
1836 Some("keychain:mur/anthropic")
1837 );
1838 }
1839
1840 #[test]
1841 fn open_items_muted_parses_and_defaults_empty() {
1842 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
1843 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
1844
1845 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
1846 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
1847 }
1848
1849 #[test]
1852 fn unreadable_config_yields_no_mutes() {
1853 let tmp = tempfile::tempdir().unwrap();
1854 let path = tmp.path().join("config.yaml");
1855 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
1856 let cfg = Config::load_or_default(&path);
1857 assert!(
1858 cfg.open_items.muted.is_empty(),
1859 "a broken config must hide nothing"
1860 );
1861
1862 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
1864 assert!(missing.open_items.muted.is_empty());
1865 }
1866
1867 #[test]
1868 fn rollup_config_accepts_backend_overrides() {
1869 let yaml = r#"
1870enabled: true
1871extractive_backend:
1872 provider: openai
1873 model: Qwen3.5-4B-MLX-4bit
1874 endpoint: http://127.0.0.1:8000/v1
1875"#;
1876 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1877 let b = c.extractive_backend.expect("override present");
1878 assert_eq!(b.provider, "openai");
1879 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
1880 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
1881 assert!(c.abstractive_backend.is_none());
1882 }
1883
1884 #[test]
1885 fn legacy_conversation_fields_are_gone_from_serialized_output() {
1886 let cfg = Config::default();
1887 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
1893 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
1894 assert!(
1895 !yaml.contains(key),
1896 "legacy key {key} still serialized:\n{yaml}"
1897 );
1898 }
1899 }
1900
1901 #[test]
1902 fn embedding_ollama_endpoint_is_omitted_when_unset() {
1903 let mut cfg = Config::default();
1904 cfg.embedding.provider = "omlx".into();
1905 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
1906 cfg.embedding.ollama_endpoint = None;
1907 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
1908 assert!(
1909 !yaml.contains("ollama_endpoint"),
1910 "dead field re-emitted:\n{yaml}"
1911 );
1912 }
1913
1914 #[test]
1915 fn embedding_ollama_endpoint_still_round_trips_when_set() {
1916 let yaml =
1917 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
1918 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1919 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
1920 }
1921}
1922
1923#[cfg(test)]
1924mod backend_config_tests {
1925 use super::*;
1926
1927 #[test]
1928 fn default_is_ollama_qwen3() {
1929 let cfg = BackendConfig::default();
1930 assert_eq!(cfg.provider, "ollama");
1931 assert_eq!(cfg.model, "qwen3.5:4b");
1932 assert_eq!(cfg.endpoint, None);
1933 assert_eq!(cfg.api_key_env, None);
1934 assert_eq!(cfg.timeout_secs, None);
1935 }
1936
1937 #[test]
1938 fn deserializes_anthropic_full() {
1939 let yaml = "\
1940provider: anthropic
1941model: claude-haiku-4-5
1942api_key_env: ANTHROPIC_API_KEY
1943timeout_secs: 60
1944";
1945 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1946 assert_eq!(cfg.provider, "anthropic");
1947 assert_eq!(cfg.model, "claude-haiku-4-5");
1948 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1949 assert_eq!(cfg.timeout_secs, Some(60));
1950 assert_eq!(cfg.endpoint, None);
1951 }
1952
1953 #[test]
1954 fn deserializes_partial_fills_defaults() {
1955 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
1956 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1957 assert_eq!(cfg.provider, "anthropic");
1958 assert_eq!(cfg.model, "claude-sonnet-5");
1959 assert_eq!(cfg.api_key_env, None);
1960 assert_eq!(cfg.timeout_secs, None);
1961 }
1962
1963 #[test]
1964 fn round_trips_through_yaml() {
1965 let original = BackendConfig {
1966 provider: "anthropic".into(),
1967 model: "claude-haiku-4-5".into(),
1968 endpoint: Some("https://api.anthropic.com".into()),
1969 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1970 api_key_ref: None,
1971 timeout_secs: Some(60),
1972 };
1973 let yaml = serde_yaml::to_string(&original).unwrap();
1974 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1975 assert_eq!(parsed, original);
1976 }
1977
1978 #[test]
1979 fn skills_config_curation_gate_defaults_on() {
1980 let c = SkillsConfig::default();
1981 assert!(c.require_human_curation_before_stable);
1982 }
1983}
1984
1985#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1993#[serde(rename_all = "lowercase")]
1994pub enum DevDisciplineIndex {
1995 #[default]
1997 Auto,
1998 Always,
2000 Never,
2002}
2003
2004#[derive(Debug, Clone, Serialize, Deserialize)]
2005#[serde(default)]
2006pub struct SkillsConfig {
2007 pub max_skills_in_prompt: usize,
2008 pub max_total_tokens: usize,
2009 pub priority_order: Vec<String>,
2010 pub adaptive: Option<AdaptiveSkillsConfig>,
2011
2012 #[serde(default = "default_require_human_curation")]
2016 pub require_human_curation_before_stable: bool,
2017
2018 #[serde(default)]
2022 pub lifecycle: SkillLifecycleConfig,
2023
2024 #[serde(default = "default_auto_upgrade")]
2029 pub auto_upgrade: bool,
2030
2031 #[serde(default)]
2033 pub dev_discipline_index: DevDisciplineIndex,
2034}
2035
2036fn default_require_human_curation() -> bool {
2037 true
2038}
2039
2040fn default_auto_upgrade() -> bool {
2041 true
2042}
2043
2044impl Default for SkillsConfig {
2045 fn default() -> Self {
2046 Self {
2047 max_skills_in_prompt: 5,
2048 max_total_tokens: 2000,
2049 priority_order: vec!["agent".into(), "global".into()],
2050 adaptive: Some(AdaptiveSkillsConfig::default()),
2051 require_human_curation_before_stable: default_require_human_curation(),
2052 lifecycle: SkillLifecycleConfig::default(),
2053 auto_upgrade: default_auto_upgrade(),
2054 dev_discipline_index: DevDisciplineIndex::default(),
2055 }
2056 }
2057}
2058
2059#[derive(Debug, Clone, Serialize, Deserialize)]
2065#[serde(default)]
2066pub struct SkillLifecycleConfig {
2067 pub note_rule_half_life_factor: f64,
2071 pub note_fact_half_life_factor: f64,
2074 pub promote_draft_uses: u64,
2076 pub promote_emerging_uses: u64,
2077 pub promote_emerging_success_rate: f64,
2078 pub promote_emerging_age_days: i64,
2079 pub promote_stable_uses: u64,
2080 pub promote_stable_success_rate: f64,
2081 pub promote_stable_age_days: i64,
2082
2083 pub demote_emerging_uses: u64,
2085 pub demote_emerging_success_rate: f64,
2086 pub demote_stable_uses: u64,
2087 pub demote_stable_success_rate: f64,
2088 pub deprecated_success_rate: f64,
2089 pub deprecated_no_success_days: i64,
2090
2091 pub auto_archive_confidence: f64,
2093 pub auto_archive_age_days: i64,
2094
2095 pub broken_workflow_streak: u32,
2100
2101 pub archive_destroy_grace_days: i64,
2106}
2107
2108impl Default for SkillLifecycleConfig {
2109 fn default() -> Self {
2110 Self {
2111 promote_draft_uses: 3,
2112 promote_emerging_uses: 10,
2113 promote_emerging_success_rate: 0.6,
2114 promote_emerging_age_days: 7,
2115 promote_stable_uses: 30,
2116 promote_stable_success_rate: 0.8,
2117 promote_stable_age_days: 30,
2118 demote_emerging_uses: 8,
2119 demote_emerging_success_rate: 0.55,
2120 demote_stable_uses: 25,
2121 demote_stable_success_rate: 0.75,
2122 deprecated_success_rate: 0.3,
2123 deprecated_no_success_days: 90,
2124 auto_archive_confidence: 0.10,
2125 auto_archive_age_days: 180,
2126 broken_workflow_streak: 3,
2127 archive_destroy_grace_days: 30,
2128 note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
2129 note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
2130 }
2131 }
2132}
2133
2134#[derive(Debug, Clone, Serialize, Deserialize)]
2135#[serde(default)]
2136pub struct AdaptiveSkillsConfig {
2137 pub context_fill_decay: f64,
2138 pub min_remaining_context_ratio: f64,
2139 pub recent_fire_boost_turns: usize,
2140 pub model_max_context_tokens: u64,
2144}
2145
2146impl Default for AdaptiveSkillsConfig {
2147 fn default() -> Self {
2148 Self {
2149 context_fill_decay: 1.5,
2150 min_remaining_context_ratio: 0.20,
2151 recent_fire_boost_turns: 5,
2152 model_max_context_tokens: 200_000,
2153 }
2154 }
2155}
2156
2157#[derive(Debug, Clone, Serialize, Deserialize)]
2160pub struct SleepCycleConfig {
2161 #[serde(default)]
2163 pub enabled: bool,
2164
2165 #[serde(default = "default_idle_threshold_minutes")]
2167 pub idle_threshold_minutes: u64,
2168
2169 #[serde(default = "default_agent_idle_minutes")]
2171 pub agent_idle_minutes: u64,
2172}
2173
2174fn default_idle_threshold_minutes() -> u64 {
2175 15
2176}
2177
2178fn default_agent_idle_minutes() -> u64 {
2179 5
2180}
2181
2182impl Default for SleepCycleConfig {
2183 fn default() -> Self {
2184 Self {
2185 enabled: false,
2186 idle_threshold_minutes: default_idle_threshold_minutes(),
2187 agent_idle_minutes: default_agent_idle_minutes(),
2188 }
2189 }
2190}
2191
2192#[derive(Debug, Clone, Serialize, Deserialize)]
2195pub struct NudgeConfig {
2196 #[serde(default = "default_nudge_enabled")]
2198 pub enabled: bool,
2199 #[serde(default = "default_nudge_daily_cap")]
2200 pub daily_cap: u32,
2201 #[serde(default = "default_nudge_snooze_days")]
2202 pub snooze_days: u32,
2203 #[serde(default = "default_nudge_threshold")]
2204 pub threshold: usize,
2205}
2206
2207fn default_nudge_enabled() -> bool {
2208 true
2209}
2210fn default_nudge_daily_cap() -> u32 {
2211 3
2212}
2213fn default_nudge_snooze_days() -> u32 {
2214 7
2215}
2216fn default_nudge_threshold() -> usize {
2217 3
2218}
2219
2220impl Default for NudgeConfig {
2221 fn default() -> Self {
2222 Self {
2223 enabled: true,
2224 daily_cap: default_nudge_daily_cap(),
2225 snooze_days: default_nudge_snooze_days(),
2226 threshold: default_nudge_threshold(),
2227 }
2228 }
2229}
2230
2231#[derive(Debug, Clone, Serialize, Deserialize)]
2235pub struct SessionCfg {
2236 #[serde(default = "default_capture_mode")]
2238 pub capture: String,
2239 #[serde(default = "default_retention_days")]
2241 pub retention_days: u32,
2242}
2243
2244impl Default for SessionCfg {
2245 fn default() -> Self {
2246 Self {
2247 capture: default_capture_mode(),
2248 retention_days: default_retention_days(),
2249 }
2250 }
2251}
2252
2253fn default_capture_mode() -> String {
2254 "ambient".to_string()
2255}
2256fn default_retention_days() -> u32 {
2257 14
2258}
2259
2260#[derive(Debug, Clone, Serialize, Deserialize)]
2262pub struct HarvestCfg {
2263 #[serde(default = "default_harvest_enabled")]
2265 pub auto_gate: bool,
2266 #[serde(default = "default_harvest_llm")]
2268 pub llm: String,
2269 #[serde(default = "default_min_events")]
2271 pub min_events: usize,
2272 #[serde(default = "default_min_user_turns")]
2273 pub min_user_turns: usize,
2274 #[serde(default = "default_min_duration_secs")]
2275 pub min_duration_secs: i64,
2276 #[serde(default = "default_idle_minutes")]
2278 pub idle_minutes: i64,
2279 #[serde(default = "default_max_steps")]
2282 pub max_steps: usize,
2283 #[serde(default = "default_max_duration_secs")]
2284 pub max_duration_secs: i64,
2285 #[serde(default = "default_max_llm_calls_per_day")]
2287 pub max_llm_calls_per_day: u32,
2288 #[serde(default = "default_max_extract_input_tokens")]
2289 pub max_extract_input_tokens: usize,
2290 #[serde(default = "default_harvest_enabled")]
2292 pub session_start_hint: bool,
2293 #[serde(default = "default_similarity_merge_threshold")]
2296 pub similarity_merge_threshold: f32,
2297 #[serde(default = "default_min_occurrences")]
2301 pub min_occurrences: usize,
2302}
2303
2304impl Default for HarvestCfg {
2305 fn default() -> Self {
2306 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2307 }
2308}
2309
2310fn default_harvest_enabled() -> bool {
2311 true
2312}
2313fn default_harvest_llm() -> String {
2314 "local-first".to_string()
2315}
2316fn default_min_events() -> usize {
2317 5
2318}
2319fn default_min_user_turns() -> usize {
2320 2
2321}
2322fn default_min_duration_secs() -> i64 {
2323 120
2324}
2325fn default_idle_minutes() -> i64 {
2326 30
2327}
2328fn default_max_steps() -> usize {
2332 20
2333}
2334fn default_max_duration_secs() -> i64 {
2337 1800
2338}
2339fn default_max_llm_calls_per_day() -> u32 {
2340 10
2341}
2342fn default_max_extract_input_tokens() -> usize {
2343 12000
2344}
2345fn default_similarity_merge_threshold() -> f32 {
2346 0.6
2347}
2348fn default_min_occurrences() -> usize {
2351 2
2352}
2353
2354#[derive(Debug, Clone, Serialize, Deserialize)]
2357#[serde(default)]
2358pub struct CrossAgentConfig {
2359 #[serde(default = "default_half_life_days")]
2360 pub fitness_half_life_days: u32,
2361 #[serde(default = "default_fitness_floor")]
2362 pub fitness_floor: f64,
2363}
2364
2365fn default_half_life_days() -> u32 {
2366 7
2367}
2368fn default_fitness_floor() -> f64 {
2369 0.1
2370}
2371
2372impl Default for CrossAgentConfig {
2373 fn default() -> Self {
2374 Self {
2375 fitness_half_life_days: default_half_life_days(),
2376 fitness_floor: default_fitness_floor(),
2377 }
2378 }
2379}
2380
2381#[derive(Debug, Clone, Serialize, Deserialize)]
2384#[serde(default)]
2385pub struct SkillLlmConfig {
2386 #[serde(default = "default_per_call_token_cap")]
2388 pub per_call_token_cap: u32,
2389
2390 #[serde(default = "default_per_day_usd_cap")]
2392 pub per_day_usd_cap: f64,
2393
2394 #[serde(default = "default_cache_ttl_days")]
2396 pub cache_ttl_days: u32,
2397
2398 #[serde(default, skip_serializing_if = "Option::is_none")]
2400 pub model_ref: Option<String>,
2401}
2402
2403fn default_per_call_token_cap() -> u32 {
2404 1500
2405}
2406fn default_per_day_usd_cap() -> f64 {
2407 0.50
2408}
2409fn default_cache_ttl_days() -> u32 {
2410 30
2411}
2412
2413impl Default for SkillLlmConfig {
2414 fn default() -> Self {
2415 Self {
2416 per_call_token_cap: default_per_call_token_cap(),
2417 per_day_usd_cap: default_per_day_usd_cap(),
2418 cache_ttl_days: default_cache_ttl_days(),
2419 model_ref: None,
2420 }
2421 }
2422}
2423#[cfg(test)]
2424mod per_stage_backend_tests {
2425 use super::*;
2426
2427 #[test]
2428 fn compact_extractive_backend_override_parses() {
2429 let yaml = "\
2430extractive_backend:
2431 provider: anthropic
2432 model: claude-haiku-4-5
2433 api_key_env: ANTHROPIC_API_KEY
2434";
2435 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2436 let extractive = cfg
2437 .extractive_backend
2438 .as_ref()
2439 .expect("override should parse");
2440 assert_eq!(extractive.provider, "anthropic");
2441 assert_eq!(extractive.model, "claude-haiku-4-5");
2442 assert!(cfg.abstractive_backend.is_none());
2443 }
2444
2445 #[test]
2446 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2447 let yaml = "\
2448backend:
2449 provider: anthropic
2450 model: claude-sonnet-5
2451 api_key_env: ANTHROPIC_API_KEY
2452rewriter_backend:
2453 provider: ollama
2454 model: llama3.2:3b
2455";
2456 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2457 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2458 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2459 }
2460
2461 #[test]
2462 fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2463 let cfg = AskConfig {
2474 backend: Some(BackendConfig {
2475 provider: "anthropic".into(),
2476 model: "claude-sonnet-5".into(),
2477 endpoint: None,
2478 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2479 api_key_ref: None,
2480 timeout_secs: None,
2481 }),
2482 ..Default::default()
2483 };
2484 let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2485 assert_eq!(rewriter.provider, "anthropic");
2486 assert_eq!(rewriter.model, "claude-sonnet-5");
2487 assert_eq!(
2488 rewriter.timeout_secs,
2489 Some(cfg.rewriter_timeout_secs as u64),
2490 "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2491 );
2492 }
2493
2494 #[test]
2495 fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2496 let mut cfg = AskConfig {
2497 rewriter_timeout_secs: 8,
2498 ..AskConfig::default()
2499 };
2500 cfg.rewriter_backend = Some(BackendConfig {
2501 provider: "anthropic".into(),
2502 model: "claude-haiku-4-5".into(),
2503 endpoint: None,
2504 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2505 api_key_ref: None,
2506 timeout_secs: Some(30),
2507 });
2508 let b = cfg.effective_rewriter_backend(&omlx_llm());
2509 assert_eq!(
2510 b.timeout_secs,
2511 Some(30),
2512 "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2513 );
2514 }
2515
2516 fn omlx_llm() -> LlmConfig {
2517 LlmConfig {
2518 provider: "omlx".into(),
2519 model: "Qwen3.5-4B-MLX-4bit".into(),
2520 api_key_env: None,
2521 api_key_ref: Some("env:OMLX_API_KEY".into()),
2522 openai_url: Some("http://127.0.0.1:8000/v1".into()),
2523 }
2524 }
2525
2526 #[test]
2527 fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2528 let ask = AskConfig::default();
2529 let b = ask.effective_backend(&omlx_llm());
2530 assert_eq!(b.provider, "openai");
2531 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2532 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2533 assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2534 assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2536 }
2537
2538 #[test]
2539 fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2540 let ask = AskConfig::default();
2541 let b = ask.effective_rewriter_backend(&omlx_llm());
2542 assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2543 assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2544 }
2545
2546 #[test]
2547 fn explicit_override_wins_over_the_smart_slot() {
2548 let ask = AskConfig {
2549 backend: Some(BackendConfig {
2550 provider: "anthropic".into(),
2551 model: "claude-haiku-4-5".into(),
2552 endpoint: None,
2553 api_key_env: None,
2554 api_key_ref: None,
2555 timeout_secs: Some(42),
2556 }),
2557 ..Default::default()
2558 };
2559 let b = ask.effective_backend(&omlx_llm());
2560 assert_eq!(b.provider, "anthropic");
2561 assert_eq!(b.timeout_secs, Some(42));
2562 }
2563
2564 #[test]
2565 fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2566 let llm = omlx_llm();
2567 for b in [
2568 CompactConfig::default().effective_extractive_backend(&llm),
2569 CompactConfig::default().effective_abstractive_backend(&llm),
2570 RollupConfig::default().effective_extractive_backend(&llm),
2571 RollupConfig::default().effective_abstractive_backend(&llm),
2572 ] {
2573 assert_eq!(b.provider, "openai");
2574 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2575 assert_eq!(b.timeout_secs, Some(120));
2576 }
2577 }
2578
2579 #[test]
2580 fn rollup_override_is_honored() {
2581 let r = RollupConfig {
2582 abstractive_backend: Some(BackendConfig {
2583 provider: "ollama".into(),
2584 model: "qwen3:4b".into(),
2585 endpoint: Some("http://box.local:11434".into()),
2586 api_key_env: None,
2587 api_key_ref: None,
2588 timeout_secs: None,
2589 }),
2590 ..Default::default()
2591 };
2592 let b = r.effective_abstractive_backend(&omlx_llm());
2593 assert_eq!(b.provider, "ollama");
2594 assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2595 }
2596}
2597
2598#[cfg(test)]
2599mod skills_config_tests {
2600 use super::*;
2601
2602 #[test]
2603 fn empty_yaml_hydrates_defaults() {
2604 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2605 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2606 assert_eq!(cfg.skills.max_total_tokens, 2000);
2607 assert!(cfg.skills.adaptive.is_some());
2608 }
2609
2610 #[test]
2611 fn load_or_default_missing_file_returns_default() {
2612 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2613 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2614 }
2615
2616 #[test]
2617 fn dev_discipline_index_defaults_auto_and_parses() {
2618 use crate::config::DevDisciplineIndex;
2619 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2620 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2621 let cfg: Config =
2622 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2623 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2624 let cfg: Config =
2625 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2626 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2627 }
2628}
2629
2630#[cfg(test)]
2631mod runs_config_tests {
2632 use super::*;
2633
2634 #[test]
2640 fn zero_heartbeat_values_clamp_to_defaults_at_load() {
2641 let tmp = tempfile::tempdir().unwrap();
2642 let path = tmp.path().join("config.yaml");
2643 std::fs::write(
2644 &path,
2645 "runs:\n heartbeat_interval_secs: 0\n heartbeat_stale_after_intervals: 0\n",
2646 )
2647 .unwrap();
2648
2649 let cfg = Config::load_or_default(&path);
2650 assert_eq!(
2651 cfg.runs.heartbeat_interval_secs, 10,
2652 "a zero interval must load as the default, not 0"
2653 );
2654 assert_eq!(
2655 cfg.runs.heartbeat_stale_after_intervals, 3,
2656 "a zero interval count must load as the default, not 0"
2657 );
2658 }
2659
2660 #[test]
2662 fn positive_heartbeat_values_survive_the_load() {
2663 let tmp = tempfile::tempdir().unwrap();
2664 let path = tmp.path().join("config.yaml");
2665 std::fs::write(
2666 &path,
2667 "runs:\n heartbeat_interval_secs: 60\n heartbeat_stale_after_intervals: 2\n",
2668 )
2669 .unwrap();
2670
2671 let cfg = Config::load_or_default(&path);
2672 assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
2673 assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
2674 }
2675}
2676
2677#[cfg(test)]
2678mod ambient_capture_cfg_tests {
2679 use super::*;
2680
2681 #[test]
2682 fn session_and_harvest_defaults() {
2683 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2684 assert_eq!(cfg.session.capture, "ambient");
2685 assert_eq!(cfg.session.retention_days, 14);
2686 assert!(cfg.harvest.auto_gate);
2687 assert_eq!(cfg.harvest.llm, "local-first");
2688 assert_eq!(cfg.harvest.min_events, 5);
2689 assert_eq!(cfg.harvest.min_user_turns, 2);
2690 assert_eq!(cfg.harvest.min_duration_secs, 120);
2691 assert_eq!(cfg.harvest.idle_minutes, 30);
2692 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2693 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2694 assert!(cfg.harvest.session_start_hint);
2695 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2696 }
2697
2698 #[test]
2699 fn session_capture_override_parses() {
2700 let cfg: Config =
2701 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2702 assert_eq!(cfg.session.capture, "off");
2703 assert_eq!(cfg.session.retention_days, 3);
2704 }
2705}
2706
2707#[cfg(test)]
2708mod cc_proxy_cfg_tests {
2709 use super::*;
2710
2711 #[test]
2712 fn defaults_to_local_cc_proxy_enabled() {
2713 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2714 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2715 assert!(cfg.cc_proxy.enabled);
2716 }
2717
2718 #[test]
2719 fn url_and_enabled_override_parse() {
2720 let cfg: Config =
2721 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2722 .unwrap();
2723 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2724 assert!(!cfg.cc_proxy.enabled);
2725 }
2726
2727 #[test]
2728 fn partial_section_keeps_other_default() {
2729 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2731 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2732 assert!(!cfg.cc_proxy.enabled);
2733 }
2734}
2735
2736#[cfg(test)]
2737mod model_switch_config_tests {
2738 use super::*;
2739
2740 #[test]
2741 fn model_switch_config_defaults_and_omitted_block() {
2742 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2744 assert_eq!(cfg.models.default, None);
2745 assert!(cfg.models.fallback_chain.is_empty());
2746 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2747 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2748 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2749 assert!(!cfg.models.routing.enabled);
2750
2751 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";
2753 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2754 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2755 assert_eq!(
2756 cfg.models.fallback_chain,
2757 vec!["claude_sonnet", "deepseek_v4_pro"]
2758 );
2759 assert!(cfg.models.routing.enabled);
2760 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2761 }
2762
2763 #[test]
2764 fn smart_config_defaults_off_with_autopick() {
2765 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2766 assert!(
2767 !cfg.models.smart.enabled,
2768 "Smart background routing is opt-in (capability-gate spec §2)"
2769 );
2770 assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2772 cfg.models.smart.max_escalations,
2773 DEFAULT_SMART_MAX_ESCALATIONS
2774 );
2775 }
2776
2777 #[test]
2778 fn smart_override_inherits_field_by_field() {
2779 let global = SmartConfig {
2780 enabled: true,
2781 cheap: Some("g".into()),
2782 max_escalations: 3,
2783 };
2784 assert_eq!(global.merged(None), global);
2785 assert_eq!(global.merged(Some(&SmartOverride::default())), global);
2786 let only_cheap = SmartOverride {
2788 cheap: Some("a".into()),
2789 ..Default::default()
2790 };
2791 let m = global.merged(Some(&only_cheap));
2792 assert!(m.enabled, "overriding cheap must not disable Smart");
2793 assert_eq!(m.cheap.as_deref(), Some("a"));
2794 assert_eq!(m.max_escalations, 3);
2795 let off = SmartOverride {
2797 enabled: Some(false),
2798 ..Default::default()
2799 };
2800 assert!(!global.merged(Some(&off)).enabled);
2801 }
2802
2803 #[test]
2804 fn routing_override_inherits_field_by_field() {
2805 let global = RoutingConfig {
2806 enabled: true,
2807 cheap: Some("c".into()),
2808 frontier: Some("f".into()),
2809 threshold_input_tokens: Some(9),
2810 };
2811 let only_cheap = RoutingOverride {
2812 cheap: Some("a".into()),
2813 ..Default::default()
2814 };
2815 let m = global.merged(Some(&only_cheap));
2816 assert!(m.enabled, "overriding cheap must not disable routing");
2817 assert_eq!(m.cheap.as_deref(), Some("a"));
2818 assert_eq!(m.frontier.as_deref(), Some("f"));
2819 assert_eq!(m.threshold_input_tokens, Some(9));
2820 }
2821}