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