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")]
280 pub api_key_ref: Option<String>,
281
282 #[serde(default, skip_serializing_if = "Option::is_none")]
284 pub openai_url: Option<String>,
285}
286
287impl Default for EmbeddingConfig {
288 fn default() -> Self {
289 Self {
290 provider: default_embedding_provider(),
291 model: default_embedding_model(),
292 dimensions: default_dimensions(),
293 ollama_endpoint: default_ollama_endpoint(),
294 api_key_env: None,
295 api_key_ref: None,
296 openai_url: None,
297 }
298 }
299}
300
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct LlmConfig {
303 #[serde(default = "default_llm_provider")]
305 pub provider: String,
306
307 #[serde(default = "default_llm_model")]
308 pub model: String,
309
310 #[serde(default, skip_serializing_if = "Option::is_none")]
312 pub api_key_env: Option<String>,
313
314 #[serde(default, skip_serializing_if = "Option::is_none")]
317 pub api_key_ref: Option<String>,
318
319 #[serde(default, skip_serializing_if = "Option::is_none")]
321 pub openai_url: Option<String>,
322}
323
324impl Default for LlmConfig {
325 fn default() -> Self {
326 Self {
327 provider: default_llm_provider(),
328 model: default_llm_model(),
329 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
330 api_key_ref: None,
331 openai_url: None,
332 }
333 }
334}
335
336impl LlmConfig {
337 pub fn to_backend_config(&self) -> BackendConfig {
350 let provider = match self.provider.as_str() {
351 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
352 _ if self.openai_url.is_some() => "openai".into(),
353 other => other.into(), };
355 BackendConfig {
356 provider,
357 model: self.model.clone(),
358 endpoint: self.openai_url.clone(),
359 api_key_env: self.api_key_env.clone(),
360 api_key_ref: self.api_key_ref.clone(),
361 timeout_secs: None,
362 }
363 }
364}
365
366#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
377#[serde(default)]
378pub struct BackendConfig {
379 pub provider: String,
381 pub model: String,
383 pub endpoint: Option<String>,
386 pub api_key_env: Option<String>,
388 pub api_key_ref: Option<String>,
390 pub timeout_secs: Option<u64>,
392}
393
394impl Default for BackendConfig {
395 fn default() -> Self {
396 Self {
397 provider: "ollama".into(),
398 model: DEFAULT_LOCAL_LLM_MODEL.into(),
399 endpoint: None,
400 api_key_env: None,
401 api_key_ref: None,
402 timeout_secs: None,
403 }
404 }
405}
406
407#[derive(Debug, Clone, Serialize, Deserialize)]
408pub struct RetrievalConfig {
409 #[serde(default = "default_max_patterns")]
411 pub max_patterns: usize,
412
413 #[serde(default = "default_max_tokens")]
415 pub max_tokens: usize,
416
417 #[serde(default = "default_min_score")]
419 pub min_score: f64,
420
421 #[serde(default = "default_mmr_threshold")]
423 pub mmr_threshold: f64,
424}
425
426impl Default for RetrievalConfig {
427 fn default() -> Self {
428 Self {
429 max_patterns: default_max_patterns(),
430 max_tokens: default_max_tokens(),
431 min_score: default_min_score(),
432 mmr_threshold: default_mmr_threshold(),
433 }
434 }
435}
436
437#[derive(Debug, Clone, Serialize, Deserialize)]
438pub struct PathConfig {
439 #[serde(default = "default_mur_dir")]
441 pub mur_dir: PathBuf,
442}
443
444impl Default for PathConfig {
445 fn default() -> Self {
446 Self {
447 mur_dir: default_mur_dir(),
448 }
449 }
450}
451
452#[derive(Debug, Clone, Serialize, Deserialize)]
453pub struct StorageConfig {
454 #[serde(default = "default_vector_backend")]
456 pub vector_backend: String,
457
458 #[serde(default, skip_serializing_if = "Option::is_none")]
460 pub qdrant_url: Option<String>,
461
462 #[serde(default, skip_serializing_if = "Option::is_none")]
464 pub qdrant_api_key_ref: Option<String>,
465}
466
467impl Default for StorageConfig {
468 fn default() -> Self {
469 Self {
470 vector_backend: default_vector_backend(),
471 qdrant_url: None,
472 qdrant_api_key_ref: None,
473 }
474 }
475}
476
477fn default_vector_backend() -> String {
478 "lancedb".to_string()
479}
480
481#[derive(Debug, Clone, Serialize, Deserialize)]
482pub struct SourcesGlobalConfig {
483 #[serde(default = "default_poll_interval_secs")]
485 pub poll_interval_secs: u64,
486
487 #[serde(default = "default_max_chunks_per_sync")]
489 pub max_chunks_per_sync: usize,
490
491 #[serde(default = "default_max_parallel_sources")]
493 pub max_parallel_sources: usize,
494
495 #[serde(default = "default_source_weight")]
497 pub default_weight: f32,
498
499 #[serde(default = "default_embedding_batch_size")]
501 pub embedding_batch_size: usize,
502}
503
504impl Default for SourcesGlobalConfig {
505 fn default() -> Self {
506 Self {
507 poll_interval_secs: default_poll_interval_secs(),
508 max_chunks_per_sync: default_max_chunks_per_sync(),
509 max_parallel_sources: default_max_parallel_sources(),
510 default_weight: default_source_weight(),
511 embedding_batch_size: default_embedding_batch_size(),
512 }
513 }
514}
515
516fn default_poll_interval_secs() -> u64 {
517 600
518}
519fn default_max_chunks_per_sync() -> usize {
520 10_000
521}
522fn default_max_parallel_sources() -> usize {
523 3
524}
525fn default_source_weight() -> f32 {
526 1.0
527}
528fn default_embedding_batch_size() -> usize {
529 32
530}
531
532fn default_embedding_provider() -> String {
533 "ollama".to_string()
534}
535fn default_embedding_model() -> String {
536 "qwen3-embedding:0.6b".to_string()
537}
538fn default_dimensions() -> usize {
539 1024
540}
541fn default_ollama_endpoint() -> String {
542 "http://localhost:11434".to_string()
543}
544fn default_llm_provider() -> String {
545 "anthropic".to_string()
546}
547fn default_llm_model() -> String {
548 "claude-opus-4-6".to_string()
549}
550fn default_max_patterns() -> usize {
551 5
552}
553fn default_max_tokens() -> usize {
554 2000
555}
556fn default_min_score() -> f64 {
557 0.35
558}
559fn default_mmr_threshold() -> f64 {
560 0.85
561}
562fn default_mur_dir() -> PathBuf {
563 let home = std::env::var("HOME")
566 .map(PathBuf::from)
567 .unwrap_or_else(|_| PathBuf::from("/tmp"));
568 home.join(".mur")
569}
570fn default_server_url() -> String {
571 "https://mur-server.fly.dev".to_string()
572}
573
574#[derive(Debug, Clone, Serialize, Deserialize)]
577pub struct AskConfig {
578 #[serde(default = "ask_default_model")]
579 pub model: String,
580 #[serde(default = "compact_default_ollama_endpoint")]
581 pub ollama_endpoint: String,
582 #[serde(default = "ask_default_k_summary")]
583 pub k_summary: u32,
584 #[serde(default = "ask_default_k_raw")]
585 pub k_raw: u32,
586 #[serde(default = "ask_default_esc")]
587 pub escalation_threshold: f64,
588 #[serde(default = "ask_default_mmr")]
589 pub mmr_threshold: f64,
590 #[serde(default = "ask_default_max_ctx")]
591 pub max_context_tokens: u32,
592 #[serde(default = "ask_default_resp_tok")]
593 pub response_tokens: u32,
594 #[serde(default = "ask_default_timeout")]
595 pub timeout_secs: u32,
596 #[serde(default = "ask_default_min_score")]
597 pub min_score: f64,
598 #[serde(default = "ask_default_continue_history_turns")]
599 pub continue_history_turns: u32,
600 #[serde(default = "ask_default_rewriter_timeout")]
606 pub rewriter_timeout_secs: u32,
607 #[serde(default = "ask_default_compress_hits_enabled")]
608 pub compress_hits_enabled: bool,
609 #[serde(default = "ask_default_summarize_hits_enabled")]
610 pub summarize_hits_enabled: bool,
611 #[serde(default)]
612 pub summarize_model: Option<String>,
613 #[serde(default)]
616 pub backend: Option<BackendConfig>,
617 #[serde(default)]
621 pub rewriter_backend: Option<BackendConfig>,
622}
623
624impl AskConfig {
625 pub fn synthesize_backend(&self) -> BackendConfig {
635 self.backend.clone().unwrap_or_else(|| BackendConfig {
636 provider: "ollama".into(),
637 model: self.model.clone(),
638 endpoint: Some(self.ollama_endpoint.clone()),
639 api_key_env: None,
640 api_key_ref: None,
641 timeout_secs: Some(self.timeout_secs as u64),
642 })
643 }
644
645 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
655 self.rewriter_backend
656 .clone()
657 .unwrap_or_else(|| BackendConfig {
658 provider: "ollama".into(),
659 model: self.model.clone(),
660 endpoint: Some(self.ollama_endpoint.clone()),
661 api_key_env: None,
662 api_key_ref: None,
663 timeout_secs: Some(self.rewriter_timeout_secs as u64),
664 })
665 }
666}
667
668impl Default for AskConfig {
669 fn default() -> Self {
670 Self {
671 model: ask_default_model(),
672 ollama_endpoint: compact_default_ollama_endpoint(),
673 k_summary: ask_default_k_summary(),
674 k_raw: ask_default_k_raw(),
675 escalation_threshold: ask_default_esc(),
676 mmr_threshold: ask_default_mmr(),
677 max_context_tokens: ask_default_max_ctx(),
678 response_tokens: ask_default_resp_tok(),
679 timeout_secs: ask_default_timeout(),
680 min_score: ask_default_min_score(),
681 continue_history_turns: ask_default_continue_history_turns(),
682 rewriter_timeout_secs: ask_default_rewriter_timeout(),
683 compress_hits_enabled: ask_default_compress_hits_enabled(),
684 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
685 summarize_model: None,
686 backend: None,
687 rewriter_backend: None,
688 }
689 }
690}
691
692fn ask_default_model() -> String {
693 DEFAULT_LOCAL_LLM_MODEL.into()
694}
695fn ask_default_k_summary() -> u32 {
696 5
697}
698fn ask_default_k_raw() -> u32 {
699 10
700}
701fn ask_default_esc() -> f64 {
702 0.5
703}
704fn ask_default_mmr() -> f64 {
705 0.88
706}
707fn ask_default_max_ctx() -> u32 {
708 6000
709}
710fn ask_default_resp_tok() -> u32 {
711 1024
712}
713fn ask_default_timeout() -> u32 {
714 120
715}
716fn ask_default_min_score() -> f64 {
717 0.35
718}
719fn ask_default_rewriter_timeout() -> u32 {
720 8
721}
722fn ask_default_continue_history_turns() -> u32 {
723 3
724}
725fn ask_default_compress_hits_enabled() -> bool {
726 true
727}
728fn ask_default_summarize_hits_enabled() -> bool {
729 true
730}
731
732#[derive(Debug, Clone, Serialize, Deserialize)]
741pub struct ConversationsConfig {
742 #[serde(default)]
743 pub enabled: bool,
744 #[serde(default = "conv_default_retention_days")]
745 pub retention_days: u32,
746 #[serde(default = "conv_default_poll_interval")]
747 pub poll_interval_secs: u64,
748 #[serde(default)]
749 pub sources: ConversationsSources,
750 #[serde(default)]
751 pub filter: ConversationsFilter,
752 #[serde(default)]
753 pub compact: CompactConfig,
754 #[serde(default)]
755 pub ask: AskConfig,
756 #[serde(default)]
757 pub rollup: RollupConfig,
758}
759
760impl Default for ConversationsConfig {
761 fn default() -> Self {
762 Self {
763 enabled: false,
764 retention_days: conv_default_retention_days(),
765 poll_interval_secs: conv_default_poll_interval(),
766 sources: ConversationsSources::default(),
767 filter: ConversationsFilter::default(),
768 compact: CompactConfig::default(),
769 ask: AskConfig::default(),
770 rollup: RollupConfig::default(),
771 }
772 }
773}
774
775fn conv_default_retention_days() -> u32 {
776 30
777}
778fn conv_default_poll_interval() -> u64 {
779 300
780}
781fn conv_truthy() -> bool {
782 true
783}
784fn conv_default_dedup() -> f64 {
785 0.85
786}
787
788#[derive(Debug, Clone, Serialize, Deserialize)]
789pub struct CompactConfig {
790 #[serde(default = "conv_truthy")]
791 pub enabled_in_daemon: bool,
792 #[serde(default = "compact_default_max_days")]
793 pub max_days_per_run: u32,
794 #[serde(default = "compact_default_model")]
795 pub extractive_model: String,
796 #[serde(default = "compact_default_model")]
797 pub abstractive_model: String,
798 #[serde(default = "compact_default_ollama_endpoint")]
799 pub ollama_endpoint: String,
800 #[serde(default = "compact_default_max_spans")]
801 pub max_extractive_spans: u32,
802 #[serde(default = "compact_default_max_words")]
803 pub max_abstractive_words: u32,
804 #[serde(default = "compact_default_chunk_tokens")]
805 pub chunk_tokens: u32,
806 #[serde(default = "compact_default_history_retain")]
807 pub history_retain: u32,
808 #[serde(default = "compact_default_cron")]
809 pub daemon_cron: String,
810 #[serde(default)]
813 pub extractive_backend: Option<BackendConfig>,
814 #[serde(default)]
817 pub abstractive_backend: Option<BackendConfig>,
818}
819
820impl CompactConfig {
821 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
830 self.extractive_backend
831 .clone()
832 .unwrap_or_else(|| BackendConfig {
833 provider: "ollama".into(),
834 model: self.extractive_model.clone(),
835 endpoint: Some(self.ollama_endpoint.clone()),
836 api_key_env: None,
837 api_key_ref: None,
838 timeout_secs: Some(120),
839 })
840 }
841
842 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
845 self.abstractive_backend
846 .clone()
847 .unwrap_or_else(|| BackendConfig {
848 provider: "ollama".into(),
849 model: self.abstractive_model.clone(),
850 endpoint: Some(self.ollama_endpoint.clone()),
851 api_key_env: None,
852 api_key_ref: None,
853 timeout_secs: Some(120),
854 })
855 }
856}
857
858impl Default for CompactConfig {
859 fn default() -> Self {
860 Self {
861 enabled_in_daemon: true,
862 max_days_per_run: compact_default_max_days(),
863 extractive_model: compact_default_model(),
864 abstractive_model: compact_default_model(),
865 ollama_endpoint: compact_default_ollama_endpoint(),
866 max_extractive_spans: compact_default_max_spans(),
867 max_abstractive_words: compact_default_max_words(),
868 chunk_tokens: compact_default_chunk_tokens(),
869 history_retain: compact_default_history_retain(),
870 daemon_cron: compact_default_cron(),
871 extractive_backend: None,
872 abstractive_backend: None,
873 }
874 }
875}
876
877fn compact_default_max_days() -> u32 {
878 7
879}
880fn compact_default_model() -> String {
881 DEFAULT_LOCAL_LLM_MODEL.into()
882}
883fn compact_default_ollama_endpoint() -> String {
884 "http://localhost:11434".into()
885}
886fn compact_default_max_spans() -> u32 {
887 20
888}
889fn compact_default_max_words() -> u32 {
890 400
891}
892fn compact_default_chunk_tokens() -> u32 {
893 6000
894}
895fn compact_default_history_retain() -> u32 {
896 5
897}
898fn compact_default_cron() -> String {
899 "0 0 3 * * * *".into()
900}
901
902#[derive(Debug, Clone, Serialize, Deserialize)]
905pub struct RollupConfig {
906 #[serde(default = "rollup_default_enabled")]
907 pub enabled: bool,
908 #[serde(default = "rollup_default_max_weeks")]
909 pub max_weeks_per_run: u32,
910 #[serde(default = "rollup_default_max_months")]
911 pub max_months_per_run: u32,
912 #[serde(default = "rollup_default_max_spans_week")]
913 pub max_extractive_spans_per_week: u32,
914 #[serde(default = "rollup_default_max_words_week")]
915 pub max_abstractive_words_per_week: u32,
916 #[serde(default = "rollup_default_max_spans_month")]
917 pub max_extractive_spans_per_month: u32,
918 #[serde(default = "rollup_default_max_words_month")]
919 pub max_abstractive_words_per_month: u32,
920 #[serde(default = "rollup_default_week_mmr")]
921 pub week_mmr_threshold: f64,
922 #[serde(default = "rollup_default_month_mmr")]
923 pub month_mmr_threshold: f64,
924 #[serde(default = "compact_default_model")]
925 pub extractive_model: String,
926 #[serde(default = "compact_default_model")]
927 pub abstractive_model: String,
928 #[serde(default = "compact_default_ollama_endpoint")]
929 pub ollama_endpoint: String,
930}
931
932impl Default for RollupConfig {
933 fn default() -> Self {
934 Self {
935 enabled: rollup_default_enabled(),
936 max_weeks_per_run: rollup_default_max_weeks(),
937 max_months_per_run: rollup_default_max_months(),
938 max_extractive_spans_per_week: rollup_default_max_spans_week(),
939 max_abstractive_words_per_week: rollup_default_max_words_week(),
940 max_extractive_spans_per_month: rollup_default_max_spans_month(),
941 max_abstractive_words_per_month: rollup_default_max_words_month(),
942 week_mmr_threshold: rollup_default_week_mmr(),
943 month_mmr_threshold: rollup_default_month_mmr(),
944 extractive_model: compact_default_model(),
945 abstractive_model: compact_default_model(),
946 ollama_endpoint: compact_default_ollama_endpoint(),
947 }
948 }
949}
950
951fn rollup_default_enabled() -> bool {
952 true
953}
954fn rollup_default_max_weeks() -> u32 {
955 4
956}
957fn rollup_default_max_months() -> u32 {
958 2
959}
960fn rollup_default_max_spans_week() -> u32 {
961 20
962}
963fn rollup_default_max_words_week() -> u32 {
964 500
965}
966fn rollup_default_max_spans_month() -> u32 {
967 20
968}
969fn rollup_default_max_words_month() -> u32 {
970 700
971}
972fn rollup_default_week_mmr() -> f64 {
973 0.85
974}
975fn rollup_default_month_mmr() -> f64 {
976 0.82
977}
978
979#[derive(Debug, Clone, Serialize, Deserialize)]
980pub struct ConversationsSources {
981 #[serde(default = "conv_truthy")]
982 pub claude_code: bool,
983 #[serde(default = "conv_truthy")]
984 pub cursor: bool,
985 #[serde(default = "conv_truthy")]
986 pub gemini: bool,
987 #[serde(default)]
988 pub aider: AiderSourceConfig,
989}
990
991impl Default for ConversationsSources {
992 fn default() -> Self {
993 Self {
994 claude_code: true,
995 cursor: true,
996 gemini: true,
997 aider: AiderSourceConfig::default(),
998 }
999 }
1000}
1001
1002#[derive(Debug, Clone, Serialize, Deserialize)]
1003pub struct AiderSourceConfig {
1004 #[serde(default = "conv_truthy")]
1005 pub enabled: bool,
1006 #[serde(default)]
1007 pub watched_dirs: Vec<String>,
1008}
1009
1010impl Default for AiderSourceConfig {
1011 fn default() -> Self {
1012 Self {
1013 enabled: true,
1014 watched_dirs: Vec::new(),
1015 }
1016 }
1017}
1018
1019#[derive(Debug, Clone, Serialize, Deserialize)]
1020pub struct ConversationsFilter {
1021 #[serde(default = "conv_default_dedup")]
1022 pub dedup_threshold: f64,
1023 #[serde(default = "conv_truthy")]
1024 pub reject_heartbeat: bool,
1025 #[serde(default = "conv_truthy")]
1026 pub reject_system_restatement: bool,
1027}
1028
1029impl Default for ConversationsFilter {
1030 fn default() -> Self {
1031 Self {
1032 dedup_threshold: conv_default_dedup(),
1033 reject_heartbeat: true,
1034 reject_system_restatement: true,
1035 }
1036 }
1037}
1038
1039#[cfg(test)]
1040mod conversations_tests {
1041 use super::*;
1042
1043 #[test]
1044 fn conversations_section_defaults() {
1045 let c = ConversationsConfig::default();
1046 assert!(!c.enabled);
1047 assert_eq!(c.retention_days, 30);
1048 assert_eq!(c.poll_interval_secs, 300);
1049 assert!(c.sources.claude_code);
1050 assert!(c.sources.cursor);
1051 assert!(c.sources.gemini);
1052 assert!(c.sources.aider.enabled);
1053 assert!(c.sources.aider.watched_dirs.is_empty());
1054 assert_eq!(c.filter.dedup_threshold, 0.85);
1055 assert!(c.filter.reject_heartbeat);
1056 assert!(c.filter.reject_system_restatement);
1057 }
1058
1059 #[test]
1060 fn parse_from_yaml_with_overrides() {
1061 let y = r#"
1062conversations:
1063 enabled: true
1064 retention_days: 45
1065 poll_interval_secs: 120
1066 sources:
1067 cursor: false
1068 aider:
1069 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1070 filter:
1071 dedup_threshold: 0.9
1072"#;
1073 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1074 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1075 assert!(conv.enabled);
1076 assert_eq!(conv.retention_days, 45);
1077 assert_eq!(conv.poll_interval_secs, 120);
1078 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1082 assert_eq!(conv.filter.dedup_threshold, 0.9);
1083 assert!(conv.filter.reject_heartbeat); }
1085
1086 #[test]
1087 fn missing_conversations_section_is_fine() {
1088 let y = r#"
1089# No conversations section at all
1090foo: bar
1091"#;
1092 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1093 let conv: ConversationsConfig = v
1095 .get("conversations")
1096 .cloned()
1097 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1098 .unwrap_or_default();
1099 assert_eq!(conv.retention_days, 30);
1100 }
1101
1102 #[test]
1103 fn compact_config_defaults() {
1104 let c = CompactConfig::default();
1105 assert!(c.enabled_in_daemon);
1106 assert_eq!(c.max_days_per_run, 7);
1107 assert_eq!(c.extractive_model, "qwen3.5:4b");
1108 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1109 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1110 assert_eq!(c.max_extractive_spans, 20);
1111 assert_eq!(c.chunk_tokens, 6000);
1112 assert_eq!(c.history_retain, 5);
1113 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1114 }
1115
1116 #[test]
1117 fn compact_parses_partial_overrides() {
1118 let y = r#"
1119conversations:
1120 compact:
1121 max_days_per_run: 3
1122 extractive_model: qwen3:4b
1123"#;
1124 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1125 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1126 assert_eq!(conv.compact.max_days_per_run, 3);
1127 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1128 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
1131
1132 #[test]
1133 fn ask_config_defaults() {
1134 let c = AskConfig::default();
1135 assert_eq!(c.model, "qwen3.5:4b");
1136 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1137 assert_eq!(c.k_raw, 10);
1138 assert_eq!(c.escalation_threshold, 0.5);
1139 assert_eq!(c.mmr_threshold, 0.88);
1140 assert_eq!(c.max_context_tokens, 6000);
1141 assert_eq!(c.response_tokens, 1024);
1142 assert_eq!(c.timeout_secs, 120);
1143 assert_eq!(c.min_score, 0.35);
1144 }
1145
1146 #[test]
1147 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1148 let c = AskConfig::default();
1150 assert!(
1151 (c.mmr_threshold - 0.88).abs() < 1e-9,
1152 "expected 0.88, got {}",
1153 c.mmr_threshold
1154 );
1155 }
1156
1157 #[test]
1158 fn rollup_config_defaults() {
1159 let c = RollupConfig::default();
1160 assert!(c.enabled);
1161 assert_eq!(c.max_weeks_per_run, 4);
1162 assert_eq!(c.max_months_per_run, 2);
1163 assert_eq!(c.max_extractive_spans_per_week, 20);
1164 assert_eq!(c.max_abstractive_words_per_week, 500);
1165 assert_eq!(c.max_extractive_spans_per_month, 20);
1166 assert_eq!(c.max_abstractive_words_per_month, 700);
1167 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1168 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1169 assert_eq!(c.extractive_model, "qwen3.5:4b");
1170 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1171 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1172 }
1173
1174 #[test]
1175 fn rollup_config_plumbed_into_conversations_config() {
1176 let c = ConversationsConfig::default();
1177 assert!(c.rollup.enabled);
1178 }
1179
1180 #[test]
1181 fn ask_config_default_continue_history_turns_is_3() {
1182 let c = AskConfig::default();
1183 assert_eq!(c.continue_history_turns, 3);
1184 }
1185
1186 #[test]
1187 fn ask_config_default_compress_hits_enabled_is_true() {
1188 let c = AskConfig::default();
1189 assert!(c.compress_hits_enabled);
1190 }
1191
1192 #[test]
1193 fn ask_config_default_summarize_hits_enabled_is_true() {
1194 let c = AskConfig::default();
1195 assert!(c.summarize_hits_enabled);
1196 }
1197
1198 #[test]
1199 fn ask_config_default_summarize_model_is_none() {
1200 let c = AskConfig::default();
1201 assert!(c.summarize_model.is_none());
1202 }
1203
1204 #[test]
1205 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1206 let y = r#"
1207conversations:
1208 ask:
1209 summarize_hits_enabled: false
1210 summarize_model: qwen3:4b
1211"#;
1212 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1213 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1214 assert!(!conv.ask.summarize_hits_enabled);
1215 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1216 }
1217
1218 #[test]
1219 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1220 let y = r#"
1224conversations:
1225 ask:
1226 model: qwen3:14b
1227"#;
1228 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1229 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1230 assert!(conv.ask.summarize_hits_enabled);
1231 assert!(conv.ask.summarize_model.is_none());
1232 }
1233}
1234
1235#[cfg(test)]
1236mod tests {
1237 use super::*;
1238
1239 #[test]
1240 fn default_bundled_model_id_is_qwen35_2b() {
1241 assert_eq!(
1242 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1243 "Qwen3.5-2B-MLX-4bit"
1244 );
1245 }
1246
1247 #[test]
1248 fn nudge_config_defaults() {
1249 let c = NudgeConfig::default();
1250 assert!(c.enabled);
1251 assert_eq!(c.daily_cap, 3);
1252 assert_eq!(c.snooze_days, 7);
1253 assert_eq!(c.threshold, 3);
1254 }
1255
1256 #[test]
1257 fn config_has_nudge_section_with_defaults() {
1258 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1259 assert_eq!(c.nudge.daily_cap, 3);
1260 }
1261
1262 #[test]
1263 fn storage_config_default_is_lancedb() {
1264 let c = StorageConfig::default();
1265 assert_eq!(c.vector_backend, "lancedb");
1266 assert_eq!(c.qdrant_url, None);
1267 assert_eq!(c.qdrant_api_key_ref, None);
1268 }
1269
1270 #[test]
1271 fn sources_global_config_has_sensible_defaults() {
1272 let c = SourcesGlobalConfig::default();
1273 assert_eq!(c.poll_interval_secs, 600);
1274 assert_eq!(c.max_chunks_per_sync, 10_000);
1275 assert_eq!(c.max_parallel_sources, 3);
1276 assert_eq!(c.default_weight, 1.0);
1277 assert_eq!(c.embedding_batch_size, 32);
1278 }
1279
1280 #[test]
1281 fn config_default_has_storage_and_sources_global() {
1282 let c = Config::default();
1283 assert_eq!(c.storage.vector_backend, "lancedb");
1284 assert_eq!(c.sources_global.default_weight, 1.0);
1285 }
1286
1287 #[test]
1288 fn config_loads_yaml_without_new_fields() {
1289 let yaml = r#"
1292embedding:
1293 provider: ollama
1294 model: test-model
1295 dimensions: 512
1296 ollama_endpoint: http://localhost:11434
1297"#;
1298 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1299 assert_eq!(c.storage.vector_backend, "lancedb");
1300 assert_eq!(c.sources_global.max_parallel_sources, 3);
1301 }
1302
1303 #[test]
1304 fn llm_config_to_backend_config_anthropic_passthrough() {
1305 let cfg = LlmConfig {
1306 provider: "anthropic".into(),
1307 model: "claude-haiku-4-5".into(),
1308 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1309 api_key_ref: None,
1310 openai_url: None,
1311 };
1312 let b = cfg.to_backend_config();
1313 assert_eq!(b.provider, "anthropic");
1314 assert_eq!(b.model, "claude-haiku-4-5");
1315 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1316 assert_eq!(b.endpoint, None);
1317 assert_eq!(b.timeout_secs, None);
1318 }
1319
1320 #[test]
1321 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1322 let cfg = LlmConfig {
1323 provider: "openai".into(),
1324 model: "gpt-4o-mini".into(),
1325 api_key_env: None,
1326 api_key_ref: None,
1327 openai_url: Some("https://api.together.xyz/v1".into()),
1328 };
1329 let b = cfg.to_backend_config();
1330 assert_eq!(b.provider, "openai");
1331 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1332 assert_eq!(b.api_key_env, None); }
1334
1335 #[test]
1336 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1337 let cfg = LlmConfig {
1338 provider: "ollama".into(),
1339 model: "qwen3:14b".into(),
1340 api_key_env: None,
1341 api_key_ref: None,
1342 openai_url: Some("http://192.168.1.10:11434".into()),
1343 };
1344 let b = cfg.to_backend_config();
1345 assert_eq!(b.provider, "ollama");
1346 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1347 }
1348
1349 #[test]
1350 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1351 let cfg = LlmConfig {
1355 provider: "custom-name".into(),
1356 model: "some-model".into(),
1357 api_key_env: Some("CUSTOM_KEY".into()),
1358 api_key_ref: None,
1359 openai_url: Some("https://my-proxy.local/v1".into()),
1360 };
1361 let b = cfg.to_backend_config();
1362 assert_eq!(
1363 b.provider, "openai",
1364 "unknown provider + openai_url should alias to openai"
1365 );
1366 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1367 }
1368
1369 #[test]
1370 fn api_key_ref_roundtrips_and_defaults_none() {
1371 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1373 assert_eq!(b.api_key_ref, None);
1374 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
1375 assert_eq!(l.api_key_ref, None);
1376 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
1377 assert_eq!(e.api_key_ref, None);
1378
1379 let mut l2 = LlmConfig::default();
1381 l2.api_key_ref = Some("keychain:mur/anthropic".into());
1382 let y = serde_yaml_ng::to_string(&l2).unwrap();
1383 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
1384 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
1385 assert_eq!(
1386 l3.to_backend_config().api_key_ref.as_deref(),
1387 Some("keychain:mur/anthropic")
1388 );
1389 }
1390}
1391
1392#[cfg(test)]
1393mod backend_config_tests {
1394 use super::*;
1395
1396 #[test]
1397 fn default_is_ollama_qwen3() {
1398 let cfg = BackendConfig::default();
1399 assert_eq!(cfg.provider, "ollama");
1400 assert_eq!(cfg.model, "qwen3.5:4b");
1401 assert_eq!(cfg.endpoint, None);
1402 assert_eq!(cfg.api_key_env, None);
1403 assert_eq!(cfg.timeout_secs, None);
1404 }
1405
1406 #[test]
1407 fn deserializes_anthropic_full() {
1408 let yaml = "\
1409provider: anthropic
1410model: claude-haiku-4-5
1411api_key_env: ANTHROPIC_API_KEY
1412timeout_secs: 60
1413";
1414 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1415 assert_eq!(cfg.provider, "anthropic");
1416 assert_eq!(cfg.model, "claude-haiku-4-5");
1417 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1418 assert_eq!(cfg.timeout_secs, Some(60));
1419 assert_eq!(cfg.endpoint, None);
1420 }
1421
1422 #[test]
1423 fn deserializes_partial_fills_defaults() {
1424 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1425 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1426 assert_eq!(cfg.provider, "anthropic");
1427 assert_eq!(cfg.model, "claude-sonnet-4-6");
1428 assert_eq!(cfg.api_key_env, None);
1429 assert_eq!(cfg.timeout_secs, None);
1430 }
1431
1432 #[test]
1433 fn round_trips_through_yaml() {
1434 let original = BackendConfig {
1435 provider: "anthropic".into(),
1436 model: "claude-haiku-4-5".into(),
1437 endpoint: Some("https://api.anthropic.com".into()),
1438 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1439 api_key_ref: None,
1440 timeout_secs: Some(60),
1441 };
1442 let yaml = serde_yaml::to_string(&original).unwrap();
1443 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1444 assert_eq!(parsed, original);
1445 }
1446
1447 #[test]
1448 fn skills_config_curation_gate_defaults_on() {
1449 let c = SkillsConfig::default();
1450 assert!(c.require_human_curation_before_stable);
1451 }
1452}
1453
1454#[derive(Debug, Clone, Serialize, Deserialize)]
1458#[serde(default)]
1459pub struct SkillsConfig {
1460 pub max_skills_in_prompt: usize,
1461 pub max_total_tokens: usize,
1462 pub priority_order: Vec<String>,
1463 pub adaptive: Option<AdaptiveSkillsConfig>,
1464
1465 #[serde(default = "default_require_human_curation")]
1469 pub require_human_curation_before_stable: bool,
1470
1471 #[serde(default)]
1475 pub lifecycle: SkillLifecycleConfig,
1476}
1477
1478fn default_require_human_curation() -> bool {
1479 true
1480}
1481
1482impl Default for SkillsConfig {
1483 fn default() -> Self {
1484 Self {
1485 max_skills_in_prompt: 5,
1486 max_total_tokens: 2000,
1487 priority_order: vec!["agent".into(), "global".into()],
1488 adaptive: Some(AdaptiveSkillsConfig::default()),
1489 require_human_curation_before_stable: default_require_human_curation(),
1490 lifecycle: SkillLifecycleConfig::default(),
1491 }
1492 }
1493}
1494
1495#[derive(Debug, Clone, Serialize, Deserialize)]
1501#[serde(default)]
1502pub struct SkillLifecycleConfig {
1503 pub promote_draft_uses: u64,
1505 pub promote_emerging_uses: u64,
1506 pub promote_emerging_success_rate: f64,
1507 pub promote_emerging_age_days: i64,
1508 pub promote_stable_uses: u64,
1509 pub promote_stable_success_rate: f64,
1510 pub promote_stable_age_days: i64,
1511
1512 pub demote_emerging_uses: u64,
1514 pub demote_emerging_success_rate: f64,
1515 pub demote_stable_uses: u64,
1516 pub demote_stable_success_rate: f64,
1517 pub deprecated_success_rate: f64,
1518 pub deprecated_no_success_days: i64,
1519
1520 pub auto_archive_confidence: f64,
1522 pub auto_archive_age_days: i64,
1523
1524 pub broken_workflow_streak: u32,
1529
1530 pub archive_destroy_grace_days: i64,
1535}
1536
1537impl Default for SkillLifecycleConfig {
1538 fn default() -> Self {
1539 Self {
1540 promote_draft_uses: 3,
1541 promote_emerging_uses: 10,
1542 promote_emerging_success_rate: 0.6,
1543 promote_emerging_age_days: 7,
1544 promote_stable_uses: 30,
1545 promote_stable_success_rate: 0.8,
1546 promote_stable_age_days: 30,
1547 demote_emerging_uses: 8,
1548 demote_emerging_success_rate: 0.55,
1549 demote_stable_uses: 25,
1550 demote_stable_success_rate: 0.75,
1551 deprecated_success_rate: 0.3,
1552 deprecated_no_success_days: 90,
1553 auto_archive_confidence: 0.10,
1554 auto_archive_age_days: 180,
1555 broken_workflow_streak: 3,
1556 archive_destroy_grace_days: 30,
1557 }
1558 }
1559}
1560
1561#[derive(Debug, Clone, Serialize, Deserialize)]
1562#[serde(default)]
1563pub struct AdaptiveSkillsConfig {
1564 pub context_fill_decay: f64,
1565 pub min_remaining_context_ratio: f64,
1566 pub recent_fire_boost_turns: usize,
1567 pub model_max_context_tokens: u64,
1571}
1572
1573impl Default for AdaptiveSkillsConfig {
1574 fn default() -> Self {
1575 Self {
1576 context_fill_decay: 1.5,
1577 min_remaining_context_ratio: 0.20,
1578 recent_fire_boost_turns: 5,
1579 model_max_context_tokens: 200_000,
1580 }
1581 }
1582}
1583
1584#[derive(Debug, Clone, Serialize, Deserialize)]
1587pub struct SleepCycleConfig {
1588 #[serde(default)]
1590 pub enabled: bool,
1591
1592 #[serde(default = "default_idle_threshold_minutes")]
1594 pub idle_threshold_minutes: u64,
1595
1596 #[serde(default = "default_agent_idle_minutes")]
1598 pub agent_idle_minutes: u64,
1599}
1600
1601fn default_idle_threshold_minutes() -> u64 {
1602 15
1603}
1604
1605fn default_agent_idle_minutes() -> u64 {
1606 5
1607}
1608
1609impl Default for SleepCycleConfig {
1610 fn default() -> Self {
1611 Self {
1612 enabled: false,
1613 idle_threshold_minutes: default_idle_threshold_minutes(),
1614 agent_idle_minutes: default_agent_idle_minutes(),
1615 }
1616 }
1617}
1618
1619#[derive(Debug, Clone, Serialize, Deserialize)]
1622pub struct NudgeConfig {
1623 #[serde(default = "default_nudge_enabled")]
1625 pub enabled: bool,
1626 #[serde(default = "default_nudge_daily_cap")]
1627 pub daily_cap: u32,
1628 #[serde(default = "default_nudge_snooze_days")]
1629 pub snooze_days: u32,
1630 #[serde(default = "default_nudge_threshold")]
1631 pub threshold: usize,
1632}
1633
1634fn default_nudge_enabled() -> bool {
1635 true
1636}
1637fn default_nudge_daily_cap() -> u32 {
1638 3
1639}
1640fn default_nudge_snooze_days() -> u32 {
1641 7
1642}
1643fn default_nudge_threshold() -> usize {
1644 3
1645}
1646
1647impl Default for NudgeConfig {
1648 fn default() -> Self {
1649 Self {
1650 enabled: true,
1651 daily_cap: default_nudge_daily_cap(),
1652 snooze_days: default_nudge_snooze_days(),
1653 threshold: default_nudge_threshold(),
1654 }
1655 }
1656}
1657
1658#[derive(Debug, Clone, Serialize, Deserialize)]
1662pub struct SessionCfg {
1663 #[serde(default = "default_capture_mode")]
1665 pub capture: String,
1666 #[serde(default = "default_retention_days")]
1668 pub retention_days: u32,
1669}
1670
1671impl Default for SessionCfg {
1672 fn default() -> Self {
1673 Self {
1674 capture: default_capture_mode(),
1675 retention_days: default_retention_days(),
1676 }
1677 }
1678}
1679
1680fn default_capture_mode() -> String {
1681 "ambient".to_string()
1682}
1683fn default_retention_days() -> u32 {
1684 14
1685}
1686
1687#[derive(Debug, Clone, Serialize, Deserialize)]
1689pub struct HarvestCfg {
1690 #[serde(default = "default_harvest_enabled")]
1692 pub auto_gate: bool,
1693 #[serde(default = "default_harvest_llm")]
1695 pub llm: String,
1696 #[serde(default = "default_min_events")]
1698 pub min_events: usize,
1699 #[serde(default = "default_min_user_turns")]
1700 pub min_user_turns: usize,
1701 #[serde(default = "default_min_duration_secs")]
1702 pub min_duration_secs: i64,
1703 #[serde(default = "default_idle_minutes")]
1705 pub idle_minutes: i64,
1706 #[serde(default = "default_max_llm_calls_per_day")]
1708 pub max_llm_calls_per_day: u32,
1709 #[serde(default = "default_max_extract_input_tokens")]
1710 pub max_extract_input_tokens: usize,
1711 #[serde(default = "default_harvest_enabled")]
1713 pub session_start_hint: bool,
1714 #[serde(default = "default_similarity_merge_threshold")]
1716 pub similarity_merge_threshold: f32,
1717}
1718
1719impl Default for HarvestCfg {
1720 fn default() -> Self {
1721 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1722 }
1723}
1724
1725fn default_harvest_enabled() -> bool {
1726 true
1727}
1728fn default_harvest_llm() -> String {
1729 "local-first".to_string()
1730}
1731fn default_min_events() -> usize {
1732 5
1733}
1734fn default_min_user_turns() -> usize {
1735 2
1736}
1737fn default_min_duration_secs() -> i64 {
1738 120
1739}
1740fn default_idle_minutes() -> i64 {
1741 30
1742}
1743fn default_max_llm_calls_per_day() -> u32 {
1744 10
1745}
1746fn default_max_extract_input_tokens() -> usize {
1747 12000
1748}
1749fn default_similarity_merge_threshold() -> f32 {
1750 0.6
1751}
1752
1753#[derive(Debug, Clone, Serialize, Deserialize)]
1756#[serde(default)]
1757pub struct CrossAgentConfig {
1758 #[serde(default = "default_half_life_days")]
1759 pub fitness_half_life_days: u32,
1760 #[serde(default = "default_fitness_floor")]
1761 pub fitness_floor: f64,
1762}
1763
1764fn default_half_life_days() -> u32 {
1765 7
1766}
1767fn default_fitness_floor() -> f64 {
1768 0.1
1769}
1770
1771impl Default for CrossAgentConfig {
1772 fn default() -> Self {
1773 Self {
1774 fitness_half_life_days: default_half_life_days(),
1775 fitness_floor: default_fitness_floor(),
1776 }
1777 }
1778}
1779
1780#[derive(Debug, Clone, Serialize, Deserialize)]
1783#[serde(default)]
1784pub struct SkillLlmConfig {
1785 #[serde(default = "default_per_call_token_cap")]
1787 pub per_call_token_cap: u32,
1788
1789 #[serde(default = "default_per_day_usd_cap")]
1791 pub per_day_usd_cap: f64,
1792
1793 #[serde(default = "default_cache_ttl_days")]
1795 pub cache_ttl_days: u32,
1796
1797 #[serde(default, skip_serializing_if = "Option::is_none")]
1799 pub model_ref: Option<String>,
1800}
1801
1802fn default_per_call_token_cap() -> u32 {
1803 1500
1804}
1805fn default_per_day_usd_cap() -> f64 {
1806 0.50
1807}
1808fn default_cache_ttl_days() -> u32 {
1809 30
1810}
1811
1812impl Default for SkillLlmConfig {
1813 fn default() -> Self {
1814 Self {
1815 per_call_token_cap: default_per_call_token_cap(),
1816 per_day_usd_cap: default_per_day_usd_cap(),
1817 cache_ttl_days: default_cache_ttl_days(),
1818 model_ref: None,
1819 }
1820 }
1821}
1822#[cfg(test)]
1823mod per_stage_backend_tests {
1824 use super::*;
1825
1826 #[test]
1827 fn legacy_compact_config_has_no_per_stage_overrides() {
1828 let yaml = "\
1829extractive_model: qwen3:14b
1830abstractive_model: qwen3:14b
1831ollama_endpoint: http://localhost:11434
1832";
1833 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1834 assert!(cfg.extractive_backend.is_none());
1835 assert!(cfg.abstractive_backend.is_none());
1836 assert_eq!(cfg.extractive_model, "qwen3:14b");
1837 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1838 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1839 }
1840
1841 #[test]
1842 fn legacy_ask_config_has_no_per_stage_overrides() {
1843 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1844 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1845 assert!(cfg.backend.is_none());
1846 assert!(cfg.rewriter_backend.is_none());
1847 assert_eq!(cfg.model, "qwen3:14b");
1848 }
1849
1850 #[test]
1851 fn compact_extractive_backend_override_parses() {
1852 let yaml = "\
1853extractive_backend:
1854 provider: anthropic
1855 model: claude-haiku-4-5
1856 api_key_env: ANTHROPIC_API_KEY
1857abstractive_model: qwen3:14b
1858";
1859 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1860 let extractive = cfg
1861 .extractive_backend
1862 .as_ref()
1863 .expect("override should parse");
1864 assert_eq!(extractive.provider, "anthropic");
1865 assert_eq!(extractive.model, "claude-haiku-4-5");
1866 assert!(cfg.abstractive_backend.is_none());
1867 }
1868
1869 #[test]
1870 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1871 let yaml = "\
1872backend:
1873 provider: anthropic
1874 model: claude-sonnet-4-6
1875 api_key_env: ANTHROPIC_API_KEY
1876rewriter_backend:
1877 provider: ollama
1878 model: llama3.2:3b
1879";
1880 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1881 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1882 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1883 }
1884
1885 #[test]
1886 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1887 let yaml = "\
1888extractive_model: qwen3:14b
1889ollama_endpoint: http://192.168.1.10:11434
1890";
1891 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1892 let synth = cfg.synthesize_extractive_backend();
1893 assert_eq!(synth.provider, "ollama");
1894 assert_eq!(synth.model, "qwen3:14b");
1895 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1896 assert_eq!(synth.api_key_env, None);
1897 }
1898
1899 #[test]
1900 fn synthesize_legacy_to_backend_config_for_ask() {
1901 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1902 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1903 let synth = cfg.synthesize_backend();
1904 assert_eq!(synth.provider, "ollama");
1905 assert_eq!(synth.model, "qwen3:14b");
1906 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1907 }
1908
1909 #[test]
1910 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1911 let yaml = "\
1920backend:
1921 provider: anthropic
1922 model: claude-sonnet-4-6
1923 api_key_env: ANTHROPIC_API_KEY
1924";
1925 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1926 let rewriter = cfg.synthesize_rewriter_backend();
1927 assert_eq!(rewriter.provider, "ollama");
1928 assert_eq!(rewriter.model, ask_default_model());
1929 assert_eq!(
1930 rewriter.timeout_secs,
1931 Some(ask_default_rewriter_timeout() as u64)
1932 );
1933 }
1934
1935 #[test]
1936 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1937 let cfg = AskConfig {
1938 timeout_secs: 45,
1939 ..AskConfig::default()
1940 };
1941 let b = cfg.synthesize_backend();
1942 assert_eq!(
1943 b.timeout_secs,
1944 Some(45),
1945 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1946 );
1947 }
1948
1949 #[test]
1950 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1951 let mut cfg = AskConfig {
1952 timeout_secs: 45,
1953 ..AskConfig::default()
1954 };
1955 cfg.backend = Some(BackendConfig {
1956 provider: "anthropic".into(),
1957 model: "claude-haiku-4-5".into(),
1958 endpoint: None,
1959 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1960 api_key_ref: None,
1961 timeout_secs: Some(10),
1962 });
1963 let b = cfg.synthesize_backend();
1964 assert_eq!(
1965 b.timeout_secs,
1966 Some(10),
1967 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1968 );
1969 }
1970
1971 #[test]
1972 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1973 let cfg = AskConfig {
1974 timeout_secs: 120,
1975 rewriter_timeout_secs: 8,
1976 ..AskConfig::default()
1977 };
1978 let b = cfg.synthesize_rewriter_backend();
1979 assert_eq!(
1980 b.timeout_secs,
1981 Some(8),
1982 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1983 );
1984 }
1985
1986 #[test]
1987 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1988 let mut cfg = AskConfig {
1989 rewriter_timeout_secs: 8,
1990 ..AskConfig::default()
1991 };
1992 cfg.rewriter_backend = Some(BackendConfig {
1993 provider: "anthropic".into(),
1994 model: "claude-haiku-4-5".into(),
1995 endpoint: None,
1996 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1997 api_key_ref: None,
1998 timeout_secs: Some(30),
1999 });
2000 let b = cfg.synthesize_rewriter_backend();
2001 assert_eq!(
2002 b.timeout_secs,
2003 Some(30),
2004 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
2005 );
2006 }
2007
2008 #[test]
2009 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
2010 let cfg = CompactConfig::default();
2013 let b = cfg.synthesize_extractive_backend();
2014 assert_eq!(
2015 b.timeout_secs,
2016 Some(120),
2017 "compact synthesis without per-stage override must produce 120s timeout"
2018 );
2019 }
2020
2021 #[test]
2022 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
2023 let cfg = CompactConfig::default();
2024 let b = cfg.synthesize_abstractive_backend();
2025 assert_eq!(b.timeout_secs, Some(120));
2026 }
2027}
2028
2029#[cfg(test)]
2030mod skills_config_tests {
2031 use super::*;
2032
2033 #[test]
2034 fn empty_yaml_hydrates_defaults() {
2035 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2036 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2037 assert_eq!(cfg.skills.max_total_tokens, 2000);
2038 assert!(cfg.skills.adaptive.is_some());
2039 }
2040
2041 #[test]
2042 fn load_or_default_missing_file_returns_default() {
2043 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2044 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2045 }
2046}
2047
2048#[cfg(test)]
2049mod ambient_capture_cfg_tests {
2050 use super::*;
2051
2052 #[test]
2053 fn session_and_harvest_defaults() {
2054 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2055 assert_eq!(cfg.session.capture, "ambient");
2056 assert_eq!(cfg.session.retention_days, 14);
2057 assert!(cfg.harvest.auto_gate);
2058 assert_eq!(cfg.harvest.llm, "local-first");
2059 assert_eq!(cfg.harvest.min_events, 5);
2060 assert_eq!(cfg.harvest.min_user_turns, 2);
2061 assert_eq!(cfg.harvest.min_duration_secs, 120);
2062 assert_eq!(cfg.harvest.idle_minutes, 30);
2063 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
2064 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
2065 assert!(cfg.harvest.session_start_hint);
2066 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
2067 }
2068
2069 #[test]
2070 fn session_capture_override_parses() {
2071 let cfg: Config =
2072 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
2073 assert_eq!(cfg.session.capture, "off");
2074 assert_eq!(cfg.session.retention_days, 3);
2075 }
2076}
2077
2078#[cfg(test)]
2079mod cc_proxy_cfg_tests {
2080 use super::*;
2081
2082 #[test]
2083 fn defaults_to_local_cc_proxy_enabled() {
2084 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2085 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2086 assert!(cfg.cc_proxy.enabled);
2087 }
2088
2089 #[test]
2090 fn url_and_enabled_override_parse() {
2091 let cfg: Config =
2092 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
2093 .unwrap();
2094 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
2095 assert!(!cfg.cc_proxy.enabled);
2096 }
2097
2098 #[test]
2099 fn partial_section_keeps_other_default() {
2100 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2102 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2103 assert!(!cfg.cc_proxy.enabled);
2104 }
2105}