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
12#[derive(Debug, Clone, Serialize, Deserialize, Default)]
14pub struct Config {
15 #[serde(default)]
16 pub embedding: EmbeddingConfig,
17
18 #[serde(default)]
19 pub llm: LlmConfig,
20
21 #[serde(default)]
22 pub retrieval: RetrievalConfig,
23
24 #[serde(default)]
25 pub paths: PathConfig,
26
27 #[serde(default)]
28 pub server: ServerConfig,
29
30 #[serde(default)]
31 pub community: CommunityConfig,
32
33 #[serde(default)]
34 pub conversations: ConversationsConfig,
35
36 #[serde(default)]
37 pub sync: SyncConfig,
38
39 #[serde(default)]
41 pub storage: StorageConfig,
42
43 #[serde(default)]
44 pub sources_global: SourcesGlobalConfig,
45
46 #[serde(default)]
48 pub sleep_cycle: SleepCycleConfig,
49
50 #[serde(default)]
52 pub skills: SkillsConfig,
53
54 #[serde(default)]
56 pub skill_llm: SkillLlmConfig,
57
58 #[serde(default)]
60 pub cross_agent: CrossAgentConfig,
61
62 #[serde(default)]
64 pub nudge: NudgeConfig,
65
66 #[serde(default)]
68 pub mobile_relay: MobileRelayConfig,
69
70 #[serde(default)]
72 pub session: SessionCfg,
73
74 #[serde(default)]
75 pub harvest: HarvestCfg,
76
77 #[serde(default)]
79 pub cc_proxy: CcProxyConfig,
80
81 #[serde(default)]
83 pub cli: CliConfig,
84
85 #[serde(default)]
87 pub parallel_jobs: ParallelJobsConfig,
88
89 #[serde(default)]
91 pub fleet: FleetConfig,
92}
93
94#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
100pub struct ParallelJobsConfig {
101 #[serde(default)]
104 pub targets: Vec<String>,
105}
106
107#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
114pub struct FleetConfig {
115 #[serde(default)]
117 pub autorun: bool,
118}
119
120#[cfg(test)]
121mod fleet_config_tests {
122 use super::*;
123
124 #[test]
125 fn fleet_config_defaults_off_and_roundtrips() {
126 assert!(!FleetConfig::default().autorun);
127
128 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
129 assert!(cfg.fleet.autorun);
130
131 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
133 assert!(!cfg2.fleet.autorun);
134 }
135}
136
137#[derive(Debug, Clone, Serialize, Deserialize)]
148pub struct CcProxyConfig {
149 #[serde(default = "default_cc_proxy_url")]
151 pub url: String,
152
153 #[serde(default = "default_true")]
156 pub enabled: bool,
157}
158
159fn default_cc_proxy_url() -> String {
160 "http://127.0.0.1:8088".to_string()
161}
162
163fn default_true() -> bool {
164 true
165}
166
167impl Default for CcProxyConfig {
168 fn default() -> Self {
169 Self {
170 url: default_cc_proxy_url(),
171 enabled: true,
172 }
173 }
174}
175
176#[derive(Debug, Clone, Serialize, Deserialize, Default)]
179pub struct CliConfig {
180 pub skin: Option<String>,
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize, Default)]
188pub struct MobileRelayConfig {
189 #[serde(default, skip_serializing_if = "Option::is_none")]
192 pub relay_url: Option<String>,
193
194 #[serde(default, skip_serializing_if = "Option::is_none")]
197 pub api_key: Option<String>,
198}
199
200impl Config {
201 pub fn load_or_default(path: &std::path::Path) -> Self {
203 std::fs::read_to_string(path)
204 .ok()
205 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
206 .unwrap_or_default()
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, Default)]
211pub struct SyncConfig {
212 #[serde(default = "default_sync_method")]
214 pub method: String,
215
216 #[serde(default, skip_serializing_if = "Option::is_none")]
218 pub git_remote: Option<String>,
219
220 #[serde(default)]
222 pub auto: bool,
223
224 #[serde(default, skip_serializing_if = "Option::is_none")]
226 pub team_id: Option<String>,
227}
228
229fn default_sync_method() -> String {
230 "local".to_string()
231}
232
233#[derive(Debug, Clone, Serialize, Deserialize)]
234pub struct ServerConfig {
235 #[serde(default = "default_server_url")]
237 pub url: String,
238}
239
240impl Default for ServerConfig {
241 fn default() -> Self {
242 Self {
243 url: default_server_url(),
244 }
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize, Default)]
249pub struct CommunityConfig {
250 #[serde(default)]
252 pub enabled: bool,
253}
254
255#[derive(Debug, Clone, Serialize, Deserialize)]
256pub struct EmbeddingConfig {
257 #[serde(default = "default_embedding_provider")]
259 pub provider: String,
260
261 #[serde(default = "default_embedding_model")]
263 pub model: String,
264
265 #[serde(default = "default_dimensions")]
267 pub dimensions: usize,
268
269 #[serde(default = "default_ollama_endpoint")]
271 pub ollama_endpoint: String,
272
273 #[serde(default, skip_serializing_if = "Option::is_none")]
275 pub api_key_env: Option<String>,
276
277 #[serde(default, skip_serializing_if = "Option::is_none")]
279 pub openai_url: Option<String>,
280}
281
282impl Default for EmbeddingConfig {
283 fn default() -> Self {
284 Self {
285 provider: default_embedding_provider(),
286 model: default_embedding_model(),
287 dimensions: default_dimensions(),
288 ollama_endpoint: default_ollama_endpoint(),
289 api_key_env: None,
290 openai_url: None,
291 }
292 }
293}
294
295#[derive(Debug, Clone, Serialize, Deserialize)]
296pub struct LlmConfig {
297 #[serde(default = "default_llm_provider")]
299 pub provider: String,
300
301 #[serde(default = "default_llm_model")]
302 pub model: String,
303
304 #[serde(default, skip_serializing_if = "Option::is_none")]
306 pub api_key_env: Option<String>,
307
308 #[serde(default, skip_serializing_if = "Option::is_none")]
310 pub openai_url: Option<String>,
311}
312
313impl Default for LlmConfig {
314 fn default() -> Self {
315 Self {
316 provider: default_llm_provider(),
317 model: default_llm_model(),
318 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
319 openai_url: None,
320 }
321 }
322}
323
324impl LlmConfig {
325 pub fn to_backend_config(&self) -> BackendConfig {
338 let provider = match self.provider.as_str() {
339 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
340 _ if self.openai_url.is_some() => "openai".into(),
341 other => other.into(), };
343 BackendConfig {
344 provider,
345 model: self.model.clone(),
346 endpoint: self.openai_url.clone(),
347 api_key_env: self.api_key_env.clone(),
348 timeout_secs: None,
349 }
350 }
351}
352
353#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
364#[serde(default)]
365pub struct BackendConfig {
366 pub provider: String,
368 pub model: String,
370 pub endpoint: Option<String>,
373 pub api_key_env: Option<String>,
375 pub timeout_secs: Option<u64>,
377}
378
379impl Default for BackendConfig {
380 fn default() -> Self {
381 Self {
382 provider: "ollama".into(),
383 model: DEFAULT_LOCAL_LLM_MODEL.into(),
384 endpoint: None,
385 api_key_env: None,
386 timeout_secs: None,
387 }
388 }
389}
390
391#[derive(Debug, Clone, Serialize, Deserialize)]
392pub struct RetrievalConfig {
393 #[serde(default = "default_max_patterns")]
395 pub max_patterns: usize,
396
397 #[serde(default = "default_max_tokens")]
399 pub max_tokens: usize,
400
401 #[serde(default = "default_min_score")]
403 pub min_score: f64,
404
405 #[serde(default = "default_mmr_threshold")]
407 pub mmr_threshold: f64,
408}
409
410impl Default for RetrievalConfig {
411 fn default() -> Self {
412 Self {
413 max_patterns: default_max_patterns(),
414 max_tokens: default_max_tokens(),
415 min_score: default_min_score(),
416 mmr_threshold: default_mmr_threshold(),
417 }
418 }
419}
420
421#[derive(Debug, Clone, Serialize, Deserialize)]
422pub struct PathConfig {
423 #[serde(default = "default_mur_dir")]
425 pub mur_dir: PathBuf,
426}
427
428impl Default for PathConfig {
429 fn default() -> Self {
430 Self {
431 mur_dir: default_mur_dir(),
432 }
433 }
434}
435
436#[derive(Debug, Clone, Serialize, Deserialize)]
437pub struct StorageConfig {
438 #[serde(default = "default_vector_backend")]
440 pub vector_backend: String,
441
442 #[serde(default, skip_serializing_if = "Option::is_none")]
444 pub qdrant_url: Option<String>,
445
446 #[serde(default, skip_serializing_if = "Option::is_none")]
448 pub qdrant_api_key_ref: Option<String>,
449}
450
451impl Default for StorageConfig {
452 fn default() -> Self {
453 Self {
454 vector_backend: default_vector_backend(),
455 qdrant_url: None,
456 qdrant_api_key_ref: None,
457 }
458 }
459}
460
461fn default_vector_backend() -> String {
462 "lancedb".to_string()
463}
464
465#[derive(Debug, Clone, Serialize, Deserialize)]
466pub struct SourcesGlobalConfig {
467 #[serde(default = "default_poll_interval_secs")]
469 pub poll_interval_secs: u64,
470
471 #[serde(default = "default_max_chunks_per_sync")]
473 pub max_chunks_per_sync: usize,
474
475 #[serde(default = "default_max_parallel_sources")]
477 pub max_parallel_sources: usize,
478
479 #[serde(default = "default_source_weight")]
481 pub default_weight: f32,
482
483 #[serde(default = "default_embedding_batch_size")]
485 pub embedding_batch_size: usize,
486}
487
488impl Default for SourcesGlobalConfig {
489 fn default() -> Self {
490 Self {
491 poll_interval_secs: default_poll_interval_secs(),
492 max_chunks_per_sync: default_max_chunks_per_sync(),
493 max_parallel_sources: default_max_parallel_sources(),
494 default_weight: default_source_weight(),
495 embedding_batch_size: default_embedding_batch_size(),
496 }
497 }
498}
499
500fn default_poll_interval_secs() -> u64 {
501 600
502}
503fn default_max_chunks_per_sync() -> usize {
504 10_000
505}
506fn default_max_parallel_sources() -> usize {
507 3
508}
509fn default_source_weight() -> f32 {
510 1.0
511}
512fn default_embedding_batch_size() -> usize {
513 32
514}
515
516fn default_embedding_provider() -> String {
517 "ollama".to_string()
518}
519fn default_embedding_model() -> String {
520 "qwen3-embedding:0.6b".to_string()
521}
522fn default_dimensions() -> usize {
523 1024
524}
525fn default_ollama_endpoint() -> String {
526 "http://localhost:11434".to_string()
527}
528fn default_llm_provider() -> String {
529 "anthropic".to_string()
530}
531fn default_llm_model() -> String {
532 "claude-opus-4-6".to_string()
533}
534fn default_max_patterns() -> usize {
535 5
536}
537fn default_max_tokens() -> usize {
538 2000
539}
540fn default_min_score() -> f64 {
541 0.35
542}
543fn default_mmr_threshold() -> f64 {
544 0.85
545}
546fn default_mur_dir() -> PathBuf {
547 let home = std::env::var("HOME")
550 .map(PathBuf::from)
551 .unwrap_or_else(|_| PathBuf::from("/tmp"));
552 home.join(".mur")
553}
554fn default_server_url() -> String {
555 "https://mur-server.fly.dev".to_string()
556}
557
558#[derive(Debug, Clone, Serialize, Deserialize)]
561pub struct AskConfig {
562 #[serde(default = "ask_default_model")]
563 pub model: String,
564 #[serde(default = "compact_default_ollama_endpoint")]
565 pub ollama_endpoint: String,
566 #[serde(default = "ask_default_k_summary")]
567 pub k_summary: u32,
568 #[serde(default = "ask_default_k_raw")]
569 pub k_raw: u32,
570 #[serde(default = "ask_default_esc")]
571 pub escalation_threshold: f64,
572 #[serde(default = "ask_default_mmr")]
573 pub mmr_threshold: f64,
574 #[serde(default = "ask_default_max_ctx")]
575 pub max_context_tokens: u32,
576 #[serde(default = "ask_default_resp_tok")]
577 pub response_tokens: u32,
578 #[serde(default = "ask_default_timeout")]
579 pub timeout_secs: u32,
580 #[serde(default = "ask_default_min_score")]
581 pub min_score: f64,
582 #[serde(default = "ask_default_continue_history_turns")]
583 pub continue_history_turns: u32,
584 #[serde(default = "ask_default_rewriter_timeout")]
590 pub rewriter_timeout_secs: u32,
591 #[serde(default = "ask_default_compress_hits_enabled")]
592 pub compress_hits_enabled: bool,
593 #[serde(default = "ask_default_summarize_hits_enabled")]
594 pub summarize_hits_enabled: bool,
595 #[serde(default)]
596 pub summarize_model: Option<String>,
597 #[serde(default)]
600 pub backend: Option<BackendConfig>,
601 #[serde(default)]
605 pub rewriter_backend: Option<BackendConfig>,
606}
607
608impl AskConfig {
609 pub fn synthesize_backend(&self) -> BackendConfig {
619 self.backend.clone().unwrap_or_else(|| BackendConfig {
620 provider: "ollama".into(),
621 model: self.model.clone(),
622 endpoint: Some(self.ollama_endpoint.clone()),
623 api_key_env: None,
624 timeout_secs: Some(self.timeout_secs as u64),
625 })
626 }
627
628 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
638 self.rewriter_backend
639 .clone()
640 .unwrap_or_else(|| BackendConfig {
641 provider: "ollama".into(),
642 model: self.model.clone(),
643 endpoint: Some(self.ollama_endpoint.clone()),
644 api_key_env: None,
645 timeout_secs: Some(self.rewriter_timeout_secs as u64),
646 })
647 }
648}
649
650impl Default for AskConfig {
651 fn default() -> Self {
652 Self {
653 model: ask_default_model(),
654 ollama_endpoint: compact_default_ollama_endpoint(),
655 k_summary: ask_default_k_summary(),
656 k_raw: ask_default_k_raw(),
657 escalation_threshold: ask_default_esc(),
658 mmr_threshold: ask_default_mmr(),
659 max_context_tokens: ask_default_max_ctx(),
660 response_tokens: ask_default_resp_tok(),
661 timeout_secs: ask_default_timeout(),
662 min_score: ask_default_min_score(),
663 continue_history_turns: ask_default_continue_history_turns(),
664 rewriter_timeout_secs: ask_default_rewriter_timeout(),
665 compress_hits_enabled: ask_default_compress_hits_enabled(),
666 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
667 summarize_model: None,
668 backend: None,
669 rewriter_backend: None,
670 }
671 }
672}
673
674fn ask_default_model() -> String {
675 DEFAULT_LOCAL_LLM_MODEL.into()
676}
677fn ask_default_k_summary() -> u32 {
678 5
679}
680fn ask_default_k_raw() -> u32 {
681 10
682}
683fn ask_default_esc() -> f64 {
684 0.5
685}
686fn ask_default_mmr() -> f64 {
687 0.88
688}
689fn ask_default_max_ctx() -> u32 {
690 6000
691}
692fn ask_default_resp_tok() -> u32 {
693 1024
694}
695fn ask_default_timeout() -> u32 {
696 120
697}
698fn ask_default_min_score() -> f64 {
699 0.35
700}
701fn ask_default_rewriter_timeout() -> u32 {
702 8
703}
704fn ask_default_continue_history_turns() -> u32 {
705 3
706}
707fn ask_default_compress_hits_enabled() -> bool {
708 true
709}
710fn ask_default_summarize_hits_enabled() -> bool {
711 true
712}
713
714#[derive(Debug, Clone, Serialize, Deserialize)]
723pub struct ConversationsConfig {
724 #[serde(default)]
725 pub enabled: bool,
726 #[serde(default = "conv_default_retention_days")]
727 pub retention_days: u32,
728 #[serde(default = "conv_default_poll_interval")]
729 pub poll_interval_secs: u64,
730 #[serde(default)]
731 pub sources: ConversationsSources,
732 #[serde(default)]
733 pub filter: ConversationsFilter,
734 #[serde(default)]
735 pub compact: CompactConfig,
736 #[serde(default)]
737 pub ask: AskConfig,
738 #[serde(default)]
739 pub rollup: RollupConfig,
740}
741
742impl Default for ConversationsConfig {
743 fn default() -> Self {
744 Self {
745 enabled: false,
746 retention_days: conv_default_retention_days(),
747 poll_interval_secs: conv_default_poll_interval(),
748 sources: ConversationsSources::default(),
749 filter: ConversationsFilter::default(),
750 compact: CompactConfig::default(),
751 ask: AskConfig::default(),
752 rollup: RollupConfig::default(),
753 }
754 }
755}
756
757fn conv_default_retention_days() -> u32 {
758 30
759}
760fn conv_default_poll_interval() -> u64 {
761 300
762}
763fn conv_truthy() -> bool {
764 true
765}
766fn conv_default_dedup() -> f64 {
767 0.85
768}
769
770#[derive(Debug, Clone, Serialize, Deserialize)]
771pub struct CompactConfig {
772 #[serde(default = "conv_truthy")]
773 pub enabled_in_daemon: bool,
774 #[serde(default = "compact_default_max_days")]
775 pub max_days_per_run: u32,
776 #[serde(default = "compact_default_model")]
777 pub extractive_model: String,
778 #[serde(default = "compact_default_model")]
779 pub abstractive_model: String,
780 #[serde(default = "compact_default_ollama_endpoint")]
781 pub ollama_endpoint: String,
782 #[serde(default = "compact_default_max_spans")]
783 pub max_extractive_spans: u32,
784 #[serde(default = "compact_default_max_words")]
785 pub max_abstractive_words: u32,
786 #[serde(default = "compact_default_chunk_tokens")]
787 pub chunk_tokens: u32,
788 #[serde(default = "compact_default_history_retain")]
789 pub history_retain: u32,
790 #[serde(default = "compact_default_cron")]
791 pub daemon_cron: String,
792 #[serde(default)]
795 pub extractive_backend: Option<BackendConfig>,
796 #[serde(default)]
799 pub abstractive_backend: Option<BackendConfig>,
800}
801
802impl CompactConfig {
803 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
812 self.extractive_backend
813 .clone()
814 .unwrap_or_else(|| BackendConfig {
815 provider: "ollama".into(),
816 model: self.extractive_model.clone(),
817 endpoint: Some(self.ollama_endpoint.clone()),
818 api_key_env: None,
819 timeout_secs: Some(120),
820 })
821 }
822
823 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
826 self.abstractive_backend
827 .clone()
828 .unwrap_or_else(|| BackendConfig {
829 provider: "ollama".into(),
830 model: self.abstractive_model.clone(),
831 endpoint: Some(self.ollama_endpoint.clone()),
832 api_key_env: None,
833 timeout_secs: Some(120),
834 })
835 }
836}
837
838impl Default for CompactConfig {
839 fn default() -> Self {
840 Self {
841 enabled_in_daemon: true,
842 max_days_per_run: compact_default_max_days(),
843 extractive_model: compact_default_model(),
844 abstractive_model: compact_default_model(),
845 ollama_endpoint: compact_default_ollama_endpoint(),
846 max_extractive_spans: compact_default_max_spans(),
847 max_abstractive_words: compact_default_max_words(),
848 chunk_tokens: compact_default_chunk_tokens(),
849 history_retain: compact_default_history_retain(),
850 daemon_cron: compact_default_cron(),
851 extractive_backend: None,
852 abstractive_backend: None,
853 }
854 }
855}
856
857fn compact_default_max_days() -> u32 {
858 7
859}
860fn compact_default_model() -> String {
861 DEFAULT_LOCAL_LLM_MODEL.into()
862}
863fn compact_default_ollama_endpoint() -> String {
864 "http://localhost:11434".into()
865}
866fn compact_default_max_spans() -> u32 {
867 20
868}
869fn compact_default_max_words() -> u32 {
870 400
871}
872fn compact_default_chunk_tokens() -> u32 {
873 6000
874}
875fn compact_default_history_retain() -> u32 {
876 5
877}
878fn compact_default_cron() -> String {
879 "0 0 3 * * * *".into()
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
885pub struct RollupConfig {
886 #[serde(default = "rollup_default_enabled")]
887 pub enabled: bool,
888 #[serde(default = "rollup_default_max_weeks")]
889 pub max_weeks_per_run: u32,
890 #[serde(default = "rollup_default_max_months")]
891 pub max_months_per_run: u32,
892 #[serde(default = "rollup_default_max_spans_week")]
893 pub max_extractive_spans_per_week: u32,
894 #[serde(default = "rollup_default_max_words_week")]
895 pub max_abstractive_words_per_week: u32,
896 #[serde(default = "rollup_default_max_spans_month")]
897 pub max_extractive_spans_per_month: u32,
898 #[serde(default = "rollup_default_max_words_month")]
899 pub max_abstractive_words_per_month: u32,
900 #[serde(default = "rollup_default_week_mmr")]
901 pub week_mmr_threshold: f64,
902 #[serde(default = "rollup_default_month_mmr")]
903 pub month_mmr_threshold: f64,
904 #[serde(default = "compact_default_model")]
905 pub extractive_model: String,
906 #[serde(default = "compact_default_model")]
907 pub abstractive_model: String,
908 #[serde(default = "compact_default_ollama_endpoint")]
909 pub ollama_endpoint: String,
910}
911
912impl Default for RollupConfig {
913 fn default() -> Self {
914 Self {
915 enabled: rollup_default_enabled(),
916 max_weeks_per_run: rollup_default_max_weeks(),
917 max_months_per_run: rollup_default_max_months(),
918 max_extractive_spans_per_week: rollup_default_max_spans_week(),
919 max_abstractive_words_per_week: rollup_default_max_words_week(),
920 max_extractive_spans_per_month: rollup_default_max_spans_month(),
921 max_abstractive_words_per_month: rollup_default_max_words_month(),
922 week_mmr_threshold: rollup_default_week_mmr(),
923 month_mmr_threshold: rollup_default_month_mmr(),
924 extractive_model: compact_default_model(),
925 abstractive_model: compact_default_model(),
926 ollama_endpoint: compact_default_ollama_endpoint(),
927 }
928 }
929}
930
931fn rollup_default_enabled() -> bool {
932 true
933}
934fn rollup_default_max_weeks() -> u32 {
935 4
936}
937fn rollup_default_max_months() -> u32 {
938 2
939}
940fn rollup_default_max_spans_week() -> u32 {
941 20
942}
943fn rollup_default_max_words_week() -> u32 {
944 500
945}
946fn rollup_default_max_spans_month() -> u32 {
947 20
948}
949fn rollup_default_max_words_month() -> u32 {
950 700
951}
952fn rollup_default_week_mmr() -> f64 {
953 0.85
954}
955fn rollup_default_month_mmr() -> f64 {
956 0.82
957}
958
959#[derive(Debug, Clone, Serialize, Deserialize)]
960pub struct ConversationsSources {
961 #[serde(default = "conv_truthy")]
962 pub claude_code: bool,
963 #[serde(default = "conv_truthy")]
964 pub cursor: bool,
965 #[serde(default = "conv_truthy")]
966 pub gemini: bool,
967 #[serde(default)]
968 pub aider: AiderSourceConfig,
969}
970
971impl Default for ConversationsSources {
972 fn default() -> Self {
973 Self {
974 claude_code: true,
975 cursor: true,
976 gemini: true,
977 aider: AiderSourceConfig::default(),
978 }
979 }
980}
981
982#[derive(Debug, Clone, Serialize, Deserialize)]
983pub struct AiderSourceConfig {
984 #[serde(default = "conv_truthy")]
985 pub enabled: bool,
986 #[serde(default)]
987 pub watched_dirs: Vec<String>,
988}
989
990impl Default for AiderSourceConfig {
991 fn default() -> Self {
992 Self {
993 enabled: true,
994 watched_dirs: Vec::new(),
995 }
996 }
997}
998
999#[derive(Debug, Clone, Serialize, Deserialize)]
1000pub struct ConversationsFilter {
1001 #[serde(default = "conv_default_dedup")]
1002 pub dedup_threshold: f64,
1003 #[serde(default = "conv_truthy")]
1004 pub reject_heartbeat: bool,
1005 #[serde(default = "conv_truthy")]
1006 pub reject_system_restatement: bool,
1007}
1008
1009impl Default for ConversationsFilter {
1010 fn default() -> Self {
1011 Self {
1012 dedup_threshold: conv_default_dedup(),
1013 reject_heartbeat: true,
1014 reject_system_restatement: true,
1015 }
1016 }
1017}
1018
1019#[cfg(test)]
1020mod conversations_tests {
1021 use super::*;
1022
1023 #[test]
1024 fn conversations_section_defaults() {
1025 let c = ConversationsConfig::default();
1026 assert!(!c.enabled);
1027 assert_eq!(c.retention_days, 30);
1028 assert_eq!(c.poll_interval_secs, 300);
1029 assert!(c.sources.claude_code);
1030 assert!(c.sources.cursor);
1031 assert!(c.sources.gemini);
1032 assert!(c.sources.aider.enabled);
1033 assert!(c.sources.aider.watched_dirs.is_empty());
1034 assert_eq!(c.filter.dedup_threshold, 0.85);
1035 assert!(c.filter.reject_heartbeat);
1036 assert!(c.filter.reject_system_restatement);
1037 }
1038
1039 #[test]
1040 fn parse_from_yaml_with_overrides() {
1041 let y = r#"
1042conversations:
1043 enabled: true
1044 retention_days: 45
1045 poll_interval_secs: 120
1046 sources:
1047 cursor: false
1048 aider:
1049 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1050 filter:
1051 dedup_threshold: 0.9
1052"#;
1053 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1054 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1055 assert!(conv.enabled);
1056 assert_eq!(conv.retention_days, 45);
1057 assert_eq!(conv.poll_interval_secs, 120);
1058 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1062 assert_eq!(conv.filter.dedup_threshold, 0.9);
1063 assert!(conv.filter.reject_heartbeat); }
1065
1066 #[test]
1067 fn missing_conversations_section_is_fine() {
1068 let y = r#"
1069# No conversations section at all
1070foo: bar
1071"#;
1072 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1073 let conv: ConversationsConfig = v
1075 .get("conversations")
1076 .cloned()
1077 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1078 .unwrap_or_default();
1079 assert_eq!(conv.retention_days, 30);
1080 }
1081
1082 #[test]
1083 fn compact_config_defaults() {
1084 let c = CompactConfig::default();
1085 assert!(c.enabled_in_daemon);
1086 assert_eq!(c.max_days_per_run, 7);
1087 assert_eq!(c.extractive_model, "qwen3.5:4b");
1088 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1089 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1090 assert_eq!(c.max_extractive_spans, 20);
1091 assert_eq!(c.chunk_tokens, 6000);
1092 assert_eq!(c.history_retain, 5);
1093 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1094 }
1095
1096 #[test]
1097 fn compact_parses_partial_overrides() {
1098 let y = r#"
1099conversations:
1100 compact:
1101 max_days_per_run: 3
1102 extractive_model: qwen3:4b
1103"#;
1104 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1105 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1106 assert_eq!(conv.compact.max_days_per_run, 3);
1107 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1108 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
1111
1112 #[test]
1113 fn ask_config_defaults() {
1114 let c = AskConfig::default();
1115 assert_eq!(c.model, "qwen3.5:4b");
1116 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1117 assert_eq!(c.k_raw, 10);
1118 assert_eq!(c.escalation_threshold, 0.5);
1119 assert_eq!(c.mmr_threshold, 0.88);
1120 assert_eq!(c.max_context_tokens, 6000);
1121 assert_eq!(c.response_tokens, 1024);
1122 assert_eq!(c.timeout_secs, 120);
1123 assert_eq!(c.min_score, 0.35);
1124 }
1125
1126 #[test]
1127 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1128 let c = AskConfig::default();
1130 assert!(
1131 (c.mmr_threshold - 0.88).abs() < 1e-9,
1132 "expected 0.88, got {}",
1133 c.mmr_threshold
1134 );
1135 }
1136
1137 #[test]
1138 fn rollup_config_defaults() {
1139 let c = RollupConfig::default();
1140 assert!(c.enabled);
1141 assert_eq!(c.max_weeks_per_run, 4);
1142 assert_eq!(c.max_months_per_run, 2);
1143 assert_eq!(c.max_extractive_spans_per_week, 20);
1144 assert_eq!(c.max_abstractive_words_per_week, 500);
1145 assert_eq!(c.max_extractive_spans_per_month, 20);
1146 assert_eq!(c.max_abstractive_words_per_month, 700);
1147 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1148 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1149 assert_eq!(c.extractive_model, "qwen3.5:4b");
1150 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1151 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1152 }
1153
1154 #[test]
1155 fn rollup_config_plumbed_into_conversations_config() {
1156 let c = ConversationsConfig::default();
1157 assert!(c.rollup.enabled);
1158 }
1159
1160 #[test]
1161 fn ask_config_default_continue_history_turns_is_3() {
1162 let c = AskConfig::default();
1163 assert_eq!(c.continue_history_turns, 3);
1164 }
1165
1166 #[test]
1167 fn ask_config_default_compress_hits_enabled_is_true() {
1168 let c = AskConfig::default();
1169 assert!(c.compress_hits_enabled);
1170 }
1171
1172 #[test]
1173 fn ask_config_default_summarize_hits_enabled_is_true() {
1174 let c = AskConfig::default();
1175 assert!(c.summarize_hits_enabled);
1176 }
1177
1178 #[test]
1179 fn ask_config_default_summarize_model_is_none() {
1180 let c = AskConfig::default();
1181 assert!(c.summarize_model.is_none());
1182 }
1183
1184 #[test]
1185 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1186 let y = r#"
1187conversations:
1188 ask:
1189 summarize_hits_enabled: false
1190 summarize_model: qwen3:4b
1191"#;
1192 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1193 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1194 assert!(!conv.ask.summarize_hits_enabled);
1195 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1196 }
1197
1198 #[test]
1199 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1200 let y = r#"
1204conversations:
1205 ask:
1206 model: qwen3:14b
1207"#;
1208 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1209 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1210 assert!(conv.ask.summarize_hits_enabled);
1211 assert!(conv.ask.summarize_model.is_none());
1212 }
1213}
1214
1215#[cfg(test)]
1216mod tests {
1217 use super::*;
1218
1219 #[test]
1220 fn default_bundled_model_id_is_qwen35_2b() {
1221 assert_eq!(
1222 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1223 "Qwen3.5-2B-MLX-4bit"
1224 );
1225 }
1226
1227 #[test]
1228 fn nudge_config_defaults() {
1229 let c = NudgeConfig::default();
1230 assert!(c.enabled);
1231 assert_eq!(c.daily_cap, 3);
1232 assert_eq!(c.snooze_days, 7);
1233 assert_eq!(c.threshold, 3);
1234 }
1235
1236 #[test]
1237 fn config_has_nudge_section_with_defaults() {
1238 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1239 assert_eq!(c.nudge.daily_cap, 3);
1240 }
1241
1242 #[test]
1243 fn storage_config_default_is_lancedb() {
1244 let c = StorageConfig::default();
1245 assert_eq!(c.vector_backend, "lancedb");
1246 assert_eq!(c.qdrant_url, None);
1247 assert_eq!(c.qdrant_api_key_ref, None);
1248 }
1249
1250 #[test]
1251 fn sources_global_config_has_sensible_defaults() {
1252 let c = SourcesGlobalConfig::default();
1253 assert_eq!(c.poll_interval_secs, 600);
1254 assert_eq!(c.max_chunks_per_sync, 10_000);
1255 assert_eq!(c.max_parallel_sources, 3);
1256 assert_eq!(c.default_weight, 1.0);
1257 assert_eq!(c.embedding_batch_size, 32);
1258 }
1259
1260 #[test]
1261 fn config_default_has_storage_and_sources_global() {
1262 let c = Config::default();
1263 assert_eq!(c.storage.vector_backend, "lancedb");
1264 assert_eq!(c.sources_global.default_weight, 1.0);
1265 }
1266
1267 #[test]
1268 fn config_loads_yaml_without_new_fields() {
1269 let yaml = r#"
1272embedding:
1273 provider: ollama
1274 model: test-model
1275 dimensions: 512
1276 ollama_endpoint: http://localhost:11434
1277"#;
1278 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1279 assert_eq!(c.storage.vector_backend, "lancedb");
1280 assert_eq!(c.sources_global.max_parallel_sources, 3);
1281 }
1282
1283 #[test]
1284 fn llm_config_to_backend_config_anthropic_passthrough() {
1285 let cfg = LlmConfig {
1286 provider: "anthropic".into(),
1287 model: "claude-haiku-4-5".into(),
1288 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1289 openai_url: None,
1290 };
1291 let b = cfg.to_backend_config();
1292 assert_eq!(b.provider, "anthropic");
1293 assert_eq!(b.model, "claude-haiku-4-5");
1294 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1295 assert_eq!(b.endpoint, None);
1296 assert_eq!(b.timeout_secs, None);
1297 }
1298
1299 #[test]
1300 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1301 let cfg = LlmConfig {
1302 provider: "openai".into(),
1303 model: "gpt-4o-mini".into(),
1304 api_key_env: None,
1305 openai_url: Some("https://api.together.xyz/v1".into()),
1306 };
1307 let b = cfg.to_backend_config();
1308 assert_eq!(b.provider, "openai");
1309 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1310 assert_eq!(b.api_key_env, None); }
1312
1313 #[test]
1314 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1315 let cfg = LlmConfig {
1316 provider: "ollama".into(),
1317 model: "qwen3:14b".into(),
1318 api_key_env: None,
1319 openai_url: Some("http://192.168.1.10:11434".into()),
1320 };
1321 let b = cfg.to_backend_config();
1322 assert_eq!(b.provider, "ollama");
1323 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1324 }
1325
1326 #[test]
1327 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1328 let cfg = LlmConfig {
1332 provider: "custom-name".into(),
1333 model: "some-model".into(),
1334 api_key_env: Some("CUSTOM_KEY".into()),
1335 openai_url: Some("https://my-proxy.local/v1".into()),
1336 };
1337 let b = cfg.to_backend_config();
1338 assert_eq!(
1339 b.provider, "openai",
1340 "unknown provider + openai_url should alias to openai"
1341 );
1342 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1343 }
1344}
1345
1346#[cfg(test)]
1347mod backend_config_tests {
1348 use super::*;
1349
1350 #[test]
1351 fn default_is_ollama_qwen3() {
1352 let cfg = BackendConfig::default();
1353 assert_eq!(cfg.provider, "ollama");
1354 assert_eq!(cfg.model, "qwen3.5:4b");
1355 assert_eq!(cfg.endpoint, None);
1356 assert_eq!(cfg.api_key_env, None);
1357 assert_eq!(cfg.timeout_secs, None);
1358 }
1359
1360 #[test]
1361 fn deserializes_anthropic_full() {
1362 let yaml = "\
1363provider: anthropic
1364model: claude-haiku-4-5
1365api_key_env: ANTHROPIC_API_KEY
1366timeout_secs: 60
1367";
1368 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1369 assert_eq!(cfg.provider, "anthropic");
1370 assert_eq!(cfg.model, "claude-haiku-4-5");
1371 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1372 assert_eq!(cfg.timeout_secs, Some(60));
1373 assert_eq!(cfg.endpoint, None);
1374 }
1375
1376 #[test]
1377 fn deserializes_partial_fills_defaults() {
1378 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1379 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1380 assert_eq!(cfg.provider, "anthropic");
1381 assert_eq!(cfg.model, "claude-sonnet-4-6");
1382 assert_eq!(cfg.api_key_env, None);
1383 assert_eq!(cfg.timeout_secs, None);
1384 }
1385
1386 #[test]
1387 fn round_trips_through_yaml() {
1388 let original = BackendConfig {
1389 provider: "anthropic".into(),
1390 model: "claude-haiku-4-5".into(),
1391 endpoint: Some("https://api.anthropic.com".into()),
1392 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1393 timeout_secs: Some(60),
1394 };
1395 let yaml = serde_yaml::to_string(&original).unwrap();
1396 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1397 assert_eq!(parsed, original);
1398 }
1399
1400 #[test]
1401 fn skills_config_curation_gate_defaults_on() {
1402 let c = SkillsConfig::default();
1403 assert!(c.require_human_curation_before_stable);
1404 }
1405}
1406
1407#[derive(Debug, Clone, Serialize, Deserialize)]
1411#[serde(default)]
1412pub struct SkillsConfig {
1413 pub max_skills_in_prompt: usize,
1414 pub max_total_tokens: usize,
1415 pub priority_order: Vec<String>,
1416 pub adaptive: Option<AdaptiveSkillsConfig>,
1417
1418 #[serde(default = "default_require_human_curation")]
1422 pub require_human_curation_before_stable: bool,
1423
1424 #[serde(default)]
1428 pub lifecycle: SkillLifecycleConfig,
1429}
1430
1431fn default_require_human_curation() -> bool {
1432 true
1433}
1434
1435impl Default for SkillsConfig {
1436 fn default() -> Self {
1437 Self {
1438 max_skills_in_prompt: 5,
1439 max_total_tokens: 2000,
1440 priority_order: vec!["agent".into(), "global".into()],
1441 adaptive: Some(AdaptiveSkillsConfig::default()),
1442 require_human_curation_before_stable: default_require_human_curation(),
1443 lifecycle: SkillLifecycleConfig::default(),
1444 }
1445 }
1446}
1447
1448#[derive(Debug, Clone, Serialize, Deserialize)]
1454#[serde(default)]
1455pub struct SkillLifecycleConfig {
1456 pub promote_draft_uses: u64,
1458 pub promote_emerging_uses: u64,
1459 pub promote_emerging_success_rate: f64,
1460 pub promote_emerging_age_days: i64,
1461 pub promote_stable_uses: u64,
1462 pub promote_stable_success_rate: f64,
1463 pub promote_stable_age_days: i64,
1464
1465 pub demote_emerging_uses: u64,
1467 pub demote_emerging_success_rate: f64,
1468 pub demote_stable_uses: u64,
1469 pub demote_stable_success_rate: f64,
1470 pub deprecated_success_rate: f64,
1471 pub deprecated_no_success_days: i64,
1472
1473 pub auto_archive_confidence: f64,
1475 pub auto_archive_age_days: i64,
1476
1477 pub broken_workflow_streak: u32,
1482
1483 pub archive_destroy_grace_days: i64,
1488}
1489
1490impl Default for SkillLifecycleConfig {
1491 fn default() -> Self {
1492 Self {
1493 promote_draft_uses: 3,
1494 promote_emerging_uses: 10,
1495 promote_emerging_success_rate: 0.6,
1496 promote_emerging_age_days: 7,
1497 promote_stable_uses: 30,
1498 promote_stable_success_rate: 0.8,
1499 promote_stable_age_days: 30,
1500 demote_emerging_uses: 8,
1501 demote_emerging_success_rate: 0.55,
1502 demote_stable_uses: 25,
1503 demote_stable_success_rate: 0.75,
1504 deprecated_success_rate: 0.3,
1505 deprecated_no_success_days: 90,
1506 auto_archive_confidence: 0.10,
1507 auto_archive_age_days: 180,
1508 broken_workflow_streak: 3,
1509 archive_destroy_grace_days: 30,
1510 }
1511 }
1512}
1513
1514#[derive(Debug, Clone, Serialize, Deserialize)]
1515#[serde(default)]
1516pub struct AdaptiveSkillsConfig {
1517 pub context_fill_decay: f64,
1518 pub min_remaining_context_ratio: f64,
1519 pub recent_fire_boost_turns: usize,
1520 pub model_max_context_tokens: u64,
1524}
1525
1526impl Default for AdaptiveSkillsConfig {
1527 fn default() -> Self {
1528 Self {
1529 context_fill_decay: 1.5,
1530 min_remaining_context_ratio: 0.20,
1531 recent_fire_boost_turns: 5,
1532 model_max_context_tokens: 200_000,
1533 }
1534 }
1535}
1536
1537#[derive(Debug, Clone, Serialize, Deserialize)]
1540pub struct SleepCycleConfig {
1541 #[serde(default)]
1543 pub enabled: bool,
1544
1545 #[serde(default = "default_idle_threshold_minutes")]
1547 pub idle_threshold_minutes: u64,
1548
1549 #[serde(default = "default_agent_idle_minutes")]
1551 pub agent_idle_minutes: u64,
1552}
1553
1554fn default_idle_threshold_minutes() -> u64 {
1555 15
1556}
1557
1558fn default_agent_idle_minutes() -> u64 {
1559 5
1560}
1561
1562impl Default for SleepCycleConfig {
1563 fn default() -> Self {
1564 Self {
1565 enabled: false,
1566 idle_threshold_minutes: default_idle_threshold_minutes(),
1567 agent_idle_minutes: default_agent_idle_minutes(),
1568 }
1569 }
1570}
1571
1572#[derive(Debug, Clone, Serialize, Deserialize)]
1575pub struct NudgeConfig {
1576 #[serde(default = "default_nudge_enabled")]
1578 pub enabled: bool,
1579 #[serde(default = "default_nudge_daily_cap")]
1580 pub daily_cap: u32,
1581 #[serde(default = "default_nudge_snooze_days")]
1582 pub snooze_days: u32,
1583 #[serde(default = "default_nudge_threshold")]
1584 pub threshold: usize,
1585}
1586
1587fn default_nudge_enabled() -> bool {
1588 true
1589}
1590fn default_nudge_daily_cap() -> u32 {
1591 3
1592}
1593fn default_nudge_snooze_days() -> u32 {
1594 7
1595}
1596fn default_nudge_threshold() -> usize {
1597 3
1598}
1599
1600impl Default for NudgeConfig {
1601 fn default() -> Self {
1602 Self {
1603 enabled: true,
1604 daily_cap: default_nudge_daily_cap(),
1605 snooze_days: default_nudge_snooze_days(),
1606 threshold: default_nudge_threshold(),
1607 }
1608 }
1609}
1610
1611#[derive(Debug, Clone, Serialize, Deserialize)]
1615pub struct SessionCfg {
1616 #[serde(default = "default_capture_mode")]
1618 pub capture: String,
1619 #[serde(default = "default_retention_days")]
1621 pub retention_days: u32,
1622}
1623
1624impl Default for SessionCfg {
1625 fn default() -> Self {
1626 Self {
1627 capture: default_capture_mode(),
1628 retention_days: default_retention_days(),
1629 }
1630 }
1631}
1632
1633fn default_capture_mode() -> String {
1634 "ambient".to_string()
1635}
1636fn default_retention_days() -> u32 {
1637 14
1638}
1639
1640#[derive(Debug, Clone, Serialize, Deserialize)]
1642pub struct HarvestCfg {
1643 #[serde(default = "default_harvest_enabled")]
1645 pub auto_gate: bool,
1646 #[serde(default = "default_harvest_llm")]
1648 pub llm: String,
1649 #[serde(default = "default_min_events")]
1651 pub min_events: usize,
1652 #[serde(default = "default_min_user_turns")]
1653 pub min_user_turns: usize,
1654 #[serde(default = "default_min_duration_secs")]
1655 pub min_duration_secs: i64,
1656 #[serde(default = "default_idle_minutes")]
1658 pub idle_minutes: i64,
1659 #[serde(default = "default_max_llm_calls_per_day")]
1661 pub max_llm_calls_per_day: u32,
1662 #[serde(default = "default_max_extract_input_tokens")]
1663 pub max_extract_input_tokens: usize,
1664 #[serde(default = "default_harvest_enabled")]
1666 pub session_start_hint: bool,
1667 #[serde(default = "default_similarity_merge_threshold")]
1669 pub similarity_merge_threshold: f32,
1670}
1671
1672impl Default for HarvestCfg {
1673 fn default() -> Self {
1674 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1675 }
1676}
1677
1678fn default_harvest_enabled() -> bool {
1679 true
1680}
1681fn default_harvest_llm() -> String {
1682 "local-first".to_string()
1683}
1684fn default_min_events() -> usize {
1685 5
1686}
1687fn default_min_user_turns() -> usize {
1688 2
1689}
1690fn default_min_duration_secs() -> i64 {
1691 120
1692}
1693fn default_idle_minutes() -> i64 {
1694 30
1695}
1696fn default_max_llm_calls_per_day() -> u32 {
1697 10
1698}
1699fn default_max_extract_input_tokens() -> usize {
1700 12000
1701}
1702fn default_similarity_merge_threshold() -> f32 {
1703 0.6
1704}
1705
1706#[derive(Debug, Clone, Serialize, Deserialize)]
1709#[serde(default)]
1710pub struct CrossAgentConfig {
1711 #[serde(default = "default_half_life_days")]
1712 pub fitness_half_life_days: u32,
1713 #[serde(default = "default_fitness_floor")]
1714 pub fitness_floor: f64,
1715}
1716
1717fn default_half_life_days() -> u32 {
1718 7
1719}
1720fn default_fitness_floor() -> f64 {
1721 0.1
1722}
1723
1724impl Default for CrossAgentConfig {
1725 fn default() -> Self {
1726 Self {
1727 fitness_half_life_days: default_half_life_days(),
1728 fitness_floor: default_fitness_floor(),
1729 }
1730 }
1731}
1732
1733#[derive(Debug, Clone, Serialize, Deserialize)]
1736#[serde(default)]
1737pub struct SkillLlmConfig {
1738 #[serde(default = "default_per_call_token_cap")]
1740 pub per_call_token_cap: u32,
1741
1742 #[serde(default = "default_per_day_usd_cap")]
1744 pub per_day_usd_cap: f64,
1745
1746 #[serde(default = "default_cache_ttl_days")]
1748 pub cache_ttl_days: u32,
1749
1750 #[serde(default, skip_serializing_if = "Option::is_none")]
1752 pub model_ref: Option<String>,
1753}
1754
1755fn default_per_call_token_cap() -> u32 {
1756 1500
1757}
1758fn default_per_day_usd_cap() -> f64 {
1759 0.50
1760}
1761fn default_cache_ttl_days() -> u32 {
1762 30
1763}
1764
1765impl Default for SkillLlmConfig {
1766 fn default() -> Self {
1767 Self {
1768 per_call_token_cap: default_per_call_token_cap(),
1769 per_day_usd_cap: default_per_day_usd_cap(),
1770 cache_ttl_days: default_cache_ttl_days(),
1771 model_ref: None,
1772 }
1773 }
1774}
1775#[cfg(test)]
1776mod per_stage_backend_tests {
1777 use super::*;
1778
1779 #[test]
1780 fn legacy_compact_config_has_no_per_stage_overrides() {
1781 let yaml = "\
1782extractive_model: qwen3:14b
1783abstractive_model: qwen3:14b
1784ollama_endpoint: http://localhost:11434
1785";
1786 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1787 assert!(cfg.extractive_backend.is_none());
1788 assert!(cfg.abstractive_backend.is_none());
1789 assert_eq!(cfg.extractive_model, "qwen3:14b");
1790 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1791 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1792 }
1793
1794 #[test]
1795 fn legacy_ask_config_has_no_per_stage_overrides() {
1796 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1797 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1798 assert!(cfg.backend.is_none());
1799 assert!(cfg.rewriter_backend.is_none());
1800 assert_eq!(cfg.model, "qwen3:14b");
1801 }
1802
1803 #[test]
1804 fn compact_extractive_backend_override_parses() {
1805 let yaml = "\
1806extractive_backend:
1807 provider: anthropic
1808 model: claude-haiku-4-5
1809 api_key_env: ANTHROPIC_API_KEY
1810abstractive_model: qwen3:14b
1811";
1812 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1813 let extractive = cfg
1814 .extractive_backend
1815 .as_ref()
1816 .expect("override should parse");
1817 assert_eq!(extractive.provider, "anthropic");
1818 assert_eq!(extractive.model, "claude-haiku-4-5");
1819 assert!(cfg.abstractive_backend.is_none());
1820 }
1821
1822 #[test]
1823 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1824 let yaml = "\
1825backend:
1826 provider: anthropic
1827 model: claude-sonnet-4-6
1828 api_key_env: ANTHROPIC_API_KEY
1829rewriter_backend:
1830 provider: ollama
1831 model: llama3.2:3b
1832";
1833 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1834 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1835 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1836 }
1837
1838 #[test]
1839 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1840 let yaml = "\
1841extractive_model: qwen3:14b
1842ollama_endpoint: http://192.168.1.10:11434
1843";
1844 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1845 let synth = cfg.synthesize_extractive_backend();
1846 assert_eq!(synth.provider, "ollama");
1847 assert_eq!(synth.model, "qwen3:14b");
1848 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1849 assert_eq!(synth.api_key_env, None);
1850 }
1851
1852 #[test]
1853 fn synthesize_legacy_to_backend_config_for_ask() {
1854 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1855 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1856 let synth = cfg.synthesize_backend();
1857 assert_eq!(synth.provider, "ollama");
1858 assert_eq!(synth.model, "qwen3:14b");
1859 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1860 }
1861
1862 #[test]
1863 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1864 let yaml = "\
1873backend:
1874 provider: anthropic
1875 model: claude-sonnet-4-6
1876 api_key_env: ANTHROPIC_API_KEY
1877";
1878 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1879 let rewriter = cfg.synthesize_rewriter_backend();
1880 assert_eq!(rewriter.provider, "ollama");
1881 assert_eq!(rewriter.model, ask_default_model());
1882 assert_eq!(
1883 rewriter.timeout_secs,
1884 Some(ask_default_rewriter_timeout() as u64)
1885 );
1886 }
1887
1888 #[test]
1889 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1890 let cfg = AskConfig {
1891 timeout_secs: 45,
1892 ..AskConfig::default()
1893 };
1894 let b = cfg.synthesize_backend();
1895 assert_eq!(
1896 b.timeout_secs,
1897 Some(45),
1898 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1899 );
1900 }
1901
1902 #[test]
1903 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1904 let mut cfg = AskConfig {
1905 timeout_secs: 45,
1906 ..AskConfig::default()
1907 };
1908 cfg.backend = Some(BackendConfig {
1909 provider: "anthropic".into(),
1910 model: "claude-haiku-4-5".into(),
1911 endpoint: None,
1912 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1913 timeout_secs: Some(10),
1914 });
1915 let b = cfg.synthesize_backend();
1916 assert_eq!(
1917 b.timeout_secs,
1918 Some(10),
1919 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1920 );
1921 }
1922
1923 #[test]
1924 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1925 let cfg = AskConfig {
1926 timeout_secs: 120,
1927 rewriter_timeout_secs: 8,
1928 ..AskConfig::default()
1929 };
1930 let b = cfg.synthesize_rewriter_backend();
1931 assert_eq!(
1932 b.timeout_secs,
1933 Some(8),
1934 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1935 );
1936 }
1937
1938 #[test]
1939 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1940 let mut cfg = AskConfig {
1941 rewriter_timeout_secs: 8,
1942 ..AskConfig::default()
1943 };
1944 cfg.rewriter_backend = Some(BackendConfig {
1945 provider: "anthropic".into(),
1946 model: "claude-haiku-4-5".into(),
1947 endpoint: None,
1948 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1949 timeout_secs: Some(30),
1950 });
1951 let b = cfg.synthesize_rewriter_backend();
1952 assert_eq!(
1953 b.timeout_secs,
1954 Some(30),
1955 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1956 );
1957 }
1958
1959 #[test]
1960 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1961 let cfg = CompactConfig::default();
1964 let b = cfg.synthesize_extractive_backend();
1965 assert_eq!(
1966 b.timeout_secs,
1967 Some(120),
1968 "compact synthesis without per-stage override must produce 120s timeout"
1969 );
1970 }
1971
1972 #[test]
1973 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1974 let cfg = CompactConfig::default();
1975 let b = cfg.synthesize_abstractive_backend();
1976 assert_eq!(b.timeout_secs, Some(120));
1977 }
1978}
1979
1980#[cfg(test)]
1981mod skills_config_tests {
1982 use super::*;
1983
1984 #[test]
1985 fn empty_yaml_hydrates_defaults() {
1986 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1987 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1988 assert_eq!(cfg.skills.max_total_tokens, 2000);
1989 assert!(cfg.skills.adaptive.is_some());
1990 }
1991
1992 #[test]
1993 fn load_or_default_missing_file_returns_default() {
1994 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1995 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1996 }
1997}
1998
1999#[cfg(test)]
2000mod ambient_capture_cfg_tests {
2001 use super::*;
2002
2003 #[test]
2004 fn session_and_harvest_defaults() {
2005 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2006 assert_eq!(cfg.session.capture, "ambient");
2007 assert_eq!(cfg.session.retention_days, 14);
2008 assert!(cfg.harvest.auto_gate);
2009 assert_eq!(cfg.harvest.llm, "local-first");
2010 assert_eq!(cfg.harvest.min_events, 5);
2011 assert_eq!(cfg.harvest.min_user_turns, 2);
2012 assert_eq!(cfg.harvest.min_duration_secs, 120);
2013 assert_eq!(cfg.harvest.idle_minutes, 30);
2014 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2015 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2016 assert!(cfg.harvest.session_start_hint);
2017 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2018 }
2019
2020 #[test]
2021 fn session_capture_override_parses() {
2022 let cfg: Config =
2023 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2024 assert_eq!(cfg.session.capture, "off");
2025 assert_eq!(cfg.session.retention_days, 3);
2026 }
2027}
2028
2029#[cfg(test)]
2030mod cc_proxy_cfg_tests {
2031 use super::*;
2032
2033 #[test]
2034 fn defaults_to_local_cc_proxy_enabled() {
2035 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2036 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2037 assert!(cfg.cc_proxy.enabled);
2038 }
2039
2040 #[test]
2041 fn url_and_enabled_override_parse() {
2042 let cfg: Config =
2043 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2044 .unwrap();
2045 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2046 assert!(!cfg.cc_proxy.enabled);
2047 }
2048
2049 #[test]
2050 fn partial_section_keeps_other_default() {
2051 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2053 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2054 assert!(!cfg.cc_proxy.enabled);
2055 }
2056}