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
86#[derive(Debug, Clone, Serialize, Deserialize)]
97pub struct CcProxyConfig {
98 #[serde(default = "default_cc_proxy_url")]
100 pub url: String,
101
102 #[serde(default = "default_true")]
105 pub enabled: bool,
106}
107
108fn default_cc_proxy_url() -> String {
109 "http://127.0.0.1:8088".to_string()
110}
111
112fn default_true() -> bool {
113 true
114}
115
116impl Default for CcProxyConfig {
117 fn default() -> Self {
118 Self {
119 url: default_cc_proxy_url(),
120 enabled: true,
121 }
122 }
123}
124
125#[derive(Debug, Clone, Serialize, Deserialize, Default)]
128pub struct CliConfig {
129 pub skin: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default)]
137pub struct MobileRelayConfig {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub relay_url: Option<String>,
142
143 #[serde(default, skip_serializing_if = "Option::is_none")]
146 pub api_key: Option<String>,
147}
148
149impl Config {
150 pub fn load_or_default(path: &std::path::Path) -> Self {
152 std::fs::read_to_string(path)
153 .ok()
154 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
155 .unwrap_or_default()
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize, Default)]
160pub struct SyncConfig {
161 #[serde(default = "default_sync_method")]
163 pub method: String,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub git_remote: Option<String>,
168
169 #[serde(default)]
171 pub auto: bool,
172
173 #[serde(default, skip_serializing_if = "Option::is_none")]
175 pub team_id: Option<String>,
176}
177
178fn default_sync_method() -> String {
179 "local".to_string()
180}
181
182#[derive(Debug, Clone, Serialize, Deserialize)]
183pub struct ServerConfig {
184 #[serde(default = "default_server_url")]
186 pub url: String,
187}
188
189impl Default for ServerConfig {
190 fn default() -> Self {
191 Self {
192 url: default_server_url(),
193 }
194 }
195}
196
197#[derive(Debug, Clone, Serialize, Deserialize, Default)]
198pub struct CommunityConfig {
199 #[serde(default)]
201 pub enabled: bool,
202}
203
204#[derive(Debug, Clone, Serialize, Deserialize)]
205pub struct EmbeddingConfig {
206 #[serde(default = "default_embedding_provider")]
208 pub provider: String,
209
210 #[serde(default = "default_embedding_model")]
212 pub model: String,
213
214 #[serde(default = "default_dimensions")]
216 pub dimensions: usize,
217
218 #[serde(default = "default_ollama_endpoint")]
220 pub ollama_endpoint: String,
221
222 #[serde(default, skip_serializing_if = "Option::is_none")]
224 pub api_key_env: Option<String>,
225
226 #[serde(default, skip_serializing_if = "Option::is_none")]
228 pub openai_url: Option<String>,
229}
230
231impl Default for EmbeddingConfig {
232 fn default() -> Self {
233 Self {
234 provider: default_embedding_provider(),
235 model: default_embedding_model(),
236 dimensions: default_dimensions(),
237 ollama_endpoint: default_ollama_endpoint(),
238 api_key_env: None,
239 openai_url: None,
240 }
241 }
242}
243
244#[derive(Debug, Clone, Serialize, Deserialize)]
245pub struct LlmConfig {
246 #[serde(default = "default_llm_provider")]
248 pub provider: String,
249
250 #[serde(default = "default_llm_model")]
251 pub model: String,
252
253 #[serde(default, skip_serializing_if = "Option::is_none")]
255 pub api_key_env: Option<String>,
256
257 #[serde(default, skip_serializing_if = "Option::is_none")]
259 pub openai_url: Option<String>,
260}
261
262impl Default for LlmConfig {
263 fn default() -> Self {
264 Self {
265 provider: default_llm_provider(),
266 model: default_llm_model(),
267 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
268 openai_url: None,
269 }
270 }
271}
272
273impl LlmConfig {
274 pub fn to_backend_config(&self) -> BackendConfig {
287 let provider = match self.provider.as_str() {
288 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
289 _ if self.openai_url.is_some() => "openai".into(),
290 other => other.into(), };
292 BackendConfig {
293 provider,
294 model: self.model.clone(),
295 endpoint: self.openai_url.clone(),
296 api_key_env: self.api_key_env.clone(),
297 timeout_secs: None,
298 }
299 }
300}
301
302#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
313#[serde(default)]
314pub struct BackendConfig {
315 pub provider: String,
317 pub model: String,
319 pub endpoint: Option<String>,
322 pub api_key_env: Option<String>,
324 pub timeout_secs: Option<u64>,
326}
327
328impl Default for BackendConfig {
329 fn default() -> Self {
330 Self {
331 provider: "ollama".into(),
332 model: DEFAULT_LOCAL_LLM_MODEL.into(),
333 endpoint: None,
334 api_key_env: None,
335 timeout_secs: None,
336 }
337 }
338}
339
340#[derive(Debug, Clone, Serialize, Deserialize)]
341pub struct RetrievalConfig {
342 #[serde(default = "default_max_patterns")]
344 pub max_patterns: usize,
345
346 #[serde(default = "default_max_tokens")]
348 pub max_tokens: usize,
349
350 #[serde(default = "default_min_score")]
352 pub min_score: f64,
353
354 #[serde(default = "default_mmr_threshold")]
356 pub mmr_threshold: f64,
357}
358
359impl Default for RetrievalConfig {
360 fn default() -> Self {
361 Self {
362 max_patterns: default_max_patterns(),
363 max_tokens: default_max_tokens(),
364 min_score: default_min_score(),
365 mmr_threshold: default_mmr_threshold(),
366 }
367 }
368}
369
370#[derive(Debug, Clone, Serialize, Deserialize)]
371pub struct PathConfig {
372 #[serde(default = "default_mur_dir")]
374 pub mur_dir: PathBuf,
375}
376
377impl Default for PathConfig {
378 fn default() -> Self {
379 Self {
380 mur_dir: default_mur_dir(),
381 }
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
386pub struct StorageConfig {
387 #[serde(default = "default_vector_backend")]
389 pub vector_backend: String,
390
391 #[serde(default, skip_serializing_if = "Option::is_none")]
393 pub qdrant_url: Option<String>,
394
395 #[serde(default, skip_serializing_if = "Option::is_none")]
397 pub qdrant_api_key_ref: Option<String>,
398}
399
400impl Default for StorageConfig {
401 fn default() -> Self {
402 Self {
403 vector_backend: default_vector_backend(),
404 qdrant_url: None,
405 qdrant_api_key_ref: None,
406 }
407 }
408}
409
410fn default_vector_backend() -> String {
411 "lancedb".to_string()
412}
413
414#[derive(Debug, Clone, Serialize, Deserialize)]
415pub struct SourcesGlobalConfig {
416 #[serde(default = "default_poll_interval_secs")]
418 pub poll_interval_secs: u64,
419
420 #[serde(default = "default_max_chunks_per_sync")]
422 pub max_chunks_per_sync: usize,
423
424 #[serde(default = "default_max_parallel_sources")]
426 pub max_parallel_sources: usize,
427
428 #[serde(default = "default_source_weight")]
430 pub default_weight: f32,
431
432 #[serde(default = "default_embedding_batch_size")]
434 pub embedding_batch_size: usize,
435}
436
437impl Default for SourcesGlobalConfig {
438 fn default() -> Self {
439 Self {
440 poll_interval_secs: default_poll_interval_secs(),
441 max_chunks_per_sync: default_max_chunks_per_sync(),
442 max_parallel_sources: default_max_parallel_sources(),
443 default_weight: default_source_weight(),
444 embedding_batch_size: default_embedding_batch_size(),
445 }
446 }
447}
448
449fn default_poll_interval_secs() -> u64 {
450 600
451}
452fn default_max_chunks_per_sync() -> usize {
453 10_000
454}
455fn default_max_parallel_sources() -> usize {
456 3
457}
458fn default_source_weight() -> f32 {
459 1.0
460}
461fn default_embedding_batch_size() -> usize {
462 32
463}
464
465fn default_embedding_provider() -> String {
466 "ollama".to_string()
467}
468fn default_embedding_model() -> String {
469 "qwen3-embedding:0.6b".to_string()
470}
471fn default_dimensions() -> usize {
472 1024
473}
474fn default_ollama_endpoint() -> String {
475 "http://localhost:11434".to_string()
476}
477fn default_llm_provider() -> String {
478 "anthropic".to_string()
479}
480fn default_llm_model() -> String {
481 "claude-opus-4-6".to_string()
482}
483fn default_max_patterns() -> usize {
484 5
485}
486fn default_max_tokens() -> usize {
487 2000
488}
489fn default_min_score() -> f64 {
490 0.35
491}
492fn default_mmr_threshold() -> f64 {
493 0.85
494}
495fn default_mur_dir() -> PathBuf {
496 let home = std::env::var("HOME")
499 .map(PathBuf::from)
500 .unwrap_or_else(|_| PathBuf::from("/tmp"));
501 home.join(".mur")
502}
503fn default_server_url() -> String {
504 "https://mur-server.fly.dev".to_string()
505}
506
507#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct AskConfig {
511 #[serde(default = "ask_default_model")]
512 pub model: String,
513 #[serde(default = "compact_default_ollama_endpoint")]
514 pub ollama_endpoint: String,
515 #[serde(default = "ask_default_k_summary")]
516 pub k_summary: u32,
517 #[serde(default = "ask_default_k_raw")]
518 pub k_raw: u32,
519 #[serde(default = "ask_default_esc")]
520 pub escalation_threshold: f64,
521 #[serde(default = "ask_default_mmr")]
522 pub mmr_threshold: f64,
523 #[serde(default = "ask_default_max_ctx")]
524 pub max_context_tokens: u32,
525 #[serde(default = "ask_default_resp_tok")]
526 pub response_tokens: u32,
527 #[serde(default = "ask_default_timeout")]
528 pub timeout_secs: u32,
529 #[serde(default = "ask_default_min_score")]
530 pub min_score: f64,
531 #[serde(default = "ask_default_continue_history_turns")]
532 pub continue_history_turns: u32,
533 #[serde(default = "ask_default_rewriter_timeout")]
539 pub rewriter_timeout_secs: u32,
540 #[serde(default = "ask_default_compress_hits_enabled")]
541 pub compress_hits_enabled: bool,
542 #[serde(default = "ask_default_summarize_hits_enabled")]
543 pub summarize_hits_enabled: bool,
544 #[serde(default)]
545 pub summarize_model: Option<String>,
546 #[serde(default)]
549 pub backend: Option<BackendConfig>,
550 #[serde(default)]
554 pub rewriter_backend: Option<BackendConfig>,
555}
556
557impl AskConfig {
558 pub fn synthesize_backend(&self) -> BackendConfig {
568 self.backend.clone().unwrap_or_else(|| BackendConfig {
569 provider: "ollama".into(),
570 model: self.model.clone(),
571 endpoint: Some(self.ollama_endpoint.clone()),
572 api_key_env: None,
573 timeout_secs: Some(self.timeout_secs as u64),
574 })
575 }
576
577 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
587 self.rewriter_backend
588 .clone()
589 .unwrap_or_else(|| BackendConfig {
590 provider: "ollama".into(),
591 model: self.model.clone(),
592 endpoint: Some(self.ollama_endpoint.clone()),
593 api_key_env: None,
594 timeout_secs: Some(self.rewriter_timeout_secs as u64),
595 })
596 }
597}
598
599impl Default for AskConfig {
600 fn default() -> Self {
601 Self {
602 model: ask_default_model(),
603 ollama_endpoint: compact_default_ollama_endpoint(),
604 k_summary: ask_default_k_summary(),
605 k_raw: ask_default_k_raw(),
606 escalation_threshold: ask_default_esc(),
607 mmr_threshold: ask_default_mmr(),
608 max_context_tokens: ask_default_max_ctx(),
609 response_tokens: ask_default_resp_tok(),
610 timeout_secs: ask_default_timeout(),
611 min_score: ask_default_min_score(),
612 continue_history_turns: ask_default_continue_history_turns(),
613 rewriter_timeout_secs: ask_default_rewriter_timeout(),
614 compress_hits_enabled: ask_default_compress_hits_enabled(),
615 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
616 summarize_model: None,
617 backend: None,
618 rewriter_backend: None,
619 }
620 }
621}
622
623fn ask_default_model() -> String {
624 DEFAULT_LOCAL_LLM_MODEL.into()
625}
626fn ask_default_k_summary() -> u32 {
627 5
628}
629fn ask_default_k_raw() -> u32 {
630 10
631}
632fn ask_default_esc() -> f64 {
633 0.5
634}
635fn ask_default_mmr() -> f64 {
636 0.88
637}
638fn ask_default_max_ctx() -> u32 {
639 6000
640}
641fn ask_default_resp_tok() -> u32 {
642 1024
643}
644fn ask_default_timeout() -> u32 {
645 120
646}
647fn ask_default_min_score() -> f64 {
648 0.35
649}
650fn ask_default_rewriter_timeout() -> u32 {
651 8
652}
653fn ask_default_continue_history_turns() -> u32 {
654 3
655}
656fn ask_default_compress_hits_enabled() -> bool {
657 true
658}
659fn ask_default_summarize_hits_enabled() -> bool {
660 true
661}
662
663#[derive(Debug, Clone, Serialize, Deserialize)]
672pub struct ConversationsConfig {
673 #[serde(default)]
674 pub enabled: bool,
675 #[serde(default = "conv_default_retention_days")]
676 pub retention_days: u32,
677 #[serde(default = "conv_default_poll_interval")]
678 pub poll_interval_secs: u64,
679 #[serde(default)]
680 pub sources: ConversationsSources,
681 #[serde(default)]
682 pub filter: ConversationsFilter,
683 #[serde(default)]
684 pub compact: CompactConfig,
685 #[serde(default)]
686 pub ask: AskConfig,
687 #[serde(default)]
688 pub rollup: RollupConfig,
689}
690
691impl Default for ConversationsConfig {
692 fn default() -> Self {
693 Self {
694 enabled: false,
695 retention_days: conv_default_retention_days(),
696 poll_interval_secs: conv_default_poll_interval(),
697 sources: ConversationsSources::default(),
698 filter: ConversationsFilter::default(),
699 compact: CompactConfig::default(),
700 ask: AskConfig::default(),
701 rollup: RollupConfig::default(),
702 }
703 }
704}
705
706fn conv_default_retention_days() -> u32 {
707 30
708}
709fn conv_default_poll_interval() -> u64 {
710 300
711}
712fn conv_truthy() -> bool {
713 true
714}
715fn conv_default_dedup() -> f64 {
716 0.85
717}
718
719#[derive(Debug, Clone, Serialize, Deserialize)]
720pub struct CompactConfig {
721 #[serde(default = "conv_truthy")]
722 pub enabled_in_daemon: bool,
723 #[serde(default = "compact_default_max_days")]
724 pub max_days_per_run: u32,
725 #[serde(default = "compact_default_model")]
726 pub extractive_model: String,
727 #[serde(default = "compact_default_model")]
728 pub abstractive_model: String,
729 #[serde(default = "compact_default_ollama_endpoint")]
730 pub ollama_endpoint: String,
731 #[serde(default = "compact_default_max_spans")]
732 pub max_extractive_spans: u32,
733 #[serde(default = "compact_default_max_words")]
734 pub max_abstractive_words: u32,
735 #[serde(default = "compact_default_chunk_tokens")]
736 pub chunk_tokens: u32,
737 #[serde(default = "compact_default_history_retain")]
738 pub history_retain: u32,
739 #[serde(default = "compact_default_cron")]
740 pub daemon_cron: String,
741 #[serde(default)]
744 pub extractive_backend: Option<BackendConfig>,
745 #[serde(default)]
748 pub abstractive_backend: Option<BackendConfig>,
749}
750
751impl CompactConfig {
752 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
761 self.extractive_backend
762 .clone()
763 .unwrap_or_else(|| BackendConfig {
764 provider: "ollama".into(),
765 model: self.extractive_model.clone(),
766 endpoint: Some(self.ollama_endpoint.clone()),
767 api_key_env: None,
768 timeout_secs: Some(120),
769 })
770 }
771
772 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
775 self.abstractive_backend
776 .clone()
777 .unwrap_or_else(|| BackendConfig {
778 provider: "ollama".into(),
779 model: self.abstractive_model.clone(),
780 endpoint: Some(self.ollama_endpoint.clone()),
781 api_key_env: None,
782 timeout_secs: Some(120),
783 })
784 }
785}
786
787impl Default for CompactConfig {
788 fn default() -> Self {
789 Self {
790 enabled_in_daemon: true,
791 max_days_per_run: compact_default_max_days(),
792 extractive_model: compact_default_model(),
793 abstractive_model: compact_default_model(),
794 ollama_endpoint: compact_default_ollama_endpoint(),
795 max_extractive_spans: compact_default_max_spans(),
796 max_abstractive_words: compact_default_max_words(),
797 chunk_tokens: compact_default_chunk_tokens(),
798 history_retain: compact_default_history_retain(),
799 daemon_cron: compact_default_cron(),
800 extractive_backend: None,
801 abstractive_backend: None,
802 }
803 }
804}
805
806fn compact_default_max_days() -> u32 {
807 7
808}
809fn compact_default_model() -> String {
810 DEFAULT_LOCAL_LLM_MODEL.into()
811}
812fn compact_default_ollama_endpoint() -> String {
813 "http://localhost:11434".into()
814}
815fn compact_default_max_spans() -> u32 {
816 20
817}
818fn compact_default_max_words() -> u32 {
819 400
820}
821fn compact_default_chunk_tokens() -> u32 {
822 6000
823}
824fn compact_default_history_retain() -> u32 {
825 5
826}
827fn compact_default_cron() -> String {
828 "0 0 3 * * * *".into()
829}
830
831#[derive(Debug, Clone, Serialize, Deserialize)]
834pub struct RollupConfig {
835 #[serde(default = "rollup_default_enabled")]
836 pub enabled: bool,
837 #[serde(default = "rollup_default_max_weeks")]
838 pub max_weeks_per_run: u32,
839 #[serde(default = "rollup_default_max_months")]
840 pub max_months_per_run: u32,
841 #[serde(default = "rollup_default_max_spans_week")]
842 pub max_extractive_spans_per_week: u32,
843 #[serde(default = "rollup_default_max_words_week")]
844 pub max_abstractive_words_per_week: u32,
845 #[serde(default = "rollup_default_max_spans_month")]
846 pub max_extractive_spans_per_month: u32,
847 #[serde(default = "rollup_default_max_words_month")]
848 pub max_abstractive_words_per_month: u32,
849 #[serde(default = "rollup_default_week_mmr")]
850 pub week_mmr_threshold: f64,
851 #[serde(default = "rollup_default_month_mmr")]
852 pub month_mmr_threshold: f64,
853 #[serde(default = "compact_default_model")]
854 pub extractive_model: String,
855 #[serde(default = "compact_default_model")]
856 pub abstractive_model: String,
857 #[serde(default = "compact_default_ollama_endpoint")]
858 pub ollama_endpoint: String,
859}
860
861impl Default for RollupConfig {
862 fn default() -> Self {
863 Self {
864 enabled: rollup_default_enabled(),
865 max_weeks_per_run: rollup_default_max_weeks(),
866 max_months_per_run: rollup_default_max_months(),
867 max_extractive_spans_per_week: rollup_default_max_spans_week(),
868 max_abstractive_words_per_week: rollup_default_max_words_week(),
869 max_extractive_spans_per_month: rollup_default_max_spans_month(),
870 max_abstractive_words_per_month: rollup_default_max_words_month(),
871 week_mmr_threshold: rollup_default_week_mmr(),
872 month_mmr_threshold: rollup_default_month_mmr(),
873 extractive_model: compact_default_model(),
874 abstractive_model: compact_default_model(),
875 ollama_endpoint: compact_default_ollama_endpoint(),
876 }
877 }
878}
879
880fn rollup_default_enabled() -> bool {
881 true
882}
883fn rollup_default_max_weeks() -> u32 {
884 4
885}
886fn rollup_default_max_months() -> u32 {
887 2
888}
889fn rollup_default_max_spans_week() -> u32 {
890 20
891}
892fn rollup_default_max_words_week() -> u32 {
893 500
894}
895fn rollup_default_max_spans_month() -> u32 {
896 20
897}
898fn rollup_default_max_words_month() -> u32 {
899 700
900}
901fn rollup_default_week_mmr() -> f64 {
902 0.85
903}
904fn rollup_default_month_mmr() -> f64 {
905 0.82
906}
907
908#[derive(Debug, Clone, Serialize, Deserialize)]
909pub struct ConversationsSources {
910 #[serde(default = "conv_truthy")]
911 pub claude_code: bool,
912 #[serde(default = "conv_truthy")]
913 pub cursor: bool,
914 #[serde(default = "conv_truthy")]
915 pub gemini: bool,
916 #[serde(default)]
917 pub aider: AiderSourceConfig,
918}
919
920impl Default for ConversationsSources {
921 fn default() -> Self {
922 Self {
923 claude_code: true,
924 cursor: true,
925 gemini: true,
926 aider: AiderSourceConfig::default(),
927 }
928 }
929}
930
931#[derive(Debug, Clone, Serialize, Deserialize)]
932pub struct AiderSourceConfig {
933 #[serde(default = "conv_truthy")]
934 pub enabled: bool,
935 #[serde(default)]
936 pub watched_dirs: Vec<String>,
937}
938
939impl Default for AiderSourceConfig {
940 fn default() -> Self {
941 Self {
942 enabled: true,
943 watched_dirs: Vec::new(),
944 }
945 }
946}
947
948#[derive(Debug, Clone, Serialize, Deserialize)]
949pub struct ConversationsFilter {
950 #[serde(default = "conv_default_dedup")]
951 pub dedup_threshold: f64,
952 #[serde(default = "conv_truthy")]
953 pub reject_heartbeat: bool,
954 #[serde(default = "conv_truthy")]
955 pub reject_system_restatement: bool,
956}
957
958impl Default for ConversationsFilter {
959 fn default() -> Self {
960 Self {
961 dedup_threshold: conv_default_dedup(),
962 reject_heartbeat: true,
963 reject_system_restatement: true,
964 }
965 }
966}
967
968#[cfg(test)]
969mod conversations_tests {
970 use super::*;
971
972 #[test]
973 fn conversations_section_defaults() {
974 let c = ConversationsConfig::default();
975 assert!(!c.enabled);
976 assert_eq!(c.retention_days, 30);
977 assert_eq!(c.poll_interval_secs, 300);
978 assert!(c.sources.claude_code);
979 assert!(c.sources.cursor);
980 assert!(c.sources.gemini);
981 assert!(c.sources.aider.enabled);
982 assert!(c.sources.aider.watched_dirs.is_empty());
983 assert_eq!(c.filter.dedup_threshold, 0.85);
984 assert!(c.filter.reject_heartbeat);
985 assert!(c.filter.reject_system_restatement);
986 }
987
988 #[test]
989 fn parse_from_yaml_with_overrides() {
990 let y = r#"
991conversations:
992 enabled: true
993 retention_days: 45
994 poll_interval_secs: 120
995 sources:
996 cursor: false
997 aider:
998 watched_dirs: ["~/Projects/a", "~/Projects/b"]
999 filter:
1000 dedup_threshold: 0.9
1001"#;
1002 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1003 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1004 assert!(conv.enabled);
1005 assert_eq!(conv.retention_days, 45);
1006 assert_eq!(conv.poll_interval_secs, 120);
1007 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1011 assert_eq!(conv.filter.dedup_threshold, 0.9);
1012 assert!(conv.filter.reject_heartbeat); }
1014
1015 #[test]
1016 fn missing_conversations_section_is_fine() {
1017 let y = r#"
1018# No conversations section at all
1019foo: bar
1020"#;
1021 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1022 let conv: ConversationsConfig = v
1024 .get("conversations")
1025 .cloned()
1026 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1027 .unwrap_or_default();
1028 assert_eq!(conv.retention_days, 30);
1029 }
1030
1031 #[test]
1032 fn compact_config_defaults() {
1033 let c = CompactConfig::default();
1034 assert!(c.enabled_in_daemon);
1035 assert_eq!(c.max_days_per_run, 7);
1036 assert_eq!(c.extractive_model, "qwen3.5:4b");
1037 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1038 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1039 assert_eq!(c.max_extractive_spans, 20);
1040 assert_eq!(c.chunk_tokens, 6000);
1041 assert_eq!(c.history_retain, 5);
1042 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1043 }
1044
1045 #[test]
1046 fn compact_parses_partial_overrides() {
1047 let y = r#"
1048conversations:
1049 compact:
1050 max_days_per_run: 3
1051 extractive_model: qwen3:4b
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_eq!(conv.compact.max_days_per_run, 3);
1056 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
1057 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
1060
1061 #[test]
1062 fn ask_config_defaults() {
1063 let c = AskConfig::default();
1064 assert_eq!(c.model, "qwen3.5:4b");
1065 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1066 assert_eq!(c.k_raw, 10);
1067 assert_eq!(c.escalation_threshold, 0.5);
1068 assert_eq!(c.mmr_threshold, 0.88);
1069 assert_eq!(c.max_context_tokens, 6000);
1070 assert_eq!(c.response_tokens, 1024);
1071 assert_eq!(c.timeout_secs, 120);
1072 assert_eq!(c.min_score, 0.35);
1073 }
1074
1075 #[test]
1076 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1077 let c = AskConfig::default();
1079 assert!(
1080 (c.mmr_threshold - 0.88).abs() < 1e-9,
1081 "expected 0.88, got {}",
1082 c.mmr_threshold
1083 );
1084 }
1085
1086 #[test]
1087 fn rollup_config_defaults() {
1088 let c = RollupConfig::default();
1089 assert!(c.enabled);
1090 assert_eq!(c.max_weeks_per_run, 4);
1091 assert_eq!(c.max_months_per_run, 2);
1092 assert_eq!(c.max_extractive_spans_per_week, 20);
1093 assert_eq!(c.max_abstractive_words_per_week, 500);
1094 assert_eq!(c.max_extractive_spans_per_month, 20);
1095 assert_eq!(c.max_abstractive_words_per_month, 700);
1096 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1097 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1098 assert_eq!(c.extractive_model, "qwen3.5:4b");
1099 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1100 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1101 }
1102
1103 #[test]
1104 fn rollup_config_plumbed_into_conversations_config() {
1105 let c = ConversationsConfig::default();
1106 assert!(c.rollup.enabled);
1107 }
1108
1109 #[test]
1110 fn ask_config_default_continue_history_turns_is_3() {
1111 let c = AskConfig::default();
1112 assert_eq!(c.continue_history_turns, 3);
1113 }
1114
1115 #[test]
1116 fn ask_config_default_compress_hits_enabled_is_true() {
1117 let c = AskConfig::default();
1118 assert!(c.compress_hits_enabled);
1119 }
1120
1121 #[test]
1122 fn ask_config_default_summarize_hits_enabled_is_true() {
1123 let c = AskConfig::default();
1124 assert!(c.summarize_hits_enabled);
1125 }
1126
1127 #[test]
1128 fn ask_config_default_summarize_model_is_none() {
1129 let c = AskConfig::default();
1130 assert!(c.summarize_model.is_none());
1131 }
1132
1133 #[test]
1134 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1135 let y = r#"
1136conversations:
1137 ask:
1138 summarize_hits_enabled: false
1139 summarize_model: qwen3:4b
1140"#;
1141 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1142 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1143 assert!(!conv.ask.summarize_hits_enabled);
1144 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1145 }
1146
1147 #[test]
1148 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1149 let y = r#"
1153conversations:
1154 ask:
1155 model: qwen3:14b
1156"#;
1157 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1158 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1159 assert!(conv.ask.summarize_hits_enabled);
1160 assert!(conv.ask.summarize_model.is_none());
1161 }
1162}
1163
1164#[cfg(test)]
1165mod tests {
1166 use super::*;
1167
1168 #[test]
1169 fn default_bundled_model_id_is_qwen35_2b() {
1170 assert_eq!(
1171 crate::config::DEFAULT_BUNDLED_MODEL_ID,
1172 "Qwen3.5-2B-MLX-4bit"
1173 );
1174 }
1175
1176 #[test]
1177 fn nudge_config_defaults() {
1178 let c = NudgeConfig::default();
1179 assert!(c.enabled);
1180 assert_eq!(c.daily_cap, 3);
1181 assert_eq!(c.snooze_days, 7);
1182 assert_eq!(c.threshold, 3);
1183 }
1184
1185 #[test]
1186 fn config_has_nudge_section_with_defaults() {
1187 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1188 assert_eq!(c.nudge.daily_cap, 3);
1189 }
1190
1191 #[test]
1192 fn storage_config_default_is_lancedb() {
1193 let c = StorageConfig::default();
1194 assert_eq!(c.vector_backend, "lancedb");
1195 assert_eq!(c.qdrant_url, None);
1196 assert_eq!(c.qdrant_api_key_ref, None);
1197 }
1198
1199 #[test]
1200 fn sources_global_config_has_sensible_defaults() {
1201 let c = SourcesGlobalConfig::default();
1202 assert_eq!(c.poll_interval_secs, 600);
1203 assert_eq!(c.max_chunks_per_sync, 10_000);
1204 assert_eq!(c.max_parallel_sources, 3);
1205 assert_eq!(c.default_weight, 1.0);
1206 assert_eq!(c.embedding_batch_size, 32);
1207 }
1208
1209 #[test]
1210 fn config_default_has_storage_and_sources_global() {
1211 let c = Config::default();
1212 assert_eq!(c.storage.vector_backend, "lancedb");
1213 assert_eq!(c.sources_global.default_weight, 1.0);
1214 }
1215
1216 #[test]
1217 fn config_loads_yaml_without_new_fields() {
1218 let yaml = r#"
1221embedding:
1222 provider: ollama
1223 model: test-model
1224 dimensions: 512
1225 ollama_endpoint: http://localhost:11434
1226"#;
1227 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1228 assert_eq!(c.storage.vector_backend, "lancedb");
1229 assert_eq!(c.sources_global.max_parallel_sources, 3);
1230 }
1231
1232 #[test]
1233 fn llm_config_to_backend_config_anthropic_passthrough() {
1234 let cfg = LlmConfig {
1235 provider: "anthropic".into(),
1236 model: "claude-haiku-4-5".into(),
1237 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1238 openai_url: None,
1239 };
1240 let b = cfg.to_backend_config();
1241 assert_eq!(b.provider, "anthropic");
1242 assert_eq!(b.model, "claude-haiku-4-5");
1243 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1244 assert_eq!(b.endpoint, None);
1245 assert_eq!(b.timeout_secs, None);
1246 }
1247
1248 #[test]
1249 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1250 let cfg = LlmConfig {
1251 provider: "openai".into(),
1252 model: "gpt-4o-mini".into(),
1253 api_key_env: None,
1254 openai_url: Some("https://api.together.xyz/v1".into()),
1255 };
1256 let b = cfg.to_backend_config();
1257 assert_eq!(b.provider, "openai");
1258 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1259 assert_eq!(b.api_key_env, None); }
1261
1262 #[test]
1263 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1264 let cfg = LlmConfig {
1265 provider: "ollama".into(),
1266 model: "qwen3:14b".into(),
1267 api_key_env: None,
1268 openai_url: Some("http://192.168.1.10:11434".into()),
1269 };
1270 let b = cfg.to_backend_config();
1271 assert_eq!(b.provider, "ollama");
1272 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1273 }
1274
1275 #[test]
1276 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1277 let cfg = LlmConfig {
1281 provider: "custom-name".into(),
1282 model: "some-model".into(),
1283 api_key_env: Some("CUSTOM_KEY".into()),
1284 openai_url: Some("https://my-proxy.local/v1".into()),
1285 };
1286 let b = cfg.to_backend_config();
1287 assert_eq!(
1288 b.provider, "openai",
1289 "unknown provider + openai_url should alias to openai"
1290 );
1291 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1292 }
1293}
1294
1295#[cfg(test)]
1296mod backend_config_tests {
1297 use super::*;
1298
1299 #[test]
1300 fn default_is_ollama_qwen3() {
1301 let cfg = BackendConfig::default();
1302 assert_eq!(cfg.provider, "ollama");
1303 assert_eq!(cfg.model, "qwen3.5:4b");
1304 assert_eq!(cfg.endpoint, None);
1305 assert_eq!(cfg.api_key_env, None);
1306 assert_eq!(cfg.timeout_secs, None);
1307 }
1308
1309 #[test]
1310 fn deserializes_anthropic_full() {
1311 let yaml = "\
1312provider: anthropic
1313model: claude-haiku-4-5
1314api_key_env: ANTHROPIC_API_KEY
1315timeout_secs: 60
1316";
1317 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1318 assert_eq!(cfg.provider, "anthropic");
1319 assert_eq!(cfg.model, "claude-haiku-4-5");
1320 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1321 assert_eq!(cfg.timeout_secs, Some(60));
1322 assert_eq!(cfg.endpoint, None);
1323 }
1324
1325 #[test]
1326 fn deserializes_partial_fills_defaults() {
1327 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1328 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1329 assert_eq!(cfg.provider, "anthropic");
1330 assert_eq!(cfg.model, "claude-sonnet-4-6");
1331 assert_eq!(cfg.api_key_env, None);
1332 assert_eq!(cfg.timeout_secs, None);
1333 }
1334
1335 #[test]
1336 fn round_trips_through_yaml() {
1337 let original = BackendConfig {
1338 provider: "anthropic".into(),
1339 model: "claude-haiku-4-5".into(),
1340 endpoint: Some("https://api.anthropic.com".into()),
1341 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1342 timeout_secs: Some(60),
1343 };
1344 let yaml = serde_yaml::to_string(&original).unwrap();
1345 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1346 assert_eq!(parsed, original);
1347 }
1348
1349 #[test]
1350 fn skills_config_curation_gate_defaults_on() {
1351 let c = SkillsConfig::default();
1352 assert!(c.require_human_curation_before_stable);
1353 }
1354}
1355
1356#[derive(Debug, Clone, Serialize, Deserialize)]
1360#[serde(default)]
1361pub struct SkillsConfig {
1362 pub max_skills_in_prompt: usize,
1363 pub max_total_tokens: usize,
1364 pub priority_order: Vec<String>,
1365 pub adaptive: Option<AdaptiveSkillsConfig>,
1366
1367 #[serde(default = "default_require_human_curation")]
1371 pub require_human_curation_before_stable: bool,
1372
1373 #[serde(default)]
1377 pub lifecycle: SkillLifecycleConfig,
1378}
1379
1380fn default_require_human_curation() -> bool {
1381 true
1382}
1383
1384impl Default for SkillsConfig {
1385 fn default() -> Self {
1386 Self {
1387 max_skills_in_prompt: 5,
1388 max_total_tokens: 2000,
1389 priority_order: vec!["agent".into(), "global".into()],
1390 adaptive: Some(AdaptiveSkillsConfig::default()),
1391 require_human_curation_before_stable: default_require_human_curation(),
1392 lifecycle: SkillLifecycleConfig::default(),
1393 }
1394 }
1395}
1396
1397#[derive(Debug, Clone, Serialize, Deserialize)]
1403#[serde(default)]
1404pub struct SkillLifecycleConfig {
1405 pub promote_draft_uses: u64,
1407 pub promote_emerging_uses: u64,
1408 pub promote_emerging_success_rate: f64,
1409 pub promote_emerging_age_days: i64,
1410 pub promote_stable_uses: u64,
1411 pub promote_stable_success_rate: f64,
1412 pub promote_stable_age_days: i64,
1413
1414 pub demote_emerging_uses: u64,
1416 pub demote_emerging_success_rate: f64,
1417 pub demote_stable_uses: u64,
1418 pub demote_stable_success_rate: f64,
1419 pub deprecated_success_rate: f64,
1420 pub deprecated_no_success_days: i64,
1421
1422 pub auto_archive_confidence: f64,
1424 pub auto_archive_age_days: i64,
1425
1426 pub broken_workflow_streak: u32,
1431
1432 pub archive_destroy_grace_days: i64,
1437}
1438
1439impl Default for SkillLifecycleConfig {
1440 fn default() -> Self {
1441 Self {
1442 promote_draft_uses: 3,
1443 promote_emerging_uses: 10,
1444 promote_emerging_success_rate: 0.6,
1445 promote_emerging_age_days: 7,
1446 promote_stable_uses: 30,
1447 promote_stable_success_rate: 0.8,
1448 promote_stable_age_days: 30,
1449 demote_emerging_uses: 8,
1450 demote_emerging_success_rate: 0.55,
1451 demote_stable_uses: 25,
1452 demote_stable_success_rate: 0.75,
1453 deprecated_success_rate: 0.3,
1454 deprecated_no_success_days: 90,
1455 auto_archive_confidence: 0.10,
1456 auto_archive_age_days: 180,
1457 broken_workflow_streak: 3,
1458 archive_destroy_grace_days: 30,
1459 }
1460 }
1461}
1462
1463#[derive(Debug, Clone, Serialize, Deserialize)]
1464#[serde(default)]
1465pub struct AdaptiveSkillsConfig {
1466 pub context_fill_decay: f64,
1467 pub min_remaining_context_ratio: f64,
1468 pub recent_fire_boost_turns: usize,
1469 pub model_max_context_tokens: u64,
1473}
1474
1475impl Default for AdaptiveSkillsConfig {
1476 fn default() -> Self {
1477 Self {
1478 context_fill_decay: 1.5,
1479 min_remaining_context_ratio: 0.20,
1480 recent_fire_boost_turns: 5,
1481 model_max_context_tokens: 200_000,
1482 }
1483 }
1484}
1485
1486#[derive(Debug, Clone, Serialize, Deserialize)]
1489pub struct SleepCycleConfig {
1490 #[serde(default)]
1492 pub enabled: bool,
1493
1494 #[serde(default = "default_idle_threshold_minutes")]
1496 pub idle_threshold_minutes: u64,
1497
1498 #[serde(default = "default_agent_idle_minutes")]
1500 pub agent_idle_minutes: u64,
1501}
1502
1503fn default_idle_threshold_minutes() -> u64 {
1504 15
1505}
1506
1507fn default_agent_idle_minutes() -> u64 {
1508 5
1509}
1510
1511impl Default for SleepCycleConfig {
1512 fn default() -> Self {
1513 Self {
1514 enabled: false,
1515 idle_threshold_minutes: default_idle_threshold_minutes(),
1516 agent_idle_minutes: default_agent_idle_minutes(),
1517 }
1518 }
1519}
1520
1521#[derive(Debug, Clone, Serialize, Deserialize)]
1524pub struct NudgeConfig {
1525 #[serde(default = "default_nudge_enabled")]
1527 pub enabled: bool,
1528 #[serde(default = "default_nudge_daily_cap")]
1529 pub daily_cap: u32,
1530 #[serde(default = "default_nudge_snooze_days")]
1531 pub snooze_days: u32,
1532 #[serde(default = "default_nudge_threshold")]
1533 pub threshold: usize,
1534}
1535
1536fn default_nudge_enabled() -> bool {
1537 true
1538}
1539fn default_nudge_daily_cap() -> u32 {
1540 3
1541}
1542fn default_nudge_snooze_days() -> u32 {
1543 7
1544}
1545fn default_nudge_threshold() -> usize {
1546 3
1547}
1548
1549impl Default for NudgeConfig {
1550 fn default() -> Self {
1551 Self {
1552 enabled: true,
1553 daily_cap: default_nudge_daily_cap(),
1554 snooze_days: default_nudge_snooze_days(),
1555 threshold: default_nudge_threshold(),
1556 }
1557 }
1558}
1559
1560#[derive(Debug, Clone, Serialize, Deserialize)]
1564pub struct SessionCfg {
1565 #[serde(default = "default_capture_mode")]
1567 pub capture: String,
1568 #[serde(default = "default_retention_days")]
1570 pub retention_days: u32,
1571}
1572
1573impl Default for SessionCfg {
1574 fn default() -> Self {
1575 Self {
1576 capture: default_capture_mode(),
1577 retention_days: default_retention_days(),
1578 }
1579 }
1580}
1581
1582fn default_capture_mode() -> String {
1583 "ambient".to_string()
1584}
1585fn default_retention_days() -> u32 {
1586 14
1587}
1588
1589#[derive(Debug, Clone, Serialize, Deserialize)]
1591pub struct HarvestCfg {
1592 #[serde(default = "default_harvest_enabled")]
1594 pub auto_gate: bool,
1595 #[serde(default = "default_harvest_llm")]
1597 pub llm: String,
1598 #[serde(default = "default_min_events")]
1600 pub min_events: usize,
1601 #[serde(default = "default_min_user_turns")]
1602 pub min_user_turns: usize,
1603 #[serde(default = "default_min_duration_secs")]
1604 pub min_duration_secs: i64,
1605 #[serde(default = "default_idle_minutes")]
1607 pub idle_minutes: i64,
1608 #[serde(default = "default_max_llm_calls_per_day")]
1610 pub max_llm_calls_per_day: u32,
1611 #[serde(default = "default_max_extract_input_tokens")]
1612 pub max_extract_input_tokens: usize,
1613 #[serde(default = "default_harvest_enabled")]
1615 pub session_start_hint: bool,
1616 #[serde(default = "default_similarity_merge_threshold")]
1618 pub similarity_merge_threshold: f32,
1619}
1620
1621impl Default for HarvestCfg {
1622 fn default() -> Self {
1623 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
1624 }
1625}
1626
1627fn default_harvest_enabled() -> bool {
1628 true
1629}
1630fn default_harvest_llm() -> String {
1631 "local-first".to_string()
1632}
1633fn default_min_events() -> usize {
1634 5
1635}
1636fn default_min_user_turns() -> usize {
1637 2
1638}
1639fn default_min_duration_secs() -> i64 {
1640 120
1641}
1642fn default_idle_minutes() -> i64 {
1643 30
1644}
1645fn default_max_llm_calls_per_day() -> u32 {
1646 10
1647}
1648fn default_max_extract_input_tokens() -> usize {
1649 12000
1650}
1651fn default_similarity_merge_threshold() -> f32 {
1652 0.6
1653}
1654
1655#[derive(Debug, Clone, Serialize, Deserialize)]
1658#[serde(default)]
1659pub struct CrossAgentConfig {
1660 #[serde(default = "default_half_life_days")]
1661 pub fitness_half_life_days: u32,
1662 #[serde(default = "default_fitness_floor")]
1663 pub fitness_floor: f64,
1664}
1665
1666fn default_half_life_days() -> u32 {
1667 7
1668}
1669fn default_fitness_floor() -> f64 {
1670 0.1
1671}
1672
1673impl Default for CrossAgentConfig {
1674 fn default() -> Self {
1675 Self {
1676 fitness_half_life_days: default_half_life_days(),
1677 fitness_floor: default_fitness_floor(),
1678 }
1679 }
1680}
1681
1682#[derive(Debug, Clone, Serialize, Deserialize)]
1685#[serde(default)]
1686pub struct SkillLlmConfig {
1687 #[serde(default = "default_per_call_token_cap")]
1689 pub per_call_token_cap: u32,
1690
1691 #[serde(default = "default_per_day_usd_cap")]
1693 pub per_day_usd_cap: f64,
1694
1695 #[serde(default = "default_cache_ttl_days")]
1697 pub cache_ttl_days: u32,
1698
1699 #[serde(default, skip_serializing_if = "Option::is_none")]
1701 pub model_ref: Option<String>,
1702}
1703
1704fn default_per_call_token_cap() -> u32 {
1705 1500
1706}
1707fn default_per_day_usd_cap() -> f64 {
1708 0.50
1709}
1710fn default_cache_ttl_days() -> u32 {
1711 30
1712}
1713
1714impl Default for SkillLlmConfig {
1715 fn default() -> Self {
1716 Self {
1717 per_call_token_cap: default_per_call_token_cap(),
1718 per_day_usd_cap: default_per_day_usd_cap(),
1719 cache_ttl_days: default_cache_ttl_days(),
1720 model_ref: None,
1721 }
1722 }
1723}
1724#[cfg(test)]
1725mod per_stage_backend_tests {
1726 use super::*;
1727
1728 #[test]
1729 fn legacy_compact_config_has_no_per_stage_overrides() {
1730 let yaml = "\
1731extractive_model: qwen3:14b
1732abstractive_model: qwen3:14b
1733ollama_endpoint: http://localhost:11434
1734";
1735 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1736 assert!(cfg.extractive_backend.is_none());
1737 assert!(cfg.abstractive_backend.is_none());
1738 assert_eq!(cfg.extractive_model, "qwen3:14b");
1739 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1740 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1741 }
1742
1743 #[test]
1744 fn legacy_ask_config_has_no_per_stage_overrides() {
1745 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1746 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1747 assert!(cfg.backend.is_none());
1748 assert!(cfg.rewriter_backend.is_none());
1749 assert_eq!(cfg.model, "qwen3:14b");
1750 }
1751
1752 #[test]
1753 fn compact_extractive_backend_override_parses() {
1754 let yaml = "\
1755extractive_backend:
1756 provider: anthropic
1757 model: claude-haiku-4-5
1758 api_key_env: ANTHROPIC_API_KEY
1759abstractive_model: qwen3:14b
1760";
1761 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1762 let extractive = cfg
1763 .extractive_backend
1764 .as_ref()
1765 .expect("override should parse");
1766 assert_eq!(extractive.provider, "anthropic");
1767 assert_eq!(extractive.model, "claude-haiku-4-5");
1768 assert!(cfg.abstractive_backend.is_none());
1769 }
1770
1771 #[test]
1772 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1773 let yaml = "\
1774backend:
1775 provider: anthropic
1776 model: claude-sonnet-4-6
1777 api_key_env: ANTHROPIC_API_KEY
1778rewriter_backend:
1779 provider: ollama
1780 model: llama3.2:3b
1781";
1782 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1783 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1784 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1785 }
1786
1787 #[test]
1788 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1789 let yaml = "\
1790extractive_model: qwen3:14b
1791ollama_endpoint: http://192.168.1.10:11434
1792";
1793 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1794 let synth = cfg.synthesize_extractive_backend();
1795 assert_eq!(synth.provider, "ollama");
1796 assert_eq!(synth.model, "qwen3:14b");
1797 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1798 assert_eq!(synth.api_key_env, None);
1799 }
1800
1801 #[test]
1802 fn synthesize_legacy_to_backend_config_for_ask() {
1803 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1804 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1805 let synth = cfg.synthesize_backend();
1806 assert_eq!(synth.provider, "ollama");
1807 assert_eq!(synth.model, "qwen3:14b");
1808 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1809 }
1810
1811 #[test]
1812 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1813 let yaml = "\
1822backend:
1823 provider: anthropic
1824 model: claude-sonnet-4-6
1825 api_key_env: ANTHROPIC_API_KEY
1826";
1827 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1828 let rewriter = cfg.synthesize_rewriter_backend();
1829 assert_eq!(rewriter.provider, "ollama");
1830 assert_eq!(rewriter.model, ask_default_model());
1831 assert_eq!(
1832 rewriter.timeout_secs,
1833 Some(ask_default_rewriter_timeout() as u64)
1834 );
1835 }
1836
1837 #[test]
1838 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1839 let cfg = AskConfig {
1840 timeout_secs: 45,
1841 ..AskConfig::default()
1842 };
1843 let b = cfg.synthesize_backend();
1844 assert_eq!(
1845 b.timeout_secs,
1846 Some(45),
1847 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1848 );
1849 }
1850
1851 #[test]
1852 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1853 let mut cfg = AskConfig {
1854 timeout_secs: 45,
1855 ..AskConfig::default()
1856 };
1857 cfg.backend = Some(BackendConfig {
1858 provider: "anthropic".into(),
1859 model: "claude-haiku-4-5".into(),
1860 endpoint: None,
1861 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1862 timeout_secs: Some(10),
1863 });
1864 let b = cfg.synthesize_backend();
1865 assert_eq!(
1866 b.timeout_secs,
1867 Some(10),
1868 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1869 );
1870 }
1871
1872 #[test]
1873 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1874 let cfg = AskConfig {
1875 timeout_secs: 120,
1876 rewriter_timeout_secs: 8,
1877 ..AskConfig::default()
1878 };
1879 let b = cfg.synthesize_rewriter_backend();
1880 assert_eq!(
1881 b.timeout_secs,
1882 Some(8),
1883 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1884 );
1885 }
1886
1887 #[test]
1888 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1889 let mut cfg = AskConfig {
1890 rewriter_timeout_secs: 8,
1891 ..AskConfig::default()
1892 };
1893 cfg.rewriter_backend = Some(BackendConfig {
1894 provider: "anthropic".into(),
1895 model: "claude-haiku-4-5".into(),
1896 endpoint: None,
1897 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1898 timeout_secs: Some(30),
1899 });
1900 let b = cfg.synthesize_rewriter_backend();
1901 assert_eq!(
1902 b.timeout_secs,
1903 Some(30),
1904 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1905 );
1906 }
1907
1908 #[test]
1909 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1910 let cfg = CompactConfig::default();
1913 let b = cfg.synthesize_extractive_backend();
1914 assert_eq!(
1915 b.timeout_secs,
1916 Some(120),
1917 "compact synthesis without per-stage override must produce 120s timeout"
1918 );
1919 }
1920
1921 #[test]
1922 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1923 let cfg = CompactConfig::default();
1924 let b = cfg.synthesize_abstractive_backend();
1925 assert_eq!(b.timeout_secs, Some(120));
1926 }
1927}
1928
1929#[cfg(test)]
1930mod skills_config_tests {
1931 use super::*;
1932
1933 #[test]
1934 fn empty_yaml_hydrates_defaults() {
1935 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1936 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1937 assert_eq!(cfg.skills.max_total_tokens, 2000);
1938 assert!(cfg.skills.adaptive.is_some());
1939 }
1940
1941 #[test]
1942 fn load_or_default_missing_file_returns_default() {
1943 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1944 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1945 }
1946}
1947
1948#[cfg(test)]
1949mod ambient_capture_cfg_tests {
1950 use super::*;
1951
1952 #[test]
1953 fn session_and_harvest_defaults() {
1954 let cfg: Config = serde_yaml::from_str("{}").unwrap();
1955 assert_eq!(cfg.session.capture, "ambient");
1956 assert_eq!(cfg.session.retention_days, 14);
1957 assert!(cfg.harvest.auto_gate);
1958 assert_eq!(cfg.harvest.llm, "local-first");
1959 assert_eq!(cfg.harvest.min_events, 5);
1960 assert_eq!(cfg.harvest.min_user_turns, 2);
1961 assert_eq!(cfg.harvest.min_duration_secs, 120);
1962 assert_eq!(cfg.harvest.idle_minutes, 30);
1963 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
1964 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
1965 assert!(cfg.harvest.session_start_hint);
1966 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
1967 }
1968
1969 #[test]
1970 fn session_capture_override_parses() {
1971 let cfg: Config =
1972 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
1973 assert_eq!(cfg.session.capture, "off");
1974 assert_eq!(cfg.session.retention_days, 3);
1975 }
1976}
1977
1978#[cfg(test)]
1979mod cc_proxy_cfg_tests {
1980 use super::*;
1981
1982 #[test]
1983 fn defaults_to_local_cc_proxy_enabled() {
1984 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1985 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
1986 assert!(cfg.cc_proxy.enabled);
1987 }
1988
1989 #[test]
1990 fn url_and_enabled_override_parse() {
1991 let cfg: Config =
1992 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
1993 .unwrap();
1994 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
1995 assert!(!cfg.cc_proxy.enabled);
1996 }
1997
1998 #[test]
1999 fn partial_section_keeps_other_default() {
2000 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
2002 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
2003 assert!(!cfg.cc_proxy.enabled);
2004 }
2005}