1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
5
6#[derive(Debug, Clone, Serialize, Deserialize, Default)]
8pub struct Config {
9 #[serde(default)]
10 pub embedding: EmbeddingConfig,
11
12 #[serde(default)]
13 pub llm: LlmConfig,
14
15 #[serde(default)]
16 pub retrieval: RetrievalConfig,
17
18 #[serde(default)]
19 pub paths: PathConfig,
20
21 #[serde(default)]
22 pub server: ServerConfig,
23
24 #[serde(default)]
25 pub community: CommunityConfig,
26
27 #[serde(default)]
28 pub conversations: ConversationsConfig,
29
30 #[serde(default)]
31 pub sync: SyncConfig,
32
33 #[serde(default)]
35 pub storage: StorageConfig,
36
37 #[serde(default)]
38 pub sources_global: SourcesGlobalConfig,
39
40 #[serde(default)]
42 pub sleep_cycle: SleepCycleConfig,
43
44 #[serde(default)]
46 pub skills: SkillsConfig,
47
48 #[serde(default)]
50 pub skill_llm: SkillLlmConfig,
51
52 #[serde(default)]
54 pub cross_agent: CrossAgentConfig,
55}
56
57impl Config {
58 pub fn load_or_default(path: &std::path::Path) -> Self {
60 std::fs::read_to_string(path)
61 .ok()
62 .and_then(|s| serde_yaml_ng::from_str(&s).ok())
63 .unwrap_or_default()
64 }
65}
66
67#[derive(Debug, Clone, Serialize, Deserialize, Default)]
68pub struct SyncConfig {
69 #[serde(default = "default_sync_method")]
71 pub method: String,
72
73 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub git_remote: Option<String>,
76
77 #[serde(default)]
79 pub auto: bool,
80
81 #[serde(default, skip_serializing_if = "Option::is_none")]
83 pub team_id: Option<String>,
84}
85
86fn default_sync_method() -> String {
87 "local".to_string()
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize)]
91pub struct ServerConfig {
92 #[serde(default = "default_server_url")]
94 pub url: String,
95}
96
97impl Default for ServerConfig {
98 fn default() -> Self {
99 Self {
100 url: default_server_url(),
101 }
102 }
103}
104
105#[derive(Debug, Clone, Serialize, Deserialize, Default)]
106pub struct CommunityConfig {
107 #[serde(default)]
109 pub enabled: bool,
110}
111
112#[derive(Debug, Clone, Serialize, Deserialize)]
113pub struct EmbeddingConfig {
114 #[serde(default = "default_embedding_provider")]
116 pub provider: String,
117
118 #[serde(default = "default_embedding_model")]
120 pub model: String,
121
122 #[serde(default = "default_dimensions")]
124 pub dimensions: usize,
125
126 #[serde(default = "default_ollama_endpoint")]
128 pub ollama_endpoint: String,
129
130 #[serde(default, skip_serializing_if = "Option::is_none")]
132 pub api_key_env: Option<String>,
133
134 #[serde(default, skip_serializing_if = "Option::is_none")]
136 pub openai_url: Option<String>,
137}
138
139impl Default for EmbeddingConfig {
140 fn default() -> Self {
141 Self {
142 provider: default_embedding_provider(),
143 model: default_embedding_model(),
144 dimensions: default_dimensions(),
145 ollama_endpoint: default_ollama_endpoint(),
146 api_key_env: None,
147 openai_url: None,
148 }
149 }
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize)]
153pub struct LlmConfig {
154 #[serde(default = "default_llm_provider")]
156 pub provider: String,
157
158 #[serde(default = "default_llm_model")]
159 pub model: String,
160
161 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub api_key_env: Option<String>,
164
165 #[serde(default, skip_serializing_if = "Option::is_none")]
167 pub openai_url: Option<String>,
168}
169
170impl Default for LlmConfig {
171 fn default() -> Self {
172 Self {
173 provider: default_llm_provider(),
174 model: default_llm_model(),
175 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
176 openai_url: None,
177 }
178 }
179}
180
181impl LlmConfig {
182 pub fn to_backend_config(&self) -> BackendConfig {
195 let provider = match self.provider.as_str() {
196 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
197 _ if self.openai_url.is_some() => "openai".into(),
198 other => other.into(), };
200 BackendConfig {
201 provider,
202 model: self.model.clone(),
203 endpoint: self.openai_url.clone(),
204 api_key_env: self.api_key_env.clone(),
205 timeout_secs: None,
206 }
207 }
208}
209
210#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
221#[serde(default)]
222pub struct BackendConfig {
223 pub provider: String,
225 pub model: String,
227 pub endpoint: Option<String>,
230 pub api_key_env: Option<String>,
232 pub timeout_secs: Option<u64>,
234}
235
236impl Default for BackendConfig {
237 fn default() -> Self {
238 Self {
239 provider: "ollama".into(),
240 model: DEFAULT_LOCAL_LLM_MODEL.into(),
241 endpoint: None,
242 api_key_env: None,
243 timeout_secs: None,
244 }
245 }
246}
247
248#[derive(Debug, Clone, Serialize, Deserialize)]
249pub struct RetrievalConfig {
250 #[serde(default = "default_max_patterns")]
252 pub max_patterns: usize,
253
254 #[serde(default = "default_max_tokens")]
256 pub max_tokens: usize,
257
258 #[serde(default = "default_min_score")]
260 pub min_score: f64,
261
262 #[serde(default = "default_mmr_threshold")]
264 pub mmr_threshold: f64,
265}
266
267impl Default for RetrievalConfig {
268 fn default() -> Self {
269 Self {
270 max_patterns: default_max_patterns(),
271 max_tokens: default_max_tokens(),
272 min_score: default_min_score(),
273 mmr_threshold: default_mmr_threshold(),
274 }
275 }
276}
277
278#[derive(Debug, Clone, Serialize, Deserialize)]
279pub struct PathConfig {
280 #[serde(default = "default_mur_dir")]
282 pub mur_dir: PathBuf,
283}
284
285impl Default for PathConfig {
286 fn default() -> Self {
287 Self {
288 mur_dir: default_mur_dir(),
289 }
290 }
291}
292
293#[derive(Debug, Clone, Serialize, Deserialize)]
294pub struct StorageConfig {
295 #[serde(default = "default_vector_backend")]
297 pub vector_backend: String,
298
299 #[serde(default, skip_serializing_if = "Option::is_none")]
301 pub qdrant_url: Option<String>,
302
303 #[serde(default, skip_serializing_if = "Option::is_none")]
305 pub qdrant_api_key_ref: Option<String>,
306}
307
308impl Default for StorageConfig {
309 fn default() -> Self {
310 Self {
311 vector_backend: default_vector_backend(),
312 qdrant_url: None,
313 qdrant_api_key_ref: None,
314 }
315 }
316}
317
318fn default_vector_backend() -> String {
319 "lancedb".to_string()
320}
321
322#[derive(Debug, Clone, Serialize, Deserialize)]
323pub struct SourcesGlobalConfig {
324 #[serde(default = "default_poll_interval_secs")]
326 pub poll_interval_secs: u64,
327
328 #[serde(default = "default_max_chunks_per_sync")]
330 pub max_chunks_per_sync: usize,
331
332 #[serde(default = "default_max_parallel_sources")]
334 pub max_parallel_sources: usize,
335
336 #[serde(default = "default_source_weight")]
338 pub default_weight: f32,
339
340 #[serde(default = "default_embedding_batch_size")]
342 pub embedding_batch_size: usize,
343}
344
345impl Default for SourcesGlobalConfig {
346 fn default() -> Self {
347 Self {
348 poll_interval_secs: default_poll_interval_secs(),
349 max_chunks_per_sync: default_max_chunks_per_sync(),
350 max_parallel_sources: default_max_parallel_sources(),
351 default_weight: default_source_weight(),
352 embedding_batch_size: default_embedding_batch_size(),
353 }
354 }
355}
356
357fn default_poll_interval_secs() -> u64 {
358 600
359}
360fn default_max_chunks_per_sync() -> usize {
361 10_000
362}
363fn default_max_parallel_sources() -> usize {
364 3
365}
366fn default_source_weight() -> f32 {
367 1.0
368}
369fn default_embedding_batch_size() -> usize {
370 32
371}
372
373fn default_embedding_provider() -> String {
374 "ollama".to_string()
375}
376fn default_embedding_model() -> String {
377 "qwen3-embedding:0.6b".to_string()
378}
379fn default_dimensions() -> usize {
380 1024
381}
382fn default_ollama_endpoint() -> String {
383 "http://localhost:11434".to_string()
384}
385fn default_llm_provider() -> String {
386 "anthropic".to_string()
387}
388fn default_llm_model() -> String {
389 "claude-opus-4-6".to_string()
390}
391fn default_max_patterns() -> usize {
392 5
393}
394fn default_max_tokens() -> usize {
395 2000
396}
397fn default_min_score() -> f64 {
398 0.35
399}
400fn default_mmr_threshold() -> f64 {
401 0.85
402}
403fn default_mur_dir() -> PathBuf {
404 let home = std::env::var("HOME")
407 .map(PathBuf::from)
408 .unwrap_or_else(|_| PathBuf::from("/tmp"));
409 home.join(".mur")
410}
411fn default_server_url() -> String {
412 "https://mur-server.fly.dev".to_string()
413}
414
415#[derive(Debug, Clone, Serialize, Deserialize)]
418pub struct AskConfig {
419 #[serde(default = "ask_default_model")]
420 pub model: String,
421 #[serde(default = "compact_default_ollama_endpoint")]
422 pub ollama_endpoint: String,
423 #[serde(default = "ask_default_k_summary")]
424 pub k_summary: u32,
425 #[serde(default = "ask_default_k_raw")]
426 pub k_raw: u32,
427 #[serde(default = "ask_default_esc")]
428 pub escalation_threshold: f64,
429 #[serde(default = "ask_default_mmr")]
430 pub mmr_threshold: f64,
431 #[serde(default = "ask_default_max_ctx")]
432 pub max_context_tokens: u32,
433 #[serde(default = "ask_default_resp_tok")]
434 pub response_tokens: u32,
435 #[serde(default = "ask_default_timeout")]
436 pub timeout_secs: u32,
437 #[serde(default = "ask_default_min_score")]
438 pub min_score: f64,
439 #[serde(default = "ask_default_continue_history_turns")]
440 pub continue_history_turns: u32,
441 #[serde(default = "ask_default_rewriter_timeout")]
447 pub rewriter_timeout_secs: u32,
448 #[serde(default = "ask_default_compress_hits_enabled")]
449 pub compress_hits_enabled: bool,
450 #[serde(default = "ask_default_summarize_hits_enabled")]
451 pub summarize_hits_enabled: bool,
452 #[serde(default)]
453 pub summarize_model: Option<String>,
454 #[serde(default)]
457 pub backend: Option<BackendConfig>,
458 #[serde(default)]
462 pub rewriter_backend: Option<BackendConfig>,
463}
464
465impl AskConfig {
466 pub fn synthesize_backend(&self) -> BackendConfig {
476 self.backend.clone().unwrap_or_else(|| BackendConfig {
477 provider: "ollama".into(),
478 model: self.model.clone(),
479 endpoint: Some(self.ollama_endpoint.clone()),
480 api_key_env: None,
481 timeout_secs: Some(self.timeout_secs as u64),
482 })
483 }
484
485 pub fn synthesize_rewriter_backend(&self) -> BackendConfig {
495 self.rewriter_backend
496 .clone()
497 .unwrap_or_else(|| BackendConfig {
498 provider: "ollama".into(),
499 model: self.model.clone(),
500 endpoint: Some(self.ollama_endpoint.clone()),
501 api_key_env: None,
502 timeout_secs: Some(self.rewriter_timeout_secs as u64),
503 })
504 }
505}
506
507impl Default for AskConfig {
508 fn default() -> Self {
509 Self {
510 model: ask_default_model(),
511 ollama_endpoint: compact_default_ollama_endpoint(),
512 k_summary: ask_default_k_summary(),
513 k_raw: ask_default_k_raw(),
514 escalation_threshold: ask_default_esc(),
515 mmr_threshold: ask_default_mmr(),
516 max_context_tokens: ask_default_max_ctx(),
517 response_tokens: ask_default_resp_tok(),
518 timeout_secs: ask_default_timeout(),
519 min_score: ask_default_min_score(),
520 continue_history_turns: ask_default_continue_history_turns(),
521 rewriter_timeout_secs: ask_default_rewriter_timeout(),
522 compress_hits_enabled: ask_default_compress_hits_enabled(),
523 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
524 summarize_model: None,
525 backend: None,
526 rewriter_backend: None,
527 }
528 }
529}
530
531fn ask_default_model() -> String {
532 DEFAULT_LOCAL_LLM_MODEL.into()
533}
534fn ask_default_k_summary() -> u32 {
535 5
536}
537fn ask_default_k_raw() -> u32 {
538 10
539}
540fn ask_default_esc() -> f64 {
541 0.5
542}
543fn ask_default_mmr() -> f64 {
544 0.88
545}
546fn ask_default_max_ctx() -> u32 {
547 6000
548}
549fn ask_default_resp_tok() -> u32 {
550 1024
551}
552fn ask_default_timeout() -> u32 {
553 120
554}
555fn ask_default_min_score() -> f64 {
556 0.35
557}
558fn ask_default_rewriter_timeout() -> u32 {
559 8
560}
561fn ask_default_continue_history_turns() -> u32 {
562 3
563}
564fn ask_default_compress_hits_enabled() -> bool {
565 true
566}
567fn ask_default_summarize_hits_enabled() -> bool {
568 true
569}
570
571#[derive(Debug, Clone, Serialize, Deserialize)]
580pub struct ConversationsConfig {
581 #[serde(default)]
582 pub enabled: bool,
583 #[serde(default = "conv_default_retention_days")]
584 pub retention_days: u32,
585 #[serde(default = "conv_default_poll_interval")]
586 pub poll_interval_secs: u64,
587 #[serde(default)]
588 pub sources: ConversationsSources,
589 #[serde(default)]
590 pub filter: ConversationsFilter,
591 #[serde(default)]
592 pub compact: CompactConfig,
593 #[serde(default)]
594 pub ask: AskConfig,
595 #[serde(default)]
596 pub rollup: RollupConfig,
597}
598
599impl Default for ConversationsConfig {
600 fn default() -> Self {
601 Self {
602 enabled: false,
603 retention_days: conv_default_retention_days(),
604 poll_interval_secs: conv_default_poll_interval(),
605 sources: ConversationsSources::default(),
606 filter: ConversationsFilter::default(),
607 compact: CompactConfig::default(),
608 ask: AskConfig::default(),
609 rollup: RollupConfig::default(),
610 }
611 }
612}
613
614fn conv_default_retention_days() -> u32 {
615 30
616}
617fn conv_default_poll_interval() -> u64 {
618 300
619}
620fn conv_truthy() -> bool {
621 true
622}
623fn conv_default_dedup() -> f64 {
624 0.85
625}
626
627#[derive(Debug, Clone, Serialize, Deserialize)]
628pub struct CompactConfig {
629 #[serde(default = "conv_truthy")]
630 pub enabled_in_daemon: bool,
631 #[serde(default = "compact_default_max_days")]
632 pub max_days_per_run: u32,
633 #[serde(default = "compact_default_model")]
634 pub extractive_model: String,
635 #[serde(default = "compact_default_model")]
636 pub abstractive_model: String,
637 #[serde(default = "compact_default_ollama_endpoint")]
638 pub ollama_endpoint: String,
639 #[serde(default = "compact_default_max_spans")]
640 pub max_extractive_spans: u32,
641 #[serde(default = "compact_default_max_words")]
642 pub max_abstractive_words: u32,
643 #[serde(default = "compact_default_chunk_tokens")]
644 pub chunk_tokens: u32,
645 #[serde(default = "compact_default_history_retain")]
646 pub history_retain: u32,
647 #[serde(default = "compact_default_cron")]
648 pub daemon_cron: String,
649 #[serde(default)]
652 pub extractive_backend: Option<BackendConfig>,
653 #[serde(default)]
656 pub abstractive_backend: Option<BackendConfig>,
657}
658
659impl CompactConfig {
660 pub fn synthesize_extractive_backend(&self) -> BackendConfig {
669 self.extractive_backend
670 .clone()
671 .unwrap_or_else(|| BackendConfig {
672 provider: "ollama".into(),
673 model: self.extractive_model.clone(),
674 endpoint: Some(self.ollama_endpoint.clone()),
675 api_key_env: None,
676 timeout_secs: Some(120),
677 })
678 }
679
680 pub fn synthesize_abstractive_backend(&self) -> BackendConfig {
683 self.abstractive_backend
684 .clone()
685 .unwrap_or_else(|| BackendConfig {
686 provider: "ollama".into(),
687 model: self.abstractive_model.clone(),
688 endpoint: Some(self.ollama_endpoint.clone()),
689 api_key_env: None,
690 timeout_secs: Some(120),
691 })
692 }
693}
694
695impl Default for CompactConfig {
696 fn default() -> Self {
697 Self {
698 enabled_in_daemon: true,
699 max_days_per_run: compact_default_max_days(),
700 extractive_model: compact_default_model(),
701 abstractive_model: compact_default_model(),
702 ollama_endpoint: compact_default_ollama_endpoint(),
703 max_extractive_spans: compact_default_max_spans(),
704 max_abstractive_words: compact_default_max_words(),
705 chunk_tokens: compact_default_chunk_tokens(),
706 history_retain: compact_default_history_retain(),
707 daemon_cron: compact_default_cron(),
708 extractive_backend: None,
709 abstractive_backend: None,
710 }
711 }
712}
713
714fn compact_default_max_days() -> u32 {
715 7
716}
717fn compact_default_model() -> String {
718 DEFAULT_LOCAL_LLM_MODEL.into()
719}
720fn compact_default_ollama_endpoint() -> String {
721 "http://localhost:11434".into()
722}
723fn compact_default_max_spans() -> u32 {
724 20
725}
726fn compact_default_max_words() -> u32 {
727 400
728}
729fn compact_default_chunk_tokens() -> u32 {
730 6000
731}
732fn compact_default_history_retain() -> u32 {
733 5
734}
735fn compact_default_cron() -> String {
736 "0 0 3 * * * *".into()
737}
738
739#[derive(Debug, Clone, Serialize, Deserialize)]
742pub struct RollupConfig {
743 #[serde(default = "rollup_default_enabled")]
744 pub enabled: bool,
745 #[serde(default = "rollup_default_max_weeks")]
746 pub max_weeks_per_run: u32,
747 #[serde(default = "rollup_default_max_months")]
748 pub max_months_per_run: u32,
749 #[serde(default = "rollup_default_max_spans_week")]
750 pub max_extractive_spans_per_week: u32,
751 #[serde(default = "rollup_default_max_words_week")]
752 pub max_abstractive_words_per_week: u32,
753 #[serde(default = "rollup_default_max_spans_month")]
754 pub max_extractive_spans_per_month: u32,
755 #[serde(default = "rollup_default_max_words_month")]
756 pub max_abstractive_words_per_month: u32,
757 #[serde(default = "rollup_default_week_mmr")]
758 pub week_mmr_threshold: f64,
759 #[serde(default = "rollup_default_month_mmr")]
760 pub month_mmr_threshold: f64,
761 #[serde(default = "compact_default_model")]
762 pub extractive_model: String,
763 #[serde(default = "compact_default_model")]
764 pub abstractive_model: String,
765 #[serde(default = "compact_default_ollama_endpoint")]
766 pub ollama_endpoint: String,
767}
768
769impl Default for RollupConfig {
770 fn default() -> Self {
771 Self {
772 enabled: rollup_default_enabled(),
773 max_weeks_per_run: rollup_default_max_weeks(),
774 max_months_per_run: rollup_default_max_months(),
775 max_extractive_spans_per_week: rollup_default_max_spans_week(),
776 max_abstractive_words_per_week: rollup_default_max_words_week(),
777 max_extractive_spans_per_month: rollup_default_max_spans_month(),
778 max_abstractive_words_per_month: rollup_default_max_words_month(),
779 week_mmr_threshold: rollup_default_week_mmr(),
780 month_mmr_threshold: rollup_default_month_mmr(),
781 extractive_model: compact_default_model(),
782 abstractive_model: compact_default_model(),
783 ollama_endpoint: compact_default_ollama_endpoint(),
784 }
785 }
786}
787
788fn rollup_default_enabled() -> bool {
789 true
790}
791fn rollup_default_max_weeks() -> u32 {
792 4
793}
794fn rollup_default_max_months() -> u32 {
795 2
796}
797fn rollup_default_max_spans_week() -> u32 {
798 20
799}
800fn rollup_default_max_words_week() -> u32 {
801 500
802}
803fn rollup_default_max_spans_month() -> u32 {
804 20
805}
806fn rollup_default_max_words_month() -> u32 {
807 700
808}
809fn rollup_default_week_mmr() -> f64 {
810 0.85
811}
812fn rollup_default_month_mmr() -> f64 {
813 0.82
814}
815
816#[derive(Debug, Clone, Serialize, Deserialize)]
817pub struct ConversationsSources {
818 #[serde(default = "conv_truthy")]
819 pub claude_code: bool,
820 #[serde(default = "conv_truthy")]
821 pub cursor: bool,
822 #[serde(default = "conv_truthy")]
823 pub gemini: bool,
824 #[serde(default)]
825 pub aider: AiderSourceConfig,
826}
827
828impl Default for ConversationsSources {
829 fn default() -> Self {
830 Self {
831 claude_code: true,
832 cursor: true,
833 gemini: true,
834 aider: AiderSourceConfig::default(),
835 }
836 }
837}
838
839#[derive(Debug, Clone, Serialize, Deserialize)]
840pub struct AiderSourceConfig {
841 #[serde(default = "conv_truthy")]
842 pub enabled: bool,
843 #[serde(default)]
844 pub watched_dirs: Vec<String>,
845}
846
847impl Default for AiderSourceConfig {
848 fn default() -> Self {
849 Self {
850 enabled: true,
851 watched_dirs: Vec::new(),
852 }
853 }
854}
855
856#[derive(Debug, Clone, Serialize, Deserialize)]
857pub struct ConversationsFilter {
858 #[serde(default = "conv_default_dedup")]
859 pub dedup_threshold: f64,
860 #[serde(default = "conv_truthy")]
861 pub reject_heartbeat: bool,
862 #[serde(default = "conv_truthy")]
863 pub reject_system_restatement: bool,
864}
865
866impl Default for ConversationsFilter {
867 fn default() -> Self {
868 Self {
869 dedup_threshold: conv_default_dedup(),
870 reject_heartbeat: true,
871 reject_system_restatement: true,
872 }
873 }
874}
875
876#[cfg(test)]
877mod conversations_tests {
878 use super::*;
879
880 #[test]
881 fn conversations_section_defaults() {
882 let c = ConversationsConfig::default();
883 assert!(!c.enabled);
884 assert_eq!(c.retention_days, 30);
885 assert_eq!(c.poll_interval_secs, 300);
886 assert!(c.sources.claude_code);
887 assert!(c.sources.cursor);
888 assert!(c.sources.gemini);
889 assert!(c.sources.aider.enabled);
890 assert!(c.sources.aider.watched_dirs.is_empty());
891 assert_eq!(c.filter.dedup_threshold, 0.85);
892 assert!(c.filter.reject_heartbeat);
893 assert!(c.filter.reject_system_restatement);
894 }
895
896 #[test]
897 fn parse_from_yaml_with_overrides() {
898 let y = r#"
899conversations:
900 enabled: true
901 retention_days: 45
902 poll_interval_secs: 120
903 sources:
904 cursor: false
905 aider:
906 watched_dirs: ["~/Projects/a", "~/Projects/b"]
907 filter:
908 dedup_threshold: 0.9
909"#;
910 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
911 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
912 assert!(conv.enabled);
913 assert_eq!(conv.retention_days, 45);
914 assert_eq!(conv.poll_interval_secs, 120);
915 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
919 assert_eq!(conv.filter.dedup_threshold, 0.9);
920 assert!(conv.filter.reject_heartbeat); }
922
923 #[test]
924 fn missing_conversations_section_is_fine() {
925 let y = r#"
926# No conversations section at all
927foo: bar
928"#;
929 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
930 let conv: ConversationsConfig = v
932 .get("conversations")
933 .cloned()
934 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
935 .unwrap_or_default();
936 assert_eq!(conv.retention_days, 30);
937 }
938
939 #[test]
940 fn compact_config_defaults() {
941 let c = CompactConfig::default();
942 assert!(c.enabled_in_daemon);
943 assert_eq!(c.max_days_per_run, 7);
944 assert_eq!(c.extractive_model, "qwen3.5:4b");
945 assert_eq!(c.abstractive_model, "qwen3.5:4b");
946 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
947 assert_eq!(c.max_extractive_spans, 20);
948 assert_eq!(c.chunk_tokens, 6000);
949 assert_eq!(c.history_retain, 5);
950 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
951 }
952
953 #[test]
954 fn compact_parses_partial_overrides() {
955 let y = r#"
956conversations:
957 compact:
958 max_days_per_run: 3
959 extractive_model: qwen3:4b
960"#;
961 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
962 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
963 assert_eq!(conv.compact.max_days_per_run, 3);
964 assert_eq!(conv.compact.extractive_model, "qwen3:4b");
965 assert!(conv.compact.enabled_in_daemon); assert_eq!(conv.compact.abstractive_model, "qwen3.5:4b"); }
968
969 #[test]
970 fn ask_config_defaults() {
971 let c = AskConfig::default();
972 assert_eq!(c.model, "qwen3.5:4b");
973 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
974 assert_eq!(c.k_raw, 10);
975 assert_eq!(c.escalation_threshold, 0.5);
976 assert_eq!(c.mmr_threshold, 0.88);
977 assert_eq!(c.max_context_tokens, 6000);
978 assert_eq!(c.response_tokens, 1024);
979 assert_eq!(c.timeout_secs, 120);
980 assert_eq!(c.min_score, 0.35);
981 }
982
983 #[test]
984 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
985 let c = AskConfig::default();
987 assert!(
988 (c.mmr_threshold - 0.88).abs() < 1e-9,
989 "expected 0.88, got {}",
990 c.mmr_threshold
991 );
992 }
993
994 #[test]
995 fn rollup_config_defaults() {
996 let c = RollupConfig::default();
997 assert!(c.enabled);
998 assert_eq!(c.max_weeks_per_run, 4);
999 assert_eq!(c.max_months_per_run, 2);
1000 assert_eq!(c.max_extractive_spans_per_week, 20);
1001 assert_eq!(c.max_abstractive_words_per_week, 500);
1002 assert_eq!(c.max_extractive_spans_per_month, 20);
1003 assert_eq!(c.max_abstractive_words_per_month, 700);
1004 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1005 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1006 assert_eq!(c.extractive_model, "qwen3.5:4b");
1007 assert_eq!(c.abstractive_model, "qwen3.5:4b");
1008 assert_eq!(c.ollama_endpoint, "http://localhost:11434");
1009 }
1010
1011 #[test]
1012 fn rollup_config_plumbed_into_conversations_config() {
1013 let c = ConversationsConfig::default();
1014 assert!(c.rollup.enabled);
1015 }
1016
1017 #[test]
1018 fn ask_config_default_continue_history_turns_is_3() {
1019 let c = AskConfig::default();
1020 assert_eq!(c.continue_history_turns, 3);
1021 }
1022
1023 #[test]
1024 fn ask_config_default_compress_hits_enabled_is_true() {
1025 let c = AskConfig::default();
1026 assert!(c.compress_hits_enabled);
1027 }
1028
1029 #[test]
1030 fn ask_config_default_summarize_hits_enabled_is_true() {
1031 let c = AskConfig::default();
1032 assert!(c.summarize_hits_enabled);
1033 }
1034
1035 #[test]
1036 fn ask_config_default_summarize_model_is_none() {
1037 let c = AskConfig::default();
1038 assert!(c.summarize_model.is_none());
1039 }
1040
1041 #[test]
1042 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1043 let y = r#"
1044conversations:
1045 ask:
1046 summarize_hits_enabled: false
1047 summarize_model: qwen3:4b
1048"#;
1049 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1050 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1051 assert!(!conv.ask.summarize_hits_enabled);
1052 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1053 }
1054
1055 #[test]
1056 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1057 let y = r#"
1061conversations:
1062 ask:
1063 model: qwen3:14b
1064"#;
1065 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1066 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1067 assert!(conv.ask.summarize_hits_enabled);
1068 assert!(conv.ask.summarize_model.is_none());
1069 }
1070}
1071
1072#[cfg(test)]
1073mod tests {
1074 use super::*;
1075
1076 #[test]
1077 fn storage_config_default_is_lancedb() {
1078 let c = StorageConfig::default();
1079 assert_eq!(c.vector_backend, "lancedb");
1080 assert_eq!(c.qdrant_url, None);
1081 assert_eq!(c.qdrant_api_key_ref, None);
1082 }
1083
1084 #[test]
1085 fn sources_global_config_has_sensible_defaults() {
1086 let c = SourcesGlobalConfig::default();
1087 assert_eq!(c.poll_interval_secs, 600);
1088 assert_eq!(c.max_chunks_per_sync, 10_000);
1089 assert_eq!(c.max_parallel_sources, 3);
1090 assert_eq!(c.default_weight, 1.0);
1091 assert_eq!(c.embedding_batch_size, 32);
1092 }
1093
1094 #[test]
1095 fn config_default_has_storage_and_sources_global() {
1096 let c = Config::default();
1097 assert_eq!(c.storage.vector_backend, "lancedb");
1098 assert_eq!(c.sources_global.default_weight, 1.0);
1099 }
1100
1101 #[test]
1102 fn config_loads_yaml_without_new_fields() {
1103 let yaml = r#"
1106embedding:
1107 provider: ollama
1108 model: test-model
1109 dimensions: 512
1110 ollama_endpoint: http://localhost:11434
1111"#;
1112 let c: Config = serde_yaml::from_str(yaml).expect("parses");
1113 assert_eq!(c.storage.vector_backend, "lancedb");
1114 assert_eq!(c.sources_global.max_parallel_sources, 3);
1115 }
1116
1117 #[test]
1118 fn llm_config_to_backend_config_anthropic_passthrough() {
1119 let cfg = LlmConfig {
1120 provider: "anthropic".into(),
1121 model: "claude-haiku-4-5".into(),
1122 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1123 openai_url: None,
1124 };
1125 let b = cfg.to_backend_config();
1126 assert_eq!(b.provider, "anthropic");
1127 assert_eq!(b.model, "claude-haiku-4-5");
1128 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
1129 assert_eq!(b.endpoint, None);
1130 assert_eq!(b.timeout_secs, None);
1131 }
1132
1133 #[test]
1134 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
1135 let cfg = LlmConfig {
1136 provider: "openai".into(),
1137 model: "gpt-4o-mini".into(),
1138 api_key_env: None,
1139 openai_url: Some("https://api.together.xyz/v1".into()),
1140 };
1141 let b = cfg.to_backend_config();
1142 assert_eq!(b.provider, "openai");
1143 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
1144 assert_eq!(b.api_key_env, None); }
1146
1147 #[test]
1148 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
1149 let cfg = LlmConfig {
1150 provider: "ollama".into(),
1151 model: "qwen3:14b".into(),
1152 api_key_env: None,
1153 openai_url: Some("http://192.168.1.10:11434".into()),
1154 };
1155 let b = cfg.to_backend_config();
1156 assert_eq!(b.provider, "ollama");
1157 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1158 }
1159
1160 #[test]
1161 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
1162 let cfg = LlmConfig {
1166 provider: "custom-name".into(),
1167 model: "some-model".into(),
1168 api_key_env: Some("CUSTOM_KEY".into()),
1169 openai_url: Some("https://my-proxy.local/v1".into()),
1170 };
1171 let b = cfg.to_backend_config();
1172 assert_eq!(
1173 b.provider, "openai",
1174 "unknown provider + openai_url should alias to openai"
1175 );
1176 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
1177 }
1178}
1179
1180#[cfg(test)]
1181mod backend_config_tests {
1182 use super::*;
1183
1184 #[test]
1185 fn default_is_ollama_qwen3() {
1186 let cfg = BackendConfig::default();
1187 assert_eq!(cfg.provider, "ollama");
1188 assert_eq!(cfg.model, "qwen3.5:4b");
1189 assert_eq!(cfg.endpoint, None);
1190 assert_eq!(cfg.api_key_env, None);
1191 assert_eq!(cfg.timeout_secs, None);
1192 }
1193
1194 #[test]
1195 fn deserializes_anthropic_full() {
1196 let yaml = "\
1197provider: anthropic
1198model: claude-haiku-4-5
1199api_key_env: ANTHROPIC_API_KEY
1200timeout_secs: 60
1201";
1202 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1203 assert_eq!(cfg.provider, "anthropic");
1204 assert_eq!(cfg.model, "claude-haiku-4-5");
1205 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
1206 assert_eq!(cfg.timeout_secs, Some(60));
1207 assert_eq!(cfg.endpoint, None);
1208 }
1209
1210 #[test]
1211 fn deserializes_partial_fills_defaults() {
1212 let yaml = "provider: anthropic\nmodel: claude-sonnet-4-6\n";
1213 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
1214 assert_eq!(cfg.provider, "anthropic");
1215 assert_eq!(cfg.model, "claude-sonnet-4-6");
1216 assert_eq!(cfg.api_key_env, None);
1217 assert_eq!(cfg.timeout_secs, None);
1218 }
1219
1220 #[test]
1221 fn round_trips_through_yaml() {
1222 let original = BackendConfig {
1223 provider: "anthropic".into(),
1224 model: "claude-haiku-4-5".into(),
1225 endpoint: Some("https://api.anthropic.com".into()),
1226 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1227 timeout_secs: Some(60),
1228 };
1229 let yaml = serde_yaml::to_string(&original).unwrap();
1230 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
1231 assert_eq!(parsed, original);
1232 }
1233}
1234
1235#[derive(Debug, Clone, Serialize, Deserialize)]
1239#[serde(default)]
1240pub struct SkillsConfig {
1241 pub max_skills_in_prompt: usize,
1242 pub max_total_tokens: usize,
1243 pub priority_order: Vec<String>,
1244 pub adaptive: Option<AdaptiveSkillsConfig>,
1245}
1246
1247impl Default for SkillsConfig {
1248 fn default() -> Self {
1249 Self {
1250 max_skills_in_prompt: 5,
1251 max_total_tokens: 2000,
1252 priority_order: vec!["agent".into(), "global".into()],
1253 adaptive: Some(AdaptiveSkillsConfig::default()),
1254 }
1255 }
1256}
1257
1258#[derive(Debug, Clone, Serialize, Deserialize)]
1259#[serde(default)]
1260pub struct AdaptiveSkillsConfig {
1261 pub context_fill_decay: f64,
1262 pub min_remaining_context_ratio: f64,
1263 pub recent_fire_boost_turns: usize,
1264 pub model_max_context_tokens: u64,
1268}
1269
1270impl Default for AdaptiveSkillsConfig {
1271 fn default() -> Self {
1272 Self {
1273 context_fill_decay: 1.5,
1274 min_remaining_context_ratio: 0.20,
1275 recent_fire_boost_turns: 5,
1276 model_max_context_tokens: 200_000,
1277 }
1278 }
1279}
1280
1281#[derive(Debug, Clone, Serialize, Deserialize)]
1284pub struct SleepCycleConfig {
1285 #[serde(default)]
1287 pub enabled: bool,
1288
1289 #[serde(default = "default_idle_threshold_minutes")]
1291 pub idle_threshold_minutes: u64,
1292
1293 #[serde(default = "default_agent_idle_minutes")]
1295 pub agent_idle_minutes: u64,
1296}
1297
1298fn default_idle_threshold_minutes() -> u64 {
1299 15
1300}
1301
1302fn default_agent_idle_minutes() -> u64 {
1303 5
1304}
1305
1306impl Default for SleepCycleConfig {
1307 fn default() -> Self {
1308 Self {
1309 enabled: false,
1310 idle_threshold_minutes: default_idle_threshold_minutes(),
1311 agent_idle_minutes: default_agent_idle_minutes(),
1312 }
1313 }
1314}
1315
1316#[derive(Debug, Clone, Serialize, Deserialize)]
1319#[serde(default)]
1320pub struct CrossAgentConfig {
1321 #[serde(default = "default_half_life_days")]
1322 pub fitness_half_life_days: u32,
1323 #[serde(default = "default_fitness_floor")]
1324 pub fitness_floor: f64,
1325}
1326
1327fn default_half_life_days() -> u32 {
1328 7
1329}
1330fn default_fitness_floor() -> f64 {
1331 0.1
1332}
1333
1334impl Default for CrossAgentConfig {
1335 fn default() -> Self {
1336 Self {
1337 fitness_half_life_days: default_half_life_days(),
1338 fitness_floor: default_fitness_floor(),
1339 }
1340 }
1341}
1342
1343#[derive(Debug, Clone, Serialize, Deserialize)]
1346#[serde(default)]
1347pub struct SkillLlmConfig {
1348 #[serde(default = "default_per_call_token_cap")]
1350 pub per_call_token_cap: u32,
1351
1352 #[serde(default = "default_per_day_usd_cap")]
1354 pub per_day_usd_cap: f64,
1355
1356 #[serde(default = "default_cache_ttl_days")]
1358 pub cache_ttl_days: u32,
1359
1360 #[serde(default, skip_serializing_if = "Option::is_none")]
1362 pub model_ref: Option<String>,
1363}
1364
1365fn default_per_call_token_cap() -> u32 {
1366 1500
1367}
1368fn default_per_day_usd_cap() -> f64 {
1369 0.50
1370}
1371fn default_cache_ttl_days() -> u32 {
1372 30
1373}
1374
1375impl Default for SkillLlmConfig {
1376 fn default() -> Self {
1377 Self {
1378 per_call_token_cap: default_per_call_token_cap(),
1379 per_day_usd_cap: default_per_day_usd_cap(),
1380 cache_ttl_days: default_cache_ttl_days(),
1381 model_ref: None,
1382 }
1383 }
1384}
1385#[cfg(test)]
1386mod per_stage_backend_tests {
1387 use super::*;
1388
1389 #[test]
1390 fn legacy_compact_config_has_no_per_stage_overrides() {
1391 let yaml = "\
1392extractive_model: qwen3:14b
1393abstractive_model: qwen3:14b
1394ollama_endpoint: http://localhost:11434
1395";
1396 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1397 assert!(cfg.extractive_backend.is_none());
1398 assert!(cfg.abstractive_backend.is_none());
1399 assert_eq!(cfg.extractive_model, "qwen3:14b");
1400 assert_eq!(cfg.abstractive_model, "qwen3:14b");
1401 assert_eq!(cfg.ollama_endpoint, "http://localhost:11434");
1402 }
1403
1404 #[test]
1405 fn legacy_ask_config_has_no_per_stage_overrides() {
1406 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1407 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1408 assert!(cfg.backend.is_none());
1409 assert!(cfg.rewriter_backend.is_none());
1410 assert_eq!(cfg.model, "qwen3:14b");
1411 }
1412
1413 #[test]
1414 fn compact_extractive_backend_override_parses() {
1415 let yaml = "\
1416extractive_backend:
1417 provider: anthropic
1418 model: claude-haiku-4-5
1419 api_key_env: ANTHROPIC_API_KEY
1420abstractive_model: qwen3:14b
1421";
1422 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1423 let extractive = cfg
1424 .extractive_backend
1425 .as_ref()
1426 .expect("override should parse");
1427 assert_eq!(extractive.provider, "anthropic");
1428 assert_eq!(extractive.model, "claude-haiku-4-5");
1429 assert!(cfg.abstractive_backend.is_none());
1430 }
1431
1432 #[test]
1433 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
1434 let yaml = "\
1435backend:
1436 provider: anthropic
1437 model: claude-sonnet-4-6
1438 api_key_env: ANTHROPIC_API_KEY
1439rewriter_backend:
1440 provider: ollama
1441 model: llama3.2:3b
1442";
1443 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1444 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
1445 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
1446 }
1447
1448 #[test]
1449 fn synthesize_legacy_to_backend_config_for_compact_extractive() {
1450 let yaml = "\
1451extractive_model: qwen3:14b
1452ollama_endpoint: http://192.168.1.10:11434
1453";
1454 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
1455 let synth = cfg.synthesize_extractive_backend();
1456 assert_eq!(synth.provider, "ollama");
1457 assert_eq!(synth.model, "qwen3:14b");
1458 assert_eq!(synth.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
1459 assert_eq!(synth.api_key_env, None);
1460 }
1461
1462 #[test]
1463 fn synthesize_legacy_to_backend_config_for_ask() {
1464 let yaml = "model: qwen3:14b\nollama_endpoint: http://localhost:11434\n";
1465 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1466 let synth = cfg.synthesize_backend();
1467 assert_eq!(synth.provider, "ollama");
1468 assert_eq!(synth.model, "qwen3:14b");
1469 assert_eq!(synth.endpoint.as_deref(), Some("http://localhost:11434"));
1470 }
1471
1472 #[test]
1473 fn synthesize_rewriter_uses_legacy_ollama_when_no_rewriter_override() {
1474 let yaml = "\
1483backend:
1484 provider: anthropic
1485 model: claude-sonnet-4-6
1486 api_key_env: ANTHROPIC_API_KEY
1487";
1488 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
1489 let rewriter = cfg.synthesize_rewriter_backend();
1490 assert_eq!(rewriter.provider, "ollama");
1491 assert_eq!(rewriter.model, ask_default_model());
1492 assert_eq!(
1493 rewriter.timeout_secs,
1494 Some(ask_default_rewriter_timeout() as u64)
1495 );
1496 }
1497
1498 #[test]
1499 fn ask_synthesize_backend_inherits_timeout_secs_from_legacy_field() {
1500 let cfg = AskConfig {
1501 timeout_secs: 45,
1502 ..AskConfig::default()
1503 };
1504 let b = cfg.synthesize_backend();
1505 assert_eq!(
1506 b.timeout_secs,
1507 Some(45),
1508 "synthesize_backend() must propagate ask.timeout_secs into the synthesized BackendConfig"
1509 );
1510 }
1511
1512 #[test]
1513 fn ask_synthesize_backend_does_not_override_explicit_per_stage_timeout() {
1514 let mut cfg = AskConfig {
1515 timeout_secs: 45,
1516 ..AskConfig::default()
1517 };
1518 cfg.backend = Some(BackendConfig {
1519 provider: "anthropic".into(),
1520 model: "claude-haiku-4-5".into(),
1521 endpoint: None,
1522 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1523 timeout_secs: Some(10),
1524 });
1525 let b = cfg.synthesize_backend();
1526 assert_eq!(
1527 b.timeout_secs,
1528 Some(10),
1529 "explicit per-stage timeout_secs must NOT be overridden by ask.timeout_secs"
1530 );
1531 }
1532
1533 #[test]
1534 fn ask_synthesize_rewriter_backend_uses_rewriter_timeout_secs_when_synthesizing() {
1535 let cfg = AskConfig {
1536 timeout_secs: 120,
1537 rewriter_timeout_secs: 8,
1538 ..AskConfig::default()
1539 };
1540 let b = cfg.synthesize_rewriter_backend();
1541 assert_eq!(
1542 b.timeout_secs,
1543 Some(8),
1544 "rewriter synthesis must use rewriter_timeout_secs (not the answer-call timeout)"
1545 );
1546 }
1547
1548 #[test]
1549 fn ask_synthesize_rewriter_backend_does_not_override_explicit_per_stage_timeout() {
1550 let mut cfg = AskConfig {
1551 rewriter_timeout_secs: 8,
1552 ..AskConfig::default()
1553 };
1554 cfg.rewriter_backend = Some(BackendConfig {
1555 provider: "anthropic".into(),
1556 model: "claude-haiku-4-5".into(),
1557 endpoint: None,
1558 api_key_env: Some("ANTHROPIC_API_KEY".into()),
1559 timeout_secs: Some(30),
1560 });
1561 let b = cfg.synthesize_rewriter_backend();
1562 assert_eq!(
1563 b.timeout_secs,
1564 Some(30),
1565 "explicit per-stage rewriter timeout_secs must NOT be overridden by ask.rewriter_timeout_secs"
1566 );
1567 }
1568
1569 #[test]
1570 fn compact_synthesize_extractive_backend_inherits_default_timeout_when_no_override() {
1571 let cfg = CompactConfig::default();
1574 let b = cfg.synthesize_extractive_backend();
1575 assert_eq!(
1576 b.timeout_secs,
1577 Some(120),
1578 "compact synthesis without per-stage override must produce 120s timeout"
1579 );
1580 }
1581
1582 #[test]
1583 fn compact_synthesize_abstractive_backend_inherits_default_timeout_when_no_override() {
1584 let cfg = CompactConfig::default();
1585 let b = cfg.synthesize_abstractive_backend();
1586 assert_eq!(b.timeout_secs, Some(120));
1587 }
1588}
1589
1590#[cfg(test)]
1591mod skills_config_tests {
1592 use super::*;
1593
1594 #[test]
1595 fn empty_yaml_hydrates_defaults() {
1596 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
1597 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1598 assert_eq!(cfg.skills.max_total_tokens, 2000);
1599 assert!(cfg.skills.adaptive.is_some());
1600 }
1601
1602 #[test]
1603 fn load_or_default_missing_file_returns_default() {
1604 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
1605 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
1606 }
1607}