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
218#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
221pub struct UpdateConfig {
222 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub codesign_identity: Option<String>,
229 #[serde(default, skip_serializing_if = "Vec::is_empty")]
231 pub restart_exclude: Vec<String>,
232}
233
234#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
240pub struct ParallelJobsConfig {
241 #[serde(default)]
244 pub targets: Vec<String>,
245}
246
247#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
253#[serde(default)]
254pub struct SnapshotConfig {
255 pub poll_secs: u64,
257 pub request_max_age_secs: u64,
259 pub min_lifecycle: crate::skill::stats::LifecycleState,
261}
262
263impl Default for SnapshotConfig {
264 fn default() -> Self {
265 Self {
266 poll_secs: 30,
267 request_max_age_secs: 600,
268 min_lifecycle: crate::skill::stats::LifecycleState::Stable,
269 }
270 }
271}
272
273#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
277#[serde(default)]
278pub struct MemoryConfig {
279 pub capture: CaptureMode,
280}
281
282impl Default for MemoryConfig {
283 fn default() -> Self {
284 Self {
285 capture: CaptureMode::AutoAnnounce,
286 }
287 }
288}
289
290#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
292#[serde(rename_all = "snake_case")]
293pub enum CaptureMode {
294 Ask,
296 AutoAnnounce,
298 Off,
300}
301
302#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
310pub struct FleetRunConfig {
311 #[serde(default)]
313 pub agents: Vec<String>,
314 #[serde(default)]
316 pub fleets: Vec<String>,
317}
318
319#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
326pub struct OpenItemsConfig {
327 #[serde(default)]
330 pub muted: Vec<String>,
331}
332
333#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
340pub struct FleetConfig {
341 #[serde(default)]
343 pub autorun: bool,
344}
345
346#[cfg(test)]
347mod fleet_config_tests {
348 use super::*;
349
350 #[test]
351 fn fleet_config_defaults_off_and_roundtrips() {
352 assert!(!FleetConfig::default().autorun);
353
354 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
355 assert!(cfg.fleet.autorun);
356
357 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
359 assert!(!cfg2.fleet.autorun);
360 }
361
362 #[test]
363 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
364 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
366 assert!(cfg.fleet_run.agents.is_empty());
367 assert!(cfg.fleet_run.fleets.is_empty());
368
369 let cfg2: Config =
370 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
371 .unwrap();
372 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
373 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
374 }
375}
376
377#[derive(Debug, Clone, Serialize, Deserialize)]
388pub struct CcProxyConfig {
389 #[serde(default = "default_cc_proxy_url")]
391 pub url: String,
392
393 #[serde(default = "default_true")]
396 pub enabled: bool,
397}
398
399fn default_cc_proxy_url() -> String {
400 "http://127.0.0.1:8088".to_string()
401}
402
403fn default_true() -> bool {
404 true
405}
406
407impl Default for CcProxyConfig {
408 fn default() -> Self {
409 Self {
410 url: default_cc_proxy_url(),
411 enabled: true,
412 }
413 }
414}
415
416#[derive(Debug, Clone, Serialize, Deserialize, Default)]
419pub struct CliConfig {
420 pub skin: Option<String>,
423}
424
425#[derive(Debug, Clone, Serialize, Deserialize, Default)]
428pub struct MobileRelayConfig {
429 #[serde(default, skip_serializing_if = "Option::is_none")]
432 pub relay_url: Option<String>,
433
434 #[serde(default, skip_serializing_if = "Option::is_none")]
437 pub api_key: Option<String>,
438}
439
440impl Config {
441 pub fn load_or_default(path: &std::path::Path) -> Self {
446 let Ok(text) = std::fs::read_to_string(path) else {
447 return Self::default();
448 };
449 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
450 serde_yaml_ng::from_str(&text).unwrap_or_default()
451 }
452}
453
454#[derive(Debug, Clone, Serialize, Deserialize, Default)]
455pub struct SyncConfig {
456 #[serde(default = "default_sync_method")]
458 pub method: String,
459
460 #[serde(default, skip_serializing_if = "Option::is_none")]
462 pub git_remote: Option<String>,
463
464 #[serde(default)]
466 pub auto: bool,
467
468 #[serde(default, skip_serializing_if = "Option::is_none")]
470 pub team_id: Option<String>,
471}
472
473fn default_sync_method() -> String {
474 "local".to_string()
475}
476
477#[derive(Debug, Clone, Serialize, Deserialize)]
478pub struct ServerConfig {
479 #[serde(default = "default_server_url")]
481 pub url: String,
482}
483
484impl Default for ServerConfig {
485 fn default() -> Self {
486 Self {
487 url: default_server_url(),
488 }
489 }
490}
491
492#[derive(Debug, Clone, Serialize, Deserialize, Default)]
493pub struct CommunityConfig {
494 #[serde(default)]
496 pub enabled: bool,
497}
498
499#[derive(Debug, Clone, Serialize, Deserialize)]
500pub struct EmbeddingConfig {
501 #[serde(default = "default_embedding_provider")]
503 pub provider: String,
504
505 #[serde(default = "default_embedding_model")]
507 pub model: String,
508
509 #[serde(default = "default_dimensions")]
511 pub dimensions: usize,
512
513 #[serde(default, skip_serializing_if = "Option::is_none")]
517 pub ollama_endpoint: Option<String>,
518
519 #[serde(default, skip_serializing_if = "Option::is_none")]
521 pub api_key_env: Option<String>,
522
523 #[serde(default, skip_serializing_if = "Option::is_none")]
526 pub api_key_ref: Option<String>,
527
528 #[serde(default, skip_serializing_if = "Option::is_none")]
530 pub openai_url: Option<String>,
531}
532
533impl Default for EmbeddingConfig {
534 fn default() -> Self {
535 Self {
536 provider: default_embedding_provider(),
537 model: default_embedding_model(),
538 dimensions: default_dimensions(),
539 ollama_endpoint: Some(default_ollama_endpoint()),
540 api_key_env: None,
541 api_key_ref: None,
542 openai_url: None,
543 }
544 }
545}
546
547#[derive(Debug, Clone, Serialize, Deserialize)]
548pub struct LlmConfig {
549 #[serde(default = "default_llm_provider")]
551 pub provider: String,
552
553 #[serde(default = "default_llm_model")]
554 pub model: String,
555
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub api_key_env: Option<String>,
559
560 #[serde(default, skip_serializing_if = "Option::is_none")]
563 pub api_key_ref: Option<String>,
564
565 #[serde(default, skip_serializing_if = "Option::is_none")]
567 pub openai_url: Option<String>,
568}
569
570impl Default for LlmConfig {
571 fn default() -> Self {
572 Self {
573 provider: default_llm_provider(),
574 model: default_llm_model(),
575 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
576 api_key_ref: None,
577 openai_url: None,
578 }
579 }
580}
581
582impl LlmConfig {
583 pub fn to_backend_config(&self) -> BackendConfig {
596 let provider = match self.provider.as_str() {
597 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
598 _ if self.openai_url.is_some() => "openai".into(),
599 other => other.into(), };
601 BackendConfig {
602 provider,
603 model: self.model.clone(),
604 endpoint: self.openai_url.clone(),
605 api_key_env: self.api_key_env.clone(),
606 api_key_ref: self.api_key_ref.clone(),
607 timeout_secs: None,
608 }
609 }
610}
611
612#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
623#[serde(default)]
624pub struct BackendConfig {
625 pub provider: String,
627 pub model: String,
629 pub endpoint: Option<String>,
632 pub api_key_env: Option<String>,
634 pub api_key_ref: Option<String>,
636 pub timeout_secs: Option<u64>,
638}
639
640impl Default for BackendConfig {
641 fn default() -> Self {
642 Self {
643 provider: "ollama".into(),
644 model: DEFAULT_LOCAL_LLM_MODEL.into(),
645 endpoint: None,
646 api_key_env: None,
647 api_key_ref: None,
648 timeout_secs: None,
649 }
650 }
651}
652
653#[derive(Debug, Clone, Serialize, Deserialize)]
654pub struct RetrievalConfig {
655 #[serde(default = "default_max_patterns")]
657 pub max_patterns: usize,
658
659 #[serde(default = "default_max_tokens")]
661 pub max_tokens: usize,
662
663 #[serde(default = "default_min_score")]
665 pub min_score: f64,
666
667 #[serde(default = "default_mmr_threshold")]
669 pub mmr_threshold: f64,
670
671 #[serde(default = "default_reserved_note_slots")]
674 pub reserved_note_slots: usize,
675}
676
677impl Default for RetrievalConfig {
678 fn default() -> Self {
679 Self {
680 max_patterns: default_max_patterns(),
681 max_tokens: default_max_tokens(),
682 min_score: default_min_score(),
683 mmr_threshold: default_mmr_threshold(),
684 reserved_note_slots: default_reserved_note_slots(),
685 }
686 }
687}
688
689fn default_reserved_note_slots() -> usize {
690 1
691}
692
693#[derive(Debug, Clone, Serialize, Deserialize)]
694pub struct PathConfig {
695 #[serde(default = "default_mur_dir")]
697 pub mur_dir: PathBuf,
698}
699
700impl Default for PathConfig {
701 fn default() -> Self {
702 Self {
703 mur_dir: default_mur_dir(),
704 }
705 }
706}
707
708#[derive(Debug, Clone, Serialize, Deserialize)]
709pub struct StorageConfig {
710 #[serde(default = "default_vector_backend")]
712 pub vector_backend: String,
713
714 #[serde(default, skip_serializing_if = "Option::is_none")]
716 pub qdrant_url: Option<String>,
717
718 #[serde(default, skip_serializing_if = "Option::is_none")]
720 pub qdrant_api_key_ref: Option<String>,
721}
722
723impl Default for StorageConfig {
724 fn default() -> Self {
725 Self {
726 vector_backend: default_vector_backend(),
727 qdrant_url: None,
728 qdrant_api_key_ref: None,
729 }
730 }
731}
732
733fn default_vector_backend() -> String {
734 "lancedb".to_string()
735}
736
737#[derive(Debug, Clone, Serialize, Deserialize)]
738pub struct SourcesGlobalConfig {
739 #[serde(default = "default_poll_interval_secs")]
741 pub poll_interval_secs: u64,
742
743 #[serde(default = "default_max_chunks_per_sync")]
745 pub max_chunks_per_sync: usize,
746
747 #[serde(default = "default_max_parallel_sources")]
749 pub max_parallel_sources: usize,
750
751 #[serde(default = "default_source_weight")]
753 pub default_weight: f32,
754
755 #[serde(default = "default_embedding_batch_size")]
757 pub embedding_batch_size: usize,
758}
759
760impl Default for SourcesGlobalConfig {
761 fn default() -> Self {
762 Self {
763 poll_interval_secs: default_poll_interval_secs(),
764 max_chunks_per_sync: default_max_chunks_per_sync(),
765 max_parallel_sources: default_max_parallel_sources(),
766 default_weight: default_source_weight(),
767 embedding_batch_size: default_embedding_batch_size(),
768 }
769 }
770}
771
772fn default_poll_interval_secs() -> u64 {
773 600
774}
775fn default_max_chunks_per_sync() -> usize {
776 10_000
777}
778fn default_max_parallel_sources() -> usize {
779 3
780}
781fn default_source_weight() -> f32 {
782 1.0
783}
784fn default_embedding_batch_size() -> usize {
785 32
786}
787
788fn default_embedding_provider() -> String {
789 "ollama".to_string()
790}
791fn default_embedding_model() -> String {
792 "qwen3-embedding:0.6b".to_string()
793}
794fn default_dimensions() -> usize {
795 1024
796}
797fn default_ollama_endpoint() -> String {
798 DEFAULT_OLLAMA_ENDPOINT.to_string()
799}
800fn default_llm_provider() -> String {
801 "anthropic".to_string()
802}
803fn default_llm_model() -> String {
804 "claude-opus-5".to_string()
805}
806fn default_max_patterns() -> usize {
807 5
808}
809fn default_max_tokens() -> usize {
810 2000
811}
812fn default_min_score() -> f64 {
813 0.35
814}
815fn default_mmr_threshold() -> f64 {
816 0.85
817}
818fn default_mur_dir() -> PathBuf {
819 let home = std::env::var("HOME")
822 .map(PathBuf::from)
823 .unwrap_or_else(|_| PathBuf::from("/tmp"));
824 home.join(".mur")
825}
826fn default_server_url() -> String {
827 "https://mur-server.fly.dev".to_string()
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
833pub struct AskConfig {
834 #[serde(default = "ask_default_k_summary")]
835 pub k_summary: u32,
836 #[serde(default = "ask_default_k_raw")]
837 pub k_raw: u32,
838 #[serde(default = "ask_default_esc")]
839 pub escalation_threshold: f64,
840 #[serde(default = "ask_default_mmr")]
841 pub mmr_threshold: f64,
842 #[serde(default = "ask_default_max_ctx")]
843 pub max_context_tokens: u32,
844 #[serde(default = "ask_default_resp_tok")]
845 pub response_tokens: u32,
846 #[serde(default = "ask_default_timeout")]
847 pub timeout_secs: u32,
848 #[serde(default = "ask_default_min_score")]
849 pub min_score: f64,
850 #[serde(default = "ask_default_continue_history_turns")]
851 pub continue_history_turns: u32,
852 #[serde(default = "ask_default_rewriter_timeout")]
858 pub rewriter_timeout_secs: u32,
859 #[serde(default = "ask_default_compress_hits_enabled")]
860 pub compress_hits_enabled: bool,
861 #[serde(default = "ask_default_summarize_hits_enabled")]
862 pub summarize_hits_enabled: bool,
863 #[serde(default)]
864 pub summarize_model: Option<String>,
865 #[serde(default)]
868 pub backend: Option<BackendConfig>,
869 #[serde(default)]
873 pub rewriter_backend: Option<BackendConfig>,
874}
875
876impl AskConfig {
877 pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
882 self.backend.clone().unwrap_or_else(|| BackendConfig {
883 timeout_secs: Some(self.timeout_secs as u64),
884 ..llm.to_backend_config()
885 })
886 }
887
888 pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
898 self.rewriter_backend
899 .clone()
900 .unwrap_or_else(|| BackendConfig {
901 timeout_secs: Some(self.rewriter_timeout_secs as u64),
902 ..self
903 .backend
904 .clone()
905 .unwrap_or_else(|| llm.to_backend_config())
906 })
907 }
908}
909
910impl Default for AskConfig {
911 fn default() -> Self {
912 Self {
913 k_summary: ask_default_k_summary(),
914 k_raw: ask_default_k_raw(),
915 escalation_threshold: ask_default_esc(),
916 mmr_threshold: ask_default_mmr(),
917 max_context_tokens: ask_default_max_ctx(),
918 response_tokens: ask_default_resp_tok(),
919 timeout_secs: ask_default_timeout(),
920 min_score: ask_default_min_score(),
921 continue_history_turns: ask_default_continue_history_turns(),
922 rewriter_timeout_secs: ask_default_rewriter_timeout(),
923 compress_hits_enabled: ask_default_compress_hits_enabled(),
924 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
925 summarize_model: None,
926 backend: None,
927 rewriter_backend: None,
928 }
929 }
930}
931
932fn ask_default_k_summary() -> u32 {
933 5
934}
935fn ask_default_k_raw() -> u32 {
936 10
937}
938fn ask_default_esc() -> f64 {
939 0.5
940}
941fn ask_default_mmr() -> f64 {
942 0.88
943}
944fn ask_default_max_ctx() -> u32 {
945 6000
946}
947fn ask_default_resp_tok() -> u32 {
948 1024
949}
950fn ask_default_timeout() -> u32 {
951 120
952}
953fn ask_default_min_score() -> f64 {
954 0.35
955}
956fn ask_default_rewriter_timeout() -> u32 {
957 8
958}
959fn ask_default_continue_history_turns() -> u32 {
960 3
961}
962fn ask_default_compress_hits_enabled() -> bool {
963 true
964}
965fn ask_default_summarize_hits_enabled() -> bool {
966 true
967}
968
969#[derive(Debug, Clone, Serialize, Deserialize)]
978pub struct ConversationsConfig {
979 #[serde(default)]
980 pub enabled: bool,
981 #[serde(default = "conv_default_retention_days")]
982 pub retention_days: u32,
983 #[serde(default = "conv_default_poll_interval")]
984 pub poll_interval_secs: u64,
985 #[serde(default)]
986 pub sources: ConversationsSources,
987 #[serde(default)]
988 pub filter: ConversationsFilter,
989 #[serde(default)]
990 pub compact: CompactConfig,
991 #[serde(default)]
992 pub ask: AskConfig,
993 #[serde(default)]
994 pub rollup: RollupConfig,
995}
996
997impl Default for ConversationsConfig {
998 fn default() -> Self {
999 Self {
1000 enabled: false,
1001 retention_days: conv_default_retention_days(),
1002 poll_interval_secs: conv_default_poll_interval(),
1003 sources: ConversationsSources::default(),
1004 filter: ConversationsFilter::default(),
1005 compact: CompactConfig::default(),
1006 ask: AskConfig::default(),
1007 rollup: RollupConfig::default(),
1008 }
1009 }
1010}
1011
1012fn conv_default_retention_days() -> u32 {
1013 30
1014}
1015fn conv_default_poll_interval() -> u64 {
1016 300
1017}
1018fn conv_truthy() -> bool {
1019 true
1020}
1021fn conv_default_dedup() -> f64 {
1022 0.85
1023}
1024
1025#[derive(Debug, Clone, Serialize, Deserialize)]
1026pub struct CompactConfig {
1027 #[serde(default = "conv_truthy")]
1028 pub enabled_in_daemon: bool,
1029 #[serde(default = "compact_default_max_days")]
1030 pub max_days_per_run: u32,
1031 #[serde(default = "compact_default_max_spans")]
1032 pub max_extractive_spans: u32,
1033 #[serde(default = "compact_default_max_words")]
1034 pub max_abstractive_words: u32,
1035 #[serde(default = "compact_default_chunk_tokens")]
1036 pub chunk_tokens: u32,
1037 #[serde(default = "compact_default_history_retain")]
1038 pub history_retain: u32,
1039 #[serde(default = "compact_default_cron")]
1040 pub daemon_cron: String,
1041 #[serde(default)]
1044 pub extractive_backend: Option<BackendConfig>,
1045 #[serde(default)]
1048 pub abstractive_backend: Option<BackendConfig>,
1049}
1050
1051impl CompactConfig {
1052 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1057 self.extractive_backend
1058 .clone()
1059 .unwrap_or_else(|| BackendConfig {
1060 timeout_secs: Some(120),
1061 ..llm.to_backend_config()
1062 })
1063 }
1064
1065 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1068 self.abstractive_backend
1069 .clone()
1070 .unwrap_or_else(|| BackendConfig {
1071 timeout_secs: Some(120),
1072 ..llm.to_backend_config()
1073 })
1074 }
1075}
1076
1077impl Default for CompactConfig {
1078 fn default() -> Self {
1079 Self {
1080 enabled_in_daemon: true,
1081 max_days_per_run: compact_default_max_days(),
1082 max_extractive_spans: compact_default_max_spans(),
1083 max_abstractive_words: compact_default_max_words(),
1084 chunk_tokens: compact_default_chunk_tokens(),
1085 history_retain: compact_default_history_retain(),
1086 daemon_cron: compact_default_cron(),
1087 extractive_backend: None,
1088 abstractive_backend: None,
1089 }
1090 }
1091}
1092
1093fn compact_default_max_days() -> u32 {
1094 7
1095}
1096fn compact_default_max_spans() -> u32 {
1097 20
1098}
1099fn compact_default_max_words() -> u32 {
1100 400
1101}
1102fn compact_default_chunk_tokens() -> u32 {
1103 6000
1104}
1105fn compact_default_history_retain() -> u32 {
1106 5
1107}
1108fn compact_default_cron() -> String {
1109 "0 0 3 * * * *".into()
1110}
1111
1112#[derive(Debug, Clone, Serialize, Deserialize)]
1115pub struct RollupConfig {
1116 #[serde(default = "rollup_default_enabled")]
1117 pub enabled: bool,
1118 #[serde(default = "rollup_default_max_weeks")]
1119 pub max_weeks_per_run: u32,
1120 #[serde(default = "rollup_default_max_months")]
1121 pub max_months_per_run: u32,
1122 #[serde(default = "rollup_default_max_spans_week")]
1123 pub max_extractive_spans_per_week: u32,
1124 #[serde(default = "rollup_default_max_words_week")]
1125 pub max_abstractive_words_per_week: u32,
1126 #[serde(default = "rollup_default_max_spans_month")]
1127 pub max_extractive_spans_per_month: u32,
1128 #[serde(default = "rollup_default_max_words_month")]
1129 pub max_abstractive_words_per_month: u32,
1130 #[serde(default = "rollup_default_week_mmr")]
1131 pub week_mmr_threshold: f64,
1132 #[serde(default = "rollup_default_month_mmr")]
1133 pub month_mmr_threshold: f64,
1134 #[serde(default)]
1137 pub extractive_backend: Option<BackendConfig>,
1138 #[serde(default)]
1141 pub abstractive_backend: Option<BackendConfig>,
1142}
1143
1144impl Default for RollupConfig {
1145 fn default() -> Self {
1146 Self {
1147 enabled: rollup_default_enabled(),
1148 max_weeks_per_run: rollup_default_max_weeks(),
1149 max_months_per_run: rollup_default_max_months(),
1150 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1151 max_abstractive_words_per_week: rollup_default_max_words_week(),
1152 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1153 max_abstractive_words_per_month: rollup_default_max_words_month(),
1154 week_mmr_threshold: rollup_default_week_mmr(),
1155 month_mmr_threshold: rollup_default_month_mmr(),
1156 extractive_backend: None,
1157 abstractive_backend: None,
1158 }
1159 }
1160}
1161
1162impl RollupConfig {
1163 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1167 self.extractive_backend
1168 .clone()
1169 .unwrap_or_else(|| BackendConfig {
1170 timeout_secs: Some(120),
1171 ..llm.to_backend_config()
1172 })
1173 }
1174
1175 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1177 self.abstractive_backend
1178 .clone()
1179 .unwrap_or_else(|| BackendConfig {
1180 timeout_secs: Some(120),
1181 ..llm.to_backend_config()
1182 })
1183 }
1184}
1185
1186fn rollup_default_enabled() -> bool {
1187 true
1188}
1189fn rollup_default_max_weeks() -> u32 {
1190 4
1191}
1192fn rollup_default_max_months() -> u32 {
1193 2
1194}
1195fn rollup_default_max_spans_week() -> u32 {
1196 20
1197}
1198fn rollup_default_max_words_week() -> u32 {
1199 500
1200}
1201fn rollup_default_max_spans_month() -> u32 {
1202 20
1203}
1204fn rollup_default_max_words_month() -> u32 {
1205 700
1206}
1207fn rollup_default_week_mmr() -> f64 {
1208 0.85
1209}
1210fn rollup_default_month_mmr() -> f64 {
1211 0.82
1212}
1213
1214#[derive(Debug, Clone, Serialize, Deserialize)]
1215pub struct ConversationsSources {
1216 #[serde(default = "conv_truthy")]
1217 pub claude_code: bool,
1218 #[serde(default = "conv_truthy")]
1219 pub cursor: bool,
1220 #[serde(default = "conv_truthy")]
1221 pub gemini: bool,
1222 #[serde(default)]
1223 pub aider: AiderSourceConfig,
1224}
1225
1226impl Default for ConversationsSources {
1227 fn default() -> Self {
1228 Self {
1229 claude_code: true,
1230 cursor: true,
1231 gemini: true,
1232 aider: AiderSourceConfig::default(),
1233 }
1234 }
1235}
1236
1237#[derive(Debug, Clone, Serialize, Deserialize)]
1238pub struct AiderSourceConfig {
1239 #[serde(default = "conv_truthy")]
1240 pub enabled: bool,
1241 #[serde(default)]
1242 pub watched_dirs: Vec<String>,
1243}
1244
1245impl Default for AiderSourceConfig {
1246 fn default() -> Self {
1247 Self {
1248 enabled: true,
1249 watched_dirs: Vec::new(),
1250 }
1251 }
1252}
1253
1254#[derive(Debug, Clone, Serialize, Deserialize)]
1255pub struct ConversationsFilter {
1256 #[serde(default = "conv_default_dedup")]
1257 pub dedup_threshold: f64,
1258 #[serde(default = "conv_truthy")]
1259 pub reject_heartbeat: bool,
1260 #[serde(default = "conv_truthy")]
1261 pub reject_system_restatement: bool,
1262}
1263
1264impl Default for ConversationsFilter {
1265 fn default() -> Self {
1266 Self {
1267 dedup_threshold: conv_default_dedup(),
1268 reject_heartbeat: true,
1269 reject_system_restatement: true,
1270 }
1271 }
1272}
1273
1274#[cfg(test)]
1275mod conversations_tests {
1276 use super::*;
1277
1278 #[test]
1279 fn conversations_section_defaults() {
1280 let c = ConversationsConfig::default();
1281 assert!(!c.enabled);
1282 assert_eq!(c.retention_days, 30);
1283 assert_eq!(c.poll_interval_secs, 300);
1284 assert!(c.sources.claude_code);
1285 assert!(c.sources.cursor);
1286 assert!(c.sources.gemini);
1287 assert!(c.sources.aider.enabled);
1288 assert!(c.sources.aider.watched_dirs.is_empty());
1289 assert_eq!(c.filter.dedup_threshold, 0.85);
1290 assert!(c.filter.reject_heartbeat);
1291 assert!(c.filter.reject_system_restatement);
1292 }
1293
1294 #[test]
1295 fn parse_from_yaml_with_overrides() {
1296 let y = r#"
1297conversations:
1298 enabled: true
1299 retention_days: 45
1300 poll_interval_secs: 120
1301 sources:
1302 cursor: false
1303 aider:
1304 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1305 filter:
1306 dedup_threshold: 0.9
1307"#;
1308 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1309 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1310 assert!(conv.enabled);
1311 assert_eq!(conv.retention_days, 45);
1312 assert_eq!(conv.poll_interval_secs, 120);
1313 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1317 assert_eq!(conv.filter.dedup_threshold, 0.9);
1318 assert!(conv.filter.reject_heartbeat); }
1320
1321 #[test]
1322 fn missing_conversations_section_is_fine() {
1323 let y = r#"
1324# No conversations section at all
1325foo: bar
1326"#;
1327 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1328 let conv: ConversationsConfig = v
1330 .get("conversations")
1331 .cloned()
1332 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1333 .unwrap_or_default();
1334 assert_eq!(conv.retention_days, 30);
1335 }
1336
1337 #[test]
1338 fn compact_config_defaults() {
1339 let c = CompactConfig::default();
1340 assert!(c.enabled_in_daemon);
1341 assert_eq!(c.max_days_per_run, 7);
1342 assert_eq!(c.max_extractive_spans, 20);
1343 assert_eq!(c.chunk_tokens, 6000);
1344 assert_eq!(c.history_retain, 5);
1345 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1346 }
1347
1348 #[test]
1349 fn compact_parses_partial_overrides() {
1350 let y = r#"
1351conversations:
1352 compact:
1353 max_days_per_run: 3
1354 extractive_backend:
1355 provider: anthropic
1356 model: claude-haiku-4-5
1357"#;
1358 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1359 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1360 assert_eq!(conv.compact.max_days_per_run, 3);
1361 assert_eq!(
1362 conv.compact.extractive_backend.as_ref().unwrap().model,
1363 "claude-haiku-4-5"
1364 );
1365 assert!(conv.compact.enabled_in_daemon); assert!(conv.compact.abstractive_backend.is_none()); }
1368
1369 #[test]
1370 fn ask_config_defaults() {
1371 let c = AskConfig::default();
1372 assert_eq!(c.k_raw, 10);
1373 assert_eq!(c.escalation_threshold, 0.5);
1374 assert_eq!(c.mmr_threshold, 0.88);
1375 assert_eq!(c.max_context_tokens, 6000);
1376 assert_eq!(c.response_tokens, 1024);
1377 assert_eq!(c.timeout_secs, 120);
1378 assert_eq!(c.min_score, 0.35);
1379 }
1380
1381 #[test]
1382 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1383 let c = AskConfig::default();
1385 assert!(
1386 (c.mmr_threshold - 0.88).abs() < 1e-9,
1387 "expected 0.88, got {}",
1388 c.mmr_threshold
1389 );
1390 }
1391
1392 #[test]
1393 fn rollup_config_defaults() {
1394 let c = RollupConfig::default();
1395 assert!(c.enabled);
1396 assert_eq!(c.max_weeks_per_run, 4);
1397 assert_eq!(c.max_months_per_run, 2);
1398 assert_eq!(c.max_extractive_spans_per_week, 20);
1399 assert_eq!(c.max_abstractive_words_per_week, 500);
1400 assert_eq!(c.max_extractive_spans_per_month, 20);
1401 assert_eq!(c.max_abstractive_words_per_month, 700);
1402 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1403 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1404 }
1405
1406 #[test]
1407 fn rollup_config_plumbed_into_conversations_config() {
1408 let c = ConversationsConfig::default();
1409 assert!(c.rollup.enabled);
1410 }
1411
1412 #[test]
1413 fn ask_config_default_continue_history_turns_is_3() {
1414 let c = AskConfig::default();
1415 assert_eq!(c.continue_history_turns, 3);
1416 }
1417
1418 #[test]
1419 fn ask_config_default_compress_hits_enabled_is_true() {
1420 let c = AskConfig::default();
1421 assert!(c.compress_hits_enabled);
1422 }
1423
1424 #[test]
1425 fn ask_config_default_summarize_hits_enabled_is_true() {
1426 let c = AskConfig::default();
1427 assert!(c.summarize_hits_enabled);
1428 }
1429
1430 #[test]
1431 fn ask_config_default_summarize_model_is_none() {
1432 let c = AskConfig::default();
1433 assert!(c.summarize_model.is_none());
1434 }
1435
1436 #[test]
1437 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1438 let y = r#"
1439conversations:
1440 ask:
1441 summarize_hits_enabled: false
1442 summarize_model: qwen3:4b
1443"#;
1444 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1445 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1446 assert!(!conv.ask.summarize_hits_enabled);
1447 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1448 }
1449
1450 #[test]
1451 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1452 let y = r#"
1456conversations:
1457 ask:
1458 min_score: 0.4
1459"#;
1460 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1461 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1462 assert!(conv.ask.summarize_hits_enabled);
1463 assert!(conv.ask.summarize_model.is_none());
1464 }
1465}
1466
1467#[cfg(test)]
1468mod tests {
1469 #[test]
1470 fn update_defaults_apply_when_block_absent_and_fields_parse() {
1471 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1472 assert_eq!(c.update, super::UpdateConfig::default());
1473 assert!(c.update.codesign_identity.is_none());
1474 assert!(c.update.restart_exclude.is_empty());
1475
1476 let c: super::Config = serde_yaml_ng::from_str(
1477 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
1478 )
1479 .unwrap();
1480 assert_eq!(
1481 c.update.codesign_identity.as_deref(),
1482 Some("Developer ID Application: X (TEAM)")
1483 );
1484 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
1485 }
1486
1487 #[test]
1488 fn federation_snapshot_defaults_apply_when_block_absent() {
1489 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1490 assert_eq!(c.federation_snapshot.poll_secs, 30);
1491 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1492 assert_eq!(
1493 c.federation_snapshot.min_lifecycle,
1494 crate::skill::stats::LifecycleState::Stable
1495 );
1496 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1498 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
1499 assert_eq!(c.memory.capture, super::CaptureMode::Off);
1500 }
1501
1502 use super::*;
1503
1504 #[test]
1505 fn default_bundled_model_id_is_qwen35_2b() {
1506 assert_eq!(
1507 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1508 "Qwen3.5-2B-MLX-4bit"
1509 );
1510 }
1511
1512 #[test]
1513 fn nudge_config_defaults() {
1514 let c = NudgeConfig::default();
1515 assert!(c.enabled);
1516 assert_eq!(c.daily_cap, 3);
1517 assert_eq!(c.snooze_days, 7);
1518 assert_eq!(c.threshold, 3);
1519 }
1520
1521 #[test]
1522 fn config_has_nudge_section_with_defaults() {
1523 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1524 assert_eq!(c.nudge.daily_cap, 3);
1525 }
1526
1527 #[test]
1528 fn storage_config_default_is_lancedb() {
1529 let c = StorageConfig::default();
1530 assert_eq!(c.vector_backend, "lancedb");
1531 assert_eq!(c.qdrant_url, None);
1532 assert_eq!(c.qdrant_api_key_ref, None);
1533 }
1534
1535 #[test]
1536 fn sources_global_config_has_sensible_defaults() {
1537 let c = SourcesGlobalConfig::default();
1538 assert_eq!(c.poll_interval_secs, 600);
1539 assert_eq!(c.max_chunks_per_sync, 10_000);
1540 assert_eq!(c.max_parallel_sources, 3);
1541 assert_eq!(c.default_weight, 1.0);
1542 assert_eq!(c.embedding_batch_size, 32);
1543 }
1544
1545 #[test]
1546 fn config_default_has_storage_and_sources_global() {
1547 let c = Config::default();
1548 assert_eq!(c.storage.vector_backend, "lancedb");
1549 assert_eq!(c.sources_global.default_weight, 1.0);
1550 }
1551
1552 #[test]
1553 fn config_loads_yaml_without_new_fields() {
1554 let yaml = r#"
1557embedding:
1558 provider: ollama
1559 model: test-model
1560 dimensions: 512
1561 ollama_endpoint: http://localhost:11434
1562"#;
1563 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1564 assert_eq!(c.storage.vector_backend, "lancedb");
1565 assert_eq!(c.sources_global.max_parallel_sources, 3);
1566 }
1567
1568 #[test]
1569 fn llm_config_to_backend_config_anthropic_passthrough() {
1570 let cfg = LlmConfig {
1571 provider: "anthropic".into(),
1572 model: "claude-haiku-4-5".into(),
1573 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1574 api_key_ref: None,
1575 openai_url: None,
1576 };
1577 let b = cfg.to_backend_config();
1578 assert_eq!(b.provider, "anthropic");
1579 assert_eq!(b.model, "claude-haiku-4-5");
1580 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1581 assert_eq!(b.endpoint, None);
1582 assert_eq!(b.timeout_secs, None);
1583 }
1584
1585 #[test]
1586 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1587 let cfg = LlmConfig {
1588 provider: "openai".into(),
1589 model: "gpt-4o-mini".into(),
1590 api_key_env: None,
1591 api_key_ref: None,
1592 openai_url: Some("https://api.together.xyz/v1".into()),
1593 };
1594 let b = cfg.to_backend_config();
1595 assert_eq!(b.provider, "openai");
1596 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1597 assert_eq!(b.api_key_env, None); }
1599
1600 #[test]
1601 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1602 let cfg = LlmConfig {
1603 provider: "ollama".into(),
1604 model: "qwen3:14b".into(),
1605 api_key_env: None,
1606 api_key_ref: None,
1607 openai_url: Some("http://192.168.1.10:11434".into()),
1608 };
1609 let b = cfg.to_backend_config();
1610 assert_eq!(b.provider, "ollama");
1611 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1612 }
1613
1614 #[test]
1615 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1616 let cfg = LlmConfig {
1620 provider: "custom-name".into(),
1621 model: "some-model".into(),
1622 api_key_env: Some("CUSTOM_KEY".into()),
1623 api_key_ref: None,
1624 openai_url: Some("https://my-proxy.local/v1".into()),
1625 };
1626 let b = cfg.to_backend_config();
1627 assert_eq!(
1628 b.provider, "openai",
1629 "unknown provider + openai_url should alias to openai"
1630 );
1631 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1632 }
1633
1634 #[test]
1635 fn api_key_ref_roundtrips_and_defaults_none() {
1636 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1638 assert_eq!(b.api_key_ref, None);
1639 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1640 assert_eq!(l.api_key_ref, None);
1641 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1642 assert_eq!(e.api_key_ref, None);
1643
1644 let l2 = LlmConfig {
1646 api_key_ref: Some("keychain:mur/anthropic".into()),
1647 ..Default::default()
1648 };
1649 let y = serde_yaml_ng::to_string(&l2).unwrap();
1650 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1651 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1652 assert_eq!(
1653 l3.to_backend_config().api_key_ref.as_deref(),
1654 Some("keychain:mur/anthropic")
1655 );
1656 }
1657
1658 #[test]
1659 fn open_items_muted_parses_and_defaults_empty() {
1660 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
1661 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
1662
1663 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
1664 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
1665 }
1666
1667 #[test]
1670 fn unreadable_config_yields_no_mutes() {
1671 let tmp = tempfile::tempdir().unwrap();
1672 let path = tmp.path().join("config.yaml");
1673 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
1674 let cfg = Config::load_or_default(&path);
1675 assert!(
1676 cfg.open_items.muted.is_empty(),
1677 "a broken config must hide nothing"
1678 );
1679
1680 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
1682 assert!(missing.open_items.muted.is_empty());
1683 }
1684
1685 #[test]
1686 fn rollup_config_accepts_backend_overrides() {
1687 let yaml = r#"
1688enabled: true
1689extractive_backend:
1690 provider: openai
1691 model: Qwen3.5-4B-MLX-4bit
1692 endpoint: http://127.0.0.1:8000/v1
1693"#;
1694 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1695 let b = c.extractive_backend.expect("override present");
1696 assert_eq!(b.provider, "openai");
1697 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
1698 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
1699 assert!(c.abstractive_backend.is_none());
1700 }
1701
1702 #[test]
1703 fn legacy_conversation_fields_are_gone_from_serialized_output() {
1704 let cfg = Config::default();
1705 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
1711 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
1712 assert!(
1713 !yaml.contains(key),
1714 "legacy key {key} still serialized:\n{yaml}"
1715 );
1716 }
1717 }
1718
1719 #[test]
1720 fn embedding_ollama_endpoint_is_omitted_when_unset() {
1721 let mut cfg = Config::default();
1722 cfg.embedding.provider = "omlx".into();
1723 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
1724 cfg.embedding.ollama_endpoint = None;
1725 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
1726 assert!(
1727 !yaml.contains("ollama_endpoint"),
1728 "dead field re-emitted:\n{yaml}"
1729 );
1730 }
1731
1732 #[test]
1733 fn embedding_ollama_endpoint_still_round_trips_when_set() {
1734 let yaml =
1735 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
1736 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
1737 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
1738 }
1739}
1740
1741#[cfg(test)]
1742mod backend_config_tests {
1743 use super::*;
1744
1745 #[test]
1746 fn default_is_ollama_qwen3() {
1747 let cfg = BackendConfig::default();
1748 assert_eq!(cfg.provider, "ollama");
1749 assert_eq!(cfg.model, "qwen3.5:4b");
1750 assert_eq!(cfg.endpoint, None);
1751 assert_eq!(cfg.api_key_env, None);
1752 assert_eq!(cfg.timeout_secs, None);
1753 }
1754
1755 #[test]
1756 fn deserializes_anthropic_full() {
1757 let yaml = "\
1758provider: anthropic
1759model: claude-haiku-4-5
1760api_key_env: ANTHROPIC_API_KEY
1761timeout_secs: 60
1762";
1763 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1764 assert_eq!(cfg.provider, "anthropic");
1765 assert_eq!(cfg.model, "claude-haiku-4-5");
1766 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1767 assert_eq!(cfg.timeout_secs, Some(60));
1768 assert_eq!(cfg.endpoint, None);
1769 }
1770
1771 #[test]
1772 fn deserializes_partial_fills_defaults() {
1773 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
1774 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1775 assert_eq!(cfg.provider, "anthropic");
1776 assert_eq!(cfg.model, "claude-sonnet-5");
1777 assert_eq!(cfg.api_key_env, None);
1778 assert_eq!(cfg.timeout_secs, None);
1779 }
1780
1781 #[test]
1782 fn round_trips_through_yaml() {
1783 let original = BackendConfig {
1784 provider: "anthropic".into(),
1785 model: "claude-haiku-4-5".into(),
1786 endpoint: Some("https://api.anthropic.com".into()),
1787 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1788 api_key_ref: None,
1789 timeout_secs: Some(60),
1790 };
1791 let yaml = serde_yaml::to_string(&original).unwrap();
1792 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1793 assert_eq!(parsed, original);
1794 }
1795
1796 #[test]
1797 fn skills_config_curation_gate_defaults_on() {
1798 let c = SkillsConfig::default();
1799 assert!(c.require_human_curation_before_stable);
1800 }
1801}
1802
1803#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
1811#[serde(rename_all = "lowercase")]
1812pub enum DevDisciplineIndex {
1813 #[default]
1815 Auto,
1816 Always,
1818 Never,
1820}
1821
1822#[derive(Debug, Clone, Serialize, Deserialize)]
1823#[serde(default)]
1824pub struct SkillsConfig {
1825 pub max_skills_in_prompt: usize,
1826 pub max_total_tokens: usize,
1827 pub priority_order: Vec<String>,
1828 pub adaptive: Option<AdaptiveSkillsConfig>,
1829
1830 #[serde(default = "default_require_human_curation")]
1834 pub require_human_curation_before_stable: bool,
1835
1836 #[serde(default)]
1840 pub lifecycle: SkillLifecycleConfig,
1841
1842 #[serde(default = "default_auto_upgrade")]
1847 pub auto_upgrade: bool,
1848
1849 #[serde(default)]
1851 pub dev_discipline_index: DevDisciplineIndex,
1852}
1853
1854fn default_require_human_curation() -> bool {
1855 true
1856}
1857
1858fn default_auto_upgrade() -> bool {
1859 true
1860}
1861
1862impl Default for SkillsConfig {
1863 fn default() -> Self {
1864 Self {
1865 max_skills_in_prompt: 5,
1866 max_total_tokens: 2000,
1867 priority_order: vec!["agent".into(), "global".into()],
1868 adaptive: Some(AdaptiveSkillsConfig::default()),
1869 require_human_curation_before_stable: default_require_human_curation(),
1870 lifecycle: SkillLifecycleConfig::default(),
1871 auto_upgrade: default_auto_upgrade(),
1872 dev_discipline_index: DevDisciplineIndex::default(),
1873 }
1874 }
1875}
1876
1877#[derive(Debug, Clone, Serialize, Deserialize)]
1883#[serde(default)]
1884pub struct SkillLifecycleConfig {
1885 pub note_rule_half_life_factor: f64,
1889 pub note_fact_half_life_factor: f64,
1892 pub promote_draft_uses: u64,
1894 pub promote_emerging_uses: u64,
1895 pub promote_emerging_success_rate: f64,
1896 pub promote_emerging_age_days: i64,
1897 pub promote_stable_uses: u64,
1898 pub promote_stable_success_rate: f64,
1899 pub promote_stable_age_days: i64,
1900
1901 pub demote_emerging_uses: u64,
1903 pub demote_emerging_success_rate: f64,
1904 pub demote_stable_uses: u64,
1905 pub demote_stable_success_rate: f64,
1906 pub deprecated_success_rate: f64,
1907 pub deprecated_no_success_days: i64,
1908
1909 pub auto_archive_confidence: f64,
1911 pub auto_archive_age_days: i64,
1912
1913 pub broken_workflow_streak: u32,
1918
1919 pub archive_destroy_grace_days: i64,
1924}
1925
1926impl Default for SkillLifecycleConfig {
1927 fn default() -> Self {
1928 Self {
1929 promote_draft_uses: 3,
1930 promote_emerging_uses: 10,
1931 promote_emerging_success_rate: 0.6,
1932 promote_emerging_age_days: 7,
1933 promote_stable_uses: 30,
1934 promote_stable_success_rate: 0.8,
1935 promote_stable_age_days: 30,
1936 demote_emerging_uses: 8,
1937 demote_emerging_success_rate: 0.55,
1938 demote_stable_uses: 25,
1939 demote_stable_success_rate: 0.75,
1940 deprecated_success_rate: 0.3,
1941 deprecated_no_success_days: 90,
1942 auto_archive_confidence: 0.10,
1943 auto_archive_age_days: 180,
1944 broken_workflow_streak: 3,
1945 archive_destroy_grace_days: 30,
1946 note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
1947 note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
1948 }
1949 }
1950}
1951
1952#[derive(Debug, Clone, Serialize, Deserialize)]
1953#[serde(default)]
1954pub struct AdaptiveSkillsConfig {
1955 pub context_fill_decay: f64,
1956 pub min_remaining_context_ratio: f64,
1957 pub recent_fire_boost_turns: usize,
1958 pub model_max_context_tokens: u64,
1962}
1963
1964impl Default for AdaptiveSkillsConfig {
1965 fn default() -> Self {
1966 Self {
1967 context_fill_decay: 1.5,
1968 min_remaining_context_ratio: 0.20,
1969 recent_fire_boost_turns: 5,
1970 model_max_context_tokens: 200_000,
1971 }
1972 }
1973}
1974
1975#[derive(Debug, Clone, Serialize, Deserialize)]
1978pub struct SleepCycleConfig {
1979 #[serde(default)]
1981 pub enabled: bool,
1982
1983 #[serde(default = "default_idle_threshold_minutes")]
1985 pub idle_threshold_minutes: u64,
1986
1987 #[serde(default = "default_agent_idle_minutes")]
1989 pub agent_idle_minutes: u64,
1990}
1991
1992fn default_idle_threshold_minutes() -> u64 {
1993 15
1994}
1995
1996fn default_agent_idle_minutes() -> u64 {
1997 5
1998}
1999
2000impl Default for SleepCycleConfig {
2001 fn default() -> Self {
2002 Self {
2003 enabled: false,
2004 idle_threshold_minutes: default_idle_threshold_minutes(),
2005 agent_idle_minutes: default_agent_idle_minutes(),
2006 }
2007 }
2008}
2009
2010#[derive(Debug, Clone, Serialize, Deserialize)]
2013pub struct NudgeConfig {
2014 #[serde(default = "default_nudge_enabled")]
2016 pub enabled: bool,
2017 #[serde(default = "default_nudge_daily_cap")]
2018 pub daily_cap: u32,
2019 #[serde(default = "default_nudge_snooze_days")]
2020 pub snooze_days: u32,
2021 #[serde(default = "default_nudge_threshold")]
2022 pub threshold: usize,
2023}
2024
2025fn default_nudge_enabled() -> bool {
2026 true
2027}
2028fn default_nudge_daily_cap() -> u32 {
2029 3
2030}
2031fn default_nudge_snooze_days() -> u32 {
2032 7
2033}
2034fn default_nudge_threshold() -> usize {
2035 3
2036}
2037
2038impl Default for NudgeConfig {
2039 fn default() -> Self {
2040 Self {
2041 enabled: true,
2042 daily_cap: default_nudge_daily_cap(),
2043 snooze_days: default_nudge_snooze_days(),
2044 threshold: default_nudge_threshold(),
2045 }
2046 }
2047}
2048
2049#[derive(Debug, Clone, Serialize, Deserialize)]
2053pub struct SessionCfg {
2054 #[serde(default = "default_capture_mode")]
2056 pub capture: String,
2057 #[serde(default = "default_retention_days")]
2059 pub retention_days: u32,
2060}
2061
2062impl Default for SessionCfg {
2063 fn default() -> Self {
2064 Self {
2065 capture: default_capture_mode(),
2066 retention_days: default_retention_days(),
2067 }
2068 }
2069}
2070
2071fn default_capture_mode() -> String {
2072 "ambient".to_string()
2073}
2074fn default_retention_days() -> u32 {
2075 14
2076}
2077
2078#[derive(Debug, Clone, Serialize, Deserialize)]
2080pub struct HarvestCfg {
2081 #[serde(default = "default_harvest_enabled")]
2083 pub auto_gate: bool,
2084 #[serde(default = "default_harvest_llm")]
2086 pub llm: String,
2087 #[serde(default = "default_min_events")]
2089 pub min_events: usize,
2090 #[serde(default = "default_min_user_turns")]
2091 pub min_user_turns: usize,
2092 #[serde(default = "default_min_duration_secs")]
2093 pub min_duration_secs: i64,
2094 #[serde(default = "default_idle_minutes")]
2096 pub idle_minutes: i64,
2097 #[serde(default = "default_max_steps")]
2100 pub max_steps: usize,
2101 #[serde(default = "default_max_duration_secs")]
2102 pub max_duration_secs: i64,
2103 #[serde(default = "default_max_llm_calls_per_day")]
2105 pub max_llm_calls_per_day: u32,
2106 #[serde(default = "default_max_extract_input_tokens")]
2107 pub max_extract_input_tokens: usize,
2108 #[serde(default = "default_harvest_enabled")]
2110 pub session_start_hint: bool,
2111 #[serde(default = "default_similarity_merge_threshold")]
2114 pub similarity_merge_threshold: f32,
2115 #[serde(default = "default_min_occurrences")]
2119 pub min_occurrences: usize,
2120}
2121
2122impl Default for HarvestCfg {
2123 fn default() -> Self {
2124 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2125 }
2126}
2127
2128fn default_harvest_enabled() -> bool {
2129 true
2130}
2131fn default_harvest_llm() -> String {
2132 "local-first".to_string()
2133}
2134fn default_min_events() -> usize {
2135 5
2136}
2137fn default_min_user_turns() -> usize {
2138 2
2139}
2140fn default_min_duration_secs() -> i64 {
2141 120
2142}
2143fn default_idle_minutes() -> i64 {
2144 30
2145}
2146fn default_max_steps() -> usize {
2150 20
2151}
2152fn default_max_duration_secs() -> i64 {
2155 1800
2156}
2157fn default_max_llm_calls_per_day() -> u32 {
2158 10
2159}
2160fn default_max_extract_input_tokens() -> usize {
2161 12000
2162}
2163fn default_similarity_merge_threshold() -> f32 {
2164 0.6
2165}
2166fn default_min_occurrences() -> usize {
2169 2
2170}
2171
2172#[derive(Debug, Clone, Serialize, Deserialize)]
2175#[serde(default)]
2176pub struct CrossAgentConfig {
2177 #[serde(default = "default_half_life_days")]
2178 pub fitness_half_life_days: u32,
2179 #[serde(default = "default_fitness_floor")]
2180 pub fitness_floor: f64,
2181}
2182
2183fn default_half_life_days() -> u32 {
2184 7
2185}
2186fn default_fitness_floor() -> f64 {
2187 0.1
2188}
2189
2190impl Default for CrossAgentConfig {
2191 fn default() -> Self {
2192 Self {
2193 fitness_half_life_days: default_half_life_days(),
2194 fitness_floor: default_fitness_floor(),
2195 }
2196 }
2197}
2198
2199#[derive(Debug, Clone, Serialize, Deserialize)]
2202#[serde(default)]
2203pub struct SkillLlmConfig {
2204 #[serde(default = "default_per_call_token_cap")]
2206 pub per_call_token_cap: u32,
2207
2208 #[serde(default = "default_per_day_usd_cap")]
2210 pub per_day_usd_cap: f64,
2211
2212 #[serde(default = "default_cache_ttl_days")]
2214 pub cache_ttl_days: u32,
2215
2216 #[serde(default, skip_serializing_if = "Option::is_none")]
2218 pub model_ref: Option<String>,
2219}
2220
2221fn default_per_call_token_cap() -> u32 {
2222 1500
2223}
2224fn default_per_day_usd_cap() -> f64 {
2225 0.50
2226}
2227fn default_cache_ttl_days() -> u32 {
2228 30
2229}
2230
2231impl Default for SkillLlmConfig {
2232 fn default() -> Self {
2233 Self {
2234 per_call_token_cap: default_per_call_token_cap(),
2235 per_day_usd_cap: default_per_day_usd_cap(),
2236 cache_ttl_days: default_cache_ttl_days(),
2237 model_ref: None,
2238 }
2239 }
2240}
2241#[cfg(test)]
2242mod per_stage_backend_tests {
2243 use super::*;
2244
2245 #[test]
2246 fn compact_extractive_backend_override_parses() {
2247 let yaml = "\
2248extractive_backend:
2249 provider: anthropic
2250 model: claude-haiku-4-5
2251 api_key_env: ANTHROPIC_API_KEY
2252";
2253 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2254 let extractive = cfg
2255 .extractive_backend
2256 .as_ref()
2257 .expect("override should parse");
2258 assert_eq!(extractive.provider, "anthropic");
2259 assert_eq!(extractive.model, "claude-haiku-4-5");
2260 assert!(cfg.abstractive_backend.is_none());
2261 }
2262
2263 #[test]
2264 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2265 let yaml = "\
2266backend:
2267 provider: anthropic
2268 model: claude-sonnet-5
2269 api_key_env: ANTHROPIC_API_KEY
2270rewriter_backend:
2271 provider: ollama
2272 model: llama3.2:3b
2273";
2274 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2275 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2276 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2277 }
2278
2279 #[test]
2280 fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2281 let cfg = AskConfig {
2292 backend: Some(BackendConfig {
2293 provider: "anthropic".into(),
2294 model: "claude-sonnet-5".into(),
2295 endpoint: None,
2296 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2297 api_key_ref: None,
2298 timeout_secs: None,
2299 }),
2300 ..Default::default()
2301 };
2302 let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2303 assert_eq!(rewriter.provider, "anthropic");
2304 assert_eq!(rewriter.model, "claude-sonnet-5");
2305 assert_eq!(
2306 rewriter.timeout_secs,
2307 Some(cfg.rewriter_timeout_secs as u64),
2308 "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2309 );
2310 }
2311
2312 #[test]
2313 fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2314 let mut cfg = AskConfig {
2315 rewriter_timeout_secs: 8,
2316 ..AskConfig::default()
2317 };
2318 cfg.rewriter_backend = Some(BackendConfig {
2319 provider: "anthropic".into(),
2320 model: "claude-haiku-4-5".into(),
2321 endpoint: None,
2322 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2323 api_key_ref: None,
2324 timeout_secs: Some(30),
2325 });
2326 let b = cfg.effective_rewriter_backend(&omlx_llm());
2327 assert_eq!(
2328 b.timeout_secs,
2329 Some(30),
2330 "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2331 );
2332 }
2333
2334 fn omlx_llm() -> LlmConfig {
2335 LlmConfig {
2336 provider: "omlx".into(),
2337 model: "Qwen3.5-4B-MLX-4bit".into(),
2338 api_key_env: None,
2339 api_key_ref: Some("env:OMLX_API_KEY".into()),
2340 openai_url: Some("http://127.0.0.1:8000/v1".into()),
2341 }
2342 }
2343
2344 #[test]
2345 fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2346 let ask = AskConfig::default();
2347 let b = ask.effective_backend(&omlx_llm());
2348 assert_eq!(b.provider, "openai");
2349 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2350 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2351 assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2352 assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2354 }
2355
2356 #[test]
2357 fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2358 let ask = AskConfig::default();
2359 let b = ask.effective_rewriter_backend(&omlx_llm());
2360 assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2361 assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2362 }
2363
2364 #[test]
2365 fn explicit_override_wins_over_the_smart_slot() {
2366 let ask = AskConfig {
2367 backend: Some(BackendConfig {
2368 provider: "anthropic".into(),
2369 model: "claude-haiku-4-5".into(),
2370 endpoint: None,
2371 api_key_env: None,
2372 api_key_ref: None,
2373 timeout_secs: Some(42),
2374 }),
2375 ..Default::default()
2376 };
2377 let b = ask.effective_backend(&omlx_llm());
2378 assert_eq!(b.provider, "anthropic");
2379 assert_eq!(b.timeout_secs, Some(42));
2380 }
2381
2382 #[test]
2383 fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2384 let llm = omlx_llm();
2385 for b in [
2386 CompactConfig::default().effective_extractive_backend(&llm),
2387 CompactConfig::default().effective_abstractive_backend(&llm),
2388 RollupConfig::default().effective_extractive_backend(&llm),
2389 RollupConfig::default().effective_abstractive_backend(&llm),
2390 ] {
2391 assert_eq!(b.provider, "openai");
2392 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2393 assert_eq!(b.timeout_secs, Some(120));
2394 }
2395 }
2396
2397 #[test]
2398 fn rollup_override_is_honored() {
2399 let r = RollupConfig {
2400 abstractive_backend: Some(BackendConfig {
2401 provider: "ollama".into(),
2402 model: "qwen3:4b".into(),
2403 endpoint: Some("http://box.local:11434".into()),
2404 api_key_env: None,
2405 api_key_ref: None,
2406 timeout_secs: None,
2407 }),
2408 ..Default::default()
2409 };
2410 let b = r.effective_abstractive_backend(&omlx_llm());
2411 assert_eq!(b.provider, "ollama");
2412 assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2413 }
2414}
2415
2416#[cfg(test)]
2417mod skills_config_tests {
2418 use super::*;
2419
2420 #[test]
2421 fn empty_yaml_hydrates_defaults() {
2422 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2423 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2424 assert_eq!(cfg.skills.max_total_tokens, 2000);
2425 assert!(cfg.skills.adaptive.is_some());
2426 }
2427
2428 #[test]
2429 fn load_or_default_missing_file_returns_default() {
2430 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2431 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2432 }
2433
2434 #[test]
2435 fn dev_discipline_index_defaults_auto_and_parses() {
2436 use crate::config::DevDisciplineIndex;
2437 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2438 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2439 let cfg: Config =
2440 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2441 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2442 let cfg: Config =
2443 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2444 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2445 }
2446}
2447
2448#[cfg(test)]
2449mod ambient_capture_cfg_tests {
2450 use super::*;
2451
2452 #[test]
2453 fn session_and_harvest_defaults() {
2454 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2455 assert_eq!(cfg.session.capture, "ambient");
2456 assert_eq!(cfg.session.retention_days, 14);
2457 assert!(cfg.harvest.auto_gate);
2458 assert_eq!(cfg.harvest.llm, "local-first");
2459 assert_eq!(cfg.harvest.min_events, 5);
2460 assert_eq!(cfg.harvest.min_user_turns, 2);
2461 assert_eq!(cfg.harvest.min_duration_secs, 120);
2462 assert_eq!(cfg.harvest.idle_minutes, 30);
2463 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2464 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2465 assert!(cfg.harvest.session_start_hint);
2466 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2467 }
2468
2469 #[test]
2470 fn session_capture_override_parses() {
2471 let cfg: Config =
2472 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2473 assert_eq!(cfg.session.capture, "off");
2474 assert_eq!(cfg.session.retention_days, 3);
2475 }
2476}
2477
2478#[cfg(test)]
2479mod cc_proxy_cfg_tests {
2480 use super::*;
2481
2482 #[test]
2483 fn defaults_to_local_cc_proxy_enabled() {
2484 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2485 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2486 assert!(cfg.cc_proxy.enabled);
2487 }
2488
2489 #[test]
2490 fn url_and_enabled_override_parse() {
2491 let cfg: Config =
2492 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2493 .unwrap();
2494 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2495 assert!(!cfg.cc_proxy.enabled);
2496 }
2497
2498 #[test]
2499 fn partial_section_keeps_other_default() {
2500 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2502 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2503 assert!(!cfg.cc_proxy.enabled);
2504 }
2505}
2506
2507#[cfg(test)]
2508mod model_switch_config_tests {
2509 use super::*;
2510
2511 #[test]
2512 fn model_switch_config_defaults_and_omitted_block() {
2513 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2515 assert_eq!(cfg.models.default, None);
2516 assert!(cfg.models.fallback_chain.is_empty());
2517 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2518 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2519 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2520 assert!(!cfg.models.routing.enabled);
2521
2522 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";
2524 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2525 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2526 assert_eq!(
2527 cfg.models.fallback_chain,
2528 vec!["claude_sonnet", "deepseek_v4_pro"]
2529 );
2530 assert!(cfg.models.routing.enabled);
2531 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2532 }
2533
2534 #[test]
2535 fn smart_config_defaults_on_with_autopick() {
2536 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2537 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2540 cfg.models.smart.max_escalations,
2541 DEFAULT_SMART_MAX_ESCALATIONS
2542 );
2543 }
2544}