1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
11
12pub const DEFAULT_MAX_RETRIES: u32 = 1;
13pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
14pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
15pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
16pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
17
18fn default_max_retries() -> u32 {
19 DEFAULT_MAX_RETRIES
20}
21fn default_backoff_base_ms() -> u64 {
22 DEFAULT_BACKOFF_BASE_MS
23}
24fn default_cooldown_secs() -> u64 {
25 DEFAULT_COOLDOWN_SECS
26}
27fn default_smart_max_escalations() -> u32 {
28 DEFAULT_SMART_MAX_ESCALATIONS
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
36pub struct SmartConfig {
37 #[serde(default = "default_true")]
38 pub enabled: bool,
39 #[serde(default, skip_serializing_if = "Option::is_none")]
40 pub cheap: Option<String>,
41 #[serde(default = "default_smart_max_escalations")]
42 pub max_escalations: u32,
43}
44
45impl Default for SmartConfig {
46 fn default() -> Self {
47 Self {
48 enabled: true,
49 cheap: None,
50 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Serialize, Deserialize, Default)]
58pub struct ModelSwitchConfig {
59 #[serde(default, skip_serializing_if = "Option::is_none")]
61 pub default: Option<String>,
62 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub fallback_chain: Vec<String>,
65 #[serde(default)]
66 pub retry: RetryConfig,
67 #[serde(default)]
68 pub routing: RoutingConfig,
69 #[serde(default)]
70 pub smart: SmartConfig,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74pub struct RetryConfig {
75 #[serde(default = "default_max_retries")]
76 pub max_retries: u32,
77 #[serde(default = "default_backoff_base_ms")]
78 pub backoff_base_ms: u64,
79 #[serde(default = "default_cooldown_secs")]
80 pub cooldown_secs: u64,
81}
82
83impl Default for RetryConfig {
84 fn default() -> Self {
85 Self {
86 max_retries: DEFAULT_MAX_RETRIES,
87 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
88 cooldown_secs: DEFAULT_COOLDOWN_SECS,
89 }
90 }
91}
92
93#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
94pub struct RoutingConfig {
95 #[serde(default)]
96 pub enabled: bool,
97 #[serde(default, skip_serializing_if = "Option::is_none")]
98 pub cheap: Option<String>,
99 #[serde(default, skip_serializing_if = "Option::is_none")]
100 pub frontier: Option<String>,
101 #[serde(default, skip_serializing_if = "Option::is_none")]
102 pub threshold_input_tokens: Option<u32>,
103 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub smart: Option<SmartConfig>,
105}
106
107#[derive(Debug, Clone, Serialize, Deserialize, Default)]
109pub struct Config {
110 #[serde(default)]
111 pub embedding: EmbeddingConfig,
112
113 #[serde(default)]
114 pub llm: LlmConfig,
115
116 #[serde(default)]
117 pub models: ModelSwitchConfig,
118
119 #[serde(default)]
120 pub retrieval: RetrievalConfig,
121
122 #[serde(default)]
123 pub paths: PathConfig,
124
125 #[serde(default)]
126 pub server: ServerConfig,
127
128 #[serde(default)]
129 pub community: CommunityConfig,
130
131 #[serde(default)]
132 pub conversations: ConversationsConfig,
133
134 #[serde(default)]
135 pub sync: SyncConfig,
136
137 #[serde(default)]
139 pub storage: StorageConfig,
140
141 #[serde(default)]
142 pub sources_global: SourcesGlobalConfig,
143
144 #[serde(default)]
146 pub sleep_cycle: SleepCycleConfig,
147
148 #[serde(default)]
150 pub skills: SkillsConfig,
151
152 #[serde(default)]
154 pub skill_llm: SkillLlmConfig,
155
156 #[serde(default)]
158 pub cross_agent: CrossAgentConfig,
159
160 #[serde(default)]
162 pub nudge: NudgeConfig,
163
164 #[serde(default)]
166 pub mobile_relay: MobileRelayConfig,
167
168 #[serde(default)]
170 pub session: SessionCfg,
171
172 #[serde(default)]
173 pub harvest: HarvestCfg,
174
175 #[serde(default)]
177 pub cc_proxy: CcProxyConfig,
178
179 #[serde(default)]
181 pub cli: CliConfig,
182
183 #[serde(default)]
185 pub parallel_jobs: ParallelJobsConfig,
186
187 #[serde(default)]
189 pub fleet: FleetConfig,
190}
191
192#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
198pub struct ParallelJobsConfig {
199 #[serde(default)]
202 pub targets: Vec<String>,
203}
204
205#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
212pub struct FleetConfig {
213 #[serde(default)]
215 pub autorun: bool,
216}
217
218#[cfg(test)]
219mod fleet_config_tests {
220 use super::*;
221
222 #[test]
223 fn fleet_config_defaults_off_and_roundtrips() {
224 assert!(!FleetConfig::default().autorun);
225
226 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
227 assert!(cfg.fleet.autorun);
228
229 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
231 assert!(!cfg2.fleet.autorun);
232 }
233}
234
235#[derive(Debug, Clone, Serialize, Deserialize)]
246pub struct CcProxyConfig {
247 #[serde(default = "default_cc_proxy_url")]
249 pub url: String,
250
251 #[serde(default = "default_true")]
254 pub enabled: bool,
255}
256
257fn default_cc_proxy_url() -> String {
258 "http://127.0.0.1:8088".to_string()
259}
260
261fn default_true() -> bool {
262 true
263}
264
265impl Default for CcProxyConfig {
266 fn default() -> Self {
267 Self {
268 url: default_cc_proxy_url(),
269 enabled: true,
270 }
271 }
272}
273
274#[derive(Debug, Clone, Serialize, Deserialize, Default)]
277pub struct CliConfig {
278 pub skin: Option<String>,
281}
282
283#[derive(Debug, Clone, Serialize, Deserialize, Default)]
286pub struct MobileRelayConfig {
287 #[serde(default, skip_serializing_if = "Option::is_none")]
290 pub relay_url: Option<String>,
291
292 #[serde(default, skip_serializing_if = "Option::is_none")]
295 pub api_key: Option<String>,
296}
297
298impl Config {
299 pub fn load_or_default(path: &std::path::Path) -> Self {
301 std::fs::read_to_string(path)
302 .ok()
303 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
304 .unwrap_or_default()
305 }
306}
307
308#[derive(Debug, Clone, Serialize, Deserialize, Default)]
309pub struct SyncConfig {
310 #[serde(default = "default_sync_method")]
312 pub method: String,
313
314 #[serde(default, skip_serializing_if = "Option::is_none")]
316 pub git_remote: Option<String>,
317
318 #[serde(default)]
320 pub auto: bool,
321
322 #[serde(default, skip_serializing_if = "Option::is_none")]
324 pub team_id: Option<String>,
325}
326
327fn default_sync_method() -> String {
328 "local".to_string()
329}
330
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct ServerConfig {
333 #[serde(default = "default_server_url")]
335 pub url: String,
336}
337
338impl Default for ServerConfig {
339 fn default() -> Self {
340 Self {
341 url: default_server_url(),
342 }
343 }
344}
345
346#[derive(Debug, Clone, Serialize, Deserialize, Default)]
347pub struct CommunityConfig {
348 #[serde(default)]
350 pub enabled: bool,
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize)]
354pub struct EmbeddingConfig {
355 #[serde(default = "default_embedding_provider")]
357 pub provider: String,
358
359 #[serde(default = "default_embedding_model")]
361 pub model: String,
362
363 #[serde(default = "default_dimensions")]
365 pub dimensions: usize,
366
367 #[serde(default = "default_ollama_endpoint")]
369 pub ollama_endpoint: String,
370
371 #[serde(default, skip_serializing_if = "Option::is_none")]
373 pub api_key_env: Option<String>,
374
375 #[serde(default, skip_serializing_if = "Option::is_none")]
378 pub api_key_ref: Option<String>,
379
380 #[serde(default, skip_serializing_if = "Option::is_none")]
382 pub openai_url: Option<String>,
383}
384
385impl Default for EmbeddingConfig {
386 fn default() -> Self {
387 Self {
388 provider: default_embedding_provider(),
389 model: default_embedding_model(),
390 dimensions: default_dimensions(),
391 ollama_endpoint: default_ollama_endpoint(),
392 api_key_env: None,
393 api_key_ref: None,
394 openai_url: None,
395 }
396 }
397}
398
399#[derive(Debug, Clone, Serialize, Deserialize)]
400pub struct LlmConfig {
401 #[serde(default = "default_llm_provider")]
403 pub provider: String,
404
405 #[serde(default = "default_llm_model")]
406 pub model: String,
407
408 #[serde(default, skip_serializing_if = "Option::is_none")]
410 pub api_key_env: Option<String>,
411
412 #[serde(default, skip_serializing_if = "Option::is_none")]
415 pub api_key_ref: Option<String>,
416
417 #[serde(default, skip_serializing_if = "Option::is_none")]
419 pub openai_url: Option<String>,
420}
421
422impl Default for LlmConfig {
423 fn default() -> Self {
424 Self {
425 provider: default_llm_provider(),
426 model: default_llm_model(),
427 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
428 api_key_ref: None,
429 openai_url: None,
430 }
431 }
432}
433
434impl LlmConfig {
435 pub fn to_backend_config(&self) -> BackendConfig {
448 let provider = match self.provider.as_str() {
449 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
450 _ if self.openai_url.is_some() => "openai".into(),
451 other => other.into(), };
453 BackendConfig {
454 provider,
455 model: self.model.clone(),
456 endpoint: self.openai_url.clone(),
457 api_key_env: self.api_key_env.clone(),
458 api_key_ref: self.api_key_ref.clone(),
459 timeout_secs: None,
460 }
461 }
462}
463
464#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
475#[serde(default)]
476pub struct BackendConfig {
477 pub provider: String,
479 pub model: String,
481 pub endpoint: Option<String>,
484 pub api_key_env: Option<String>,
486 pub api_key_ref: Option<String>,
488 pub timeout_secs: Option<u64>,
490}
491
492impl Default for BackendConfig {
493 fn default() -> Self {
494 Self {
495 provider: "ollama".into(),
496 model: DEFAULT_LOCAL_LLM_MODEL.into(),
497 endpoint: None,
498 api_key_env: None,
499 api_key_ref: None,
500 timeout_secs: None,
501 }
502 }
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
506pub struct RetrievalConfig {
507 #[serde(default = "default_max_patterns")]
509 pub max_patterns: usize,
510
511 #[serde(default = "default_max_tokens")]
513 pub max_tokens: usize,
514
515 #[serde(default = "default_min_score")]
517 pub min_score: f64,
518
519 #[serde(default = "default_mmr_threshold")]
521 pub mmr_threshold: f64,
522}
523
524impl Default for RetrievalConfig {
525 fn default() -> Self {
526 Self {
527 max_patterns: default_max_patterns(),
528 max_tokens: default_max_tokens(),
529 min_score: default_min_score(),
530 mmr_threshold: default_mmr_threshold(),
531 }
532 }
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
536pub struct PathConfig {
537 #[serde(default = "default_mur_dir")]
539 pub mur_dir: PathBuf,
540}
541
542impl Default for PathConfig {
543 fn default() -> Self {
544 Self {
545 mur_dir: default_mur_dir(),
546 }
547 }
548}
549
550#[derive(Debug, Clone, Serialize, Deserialize)]
551pub struct StorageConfig {
552 #[serde(default = "default_vector_backend")]
554 pub vector_backend: String,
555
556 #[serde(default, skip_serializing_if = "Option::is_none")]
558 pub qdrant_url: Option<String>,
559
560 #[serde(default, skip_serializing_if = "Option::is_none")]
562 pub qdrant_api_key_ref: Option<String>,
563}
564
565impl Default for StorageConfig {
566 fn default() -> Self {
567 Self {
568 vector_backend: default_vector_backend(),
569 qdrant_url: None,
570 qdrant_api_key_ref: None,
571 }
572 }
573}
574
575fn default_vector_backend() -> String {
576 "lancedb".to_string()
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct SourcesGlobalConfig {
581 #[serde(default = "default_poll_interval_secs")]
583 pub poll_interval_secs: u64,
584
585 #[serde(default = "default_max_chunks_per_sync")]
587 pub max_chunks_per_sync: usize,
588
589 #[serde(default = "default_max_parallel_sources")]
591 pub max_parallel_sources: usize,
592
593 #[serde(default = "default_source_weight")]
595 pub default_weight: f32,
596
597 #[serde(default = "default_embedding_batch_size")]
599 pub embedding_batch_size: usize,
600}
601
602impl Default for SourcesGlobalConfig {
603 fn default() -> Self {
604 Self {
605 poll_interval_secs: default_poll_interval_secs(),
606 max_chunks_per_sync: default_max_chunks_per_sync(),
607 max_parallel_sources: default_max_parallel_sources(),
608 default_weight: default_source_weight(),
609 embedding_batch_size: default_embedding_batch_size(),
610 }
611 }
612}
613
614fn default_poll_interval_secs() -> u64 {
615 600
616}
617fn default_max_chunks_per_sync() -> usize {
618 10_000
619}
620fn default_max_parallel_sources() -> usize {
621 3
622}
623fn default_source_weight() -> f32 {
624 1.0
625}
626fn default_embedding_batch_size() -> usize {
627 32
628}
629
630fn default_embedding_provider() -> String {
631 "ollama".to_string()
632}
633fn default_embedding_model() -> String {
634 "qwen3-embedding:0.6b".to_string()
635}
636fn default_dimensions() -> usize {
637 1024
638}
639fn default_ollama_endpoint() -> String {
640 "http://localhost:11434".to_string()
641}
642fn default_llm_provider() -> String {
643 "anthropic".to_string()
644}
645fn default_llm_model() -> String {
646 "claude-opus-4-6".to_string()
647}
648fn default_max_patterns() -> usize {
649 5
650}
651fn default_max_tokens() -> usize {
652 2000
653}
654fn default_min_score() -> f64 {
655 0.35
656}
657fn default_mmr_threshold() -> f64 {
658 0.85
659}
660fn default_mur_dir() -> PathBuf {
661 let home = std::env::var("HOME")
664 .map(PathBuf::from)
665 .unwrap_or_else(|_| PathBuf::from("/tmp"));
666 home.join(".mur")
667}
668fn default_server_url() -> String {
669 "https://mur-server.fly.dev".to_string()
670}
671
672#[derive(Debug, Clone, Serialize, Deserialize)]
675pub struct AskConfig {
676 #[serde(default = "ask_default_model")]
677 pub model: String,
678 #[serde(default = "compact_default_ollama_endpoint")]
679 pub ollama_endpoint: String,
680 #[serde(default = "ask_default_k_summary")]
681 pub k_summary: u32,
682 #[serde(default = "ask_default_k_raw")]
683 pub k_raw: u32,
684 #[serde(default = "ask_default_esc")]
685 pub escalation_threshold: f64,
686 #[serde(default = "ask_default_mmr")]
687 pub mmr_threshold: f64,
688 #[serde(default = "ask_default_max_ctx")]
689 pub max_context_tokens: u32,
690 #[serde(default = "ask_default_resp_tok")]
691 pub response_tokens: u32,
692 #[serde(default = "ask_default_timeout")]
693 pub timeout_secs: u32,
694 #[serde(default = "ask_default_min_score")]
695 pub min_score: f64,
696 #[serde(default = "ask_default_continue_history_turns")]
697 pub continue_history_turns: u32,
698 #[serde(default = "ask_default_rewriter_timeout")]
704 pub rewriter_timeout_secs: u32,
705 #[serde(default = "ask_default_compress_hits_enabled")]
706 pub compress_hits_enabled: bool,
707 #[serde(default = "ask_default_summarize_hits_enabled")]
708 pub summarize_hits_enabled: bool,
709 #[serde(default)]
710 pub summarize_model: Option<String>,
711 #[serde(default)]
714 pub backend: Option<BackendConfig>,
715 #[serde(default)]
719 pub rewriter_backend: Option<BackendConfig>,
720}
721
722impl AskConfig {
723 pub fn synthesize_backend(&self) -> BackendConfig {
733 self.backend.clone().unwrap_or_else(|| BackendConfig {
734 provider: "ollama".into(),
735 model: self.model.clone(),
736 endpoint: Some(self.ollama_endpoint.clone()),
737 api_key_env: None,
738 api_key_ref: None,
739 timeout_secs: Some(self.timeout_secs as u64),
740 })
741 }
742
743 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
753 self.rewriter_backend
754 .clone()
755 .unwrap_or_else(|| BackendConfig {
756 provider: "ollama".into(),
757 model: self.model.clone(),
758 endpoint: Some(self.ollama_endpoint.clone()),
759 api_key_env: None,
760 api_key_ref: None,
761 timeout_secs: Some(self.rewriter_timeout_secs as u64),
762 })
763 }
764}
765
766impl Default for AskConfig {
767 fn default() -> Self {
768 Self {
769 model: ask_default_model(),
770 ollama_endpoint: compact_default_ollama_endpoint(),
771 k_summary: ask_default_k_summary(),
772 k_raw: ask_default_k_raw(),
773 escalation_threshold: ask_default_esc(),
774 mmr_threshold: ask_default_mmr(),
775 max_context_tokens: ask_default_max_ctx(),
776 response_tokens: ask_default_resp_tok(),
777 timeout_secs: ask_default_timeout(),
778 min_score: ask_default_min_score(),
779 continue_history_turns: ask_default_continue_history_turns(),
780 rewriter_timeout_secs: ask_default_rewriter_timeout(),
781 compress_hits_enabled: ask_default_compress_hits_enabled(),
782 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
783 summarize_model: None,
784 backend: None,
785 rewriter_backend: None,
786 }
787 }
788}
789
790fn ask_default_model() -> String {
791 DEFAULT_LOCAL_LLM_MODEL.into()
792}
793fn ask_default_k_summary() -> u32 {
794 5
795}
796fn ask_default_k_raw() -> u32 {
797 10
798}
799fn ask_default_esc() -> f64 {
800 0.5
801}
802fn ask_default_mmr() -> f64 {
803 0.88
804}
805fn ask_default_max_ctx() -> u32 {
806 6000
807}
808fn ask_default_resp_tok() -> u32 {
809 1024
810}
811fn ask_default_timeout() -> u32 {
812 120
813}
814fn ask_default_min_score() -> f64 {
815 0.35
816}
817fn ask_default_rewriter_timeout() -> u32 {
818 8
819}
820fn ask_default_continue_history_turns() -> u32 {
821 3
822}
823fn ask_default_compress_hits_enabled() -> bool {
824 true
825}
826fn ask_default_summarize_hits_enabled() -> bool {
827 true
828}
829
830#[derive(Debug, Clone, Serialize, Deserialize)]
839pub struct ConversationsConfig {
840 #[serde(default)]
841 pub enabled: bool,
842 #[serde(default = "conv_default_retention_days")]
843 pub retention_days: u32,
844 #[serde(default = "conv_default_poll_interval")]
845 pub poll_interval_secs: u64,
846 #[serde(default)]
847 pub sources: ConversationsSources,
848 #[serde(default)]
849 pub filter: ConversationsFilter,
850 #[serde(default)]
851 pub compact: CompactConfig,
852 #[serde(default)]
853 pub ask: AskConfig,
854 #[serde(default)]
855 pub rollup: RollupConfig,
856}
857
858impl Default for ConversationsConfig {
859 fn default() -> Self {
860 Self {
861 enabled: false,
862 retention_days: conv_default_retention_days(),
863 poll_interval_secs: conv_default_poll_interval(),
864 sources: ConversationsSources::default(),
865 filter: ConversationsFilter::default(),
866 compact: CompactConfig::default(),
867 ask: AskConfig::default(),
868 rollup: RollupConfig::default(),
869 }
870 }
871}
872
873fn conv_default_retention_days() -> u32 {
874 30
875}
876fn conv_default_poll_interval() -> u64 {
877 300
878}
879fn conv_truthy() -> bool {
880 true
881}
882fn conv_default_dedup() -> f64 {
883 0.85
884}
885
886#[derive(Debug, Clone, Serialize, Deserialize)]
887pub struct CompactConfig {
888 #[serde(default = "conv_truthy")]
889 pub enabled_in_daemon: bool,
890 #[serde(default = "compact_default_max_days")]
891 pub max_days_per_run: u32,
892 #[serde(default = "compact_default_model")]
893 pub extractive_model: String,
894 #[serde(default = "compact_default_model")]
895 pub abstractive_model: String,
896 #[serde(default = "compact_default_ollama_endpoint")]
897 pub ollama_endpoint: String,
898 #[serde(default = "compact_default_max_spans")]
899 pub max_extractive_spans: u32,
900 #[serde(default = "compact_default_max_words")]
901 pub max_abstractive_words: u32,
902 #[serde(default = "compact_default_chunk_tokens")]
903 pub chunk_tokens: u32,
904 #[serde(default = "compact_default_history_retain")]
905 pub history_retain: u32,
906 #[serde(default = "compact_default_cron")]
907 pub daemon_cron: String,
908 #[serde(default)]
911 pub extractive_backend: Option<BackendConfig>,
912 #[serde(default)]
915 pub abstractive_backend: Option<BackendConfig>,
916}
917
918impl CompactConfig {
919 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
928 self.extractive_backend
929 .clone()
930 .unwrap_or_else(|| BackendConfig {
931 provider: "ollama".into(),
932 model: self.extractive_model.clone(),
933 endpoint: Some(self.ollama_endpoint.clone()),
934 api_key_env: None,
935 api_key_ref: None,
936 timeout_secs: Some(120),
937 })
938 }
939
940 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
943 self.abstractive_backend
944 .clone()
945 .unwrap_or_else(|| BackendConfig {
946 provider: "ollama".into(),
947 model: self.abstractive_model.clone(),
948 endpoint: Some(self.ollama_endpoint.clone()),
949 api_key_env: None,
950 api_key_ref: None,
951 timeout_secs: Some(120),
952 })
953 }
954}
955
956impl Default for CompactConfig {
957 fn default() -> Self {
958 Self {
959 enabled_in_daemon: true,
960 max_days_per_run: compact_default_max_days(),
961 extractive_model: compact_default_model(),
962 abstractive_model: compact_default_model(),
963 ollama_endpoint: compact_default_ollama_endpoint(),
964 max_extractive_spans: compact_default_max_spans(),
965 max_abstractive_words: compact_default_max_words(),
966 chunk_tokens: compact_default_chunk_tokens(),
967 history_retain: compact_default_history_retain(),
968 daemon_cron: compact_default_cron(),
969 extractive_backend: None,
970 abstractive_backend: None,
971 }
972 }
973}
974
975fn compact_default_max_days() -> u32 {
976 7
977}
978fn compact_default_model() -> String {
979 DEFAULT_LOCAL_LLM_MODEL.into()
980}
981fn compact_default_ollama_endpoint() -> String {
982 "http://localhost:11434".into()
983}
984fn compact_default_max_spans() -> u32 {
985 20
986}
987fn compact_default_max_words() -> u32 {
988 400
989}
990fn compact_default_chunk_tokens() -> u32 {
991 6000
992}
993fn compact_default_history_retain() -> u32 {
994 5
995}
996fn compact_default_cron() -> String {
997 "0 0 3 * * * *".into()
998}
999
1000#[derive(Debug, Clone, Serialize, Deserialize)]
1003pub struct RollupConfig {
1004 #[serde(default = "rollup_default_enabled")]
1005 pub enabled: bool,
1006 #[serde(default = "rollup_default_max_weeks")]
1007 pub max_weeks_per_run: u32,
1008 #[serde(default = "rollup_default_max_months")]
1009 pub max_months_per_run: u32,
1010 #[serde(default = "rollup_default_max_spans_week")]
1011 pub max_extractive_spans_per_week: u32,
1012 #[serde(default = "rollup_default_max_words_week")]
1013 pub max_abstractive_words_per_week: u32,
1014 #[serde(default = "rollup_default_max_spans_month")]
1015 pub max_extractive_spans_per_month: u32,
1016 #[serde(default = "rollup_default_max_words_month")]
1017 pub max_abstractive_words_per_month: u32,
1018 #[serde(default = "rollup_default_week_mmr")]
1019 pub week_mmr_threshold: f64,
1020 #[serde(default = "rollup_default_month_mmr")]
1021 pub month_mmr_threshold: f64,
1022 #[serde(default = "compact_default_model")]
1023 pub extractive_model: String,
1024 #[serde(default = "compact_default_model")]
1025 pub abstractive_model: String,
1026 #[serde(default = "compact_default_ollama_endpoint")]
1027 pub ollama_endpoint: String,
1028}
1029
1030impl Default for RollupConfig {
1031 fn default() -> Self {
1032 Self {
1033 enabled: rollup_default_enabled(),
1034 max_weeks_per_run: rollup_default_max_weeks(),
1035 max_months_per_run: rollup_default_max_months(),
1036 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1037 max_abstractive_words_per_week: rollup_default_max_words_week(),
1038 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1039 max_abstractive_words_per_month: rollup_default_max_words_month(),
1040 week_mmr_threshold: rollup_default_week_mmr(),
1041 month_mmr_threshold: rollup_default_month_mmr(),
1042 extractive_model: compact_default_model(),
1043 abstractive_model: compact_default_model(),
1044 ollama_endpoint: compact_default_ollama_endpoint(),
1045 }
1046 }
1047}
1048
1049fn rollup_default_enabled() -> bool {
1050 true
1051}
1052fn rollup_default_max_weeks() -> u32 {
1053 4
1054}
1055fn rollup_default_max_months() -> u32 {
1056 2
1057}
1058fn rollup_default_max_spans_week() -> u32 {
1059 20
1060}
1061fn rollup_default_max_words_week() -> u32 {
1062 500
1063}
1064fn rollup_default_max_spans_month() -> u32 {
1065 20
1066}
1067fn rollup_default_max_words_month() -> u32 {
1068 700
1069}
1070fn rollup_default_week_mmr() -> f64 {
1071 0.85
1072}
1073fn rollup_default_month_mmr() -> f64 {
1074 0.82
1075}
1076
1077#[derive(Debug, Clone, Serialize, Deserialize)]
1078pub struct ConversationsSources {
1079 #[serde(default = "conv_truthy")]
1080 pub claude_code: bool,
1081 #[serde(default = "conv_truthy")]
1082 pub cursor: bool,
1083 #[serde(default = "conv_truthy")]
1084 pub gemini: bool,
1085 #[serde(default)]
1086 pub aider: AiderSourceConfig,
1087}
1088
1089impl Default for ConversationsSources {
1090 fn default() -> Self {
1091 Self {
1092 claude_code: true,
1093 cursor: true,
1094 gemini: true,
1095 aider: AiderSourceConfig::default(),
1096 }
1097 }
1098}
1099
1100#[derive(Debug, Clone, Serialize, Deserialize)]
1101pub struct AiderSourceConfig {
1102 #[serde(default = "conv_truthy")]
1103 pub enabled: bool,
1104 #[serde(default)]
1105 pub watched_dirs: Vec<String>,
1106}
1107
1108impl Default for AiderSourceConfig {
1109 fn default() -> Self {
1110 Self {
1111 enabled: true,
1112 watched_dirs: Vec::new(),
1113 }
1114 }
1115}
1116
1117#[derive(Debug, Clone, Serialize, Deserialize)]
1118pub struct ConversationsFilter {
1119 #[serde(default = "conv_default_dedup")]
1120 pub dedup_threshold: f64,
1121 #[serde(default = "conv_truthy")]
1122 pub reject_heartbeat: bool,
1123 #[serde(default = "conv_truthy")]
1124 pub reject_system_restatement: bool,
1125}
1126
1127impl Default for ConversationsFilter {
1128 fn default() -> Self {
1129 Self {
1130 dedup_threshold: conv_default_dedup(),
1131 reject_heartbeat: true,
1132 reject_system_restatement: true,
1133 }
1134 }
1135}
1136
1137#[cfg(test)]
1138mod conversations_tests {
1139 use super::*;
1140
1141 #[test]
1142 fn conversations_section_defaults() {
1143 let c = ConversationsConfig::default();
1144 assert!(!c.enabled);
1145 assert_eq!(c.retention_days, 30);
1146 assert_eq!(c.poll_interval_secs, 300);
1147 assert!(c.sources.claude_code);
1148 assert!(c.sources.cursor);
1149 assert!(c.sources.gemini);
1150 assert!(c.sources.aider.enabled);
1151 assert!(c.sources.aider.watched_dirs.is_empty());
1152 assert_eq!(c.filter.dedup_threshold, 0.85);
1153 assert!(c.filter.reject_heartbeat);
1154 assert!(c.filter.reject_system_restatement);
1155 }
1156
1157 #[test]
1158 fn parse_from_yaml_with_overrides() {
1159 let y = r#"
1160conversations:
1161 enabled: true
1162 retention_days: 45
1163 poll_interval_secs: 120
1164 sources:
1165 cursor: false
1166 aider:
1167 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1168 filter:
1169 dedup_threshold: 0.9
1170"#;
1171 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1172 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1173 assert!(conv.enabled);
1174 assert_eq!(conv.retention_days, 45);
1175 assert_eq!(conv.poll_interval_secs, 120);
1176 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1180 assert_eq!(conv.filter.dedup_threshold, 0.9);
1181 assert!(conv.filter.reject_heartbeat); }
1183
1184 #[test]
1185 fn missing_conversations_section_is_fine() {
1186 let y = r#"
1187# No conversations section at all
1188foo: bar
1189"#;
1190 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1191 let conv: ConversationsConfig = v
1193 .get("conversations")
1194 .cloned()
1195 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1196 .unwrap_or_default();
1197 assert_eq!(conv.retention_days, 30);
1198 }
1199
1200 #[test]
1201 fn compact_config_defaults() {
1202 let c = CompactConfig::default();
1203 assert!(c.enabled_in_daemon);
1204 assert_eq!(c.max_days_per_run, 7);
1205 assert_eq!(c.extractive_model, "qwen3.5:4b");
1206 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1207 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1208 assert_eq!(c.max_extractive_spans, 20);
1209 assert_eq!(c.chunk_tokens, 6000);
1210 assert_eq!(c.history_retain, 5);
1211 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1212 }
1213
1214 #[test]
1215 fn compact_parses_partial_overrides() {
1216 let y = r#"
1217conversations:
1218 compact:
1219 max_days_per_run: 3
1220 extractive_model: qwen3:4b
1221"#;
1222 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1223 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1224 assert_eq!(conv.compact.max_days_per_run, 3);
1225 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1226 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
1229
1230 #[test]
1231 fn ask_config_defaults() {
1232 let c = AskConfig::default();
1233 assert_eq!(c.model, "qwen3.5:4b");
1234 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1235 assert_eq!(c.k_raw, 10);
1236 assert_eq!(c.escalation_threshold, 0.5);
1237 assert_eq!(c.mmr_threshold, 0.88);
1238 assert_eq!(c.max_context_tokens, 6000);
1239 assert_eq!(c.response_tokens, 1024);
1240 assert_eq!(c.timeout_secs, 120);
1241 assert_eq!(c.min_score, 0.35);
1242 }
1243
1244 #[test]
1245 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1246 let c = AskConfig::default();
1248 assert!(
1249 (c.mmr_threshold - 0.88).abs() < 1e-9,
1250 "expected 0.88, got {}",
1251 c.mmr_threshold
1252 );
1253 }
1254
1255 #[test]
1256 fn rollup_config_defaults() {
1257 let c = RollupConfig::default();
1258 assert!(c.enabled);
1259 assert_eq!(c.max_weeks_per_run, 4);
1260 assert_eq!(c.max_months_per_run, 2);
1261 assert_eq!(c.max_extractive_spans_per_week, 20);
1262 assert_eq!(c.max_abstractive_words_per_week, 500);
1263 assert_eq!(c.max_extractive_spans_per_month, 20);
1264 assert_eq!(c.max_abstractive_words_per_month, 700);
1265 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1266 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1267 assert_eq!(c.extractive_model, "qwen3.5:4b");
1268 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1269 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1270 }
1271
1272 #[test]
1273 fn rollup_config_plumbed_into_conversations_config() {
1274 let c = ConversationsConfig::default();
1275 assert!(c.rollup.enabled);
1276 }
1277
1278 #[test]
1279 fn ask_config_default_continue_history_turns_is_3() {
1280 let c = AskConfig::default();
1281 assert_eq!(c.continue_history_turns, 3);
1282 }
1283
1284 #[test]
1285 fn ask_config_default_compress_hits_enabled_is_true() {
1286 let c = AskConfig::default();
1287 assert!(c.compress_hits_enabled);
1288 }
1289
1290 #[test]
1291 fn ask_config_default_summarize_hits_enabled_is_true() {
1292 let c = AskConfig::default();
1293 assert!(c.summarize_hits_enabled);
1294 }
1295
1296 #[test]
1297 fn ask_config_default_summarize_model_is_none() {
1298 let c = AskConfig::default();
1299 assert!(c.summarize_model.is_none());
1300 }
1301
1302 #[test]
1303 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1304 let y = r#"
1305conversations:
1306 ask:
1307 summarize_hits_enabled: false
1308 summarize_model: qwen3:4b
1309"#;
1310 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1311 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1312 assert!(!conv.ask.summarize_hits_enabled);
1313 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1314 }
1315
1316 #[test]
1317 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1318 let y = r#"
1322conversations:
1323 ask:
1324 model: qwen3:14b
1325"#;
1326 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1327 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1328 assert!(conv.ask.summarize_hits_enabled);
1329 assert!(conv.ask.summarize_model.is_none());
1330 }
1331}
1332
1333#[cfg(test)]
1334mod tests {
1335 use super::*;
1336
1337 #[test]
1338 fn default_bundled_model_id_is_qwen35_2b() {
1339 assert_eq!(
1340 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1341 "Qwen3.5-2B-MLX-4bit"
1342 );
1343 }
1344
1345 #[test]
1346 fn nudge_config_defaults() {
1347 let c = NudgeConfig::default();
1348 assert!(c.enabled);
1349 assert_eq!(c.daily_cap, 3);
1350 assert_eq!(c.snooze_days, 7);
1351 assert_eq!(c.threshold, 3);
1352 }
1353
1354 #[test]
1355 fn config_has_nudge_section_with_defaults() {
1356 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1357 assert_eq!(c.nudge.daily_cap, 3);
1358 }
1359
1360 #[test]
1361 fn storage_config_default_is_lancedb() {
1362 let c = StorageConfig::default();
1363 assert_eq!(c.vector_backend, "lancedb");
1364 assert_eq!(c.qdrant_url, None);
1365 assert_eq!(c.qdrant_api_key_ref, None);
1366 }
1367
1368 #[test]
1369 fn sources_global_config_has_sensible_defaults() {
1370 let c = SourcesGlobalConfig::default();
1371 assert_eq!(c.poll_interval_secs, 600);
1372 assert_eq!(c.max_chunks_per_sync, 10_000);
1373 assert_eq!(c.max_parallel_sources, 3);
1374 assert_eq!(c.default_weight, 1.0);
1375 assert_eq!(c.embedding_batch_size, 32);
1376 }
1377
1378 #[test]
1379 fn config_default_has_storage_and_sources_global() {
1380 let c = Config::default();
1381 assert_eq!(c.storage.vector_backend, "lancedb");
1382 assert_eq!(c.sources_global.default_weight, 1.0);
1383 }
1384
1385 #[test]
1386 fn config_loads_yaml_without_new_fields() {
1387 let yaml = r#"
1390embedding:
1391 provider: ollama
1392 model: test-model
1393 dimensions: 512
1394 ollama_endpoint: http://localhost:11434
1395"#;
1396 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1397 assert_eq!(c.storage.vector_backend, "lancedb");
1398 assert_eq!(c.sources_global.max_parallel_sources, 3);
1399 }
1400
1401 #[test]
1402 fn llm_config_to_backend_config_anthropic_passthrough() {
1403 let cfg = LlmConfig {
1404 provider: "anthropic".into(),
1405 model: "claude-haiku-4-5".into(),
1406 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1407 api_key_ref: None,
1408 openai_url: None,
1409 };
1410 let b = cfg.to_backend_config();
1411 assert_eq!(b.provider, "anthropic");
1412 assert_eq!(b.model, "claude-haiku-4-5");
1413 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1414 assert_eq!(b.endpoint, None);
1415 assert_eq!(b.timeout_secs, None);
1416 }
1417
1418 #[test]
1419 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1420 let cfg = LlmConfig {
1421 provider: "openai".into(),
1422 model: "gpt-4o-mini".into(),
1423 api_key_env: None,
1424 api_key_ref: None,
1425 openai_url: Some("https://api.together.xyz/v1".into()),
1426 };
1427 let b = cfg.to_backend_config();
1428 assert_eq!(b.provider, "openai");
1429 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1430 assert_eq!(b.api_key_env, None); }
1432
1433 #[test]
1434 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1435 let cfg = LlmConfig {
1436 provider: "ollama".into(),
1437 model: "qwen3:14b".into(),
1438 api_key_env: None,
1439 api_key_ref: None,
1440 openai_url: Some("http://192.168.1.10:11434".into()),
1441 };
1442 let b = cfg.to_backend_config();
1443 assert_eq!(b.provider, "ollama");
1444 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1445 }
1446
1447 #[test]
1448 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1449 let cfg = LlmConfig {
1453 provider: "custom-name".into(),
1454 model: "some-model".into(),
1455 api_key_env: Some("CUSTOM_KEY".into()),
1456 api_key_ref: None,
1457 openai_url: Some("https://my-proxy.local/v1".into()),
1458 };
1459 let b = cfg.to_backend_config();
1460 assert_eq!(
1461 b.provider, "openai",
1462 "unknown provider + openai_url should alias to openai"
1463 );
1464 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1465 }
1466
1467 #[test]
1468 fn api_key_ref_roundtrips_and_defaults_none() {
1469 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1471 assert_eq!(b.api_key_ref, None);
1472 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1473 assert_eq!(l.api_key_ref, None);
1474 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1475 assert_eq!(e.api_key_ref, None);
1476
1477 let mut l2 = LlmConfig::default();
1479 l2.api_key_ref = Some("keychain:mur/anthropic".into());
1480 let y = serde_yaml_ng::to_string(&l2).unwrap();
1481 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1482 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1483 assert_eq!(
1484 l3.to_backend_config().api_key_ref.as_deref(),
1485 Some("keychain:mur/anthropic")
1486 );
1487 }
1488}
1489
1490#[cfg(test)]
1491mod backend_config_tests {
1492 use super::*;
1493
1494 #[test]
1495 fn default_is_ollama_qwen3() {
1496 let cfg = BackendConfig::default();
1497 assert_eq!(cfg.provider, "ollama");
1498 assert_eq!(cfg.model, "qwen3.5:4b");
1499 assert_eq!(cfg.endpoint, None);
1500 assert_eq!(cfg.api_key_env, None);
1501 assert_eq!(cfg.timeout_secs, None);
1502 }
1503
1504 #[test]
1505 fn deserializes_anthropic_full() {
1506 let yaml = "\
1507provider: anthropic
1508model: claude-haiku-4-5
1509api_key_env: ANTHROPIC_API_KEY
1510timeout_secs: 60
1511";
1512 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1513 assert_eq!(cfg.provider, "anthropic");
1514 assert_eq!(cfg.model, "claude-haiku-4-5");
1515 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1516 assert_eq!(cfg.timeout_secs, Some(60));
1517 assert_eq!(cfg.endpoint, None);
1518 }
1519
1520 #[test]
1521 fn deserializes_partial_fills_defaults() {
1522 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1523 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1524 assert_eq!(cfg.provider, "anthropic");
1525 assert_eq!(cfg.model, "claude-sonnet-4-6");
1526 assert_eq!(cfg.api_key_env, None);
1527 assert_eq!(cfg.timeout_secs, None);
1528 }
1529
1530 #[test]
1531 fn round_trips_through_yaml() {
1532 let original = BackendConfig {
1533 provider: "anthropic".into(),
1534 model: "claude-haiku-4-5".into(),
1535 endpoint: Some("https://api.anthropic.com".into()),
1536 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1537 api_key_ref: None,
1538 timeout_secs: Some(60),
1539 };
1540 let yaml = serde_yaml::to_string(&original).unwrap();
1541 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1542 assert_eq!(parsed, original);
1543 }
1544
1545 #[test]
1546 fn skills_config_curation_gate_defaults_on() {
1547 let c = SkillsConfig::default();
1548 assert!(c.require_human_curation_before_stable);
1549 }
1550}
1551
1552#[derive(Debug, Clone, Serialize, Deserialize)]
1556#[serde(default)]
1557pub struct SkillsConfig {
1558 pub max_skills_in_prompt: usize,
1559 pub max_total_tokens: usize,
1560 pub priority_order: Vec<String>,
1561 pub adaptive: Option<AdaptiveSkillsConfig>,
1562
1563 #[serde(default = "default_require_human_curation")]
1567 pub require_human_curation_before_stable: bool,
1568
1569 #[serde(default)]
1573 pub lifecycle: SkillLifecycleConfig,
1574
1575 #[serde(default = "default_auto_upgrade")]
1580 pub auto_upgrade: bool,
1581}
1582
1583fn default_require_human_curation() -> bool {
1584 true
1585}
1586
1587fn default_auto_upgrade() -> bool {
1588 true
1589}
1590
1591impl Default for SkillsConfig {
1592 fn default() -> Self {
1593 Self {
1594 max_skills_in_prompt: 5,
1595 max_total_tokens: 2000,
1596 priority_order: vec!["agent".into(), "global".into()],
1597 adaptive: Some(AdaptiveSkillsConfig::default()),
1598 require_human_curation_before_stable: default_require_human_curation(),
1599 lifecycle: SkillLifecycleConfig::default(),
1600 auto_upgrade: default_auto_upgrade(),
1601 }
1602 }
1603}
1604
1605#[derive(Debug, Clone, Serialize, Deserialize)]
1611#[serde(default)]
1612pub struct SkillLifecycleConfig {
1613 pub promote_draft_uses: u64,
1615 pub promote_emerging_uses: u64,
1616 pub promote_emerging_success_rate: f64,
1617 pub promote_emerging_age_days: i64,
1618 pub promote_stable_uses: u64,
1619 pub promote_stable_success_rate: f64,
1620 pub promote_stable_age_days: i64,
1621
1622 pub demote_emerging_uses: u64,
1624 pub demote_emerging_success_rate: f64,
1625 pub demote_stable_uses: u64,
1626 pub demote_stable_success_rate: f64,
1627 pub deprecated_success_rate: f64,
1628 pub deprecated_no_success_days: i64,
1629
1630 pub auto_archive_confidence: f64,
1632 pub auto_archive_age_days: i64,
1633
1634 pub broken_workflow_streak: u32,
1639
1640 pub archive_destroy_grace_days: i64,
1645}
1646
1647impl Default for SkillLifecycleConfig {
1648 fn default() -> Self {
1649 Self {
1650 promote_draft_uses: 3,
1651 promote_emerging_uses: 10,
1652 promote_emerging_success_rate: 0.6,
1653 promote_emerging_age_days: 7,
1654 promote_stable_uses: 30,
1655 promote_stable_success_rate: 0.8,
1656 promote_stable_age_days: 30,
1657 demote_emerging_uses: 8,
1658 demote_emerging_success_rate: 0.55,
1659 demote_stable_uses: 25,
1660 demote_stable_success_rate: 0.75,
1661 deprecated_success_rate: 0.3,
1662 deprecated_no_success_days: 90,
1663 auto_archive_confidence: 0.10,
1664 auto_archive_age_days: 180,
1665 broken_workflow_streak: 3,
1666 archive_destroy_grace_days: 30,
1667 }
1668 }
1669}
1670
1671#[derive(Debug, Clone, Serialize, Deserialize)]
1672#[serde(default)]
1673pub struct AdaptiveSkillsConfig {
1674 pub context_fill_decay: f64,
1675 pub min_remaining_context_ratio: f64,
1676 pub recent_fire_boost_turns: usize,
1677 pub model_max_context_tokens: u64,
1681}
1682
1683impl Default for AdaptiveSkillsConfig {
1684 fn default() -> Self {
1685 Self {
1686 context_fill_decay: 1.5,
1687 min_remaining_context_ratio: 0.20,
1688 recent_fire_boost_turns: 5,
1689 model_max_context_tokens: 200_000,
1690 }
1691 }
1692}
1693
1694#[derive(Debug, Clone, Serialize, Deserialize)]
1697pub struct SleepCycleConfig {
1698 #[serde(default)]
1700 pub enabled: bool,
1701
1702 #[serde(default = "default_idle_threshold_minutes")]
1704 pub idle_threshold_minutes: u64,
1705
1706 #[serde(default = "default_agent_idle_minutes")]
1708 pub agent_idle_minutes: u64,
1709}
1710
1711fn default_idle_threshold_minutes() -> u64 {
1712 15
1713}
1714
1715fn default_agent_idle_minutes() -> u64 {
1716 5
1717}
1718
1719impl Default for SleepCycleConfig {
1720 fn default() -> Self {
1721 Self {
1722 enabled: false,
1723 idle_threshold_minutes: default_idle_threshold_minutes(),
1724 agent_idle_minutes: default_agent_idle_minutes(),
1725 }
1726 }
1727}
1728
1729#[derive(Debug, Clone, Serialize, Deserialize)]
1732pub struct NudgeConfig {
1733 #[serde(default = "default_nudge_enabled")]
1735 pub enabled: bool,
1736 #[serde(default = "default_nudge_daily_cap")]
1737 pub daily_cap: u32,
1738 #[serde(default = "default_nudge_snooze_days")]
1739 pub snooze_days: u32,
1740 #[serde(default = "default_nudge_threshold")]
1741 pub threshold: usize,
1742}
1743
1744fn default_nudge_enabled() -> bool {
1745 true
1746}
1747fn default_nudge_daily_cap() -> u32 {
1748 3
1749}
1750fn default_nudge_snooze_days() -> u32 {
1751 7
1752}
1753fn default_nudge_threshold() -> usize {
1754 3
1755}
1756
1757impl Default for NudgeConfig {
1758 fn default() -> Self {
1759 Self {
1760 enabled: true,
1761 daily_cap: default_nudge_daily_cap(),
1762 snooze_days: default_nudge_snooze_days(),
1763 threshold: default_nudge_threshold(),
1764 }
1765 }
1766}
1767
1768#[derive(Debug, Clone, Serialize, Deserialize)]
1772pub struct SessionCfg {
1773 #[serde(default = "default_capture_mode")]
1775 pub capture: String,
1776 #[serde(default = "default_retention_days")]
1778 pub retention_days: u32,
1779}
1780
1781impl Default for SessionCfg {
1782 fn default() -> Self {
1783 Self {
1784 capture: default_capture_mode(),
1785 retention_days: default_retention_days(),
1786 }
1787 }
1788}
1789
1790fn default_capture_mode() -> String {
1791 "ambient".to_string()
1792}
1793fn default_retention_days() -> u32 {
1794 14
1795}
1796
1797#[derive(Debug, Clone, Serialize, Deserialize)]
1799pub struct HarvestCfg {
1800 #[serde(default = "default_harvest_enabled")]
1802 pub auto_gate: bool,
1803 #[serde(default = "default_harvest_llm")]
1805 pub llm: String,
1806 #[serde(default = "default_min_events")]
1808 pub min_events: usize,
1809 #[serde(default = "default_min_user_turns")]
1810 pub min_user_turns: usize,
1811 #[serde(default = "default_min_duration_secs")]
1812 pub min_duration_secs: i64,
1813 #[serde(default = "default_idle_minutes")]
1815 pub idle_minutes: i64,
1816 #[serde(default = "default_max_llm_calls_per_day")]
1818 pub max_llm_calls_per_day: u32,
1819 #[serde(default = "default_max_extract_input_tokens")]
1820 pub max_extract_input_tokens: usize,
1821 #[serde(default = "default_harvest_enabled")]
1823 pub session_start_hint: bool,
1824 #[serde(default = "default_similarity_merge_threshold")]
1826 pub similarity_merge_threshold: f32,
1827}
1828
1829impl Default for HarvestCfg {
1830 fn default() -> Self {
1831 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1832 }
1833}
1834
1835fn default_harvest_enabled() -> bool {
1836 true
1837}
1838fn default_harvest_llm() -> String {
1839 "local-first".to_string()
1840}
1841fn default_min_events() -> usize {
1842 5
1843}
1844fn default_min_user_turns() -> usize {
1845 2
1846}
1847fn default_min_duration_secs() -> i64 {
1848 120
1849}
1850fn default_idle_minutes() -> i64 {
1851 30
1852}
1853fn default_max_llm_calls_per_day() -> u32 {
1854 10
1855}
1856fn default_max_extract_input_tokens() -> usize {
1857 12000
1858}
1859fn default_similarity_merge_threshold() -> f32 {
1860 0.6
1861}
1862
1863#[derive(Debug, Clone, Serialize, Deserialize)]
1866#[serde(default)]
1867pub struct CrossAgentConfig {
1868 #[serde(default = "default_half_life_days")]
1869 pub fitness_half_life_days: u32,
1870 #[serde(default = "default_fitness_floor")]
1871 pub fitness_floor: f64,
1872}
1873
1874fn default_half_life_days() -> u32 {
1875 7
1876}
1877fn default_fitness_floor() -> f64 {
1878 0.1
1879}
1880
1881impl Default for CrossAgentConfig {
1882 fn default() -> Self {
1883 Self {
1884 fitness_half_life_days: default_half_life_days(),
1885 fitness_floor: default_fitness_floor(),
1886 }
1887 }
1888}
1889
1890#[derive(Debug, Clone, Serialize, Deserialize)]
1893#[serde(default)]
1894pub struct SkillLlmConfig {
1895 #[serde(default = "default_per_call_token_cap")]
1897 pub per_call_token_cap: u32,
1898
1899 #[serde(default = "default_per_day_usd_cap")]
1901 pub per_day_usd_cap: f64,
1902
1903 #[serde(default = "default_cache_ttl_days")]
1905 pub cache_ttl_days: u32,
1906
1907 #[serde(default, skip_serializing_if = "Option::is_none")]
1909 pub model_ref: Option<String>,
1910}
1911
1912fn default_per_call_token_cap() -> u32 {
1913 1500
1914}
1915fn default_per_day_usd_cap() -> f64 {
1916 0.50
1917}
1918fn default_cache_ttl_days() -> u32 {
1919 30
1920}
1921
1922impl Default for SkillLlmConfig {
1923 fn default() -> Self {
1924 Self {
1925 per_call_token_cap: default_per_call_token_cap(),
1926 per_day_usd_cap: default_per_day_usd_cap(),
1927 cache_ttl_days: default_cache_ttl_days(),
1928 model_ref: None,
1929 }
1930 }
1931}
1932#[cfg(test)]
1933mod per_stage_backend_tests {
1934 use super::*;
1935
1936 #[test]
1937 fn legacy_compact_config_has_no_per_stage_overrides() {
1938 let yaml = "\
1939extractive_model: qwen3:14b
1940abstractive_model: qwen3:14b
1941ollama_endpoint: http://localhost:11434
1942";
1943 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1944 assert!(cfg.extractive_backend.is_none());
1945 assert!(cfg.abstractive_backend.is_none());
1946 assert_eq!(cfg.extractive_model, "qwen3:14b");
1947 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1948 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1949 }
1950
1951 #[test]
1952 fn legacy_ask_config_has_no_per_stage_overrides() {
1953 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1954 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1955 assert!(cfg.backend.is_none());
1956 assert!(cfg.rewriter_backend.is_none());
1957 assert_eq!(cfg.model, "qwen3:14b");
1958 }
1959
1960 #[test]
1961 fn compact_extractive_backend_override_parses() {
1962 let yaml = "\
1963extractive_backend:
1964 provider: anthropic
1965 model: claude-haiku-4-5
1966 api_key_env: ANTHROPIC_API_KEY
1967abstractive_model: qwen3:14b
1968";
1969 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1970 let extractive = cfg
1971 .extractive_backend
1972 .as_ref()
1973 .expect("override should parse");
1974 assert_eq!(extractive.provider, "anthropic");
1975 assert_eq!(extractive.model, "claude-haiku-4-5");
1976 assert!(cfg.abstractive_backend.is_none());
1977 }
1978
1979 #[test]
1980 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1981 let yaml = "\
1982backend:
1983 provider: anthropic
1984 model: claude-sonnet-4-6
1985 api_key_env: ANTHROPIC_API_KEY
1986rewriter_backend:
1987 provider: ollama
1988 model: llama3.2:3b
1989";
1990 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1991 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1992 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1993 }
1994
1995 #[test]
1996 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1997 let yaml = "\
1998extractive_model: qwen3:14b
1999ollama_endpoint: http://192.168.1.10:11434
2000";
2001 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2002 let synth = cfg.synthesize_extractive_backend();
2003 assert_eq!(synth.provider, "ollama");
2004 assert_eq!(synth.model, "qwen3:14b");
2005 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
2006 assert_eq!(synth.api_key_env, None);
2007 }
2008
2009 #[test]
2010 fn synthesize_legacy_to_backend_config_for_ask() {
2011 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
2012 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2013 let synth = cfg.synthesize_backend();
2014 assert_eq!(synth.provider, "ollama");
2015 assert_eq!(synth.model, "qwen3:14b");
2016 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
2017 }
2018
2019 #[test]
2020 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
2021 let yaml = "\
2030backend:
2031 provider: anthropic
2032 model: claude-sonnet-4-6
2033 api_key_env: ANTHROPIC_API_KEY
2034";
2035 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2036 let rewriter = cfg.synthesize_rewriter_backend();
2037 assert_eq!(rewriter.provider, "ollama");
2038 assert_eq!(rewriter.model, ask_default_model());
2039 assert_eq!(
2040 rewriter.timeout_secs,
2041 Some(ask_default_rewriter_timeout() as u64)
2042 );
2043 }
2044
2045 #[test]
2046 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
2047 let cfg = AskConfig {
2048 timeout_secs: 45,
2049 ..AskConfig::default()
2050 };
2051 let b = cfg.synthesize_backend();
2052 assert_eq!(
2053 b.timeout_secs,
2054 Some(45),
2055 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
2056 );
2057 }
2058
2059 #[test]
2060 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
2061 let mut cfg = AskConfig {
2062 timeout_secs: 45,
2063 ..AskConfig::default()
2064 };
2065 cfg.backend = Some(BackendConfig {
2066 provider: "anthropic".into(),
2067 model: "claude-haiku-4-5".into(),
2068 endpoint: None,
2069 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2070 api_key_ref: None,
2071 timeout_secs: Some(10),
2072 });
2073 let b = cfg.synthesize_backend();
2074 assert_eq!(
2075 b.timeout_secs,
2076 Some(10),
2077 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
2078 );
2079 }
2080
2081 #[test]
2082 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
2083 let cfg = AskConfig {
2084 timeout_secs: 120,
2085 rewriter_timeout_secs: 8,
2086 ..AskConfig::default()
2087 };
2088 let b = cfg.synthesize_rewriter_backend();
2089 assert_eq!(
2090 b.timeout_secs,
2091 Some(8),
2092 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
2093 );
2094 }
2095
2096 #[test]
2097 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
2098 let mut cfg = AskConfig {
2099 rewriter_timeout_secs: 8,
2100 ..AskConfig::default()
2101 };
2102 cfg.rewriter_backend = Some(BackendConfig {
2103 provider: "anthropic".into(),
2104 model: "claude-haiku-4-5".into(),
2105 endpoint: None,
2106 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2107 api_key_ref: None,
2108 timeout_secs: Some(30),
2109 });
2110 let b = cfg.synthesize_rewriter_backend();
2111 assert_eq!(
2112 b.timeout_secs,
2113 Some(30),
2114 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
2115 );
2116 }
2117
2118 #[test]
2119 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
2120 let cfg = CompactConfig::default();
2123 let b = cfg.synthesize_extractive_backend();
2124 assert_eq!(
2125 b.timeout_secs,
2126 Some(120),
2127 "compact synthesis without per-stage override must produce 120s timeout"
2128 );
2129 }
2130
2131 #[test]
2132 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
2133 let cfg = CompactConfig::default();
2134 let b = cfg.synthesize_abstractive_backend();
2135 assert_eq!(b.timeout_secs, Some(120));
2136 }
2137}
2138
2139#[cfg(test)]
2140mod skills_config_tests {
2141 use super::*;
2142
2143 #[test]
2144 fn empty_yaml_hydrates_defaults() {
2145 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2146 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2147 assert_eq!(cfg.skills.max_total_tokens, 2000);
2148 assert!(cfg.skills.adaptive.is_some());
2149 }
2150
2151 #[test]
2152 fn load_or_default_missing_file_returns_default() {
2153 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2154 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2155 }
2156}
2157
2158#[cfg(test)]
2159mod ambient_capture_cfg_tests {
2160 use super::*;
2161
2162 #[test]
2163 fn session_and_harvest_defaults() {
2164 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2165 assert_eq!(cfg.session.capture, "ambient");
2166 assert_eq!(cfg.session.retention_days, 14);
2167 assert!(cfg.harvest.auto_gate);
2168 assert_eq!(cfg.harvest.llm, "local-first");
2169 assert_eq!(cfg.harvest.min_events, 5);
2170 assert_eq!(cfg.harvest.min_user_turns, 2);
2171 assert_eq!(cfg.harvest.min_duration_secs, 120);
2172 assert_eq!(cfg.harvest.idle_minutes, 30);
2173 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2174 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2175 assert!(cfg.harvest.session_start_hint);
2176 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2177 }
2178
2179 #[test]
2180 fn session_capture_override_parses() {
2181 let cfg: Config =
2182 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2183 assert_eq!(cfg.session.capture, "off");
2184 assert_eq!(cfg.session.retention_days, 3);
2185 }
2186}
2187
2188#[cfg(test)]
2189mod cc_proxy_cfg_tests {
2190 use super::*;
2191
2192 #[test]
2193 fn defaults_to_local_cc_proxy_enabled() {
2194 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2195 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2196 assert!(cfg.cc_proxy.enabled);
2197 }
2198
2199 #[test]
2200 fn url_and_enabled_override_parse() {
2201 let cfg: Config =
2202 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2203 .unwrap();
2204 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2205 assert!(!cfg.cc_proxy.enabled);
2206 }
2207
2208 #[test]
2209 fn partial_section_keeps_other_default() {
2210 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2212 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2213 assert!(!cfg.cc_proxy.enabled);
2214 }
2215}
2216
2217#[cfg(test)]
2218mod model_switch_config_tests {
2219 use super::*;
2220
2221 #[test]
2222 fn model_switch_config_defaults_and_omitted_block() {
2223 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2225 assert_eq!(cfg.models.default, None);
2226 assert!(cfg.models.fallback_chain.is_empty());
2227 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
2228 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
2229 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
2230 assert!(!cfg.models.routing.enabled);
2231
2232 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";
2234 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
2235 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
2236 assert_eq!(
2237 cfg.models.fallback_chain,
2238 vec!["claude_sonnet", "deepseek_v4_pro"]
2239 );
2240 assert!(cfg.models.routing.enabled);
2241 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
2242 }
2243
2244 #[test]
2245 fn smart_config_defaults_on_with_autopick() {
2246 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2247 assert!(cfg.models.smart.enabled); assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
2250 cfg.models.smart.max_escalations,
2251 DEFAULT_SMART_MAX_ESCALATIONS
2252 );
2253 }
2254}