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
12pub const DEFAULT_MAX_RETRIES: u32 = 1;
13pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
14pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
15pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
16pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
17
18pub const DEFAULT_SMART_ENABLED: bool = false;
22
23pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
28
29fn default_max_retries() -> u32 {
30 DEFAULT_MAX_RETRIES
31}
32fn default_backoff_base_ms() -> u64 {
33 DEFAULT_BACKOFF_BASE_MS
34}
35fn default_cooldown_secs() -> u64 {
36 DEFAULT_COOLDOWN_SECS
37}
38fn default_smart_max_escalations() -> u32 {
39 DEFAULT_SMART_MAX_ESCALATIONS
40}
41fn default_smart_enabled() -> bool {
42 DEFAULT_SMART_ENABLED
43}
44
45#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
51pub struct SmartConfig {
52 #[serde(default = "default_smart_enabled")]
53 pub enabled: bool,
54 #[serde(default, skip_serializing_if = "Option::is_none")]
55 pub cheap: Option<String>,
56 #[serde(default = "default_smart_max_escalations")]
57 pub max_escalations: u32,
58}
59
60impl Default for SmartConfig {
61 fn default() -> Self {
62 Self {
63 enabled: DEFAULT_SMART_ENABLED,
64 cheap: None,
65 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
66 }
67 }
68}
69
70#[derive(Debug, Clone, Serialize, Deserialize, Default)]
73pub struct ModelSwitchConfig {
74 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub default: Option<String>,
77 #[serde(default, skip_serializing_if = "Vec::is_empty")]
79 pub fallback_chain: Vec<String>,
80 #[serde(default)]
81 pub retry: RetryConfig,
82 #[serde(default)]
83 pub routing: RoutingConfig,
84 #[serde(default)]
85 pub smart: SmartConfig,
86}
87
88#[derive(Debug, Clone, Serialize, Deserialize)]
89pub struct RetryConfig {
90 #[serde(default = "default_max_retries")]
91 pub max_retries: u32,
92 #[serde(default = "default_backoff_base_ms")]
93 pub backoff_base_ms: u64,
94 #[serde(default = "default_cooldown_secs")]
95 pub cooldown_secs: u64,
96}
97
98impl Default for RetryConfig {
99 fn default() -> Self {
100 Self {
101 max_retries: DEFAULT_MAX_RETRIES,
102 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
103 cooldown_secs: DEFAULT_COOLDOWN_SECS,
104 }
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
109pub struct RoutingConfig {
110 #[serde(default)]
111 pub enabled: bool,
112 #[serde(default, skip_serializing_if = "Option::is_none")]
113 pub cheap: Option<String>,
114 #[serde(default, skip_serializing_if = "Option::is_none")]
115 pub frontier: Option<String>,
116 #[serde(default, skip_serializing_if = "Option::is_none")]
117 pub threshold_input_tokens: Option<u32>,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
125pub struct SmartOverride {
126 #[serde(default, skip_serializing_if = "Option::is_none")]
127 pub enabled: Option<bool>,
128 #[serde(default, skip_serializing_if = "Option::is_none")]
129 pub cheap: Option<String>,
130 #[serde(default, skip_serializing_if = "Option::is_none")]
131 pub max_escalations: Option<u32>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
137pub struct RoutingOverride {
138 #[serde(default, skip_serializing_if = "Option::is_none")]
139 pub enabled: Option<bool>,
140 #[serde(default, skip_serializing_if = "Option::is_none")]
141 pub cheap: Option<String>,
142 #[serde(default, skip_serializing_if = "Option::is_none")]
143 pub frontier: Option<String>,
144 #[serde(default, skip_serializing_if = "Option::is_none")]
145 pub threshold_input_tokens: Option<u32>,
146 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub smart: Option<SmartOverride>,
152}
153
154impl SmartConfig {
155 pub fn merged(&self, ov: Option<&SmartOverride>) -> SmartConfig {
157 let Some(o) = ov else { return self.clone() };
158 SmartConfig {
159 enabled: o.enabled.unwrap_or(self.enabled),
160 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
161 max_escalations: o.max_escalations.unwrap_or(self.max_escalations),
162 }
163 }
164}
165
166impl RoutingConfig {
167 pub fn merged(&self, ov: Option<&RoutingOverride>) -> RoutingConfig {
169 let Some(o) = ov else { return self.clone() };
170 RoutingConfig {
171 enabled: o.enabled.unwrap_or(self.enabled),
172 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
173 frontier: o.frontier.clone().or_else(|| self.frontier.clone()),
174 threshold_input_tokens: o.threshold_input_tokens.or(self.threshold_input_tokens),
175 }
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize, Default)]
181pub struct Config {
182 #[serde(default)]
183 pub embedding: EmbeddingConfig,
184
185 #[serde(default)]
186 pub llm: LlmConfig,
187
188 #[serde(default)]
189 pub models: ModelSwitchConfig,
190
191 #[serde(default)]
192 pub retrieval: RetrievalConfig,
193
194 #[serde(default)]
195 pub paths: PathConfig,
196
197 #[serde(default)]
198 pub server: ServerConfig,
199
200 #[serde(default)]
201 pub community: CommunityConfig,
202
203 #[serde(default)]
204 pub conversations: ConversationsConfig,
205
206 #[serde(default)]
207 pub sync: SyncConfig,
208
209 #[serde(default)]
211 pub storage: StorageConfig,
212
213 #[serde(default)]
214 pub sources_global: SourcesGlobalConfig,
215
216 #[serde(default)]
218 pub sleep_cycle: SleepCycleConfig,
219
220 #[serde(default)]
222 pub skills: SkillsConfig,
223
224 #[serde(default)]
226 pub skill_llm: SkillLlmConfig,
227
228 #[serde(default)]
230 pub cross_agent: CrossAgentConfig,
231
232 #[serde(default)]
234 pub nudge: NudgeConfig,
235
236 #[serde(default)]
238 pub mobile_relay: MobileRelayConfig,
239
240 #[serde(default)]
242 pub session: SessionCfg,
243
244 #[serde(default)]
245 pub harvest: HarvestCfg,
246
247 #[serde(default)]
249 pub cc_proxy: CcProxyConfig,
250
251 #[serde(default)]
253 pub cli: CliConfig,
254
255 #[serde(default)]
257 pub parallel_jobs: ParallelJobsConfig,
258
259 #[serde(default)]
261 pub federation_snapshot: SnapshotConfig,
262
263 #[serde(default)]
265 pub memory: MemoryConfig,
266
267 #[serde(default)]
269 pub fleet_run: FleetRunConfig,
270
271 #[serde(default)]
273 pub open_items: OpenItemsConfig,
274
275 #[serde(default)]
277 pub fleet: FleetConfig,
278
279 #[serde(default)]
281 pub update: UpdateConfig,
282
283 #[serde(default)]
285 pub runs: RunsConfig,
286
287 #[serde(default)]
289 pub capture: CaptureConfig,
290
291 #[serde(default)]
295 pub limits: crate::limits::Limits,
296
297 #[serde(default)]
300 pub notifications: NotificationsConfig,
301
302 #[serde(default)]
305 pub monitor_resolver: MonitorResolverConfig,
306
307 #[serde(default)]
310 pub triage: TriageConfig,
311
312 #[serde(default)]
314 pub attribution: AttributionConfig,
315}
316
317#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
339#[serde(default)]
340pub struct AttributionConfig {
341 pub pr: Option<String>,
343 #[serde(skip_serializing_if = "Option::is_none")]
346 pub commit: Option<String>,
347}
348
349pub const DEFAULT_PR_ATTRIBUTION: &str = "Generated with [MUR](https://app.mur.run/products/mur)";
351
352impl Default for AttributionConfig {
353 fn default() -> Self {
354 Self {
355 pr: Some(DEFAULT_PR_ATTRIBUTION.to_string()),
356 commit: None,
357 }
358 }
359}
360
361fn signature(v: &Option<String>) -> Option<&str> {
364 v.as_deref().map(str::trim).filter(|s| !s.is_empty())
365}
366
367impl AttributionConfig {
368 pub fn pr_signature(&self) -> Option<&str> {
370 signature(&self.pr)
371 }
372
373 pub fn commit_signature(&self) -> Option<&str> {
375 signature(&self.commit)
376 }
377
378 pub fn prompt_fragment(&self) -> Option<String> {
385 let (pr, commit) = (self.pr_signature(), self.commit_signature());
386 if pr.is_none() && commit.is_none() {
387 return None;
388 }
389 let mut s = String::from(
390 "\n\n## MUR attribution\n\
391 When you publish work on the user's behalf, sign it with the exact text below — \
392 copy it verbatim, do not reword it, and add it once.\n",
393 );
394 if let Some(pr) = pr {
395 s.push_str(&format!(
396 "- Opening a pull request (`gh pr create`, or any PR/MR body you author): \
397 end the body with its own line reading:\n {pr}\n"
398 ));
399 }
400 if let Some(commit) = commit {
401 s.push_str(&format!(
402 "- Making a commit (`git commit`): add this as a trailer in the last paragraph \
403 of the message, after a blank line:\n {commit}\n"
404 ));
405 }
406 s.push_str(
407 "Never add a signature the user did not configure, and never sign a surface that is \
408 not listed here.",
409 );
410 Some(s)
411 }
412}
413
414#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
425pub struct TriageConfig {
426 #[serde(default)]
429 pub enabled: bool,
430 #[serde(default)]
433 pub enforce: bool,
434}
435
436impl TriageConfig {
437 pub fn enforces(&self) -> bool {
442 self.enabled && self.enforce
443 }
444}
445
446#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
454pub struct CaptureConfig {
455 #[serde(default = "default_rotate_at_mb")]
458 pub rotate_at_mb: u64,
459 #[serde(default = "default_keep_generations")]
462 pub keep_generations: u32,
463}
464
465fn default_rotate_at_mb() -> u64 {
466 64
467}
468
469fn default_keep_generations() -> u32 {
470 5
471}
472
473impl Default for CaptureConfig {
474 fn default() -> Self {
475 Self {
476 rotate_at_mb: default_rotate_at_mb(),
477 keep_generations: default_keep_generations(),
478 }
479 }
480}
481
482#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
485pub struct UpdateConfig {
486 #[serde(default, skip_serializing_if = "Option::is_none")]
492 pub codesign_identity: Option<String>,
493 #[serde(default, skip_serializing_if = "Vec::is_empty")]
495 pub restart_exclude: Vec<String>,
496}
497
498#[derive(Debug, Clone, Default, Serialize, Deserialize)]
502pub struct NotificationsConfig {
503 #[serde(default)]
506 pub desktop: bool,
507}
508
509#[derive(Debug, Clone, Default, Serialize, Deserialize)]
523pub struct MonitorResolverConfig {
524 #[serde(default)]
527 pub enabled: bool,
528
529 #[serde(default)]
532 pub model: Option<String>,
533}
534
535#[derive(Debug, Clone, Serialize, Deserialize)]
538pub struct RunsConfig {
539 #[serde(default = "default_heartbeat_interval_secs")]
541 pub heartbeat_interval_secs: u64,
542 #[serde(default = "default_heartbeat_stale_after_intervals")]
546 pub heartbeat_stale_after_intervals: u32,
547}
548
549fn default_heartbeat_interval_secs() -> u64 {
550 10
551}
552
553fn default_heartbeat_stale_after_intervals() -> u32 {
554 3
555}
556
557impl Default for RunsConfig {
558 fn default() -> Self {
559 Self {
560 heartbeat_interval_secs: default_heartbeat_interval_secs(),
561 heartbeat_stale_after_intervals: default_heartbeat_stale_after_intervals(),
562 }
563 }
564}
565
566#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
572pub struct ParallelJobsConfig {
573 #[serde(default)]
576 pub targets: Vec<String>,
577}
578
579#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
585#[serde(default)]
586pub struct SnapshotConfig {
587 pub poll_secs: u64,
589 pub request_max_age_secs: u64,
591 pub min_lifecycle: crate::skill::stats::LifecycleState,
593}
594
595impl Default for SnapshotConfig {
596 fn default() -> Self {
597 Self {
598 poll_secs: 30,
599 request_max_age_secs: 600,
600 min_lifecycle: crate::skill::stats::LifecycleState::Stable,
601 }
602 }
603}
604
605#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
609#[serde(default)]
610pub struct MemoryConfig {
611 pub capture: CaptureMode,
612 #[serde(default = "default_memory_in_prompt")]
617 pub max_in_prompt: usize,
618 #[serde(default = "default_memory_chars")]
620 pub max_chars: usize,
621}
622
623fn default_memory_in_prompt() -> usize {
624 20
625}
626
627fn default_memory_chars() -> usize {
628 1500
629}
630
631impl Default for MemoryConfig {
632 fn default() -> Self {
633 Self {
634 capture: CaptureMode::AutoAnnounce,
635 max_in_prompt: default_memory_in_prompt(),
636 max_chars: default_memory_chars(),
637 }
638 }
639}
640
641#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
643#[serde(rename_all = "snake_case")]
644pub enum CaptureMode {
645 Ask,
647 AutoAnnounce,
649 Off,
651}
652
653#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
661pub struct FleetRunConfig {
662 #[serde(default)]
664 pub agents: Vec<String>,
665 #[serde(default)]
667 pub fleets: Vec<String>,
668}
669
670#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
677pub struct OpenItemsConfig {
678 #[serde(default)]
681 pub muted: Vec<String>,
682}
683
684#[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq)]
691pub struct FleetConfig {
692 #[serde(default)]
694 pub autorun: bool,
695}
696
697#[cfg(test)]
698mod fleet_config_tests {
699 use super::*;
700
701 #[test]
702 fn fleet_config_defaults_off_and_roundtrips() {
703 assert!(!FleetConfig::default().autorun);
704
705 let cfg: Config = serde_yaml_ng::from_str("fleet:\n autorun: true\n").unwrap();
706 assert!(cfg.fleet.autorun);
707
708 let cfg2: Config = serde_yaml_ng::from_str("{}").unwrap();
710 assert!(!cfg2.fleet.autorun);
711 }
712
713 #[test]
714 fn fleet_run_config_defaults_deny_all_and_roundtrips() {
715 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
717 assert!(cfg.fleet_run.agents.is_empty());
718 assert!(cfg.fleet_run.fleets.is_empty());
719
720 let cfg2: Config =
721 serde_yaml_ng::from_str("fleet_run:\n agents: [mur]\n fleets: [deep-research]\n")
722 .unwrap();
723 assert_eq!(cfg2.fleet_run.agents, vec!["mur"]);
724 assert_eq!(cfg2.fleet_run.fleets, vec!["deep-research"]);
725 }
726}
727
728#[derive(Debug, Clone, Serialize, Deserialize)]
739pub struct CcProxyConfig {
740 #[serde(default = "default_cc_proxy_url")]
742 pub url: String,
743
744 #[serde(default = "default_true")]
747 pub enabled: bool,
748}
749
750fn default_cc_proxy_url() -> String {
751 "http://127.0.0.1:8088".to_string()
752}
753
754fn default_true() -> bool {
755 true
756}
757
758impl Default for CcProxyConfig {
759 fn default() -> Self {
760 Self {
761 url: default_cc_proxy_url(),
762 enabled: true,
763 }
764 }
765}
766
767#[derive(Debug, Clone, Serialize, Deserialize, Default)]
770pub struct CliConfig {
771 pub skin: Option<String>,
774
775 #[serde(default, skip_serializing_if = "std::ops::Not::not")]
783 pub seen_permanent_instructions_notice: bool,
784}
785
786#[derive(Debug, Clone, Serialize, Deserialize, Default)]
789pub struct MobileRelayConfig {
790 #[serde(default, skip_serializing_if = "Option::is_none")]
793 pub relay_url: Option<String>,
794
795 #[serde(default, skip_serializing_if = "Option::is_none")]
798 pub api_key: Option<String>,
799}
800
801impl Config {
802 pub fn load_or_default(path: &std::path::Path) -> Self {
807 let Ok(text) = std::fs::read_to_string(path) else {
808 return Self::default();
809 };
810 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
811 let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
812 cfg.sanitize();
813 cfg
814 }
815
816 fn sanitize(&mut self) {
821 if self.runs.heartbeat_interval_secs == 0 {
825 self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
826 }
827 if self.capture.rotate_at_mb == 0 {
829 self.capture.rotate_at_mb = default_rotate_at_mb();
830 }
831 if self.runs.heartbeat_stale_after_intervals == 0 {
832 self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
833 }
834 }
835}
836
837#[derive(Debug, Clone, Serialize, Deserialize, Default)]
838pub struct SyncConfig {
839 #[serde(default = "default_sync_method")]
841 pub method: String,
842
843 #[serde(default, skip_serializing_if = "Option::is_none")]
845 pub git_remote: Option<String>,
846
847 #[serde(default)]
849 pub auto: bool,
850
851 #[serde(default, skip_serializing_if = "Option::is_none")]
853 pub team_id: Option<String>,
854}
855
856fn default_sync_method() -> String {
857 "local".to_string()
858}
859
860#[derive(Debug, Clone, Serialize, Deserialize)]
861pub struct ServerConfig {
862 #[serde(default = "default_server_url")]
864 pub url: String,
865}
866
867impl Default for ServerConfig {
868 fn default() -> Self {
869 Self {
870 url: default_server_url(),
871 }
872 }
873}
874
875#[derive(Debug, Clone, Serialize, Deserialize, Default)]
876pub struct CommunityConfig {
877 #[serde(default)]
879 pub enabled: bool,
880}
881
882#[derive(Debug, Clone, Serialize, Deserialize)]
883pub struct EmbeddingConfig {
884 #[serde(default = "default_embedding_provider")]
886 pub provider: String,
887
888 #[serde(default = "default_embedding_model")]
890 pub model: String,
891
892 #[serde(default = "default_dimensions")]
894 pub dimensions: usize,
895
896 #[serde(default, skip_serializing_if = "Option::is_none")]
900 pub ollama_endpoint: Option<String>,
901
902 #[serde(default, skip_serializing_if = "Option::is_none")]
904 pub api_key_env: Option<String>,
905
906 #[serde(default, skip_serializing_if = "Option::is_none")]
909 pub api_key_ref: Option<String>,
910
911 #[serde(default, skip_serializing_if = "Option::is_none")]
913 pub openai_url: Option<String>,
914}
915
916impl Default for EmbeddingConfig {
917 fn default() -> Self {
918 Self {
919 provider: default_embedding_provider(),
920 model: default_embedding_model(),
921 dimensions: default_dimensions(),
922 ollama_endpoint: Some(default_ollama_endpoint()),
923 api_key_env: None,
924 api_key_ref: None,
925 openai_url: None,
926 }
927 }
928}
929
930#[derive(Debug, Clone, Serialize, Deserialize)]
931pub struct LlmConfig {
932 #[serde(default = "default_llm_provider")]
934 pub provider: String,
935
936 #[serde(default = "default_llm_model")]
937 pub model: String,
938
939 #[serde(default, skip_serializing_if = "Option::is_none")]
941 pub api_key_env: Option<String>,
942
943 #[serde(default, skip_serializing_if = "Option::is_none")]
946 pub api_key_ref: Option<String>,
947
948 #[serde(default, skip_serializing_if = "Option::is_none")]
950 pub openai_url: Option<String>,
951}
952
953impl Default for LlmConfig {
954 fn default() -> Self {
955 Self {
956 provider: default_llm_provider(),
957 model: default_llm_model(),
958 api_key_env: Some("ANTHROPIC_API_KEY".to_string()),
959 api_key_ref: None,
960 openai_url: None,
961 }
962 }
963}
964
965impl LlmConfig {
966 pub fn to_backend_config(&self) -> BackendConfig {
979 let provider = match self.provider.as_str() {
980 "anthropic" | "openai" | "openrouter" | "gemini" | "ollama" => self.provider.clone(),
981 _ if self.openai_url.is_some() => "openai".into(),
982 other => other.into(), };
984 BackendConfig {
985 provider,
986 model: self.model.clone(),
987 endpoint: self.openai_url.clone(),
988 api_key_env: self.api_key_env.clone(),
989 api_key_ref: self.api_key_ref.clone(),
990 timeout_secs: None,
991 }
992 }
993}
994
995#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
1006#[serde(default)]
1007pub struct BackendConfig {
1008 pub provider: String,
1010 pub model: String,
1012 pub endpoint: Option<String>,
1015 pub api_key_env: Option<String>,
1017 pub api_key_ref: Option<String>,
1019 pub timeout_secs: Option<u64>,
1021}
1022
1023impl Default for BackendConfig {
1024 fn default() -> Self {
1025 Self {
1026 provider: "ollama".into(),
1027 model: DEFAULT_LOCAL_LLM_MODEL.into(),
1028 endpoint: None,
1029 api_key_env: None,
1030 api_key_ref: None,
1031 timeout_secs: None,
1032 }
1033 }
1034}
1035
1036#[derive(Debug, Clone, Serialize, Deserialize)]
1037pub struct RetrievalConfig {
1038 #[serde(default = "default_max_patterns")]
1040 pub max_patterns: usize,
1041
1042 #[serde(default = "default_max_tokens")]
1044 pub max_tokens: usize,
1045
1046 #[serde(default = "default_min_score")]
1048 pub min_score: f64,
1049
1050 #[serde(default = "default_mmr_threshold")]
1052 pub mmr_threshold: f64,
1053
1054 #[serde(default = "default_reserved_note_slots")]
1057 pub reserved_note_slots: usize,
1058}
1059
1060impl Default for RetrievalConfig {
1061 fn default() -> Self {
1062 Self {
1063 max_patterns: default_max_patterns(),
1064 max_tokens: default_max_tokens(),
1065 min_score: default_min_score(),
1066 mmr_threshold: default_mmr_threshold(),
1067 reserved_note_slots: default_reserved_note_slots(),
1068 }
1069 }
1070}
1071
1072fn default_reserved_note_slots() -> usize {
1073 1
1074}
1075
1076#[derive(Debug, Clone, Serialize, Deserialize)]
1077pub struct PathConfig {
1078 #[serde(default = "default_mur_dir")]
1080 pub mur_dir: PathBuf,
1081}
1082
1083impl Default for PathConfig {
1084 fn default() -> Self {
1085 Self {
1086 mur_dir: default_mur_dir(),
1087 }
1088 }
1089}
1090
1091#[derive(Debug, Clone, Serialize, Deserialize)]
1092pub struct StorageConfig {
1093 #[serde(default = "default_vector_backend")]
1095 pub vector_backend: String,
1096
1097 #[serde(default, skip_serializing_if = "Option::is_none")]
1099 pub qdrant_url: Option<String>,
1100
1101 #[serde(default, skip_serializing_if = "Option::is_none")]
1103 pub qdrant_api_key_ref: Option<String>,
1104}
1105
1106impl Default for StorageConfig {
1107 fn default() -> Self {
1108 Self {
1109 vector_backend: default_vector_backend(),
1110 qdrant_url: None,
1111 qdrant_api_key_ref: None,
1112 }
1113 }
1114}
1115
1116fn default_vector_backend() -> String {
1117 "lancedb".to_string()
1118}
1119
1120#[derive(Debug, Clone, Serialize, Deserialize)]
1121pub struct SourcesGlobalConfig {
1122 #[serde(default = "default_poll_interval_secs")]
1124 pub poll_interval_secs: u64,
1125
1126 #[serde(default = "default_max_chunks_per_sync")]
1128 pub max_chunks_per_sync: usize,
1129
1130 #[serde(default = "default_max_parallel_sources")]
1132 pub max_parallel_sources: usize,
1133
1134 #[serde(default = "default_source_weight")]
1136 pub default_weight: f32,
1137
1138 #[serde(default = "default_embedding_batch_size")]
1140 pub embedding_batch_size: usize,
1141}
1142
1143impl Default for SourcesGlobalConfig {
1144 fn default() -> Self {
1145 Self {
1146 poll_interval_secs: default_poll_interval_secs(),
1147 max_chunks_per_sync: default_max_chunks_per_sync(),
1148 max_parallel_sources: default_max_parallel_sources(),
1149 default_weight: default_source_weight(),
1150 embedding_batch_size: default_embedding_batch_size(),
1151 }
1152 }
1153}
1154
1155fn default_poll_interval_secs() -> u64 {
1156 600
1157}
1158fn default_max_chunks_per_sync() -> usize {
1159 10_000
1160}
1161fn default_max_parallel_sources() -> usize {
1162 3
1163}
1164fn default_source_weight() -> f32 {
1165 1.0
1166}
1167fn default_embedding_batch_size() -> usize {
1168 32
1169}
1170
1171fn default_embedding_provider() -> String {
1172 "ollama".to_string()
1173}
1174fn default_embedding_model() -> String {
1175 "qwen3-embedding:0.6b".to_string()
1176}
1177fn default_dimensions() -> usize {
1178 1024
1179}
1180fn default_ollama_endpoint() -> String {
1181 DEFAULT_OLLAMA_ENDPOINT.to_string()
1182}
1183fn default_llm_provider() -> String {
1184 "anthropic".to_string()
1185}
1186fn default_llm_model() -> String {
1187 "claude-opus-5".to_string()
1188}
1189fn default_max_patterns() -> usize {
1190 5
1191}
1192fn default_max_tokens() -> usize {
1193 2000
1194}
1195fn default_min_score() -> f64 {
1196 0.35
1197}
1198fn default_mmr_threshold() -> f64 {
1199 0.85
1200}
1201fn default_mur_dir() -> PathBuf {
1202 let home = std::env::var("HOME")
1205 .map(PathBuf::from)
1206 .unwrap_or_else(|_| PathBuf::from("/tmp"));
1207 home.join(".mur")
1208}
1209fn default_server_url() -> String {
1210 "https://mur-server.fly.dev".to_string()
1211}
1212
1213#[derive(Debug, Clone, Serialize, Deserialize)]
1216pub struct AskConfig {
1217 #[serde(default = "ask_default_k_summary")]
1218 pub k_summary: u32,
1219 #[serde(default = "ask_default_k_raw")]
1220 pub k_raw: u32,
1221 #[serde(default = "ask_default_esc")]
1222 pub escalation_threshold: f64,
1223 #[serde(default = "ask_default_mmr")]
1224 pub mmr_threshold: f64,
1225 #[serde(default = "ask_default_max_ctx")]
1226 pub max_context_tokens: u32,
1227 #[serde(default = "ask_default_resp_tok")]
1228 pub response_tokens: u32,
1229 #[serde(default = "ask_default_timeout")]
1230 pub timeout_secs: u32,
1231 #[serde(default = "ask_default_min_score")]
1232 pub min_score: f64,
1233 #[serde(default = "ask_default_continue_history_turns")]
1234 pub continue_history_turns: u32,
1235 #[serde(default = "ask_default_rewriter_timeout")]
1241 pub rewriter_timeout_secs: u32,
1242 #[serde(default = "ask_default_compress_hits_enabled")]
1243 pub compress_hits_enabled: bool,
1244 #[serde(default = "ask_default_summarize_hits_enabled")]
1245 pub summarize_hits_enabled: bool,
1246 #[serde(default)]
1247 pub summarize_model: Option<String>,
1248 #[serde(default)]
1251 pub backend: Option<BackendConfig>,
1252 #[serde(default)]
1256 pub rewriter_backend: Option<BackendConfig>,
1257}
1258
1259impl AskConfig {
1260 pub fn effective_backend(&self, llm: &LlmConfig) -> BackendConfig {
1265 self.backend.clone().unwrap_or_else(|| BackendConfig {
1266 timeout_secs: Some(self.timeout_secs as u64),
1267 ..llm.to_backend_config()
1268 })
1269 }
1270
1271 pub fn effective_rewriter_backend(&self, llm: &LlmConfig) -> BackendConfig {
1281 self.rewriter_backend
1282 .clone()
1283 .unwrap_or_else(|| BackendConfig {
1284 timeout_secs: Some(self.rewriter_timeout_secs as u64),
1285 ..self
1286 .backend
1287 .clone()
1288 .unwrap_or_else(|| llm.to_backend_config())
1289 })
1290 }
1291}
1292
1293impl Default for AskConfig {
1294 fn default() -> Self {
1295 Self {
1296 k_summary: ask_default_k_summary(),
1297 k_raw: ask_default_k_raw(),
1298 escalation_threshold: ask_default_esc(),
1299 mmr_threshold: ask_default_mmr(),
1300 max_context_tokens: ask_default_max_ctx(),
1301 response_tokens: ask_default_resp_tok(),
1302 timeout_secs: ask_default_timeout(),
1303 min_score: ask_default_min_score(),
1304 continue_history_turns: ask_default_continue_history_turns(),
1305 rewriter_timeout_secs: ask_default_rewriter_timeout(),
1306 compress_hits_enabled: ask_default_compress_hits_enabled(),
1307 summarize_hits_enabled: ask_default_summarize_hits_enabled(),
1308 summarize_model: None,
1309 backend: None,
1310 rewriter_backend: None,
1311 }
1312 }
1313}
1314
1315fn ask_default_k_summary() -> u32 {
1316 5
1317}
1318fn ask_default_k_raw() -> u32 {
1319 10
1320}
1321fn ask_default_esc() -> f64 {
1322 0.5
1323}
1324fn ask_default_mmr() -> f64 {
1325 0.88
1326}
1327fn ask_default_max_ctx() -> u32 {
1328 6000
1329}
1330fn ask_default_resp_tok() -> u32 {
1331 1024
1332}
1333fn ask_default_timeout() -> u32 {
1334 120
1335}
1336fn ask_default_min_score() -> f64 {
1337 0.35
1338}
1339fn ask_default_rewriter_timeout() -> u32 {
1340 8
1341}
1342fn ask_default_continue_history_turns() -> u32 {
1343 3
1344}
1345fn ask_default_compress_hits_enabled() -> bool {
1346 true
1347}
1348fn ask_default_summarize_hits_enabled() -> bool {
1349 true
1350}
1351
1352#[derive(Debug, Clone, Serialize, Deserialize)]
1361pub struct ConversationsConfig {
1362 #[serde(default)]
1363 pub enabled: bool,
1364 #[serde(default = "conv_default_retention_days")]
1365 pub retention_days: u32,
1366 #[serde(default = "conv_default_poll_interval")]
1367 pub poll_interval_secs: u64,
1368 #[serde(default)]
1369 pub sources: ConversationsSources,
1370 #[serde(default)]
1371 pub filter: ConversationsFilter,
1372 #[serde(default)]
1373 pub compact: CompactConfig,
1374 #[serde(default)]
1375 pub ask: AskConfig,
1376 #[serde(default)]
1377 pub rollup: RollupConfig,
1378}
1379
1380impl Default for ConversationsConfig {
1381 fn default() -> Self {
1382 Self {
1383 enabled: false,
1384 retention_days: conv_default_retention_days(),
1385 poll_interval_secs: conv_default_poll_interval(),
1386 sources: ConversationsSources::default(),
1387 filter: ConversationsFilter::default(),
1388 compact: CompactConfig::default(),
1389 ask: AskConfig::default(),
1390 rollup: RollupConfig::default(),
1391 }
1392 }
1393}
1394
1395fn conv_default_retention_days() -> u32 {
1396 30
1397}
1398fn conv_default_poll_interval() -> u64 {
1399 300
1400}
1401fn conv_truthy() -> bool {
1402 true
1403}
1404fn conv_default_dedup() -> f64 {
1405 0.85
1406}
1407
1408#[derive(Debug, Clone, Serialize, Deserialize)]
1409pub struct CompactConfig {
1410 #[serde(default = "conv_truthy")]
1411 pub enabled_in_daemon: bool,
1412 #[serde(default = "compact_default_max_days")]
1413 pub max_days_per_run: u32,
1414 #[serde(default = "compact_default_max_spans")]
1415 pub max_extractive_spans: u32,
1416 #[serde(default = "compact_default_max_words")]
1417 pub max_abstractive_words: u32,
1418 #[serde(default = "compact_default_chunk_tokens")]
1419 pub chunk_tokens: u32,
1420 #[serde(default = "compact_default_history_retain")]
1421 pub history_retain: u32,
1422 #[serde(default = "compact_default_cron")]
1423 pub daemon_cron: String,
1424 #[serde(default)]
1427 pub extractive_backend: Option<BackendConfig>,
1428 #[serde(default)]
1431 pub abstractive_backend: Option<BackendConfig>,
1432}
1433
1434impl CompactConfig {
1435 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1440 self.extractive_backend
1441 .clone()
1442 .unwrap_or_else(|| BackendConfig {
1443 timeout_secs: Some(120),
1444 ..llm.to_backend_config()
1445 })
1446 }
1447
1448 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1451 self.abstractive_backend
1452 .clone()
1453 .unwrap_or_else(|| BackendConfig {
1454 timeout_secs: Some(120),
1455 ..llm.to_backend_config()
1456 })
1457 }
1458}
1459
1460impl Default for CompactConfig {
1461 fn default() -> Self {
1462 Self {
1463 enabled_in_daemon: true,
1464 max_days_per_run: compact_default_max_days(),
1465 max_extractive_spans: compact_default_max_spans(),
1466 max_abstractive_words: compact_default_max_words(),
1467 chunk_tokens: compact_default_chunk_tokens(),
1468 history_retain: compact_default_history_retain(),
1469 daemon_cron: compact_default_cron(),
1470 extractive_backend: None,
1471 abstractive_backend: None,
1472 }
1473 }
1474}
1475
1476fn compact_default_max_days() -> u32 {
1477 7
1478}
1479fn compact_default_max_spans() -> u32 {
1480 20
1481}
1482fn compact_default_max_words() -> u32 {
1483 400
1484}
1485fn compact_default_chunk_tokens() -> u32 {
1486 6000
1487}
1488fn compact_default_history_retain() -> u32 {
1489 5
1490}
1491fn compact_default_cron() -> String {
1492 "0 0 3 * * * *".into()
1493}
1494
1495#[derive(Debug, Clone, Serialize, Deserialize)]
1498pub struct RollupConfig {
1499 #[serde(default = "rollup_default_enabled")]
1500 pub enabled: bool,
1501 #[serde(default = "rollup_default_max_weeks")]
1502 pub max_weeks_per_run: u32,
1503 #[serde(default = "rollup_default_max_months")]
1504 pub max_months_per_run: u32,
1505 #[serde(default = "rollup_default_max_spans_week")]
1506 pub max_extractive_spans_per_week: u32,
1507 #[serde(default = "rollup_default_max_words_week")]
1508 pub max_abstractive_words_per_week: u32,
1509 #[serde(default = "rollup_default_max_spans_month")]
1510 pub max_extractive_spans_per_month: u32,
1511 #[serde(default = "rollup_default_max_words_month")]
1512 pub max_abstractive_words_per_month: u32,
1513 #[serde(default = "rollup_default_week_mmr")]
1514 pub week_mmr_threshold: f64,
1515 #[serde(default = "rollup_default_month_mmr")]
1516 pub month_mmr_threshold: f64,
1517 #[serde(default)]
1520 pub extractive_backend: Option<BackendConfig>,
1521 #[serde(default)]
1524 pub abstractive_backend: Option<BackendConfig>,
1525}
1526
1527impl Default for RollupConfig {
1528 fn default() -> Self {
1529 Self {
1530 enabled: rollup_default_enabled(),
1531 max_weeks_per_run: rollup_default_max_weeks(),
1532 max_months_per_run: rollup_default_max_months(),
1533 max_extractive_spans_per_week: rollup_default_max_spans_week(),
1534 max_abstractive_words_per_week: rollup_default_max_words_week(),
1535 max_extractive_spans_per_month: rollup_default_max_spans_month(),
1536 max_abstractive_words_per_month: rollup_default_max_words_month(),
1537 week_mmr_threshold: rollup_default_week_mmr(),
1538 month_mmr_threshold: rollup_default_month_mmr(),
1539 extractive_backend: None,
1540 abstractive_backend: None,
1541 }
1542 }
1543}
1544
1545impl RollupConfig {
1546 pub fn effective_extractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1550 self.extractive_backend
1551 .clone()
1552 .unwrap_or_else(|| BackendConfig {
1553 timeout_secs: Some(120),
1554 ..llm.to_backend_config()
1555 })
1556 }
1557
1558 pub fn effective_abstractive_backend(&self, llm: &LlmConfig) -> BackendConfig {
1560 self.abstractive_backend
1561 .clone()
1562 .unwrap_or_else(|| BackendConfig {
1563 timeout_secs: Some(120),
1564 ..llm.to_backend_config()
1565 })
1566 }
1567}
1568
1569fn rollup_default_enabled() -> bool {
1570 true
1571}
1572fn rollup_default_max_weeks() -> u32 {
1573 4
1574}
1575fn rollup_default_max_months() -> u32 {
1576 2
1577}
1578fn rollup_default_max_spans_week() -> u32 {
1579 20
1580}
1581fn rollup_default_max_words_week() -> u32 {
1582 500
1583}
1584fn rollup_default_max_spans_month() -> u32 {
1585 20
1586}
1587fn rollup_default_max_words_month() -> u32 {
1588 700
1589}
1590fn rollup_default_week_mmr() -> f64 {
1591 0.85
1592}
1593fn rollup_default_month_mmr() -> f64 {
1594 0.82
1595}
1596
1597#[derive(Debug, Clone, Serialize, Deserialize)]
1598pub struct ConversationsSources {
1599 #[serde(default = "conv_truthy")]
1600 pub claude_code: bool,
1601 #[serde(default = "conv_truthy")]
1602 pub cursor: bool,
1603 #[serde(default = "conv_truthy")]
1604 pub gemini: bool,
1605 #[serde(default)]
1606 pub aider: AiderSourceConfig,
1607}
1608
1609impl Default for ConversationsSources {
1610 fn default() -> Self {
1611 Self {
1612 claude_code: true,
1613 cursor: true,
1614 gemini: true,
1615 aider: AiderSourceConfig::default(),
1616 }
1617 }
1618}
1619
1620#[derive(Debug, Clone, Serialize, Deserialize)]
1621pub struct AiderSourceConfig {
1622 #[serde(default = "conv_truthy")]
1623 pub enabled: bool,
1624 #[serde(default)]
1625 pub watched_dirs: Vec<String>,
1626}
1627
1628impl Default for AiderSourceConfig {
1629 fn default() -> Self {
1630 Self {
1631 enabled: true,
1632 watched_dirs: Vec::new(),
1633 }
1634 }
1635}
1636
1637#[derive(Debug, Clone, Serialize, Deserialize)]
1638pub struct ConversationsFilter {
1639 #[serde(default = "conv_default_dedup")]
1640 pub dedup_threshold: f64,
1641 #[serde(default = "conv_truthy")]
1642 pub reject_heartbeat: bool,
1643 #[serde(default = "conv_truthy")]
1644 pub reject_system_restatement: bool,
1645}
1646
1647impl Default for ConversationsFilter {
1648 fn default() -> Self {
1649 Self {
1650 dedup_threshold: conv_default_dedup(),
1651 reject_heartbeat: true,
1652 reject_system_restatement: true,
1653 }
1654 }
1655}
1656
1657#[cfg(test)]
1658mod conversations_tests {
1659 use super::*;
1660
1661 #[test]
1662 fn conversations_section_defaults() {
1663 let c = ConversationsConfig::default();
1664 assert!(!c.enabled);
1665 assert_eq!(c.retention_days, 30);
1666 assert_eq!(c.poll_interval_secs, 300);
1667 assert!(c.sources.claude_code);
1668 assert!(c.sources.cursor);
1669 assert!(c.sources.gemini);
1670 assert!(c.sources.aider.enabled);
1671 assert!(c.sources.aider.watched_dirs.is_empty());
1672 assert_eq!(c.filter.dedup_threshold, 0.85);
1673 assert!(c.filter.reject_heartbeat);
1674 assert!(c.filter.reject_system_restatement);
1675 }
1676
1677 #[test]
1678 fn parse_from_yaml_with_overrides() {
1679 let y = r#"
1680conversations:
1681 enabled: true
1682 retention_days: 45
1683 poll_interval_secs: 120
1684 sources:
1685 cursor: false
1686 aider:
1687 watched_dirs: ["~/Projects/a", "~/Projects/b"]
1688 filter:
1689 dedup_threshold: 0.9
1690"#;
1691 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1692 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1693 assert!(conv.enabled);
1694 assert_eq!(conv.retention_days, 45);
1695 assert_eq!(conv.poll_interval_secs, 120);
1696 assert!(conv.sources.claude_code); assert!(!conv.sources.cursor); assert!(conv.sources.gemini); assert_eq!(conv.sources.aider.watched_dirs.len(), 2);
1700 assert_eq!(conv.filter.dedup_threshold, 0.9);
1701 assert!(conv.filter.reject_heartbeat); }
1703
1704 #[test]
1705 fn missing_conversations_section_is_fine() {
1706 let y = r#"
1707# No conversations section at all
1708foo: bar
1709"#;
1710 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1711 let conv: ConversationsConfig = v
1713 .get("conversations")
1714 .cloned()
1715 .map(|x| serde_yaml::from_value(x).unwrap_or_default())
1716 .unwrap_or_default();
1717 assert_eq!(conv.retention_days, 30);
1718 }
1719
1720 #[test]
1721 fn compact_config_defaults() {
1722 let c = CompactConfig::default();
1723 assert!(c.enabled_in_daemon);
1724 assert_eq!(c.max_days_per_run, 7);
1725 assert_eq!(c.max_extractive_spans, 20);
1726 assert_eq!(c.chunk_tokens, 6000);
1727 assert_eq!(c.history_retain, 5);
1728 assert_eq!(c.daemon_cron, "0 0 3 * * * *");
1729 }
1730
1731 #[test]
1732 fn compact_parses_partial_overrides() {
1733 let y = r#"
1734conversations:
1735 compact:
1736 max_days_per_run: 3
1737 extractive_backend:
1738 provider: anthropic
1739 model: claude-haiku-4-5
1740"#;
1741 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1742 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1743 assert_eq!(conv.compact.max_days_per_run, 3);
1744 assert_eq!(
1745 conv.compact.extractive_backend.as_ref().unwrap().model,
1746 "claude-haiku-4-5"
1747 );
1748 assert!(conv.compact.enabled_in_daemon); assert!(conv.compact.abstractive_backend.is_none()); }
1751
1752 #[test]
1753 fn ask_config_defaults() {
1754 let c = AskConfig::default();
1755 assert_eq!(c.k_raw, 10);
1756 assert_eq!(c.escalation_threshold, 0.5);
1757 assert_eq!(c.mmr_threshold, 0.88);
1758 assert_eq!(c.max_context_tokens, 6000);
1759 assert_eq!(c.response_tokens, 1024);
1760 assert_eq!(c.timeout_secs, 120);
1761 assert_eq!(c.min_score, 0.35);
1762 }
1763
1764 #[test]
1765 fn ask_config_mmr_threshold_default_is_cosine_scaled() {
1766 let c = AskConfig::default();
1768 assert!(
1769 (c.mmr_threshold - 0.88).abs() < 1e-9,
1770 "expected 0.88, got {}",
1771 c.mmr_threshold
1772 );
1773 }
1774
1775 #[test]
1776 fn rollup_config_defaults() {
1777 let c = RollupConfig::default();
1778 assert!(c.enabled);
1779 assert_eq!(c.max_weeks_per_run, 4);
1780 assert_eq!(c.max_months_per_run, 2);
1781 assert_eq!(c.max_extractive_spans_per_week, 20);
1782 assert_eq!(c.max_abstractive_words_per_week, 500);
1783 assert_eq!(c.max_extractive_spans_per_month, 20);
1784 assert_eq!(c.max_abstractive_words_per_month, 700);
1785 assert!((c.week_mmr_threshold - 0.85).abs() < 1e-9);
1786 assert!((c.month_mmr_threshold - 0.82).abs() < 1e-9);
1787 }
1788
1789 #[test]
1790 fn rollup_config_plumbed_into_conversations_config() {
1791 let c = ConversationsConfig::default();
1792 assert!(c.rollup.enabled);
1793 }
1794
1795 #[test]
1796 fn ask_config_default_continue_history_turns_is_3() {
1797 let c = AskConfig::default();
1798 assert_eq!(c.continue_history_turns, 3);
1799 }
1800
1801 #[test]
1802 fn ask_config_default_compress_hits_enabled_is_true() {
1803 let c = AskConfig::default();
1804 assert!(c.compress_hits_enabled);
1805 }
1806
1807 #[test]
1808 fn ask_config_default_summarize_hits_enabled_is_true() {
1809 let c = AskConfig::default();
1810 assert!(c.summarize_hits_enabled);
1811 }
1812
1813 #[test]
1814 fn ask_config_default_summarize_model_is_none() {
1815 let c = AskConfig::default();
1816 assert!(c.summarize_model.is_none());
1817 }
1818
1819 #[test]
1820 fn ask_config_yaml_roundtrip_preserves_summarize_fields() {
1821 let y = r#"
1822conversations:
1823 ask:
1824 summarize_hits_enabled: false
1825 summarize_model: qwen3:4b
1826"#;
1827 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1828 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1829 assert!(!conv.ask.summarize_hits_enabled);
1830 assert_eq!(conv.ask.summarize_model.as_deref(), Some("qwen3:4b"));
1831 }
1832
1833 #[test]
1834 fn ask_config_yaml_without_summarize_fields_uses_defaults() {
1835 let y = r#"
1839conversations:
1840 ask:
1841 min_score: 0.4
1842"#;
1843 let v: serde_yaml::Value = serde_yaml::from_str(y).unwrap();
1844 let conv: ConversationsConfig = serde_yaml::from_value(v["conversations"].clone()).unwrap();
1845 assert!(conv.ask.summarize_hits_enabled);
1846 assert!(conv.ask.summarize_model.is_none());
1847 }
1848}
1849
1850#[cfg(test)]
1851mod attribution_tests {
1852 use super::{AttributionConfig, Config};
1853
1854 #[test]
1858 fn pr_signature_ships_on_and_commit_trailer_ships_off() {
1859 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
1860 assert_eq!(
1861 c.attribution.pr.as_deref(),
1862 Some("Generated with [MUR](https://app.mur.run/products/mur)")
1863 );
1864 assert_eq!(c.attribution.commit, None);
1865 }
1866
1867 #[test]
1870 fn empty_string_is_the_off_switch_per_surface() {
1871 let c: Config = serde_yaml_ng::from_str("attribution:\n pr: \"\"\n").unwrap();
1872 assert!(c.attribution.pr_signature().is_none());
1873
1874 let c: Config = serde_yaml_ng::from_str(
1875 "attribution:\n pr: \" \"\n commit: \"Co-Authored-By: MUR <noreply@mur.run>\"\n",
1876 )
1877 .unwrap();
1878 assert!(
1879 c.attribution.pr_signature().is_none(),
1880 "whitespace-only counts as off, not as a signature of spaces"
1881 );
1882 assert_eq!(
1883 c.attribution.commit_signature(),
1884 Some("Co-Authored-By: MUR <noreply@mur.run>")
1885 );
1886 }
1887
1888 #[test]
1889 fn a_custom_pr_signature_replaces_the_default() {
1890 let c: Config = serde_yaml_ng::from_str("attribution:\n pr: \"Made by ACME\"\n").unwrap();
1891 assert_eq!(c.attribution.pr_signature(), Some("Made by ACME"));
1892 }
1893
1894 #[test]
1897 fn serialization_ships_pr_attribution_but_omits_disabled_commit_trailer() {
1898 let yaml = serde_yaml::to_string(&Config::default()).unwrap();
1899 assert!(
1900 yaml.contains(
1901 "attribution:\n pr: Generated with [MUR](https://app.mur.run/products/mur)"
1902 ),
1903 "{yaml}"
1904 );
1905 assert!(!yaml.contains("commit:"), "{yaml}");
1906 }
1907
1908 #[test]
1911 fn prompt_fragment_is_none_when_both_surfaces_are_off() {
1912 let off = AttributionConfig {
1913 pr: Some(String::new()),
1914 commit: None,
1915 };
1916 assert_eq!(off.prompt_fragment(), None);
1917 }
1918
1919 #[test]
1922 fn prompt_fragment_quotes_each_enabled_surface_verbatim() {
1923 let c = AttributionConfig::default();
1924 let f = c.prompt_fragment().expect("pr is on by default");
1925 assert!(f.contains("## MUR attribution"), "{f}");
1926 assert!(
1927 f.contains("Generated with [MUR](https://app.mur.run/products/mur)"),
1928 "the exact signature must appear, not a description of it: {f}"
1929 );
1930 assert!(f.contains("gh pr create"), "{f}");
1931 assert!(
1932 !f.to_lowercase().contains("git commit"),
1933 "commit trailer is off by default, so its instruction must be absent: {f}"
1934 );
1935
1936 let both = AttributionConfig {
1937 pr: Some("Generated with MUR".into()),
1938 commit: Some("Co-Authored-By: MUR <noreply@mur.run>".into()),
1939 };
1940 let f = both.prompt_fragment().unwrap();
1941 assert!(f.contains("git commit"), "{f}");
1942 assert!(f.contains("Co-Authored-By: MUR <noreply@mur.run>"), "{f}");
1943 }
1944
1945 #[test]
1948 fn commit_only_configuration_injects_only_the_commit_rule() {
1949 let c = AttributionConfig {
1950 pr: Some(String::new()),
1951 commit: Some("Co-Authored-By: MUR <noreply@mur.run>".into()),
1952 };
1953 let f = c.prompt_fragment().unwrap();
1954 assert!(f.contains("git commit"), "{f}");
1955 assert!(!f.contains("gh pr create"), "{f}");
1956 }
1957}
1958
1959#[cfg(test)]
1960mod tests {
1961 #[test]
1962 fn update_defaults_apply_when_block_absent_and_fields_parse() {
1963 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1964 assert_eq!(c.update, super::UpdateConfig::default());
1965 assert!(c.update.codesign_identity.is_none());
1966 assert!(c.update.restart_exclude.is_empty());
1967
1968 let c: super::Config = serde_yaml_ng::from_str(
1969 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
1970 )
1971 .unwrap();
1972 assert_eq!(
1973 c.update.codesign_identity.as_deref(),
1974 Some("Developer ID Application: X (TEAM)")
1975 );
1976 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
1977 }
1978
1979 #[test]
1980 fn federation_snapshot_defaults_apply_when_block_absent() {
1981 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
1982 assert_eq!(c.federation_snapshot.poll_secs, 30);
1983 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
1984 assert_eq!(
1985 c.federation_snapshot.min_lifecycle,
1986 crate::skill::stats::LifecycleState::Stable
1987 );
1988 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
1990 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
1991 assert_eq!(c.memory.capture, super::CaptureMode::Off);
1992 }
1993
1994 use super::*;
1995
1996 #[test]
1997 fn default_bundled_model_id_is_qwen35_2b() {
1998 assert_eq!(
1999 crate::config::DEFAULT_BUNDLED_MODEL_ID,
2000 "Qwen3.5-2B-MLX-4bit"
2001 );
2002 }
2003
2004 #[test]
2005 fn nudge_config_defaults() {
2006 let c = NudgeConfig::default();
2007 assert!(c.enabled);
2008 assert_eq!(c.daily_cap, 3);
2009 assert_eq!(c.snooze_days, 7);
2010 assert_eq!(c.threshold, 3);
2011 }
2012
2013 #[test]
2014 fn config_has_nudge_section_with_defaults() {
2015 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
2016 assert_eq!(c.nudge.daily_cap, 3);
2017 }
2018
2019 #[test]
2020 fn storage_config_default_is_lancedb() {
2021 let c = StorageConfig::default();
2022 assert_eq!(c.vector_backend, "lancedb");
2023 assert_eq!(c.qdrant_url, None);
2024 assert_eq!(c.qdrant_api_key_ref, None);
2025 }
2026
2027 #[test]
2028 fn sources_global_config_has_sensible_defaults() {
2029 let c = SourcesGlobalConfig::default();
2030 assert_eq!(c.poll_interval_secs, 600);
2031 assert_eq!(c.max_chunks_per_sync, 10_000);
2032 assert_eq!(c.max_parallel_sources, 3);
2033 assert_eq!(c.default_weight, 1.0);
2034 assert_eq!(c.embedding_batch_size, 32);
2035 }
2036
2037 #[test]
2038 fn config_default_has_storage_and_sources_global() {
2039 let c = Config::default();
2040 assert_eq!(c.storage.vector_backend, "lancedb");
2041 assert_eq!(c.sources_global.default_weight, 1.0);
2042 }
2043
2044 #[test]
2045 fn config_loads_yaml_without_new_fields() {
2046 let yaml = r#"
2049embedding:
2050 provider: ollama
2051 model: test-model
2052 dimensions: 512
2053 ollama_endpoint: http://localhost:11434
2054"#;
2055 let c: Config = serde_yaml::from_str(yaml).expect("parses");
2056 assert_eq!(c.storage.vector_backend, "lancedb");
2057 assert_eq!(c.sources_global.max_parallel_sources, 3);
2058 }
2059
2060 #[test]
2061 fn llm_config_to_backend_config_anthropic_passthrough() {
2062 let cfg = LlmConfig {
2063 provider: "anthropic".into(),
2064 model: "claude-haiku-4-5".into(),
2065 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2066 api_key_ref: None,
2067 openai_url: None,
2068 };
2069 let b = cfg.to_backend_config();
2070 assert_eq!(b.provider, "anthropic");
2071 assert_eq!(b.model, "claude-haiku-4-5");
2072 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
2073 assert_eq!(b.endpoint, None);
2074 assert_eq!(b.timeout_secs, None);
2075 }
2076
2077 #[test]
2078 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
2079 let cfg = LlmConfig {
2080 provider: "openai".into(),
2081 model: "gpt-4o-mini".into(),
2082 api_key_env: None,
2083 api_key_ref: None,
2084 openai_url: Some("https://api.together.xyz/v1".into()),
2085 };
2086 let b = cfg.to_backend_config();
2087 assert_eq!(b.provider, "openai");
2088 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
2089 assert_eq!(b.api_key_env, None); }
2091
2092 #[test]
2093 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
2094 let cfg = LlmConfig {
2095 provider: "ollama".into(),
2096 model: "qwen3:14b".into(),
2097 api_key_env: None,
2098 api_key_ref: None,
2099 openai_url: Some("http://192.168.1.10:11434".into()),
2100 };
2101 let b = cfg.to_backend_config();
2102 assert_eq!(b.provider, "ollama");
2103 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
2104 }
2105
2106 #[test]
2107 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
2108 let cfg = LlmConfig {
2112 provider: "custom-name".into(),
2113 model: "some-model".into(),
2114 api_key_env: Some("CUSTOM_KEY".into()),
2115 api_key_ref: None,
2116 openai_url: Some("https://my-proxy.local/v1".into()),
2117 };
2118 let b = cfg.to_backend_config();
2119 assert_eq!(
2120 b.provider, "openai",
2121 "unknown provider + openai_url should alias to openai"
2122 );
2123 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
2124 }
2125
2126 #[test]
2127 fn api_key_ref_roundtrips_and_defaults_none() {
2128 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
2130 assert_eq!(b.api_key_ref, None);
2131 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
2132 assert_eq!(l.api_key_ref, None);
2133 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
2134 assert_eq!(e.api_key_ref, None);
2135
2136 let l2 = LlmConfig {
2138 api_key_ref: Some("keychain:mur/anthropic".into()),
2139 ..Default::default()
2140 };
2141 let y = serde_yaml_ng::to_string(&l2).unwrap();
2142 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
2143 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
2144 assert_eq!(
2145 l3.to_backend_config().api_key_ref.as_deref(),
2146 Some("keychain:mur/anthropic")
2147 );
2148 }
2149
2150 #[test]
2151 fn open_items_muted_parses_and_defaults_empty() {
2152 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
2153 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
2154
2155 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
2156 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
2157 }
2158
2159 #[test]
2162 fn unreadable_config_yields_no_mutes() {
2163 let tmp = tempfile::tempdir().unwrap();
2164 let path = tmp.path().join("config.yaml");
2165 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
2166 let cfg = Config::load_or_default(&path);
2167 assert!(
2168 cfg.open_items.muted.is_empty(),
2169 "a broken config must hide nothing"
2170 );
2171
2172 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
2174 assert!(missing.open_items.muted.is_empty());
2175 }
2176
2177 #[test]
2178 fn rollup_config_accepts_backend_overrides() {
2179 let yaml = r#"
2180enabled: true
2181extractive_backend:
2182 provider: openai
2183 model: Qwen3.5-4B-MLX-4bit
2184 endpoint: http://127.0.0.1:8000/v1
2185"#;
2186 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
2187 let b = c.extractive_backend.expect("override present");
2188 assert_eq!(b.provider, "openai");
2189 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2190 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2191 assert!(c.abstractive_backend.is_none());
2192 }
2193
2194 #[test]
2195 fn legacy_conversation_fields_are_gone_from_serialized_output() {
2196 let cfg = Config::default();
2197 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
2203 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
2204 assert!(
2205 !yaml.contains(key),
2206 "legacy key {key} still serialized:\n{yaml}"
2207 );
2208 }
2209 }
2210
2211 #[test]
2212 fn embedding_ollama_endpoint_is_omitted_when_unset() {
2213 let mut cfg = Config::default();
2214 cfg.embedding.provider = "omlx".into();
2215 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
2216 cfg.embedding.ollama_endpoint = None;
2217 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
2218 assert!(
2219 !yaml.contains("ollama_endpoint"),
2220 "dead field re-emitted:\n{yaml}"
2221 );
2222 }
2223
2224 #[test]
2225 fn embedding_ollama_endpoint_still_round_trips_when_set() {
2226 let yaml =
2227 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
2228 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
2229 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
2230 }
2231}
2232
2233#[cfg(test)]
2234mod backend_config_tests {
2235 use super::*;
2236
2237 #[test]
2238 fn default_is_ollama_qwen3() {
2239 let cfg = BackendConfig::default();
2240 assert_eq!(cfg.provider, "ollama");
2241 assert_eq!(cfg.model, "qwen3.5:4b");
2242 assert_eq!(cfg.endpoint, None);
2243 assert_eq!(cfg.api_key_env, None);
2244 assert_eq!(cfg.timeout_secs, None);
2245 }
2246
2247 #[test]
2248 fn deserializes_anthropic_full() {
2249 let yaml = "\
2250provider: anthropic
2251model: claude-haiku-4-5
2252api_key_env: ANTHROPIC_API_KEY
2253timeout_secs: 60
2254";
2255 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
2256 assert_eq!(cfg.provider, "anthropic");
2257 assert_eq!(cfg.model, "claude-haiku-4-5");
2258 assert_eq!(cfg.api_key_env, Some("ANTHROPIC_API_KEY".into()));
2259 assert_eq!(cfg.timeout_secs, Some(60));
2260 assert_eq!(cfg.endpoint, None);
2261 }
2262
2263 #[test]
2264 fn deserializes_partial_fills_defaults() {
2265 let yaml = "provider: anthropic\nmodel: claude-sonnet-5\n";
2266 let cfg: BackendConfig = serde_yaml::from_str(yaml).unwrap();
2267 assert_eq!(cfg.provider, "anthropic");
2268 assert_eq!(cfg.model, "claude-sonnet-5");
2269 assert_eq!(cfg.api_key_env, None);
2270 assert_eq!(cfg.timeout_secs, None);
2271 }
2272
2273 #[test]
2274 fn round_trips_through_yaml() {
2275 let original = BackendConfig {
2276 provider: "anthropic".into(),
2277 model: "claude-haiku-4-5".into(),
2278 endpoint: Some("https://api.anthropic.com".into()),
2279 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2280 api_key_ref: None,
2281 timeout_secs: Some(60),
2282 };
2283 let yaml = serde_yaml::to_string(&original).unwrap();
2284 let parsed: BackendConfig = serde_yaml::from_str(&yaml).unwrap();
2285 assert_eq!(parsed, original);
2286 }
2287
2288 #[test]
2289 fn skills_config_curation_gate_defaults_on() {
2290 let c = SkillsConfig::default();
2291 assert!(c.require_human_curation_before_stable);
2292 }
2293}
2294
2295#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
2303#[serde(rename_all = "lowercase")]
2304pub enum DevDisciplineIndex {
2305 #[default]
2307 Auto,
2308 Always,
2310 Never,
2312}
2313
2314#[derive(Debug, Clone, Serialize, Deserialize)]
2315#[serde(default)]
2316pub struct SkillsConfig {
2317 pub max_skills_in_prompt: usize,
2318 pub max_total_tokens: usize,
2319 pub priority_order: Vec<String>,
2320 pub adaptive: Option<AdaptiveSkillsConfig>,
2321
2322 #[serde(default = "default_require_human_curation")]
2326 pub require_human_curation_before_stable: bool,
2327
2328 #[serde(default)]
2332 pub lifecycle: SkillLifecycleConfig,
2333
2334 #[serde(default = "default_auto_upgrade")]
2339 pub auto_upgrade: bool,
2340
2341 #[serde(default)]
2343 pub dev_discipline_index: DevDisciplineIndex,
2344}
2345
2346fn default_require_human_curation() -> bool {
2347 true
2348}
2349
2350fn default_auto_upgrade() -> bool {
2351 true
2352}
2353
2354impl Default for SkillsConfig {
2355 fn default() -> Self {
2356 Self {
2357 max_skills_in_prompt: 5,
2358 max_total_tokens: 2000,
2359 priority_order: vec!["agent".into(), "global".into()],
2360 adaptive: Some(AdaptiveSkillsConfig::default()),
2361 require_human_curation_before_stable: default_require_human_curation(),
2362 lifecycle: SkillLifecycleConfig::default(),
2363 auto_upgrade: default_auto_upgrade(),
2364 dev_discipline_index: DevDisciplineIndex::default(),
2365 }
2366 }
2367}
2368
2369#[derive(Debug, Clone, Serialize, Deserialize)]
2375#[serde(default)]
2376pub struct SkillLifecycleConfig {
2377 pub note_rule_half_life_factor: f64,
2381 pub note_fact_half_life_factor: f64,
2384 pub promote_draft_uses: u64,
2386 pub promote_emerging_uses: u64,
2387 pub promote_emerging_success_rate: f64,
2388 pub promote_emerging_age_days: i64,
2389 pub promote_stable_uses: u64,
2390 pub promote_stable_success_rate: f64,
2391 pub promote_stable_age_days: i64,
2392
2393 pub demote_emerging_uses: u64,
2395 pub demote_emerging_success_rate: f64,
2396 pub demote_stable_uses: u64,
2397 pub demote_stable_success_rate: f64,
2398 pub deprecated_success_rate: f64,
2399 pub deprecated_no_success_days: i64,
2400
2401 pub auto_archive_confidence: f64,
2403 pub auto_archive_age_days: i64,
2404
2405 pub broken_workflow_streak: u32,
2410
2411 pub archive_destroy_grace_days: i64,
2416}
2417
2418impl Default for SkillLifecycleConfig {
2419 fn default() -> Self {
2420 Self {
2421 promote_draft_uses: 3,
2422 promote_emerging_uses: 10,
2423 promote_emerging_success_rate: 0.6,
2424 promote_emerging_age_days: 7,
2425 promote_stable_uses: 30,
2426 promote_stable_success_rate: 0.8,
2427 promote_stable_age_days: 30,
2428 demote_emerging_uses: 8,
2429 demote_emerging_success_rate: 0.55,
2430 demote_stable_uses: 25,
2431 demote_stable_success_rate: 0.75,
2432 deprecated_success_rate: 0.3,
2433 deprecated_no_success_days: 90,
2434 auto_archive_confidence: 0.10,
2435 auto_archive_age_days: 180,
2436 broken_workflow_streak: 3,
2437 archive_destroy_grace_days: 30,
2438 note_rule_half_life_factor: crate::skill::lifecycle::NOTE_RULE_HALF_LIFE_FACTOR,
2439 note_fact_half_life_factor: crate::skill::lifecycle::NOTE_FACT_HALF_LIFE_FACTOR,
2440 }
2441 }
2442}
2443
2444#[derive(Debug, Clone, Serialize, Deserialize)]
2445#[serde(default)]
2446pub struct AdaptiveSkillsConfig {
2447 pub context_fill_decay: f64,
2448 pub min_remaining_context_ratio: f64,
2449 pub recent_fire_boost_turns: usize,
2450 pub model_max_context_tokens: u64,
2454}
2455
2456impl Default for AdaptiveSkillsConfig {
2457 fn default() -> Self {
2458 Self {
2459 context_fill_decay: 1.5,
2460 min_remaining_context_ratio: 0.20,
2461 recent_fire_boost_turns: 5,
2462 model_max_context_tokens: 200_000,
2463 }
2464 }
2465}
2466
2467#[derive(Debug, Clone, Serialize, Deserialize)]
2470pub struct SleepCycleConfig {
2471 #[serde(default)]
2473 pub enabled: bool,
2474
2475 #[serde(default = "default_idle_threshold_minutes")]
2477 pub idle_threshold_minutes: u64,
2478
2479 #[serde(default = "default_agent_idle_minutes")]
2481 pub agent_idle_minutes: u64,
2482}
2483
2484fn default_idle_threshold_minutes() -> u64 {
2485 15
2486}
2487
2488fn default_agent_idle_minutes() -> u64 {
2489 5
2490}
2491
2492impl Default for SleepCycleConfig {
2493 fn default() -> Self {
2494 Self {
2495 enabled: false,
2496 idle_threshold_minutes: default_idle_threshold_minutes(),
2497 agent_idle_minutes: default_agent_idle_minutes(),
2498 }
2499 }
2500}
2501
2502#[derive(Debug, Clone, Serialize, Deserialize)]
2505pub struct NudgeConfig {
2506 #[serde(default = "default_nudge_enabled")]
2508 pub enabled: bool,
2509 #[serde(default = "default_nudge_daily_cap")]
2510 pub daily_cap: u32,
2511 #[serde(default = "default_nudge_snooze_days")]
2512 pub snooze_days: u32,
2513 #[serde(default = "default_nudge_threshold")]
2514 pub threshold: usize,
2515}
2516
2517fn default_nudge_enabled() -> bool {
2518 true
2519}
2520fn default_nudge_daily_cap() -> u32 {
2521 3
2522}
2523fn default_nudge_snooze_days() -> u32 {
2524 7
2525}
2526fn default_nudge_threshold() -> usize {
2527 3
2528}
2529
2530impl Default for NudgeConfig {
2531 fn default() -> Self {
2532 Self {
2533 enabled: true,
2534 daily_cap: default_nudge_daily_cap(),
2535 snooze_days: default_nudge_snooze_days(),
2536 threshold: default_nudge_threshold(),
2537 }
2538 }
2539}
2540
2541#[derive(Debug, Clone, Serialize, Deserialize)]
2545pub struct SessionCfg {
2546 #[serde(default = "default_capture_mode")]
2548 pub capture: String,
2549 #[serde(default = "default_retention_days")]
2551 pub retention_days: u32,
2552}
2553
2554impl Default for SessionCfg {
2555 fn default() -> Self {
2556 Self {
2557 capture: default_capture_mode(),
2558 retention_days: default_retention_days(),
2559 }
2560 }
2561}
2562
2563fn default_capture_mode() -> String {
2564 "ambient".to_string()
2565}
2566fn default_retention_days() -> u32 {
2567 14
2568}
2569
2570#[derive(Debug, Clone, Serialize, Deserialize)]
2572pub struct HarvestCfg {
2573 #[serde(default = "default_harvest_enabled")]
2575 pub auto_gate: bool,
2576 #[serde(default = "default_harvest_llm")]
2578 pub llm: String,
2579 #[serde(default = "default_min_events")]
2581 pub min_events: usize,
2582 #[serde(default = "default_min_user_turns")]
2583 pub min_user_turns: usize,
2584 #[serde(default = "default_min_duration_secs")]
2585 pub min_duration_secs: i64,
2586 #[serde(default = "default_idle_minutes")]
2588 pub idle_minutes: i64,
2589 #[serde(default = "default_max_steps")]
2592 pub max_steps: usize,
2593 #[serde(default = "default_max_duration_secs")]
2594 pub max_duration_secs: i64,
2595 #[serde(default = "default_max_llm_calls_per_day")]
2597 pub max_llm_calls_per_day: u32,
2598 #[serde(default = "default_max_extract_input_tokens")]
2599 pub max_extract_input_tokens: usize,
2600 #[serde(default = "default_harvest_enabled")]
2602 pub session_start_hint: bool,
2603 #[serde(default = "default_similarity_merge_threshold")]
2606 pub similarity_merge_threshold: f32,
2607 #[serde(default = "default_min_occurrences")]
2611 pub min_occurrences: usize,
2612}
2613
2614impl Default for HarvestCfg {
2615 fn default() -> Self {
2616 serde_yaml::from_str("{}").expect("HarvestCfg defaults")
2617 }
2618}
2619
2620fn default_harvest_enabled() -> bool {
2621 true
2622}
2623fn default_harvest_llm() -> String {
2624 "local-first".to_string()
2625}
2626fn default_min_events() -> usize {
2627 5
2628}
2629fn default_min_user_turns() -> usize {
2630 2
2631}
2632fn default_min_duration_secs() -> i64 {
2633 120
2634}
2635fn default_idle_minutes() -> i64 {
2636 30
2637}
2638fn default_max_steps() -> usize {
2642 20
2643}
2644fn default_max_duration_secs() -> i64 {
2647 1800
2648}
2649fn default_max_llm_calls_per_day() -> u32 {
2650 10
2651}
2652fn default_max_extract_input_tokens() -> usize {
2653 12000
2654}
2655fn default_similarity_merge_threshold() -> f32 {
2656 0.6
2657}
2658fn default_min_occurrences() -> usize {
2661 2
2662}
2663
2664#[derive(Debug, Clone, Serialize, Deserialize)]
2667#[serde(default)]
2668pub struct CrossAgentConfig {
2669 #[serde(default = "default_half_life_days")]
2670 pub fitness_half_life_days: u32,
2671 #[serde(default = "default_fitness_floor")]
2672 pub fitness_floor: f64,
2673}
2674
2675fn default_half_life_days() -> u32 {
2676 7
2677}
2678fn default_fitness_floor() -> f64 {
2679 0.1
2680}
2681
2682impl Default for CrossAgentConfig {
2683 fn default() -> Self {
2684 Self {
2685 fitness_half_life_days: default_half_life_days(),
2686 fitness_floor: default_fitness_floor(),
2687 }
2688 }
2689}
2690
2691#[derive(Debug, Clone, Serialize, Deserialize)]
2694#[serde(default)]
2695pub struct SkillLlmConfig {
2696 #[serde(default = "default_per_call_token_cap")]
2698 pub per_call_token_cap: u32,
2699
2700 #[serde(default = "default_per_day_usd_cap")]
2702 pub per_day_usd_cap: f64,
2703
2704 #[serde(default = "default_cache_ttl_days")]
2706 pub cache_ttl_days: u32,
2707
2708 #[serde(default, skip_serializing_if = "Option::is_none")]
2710 pub model_ref: Option<String>,
2711}
2712
2713fn default_per_call_token_cap() -> u32 {
2714 1500
2715}
2716fn default_per_day_usd_cap() -> f64 {
2717 0.50
2718}
2719fn default_cache_ttl_days() -> u32 {
2720 30
2721}
2722
2723impl Default for SkillLlmConfig {
2724 fn default() -> Self {
2725 Self {
2726 per_call_token_cap: default_per_call_token_cap(),
2727 per_day_usd_cap: default_per_day_usd_cap(),
2728 cache_ttl_days: default_cache_ttl_days(),
2729 model_ref: None,
2730 }
2731 }
2732}
2733#[cfg(test)]
2734mod per_stage_backend_tests {
2735 use super::*;
2736
2737 #[test]
2738 fn compact_extractive_backend_override_parses() {
2739 let yaml = "\
2740extractive_backend:
2741 provider: anthropic
2742 model: claude-haiku-4-5
2743 api_key_env: ANTHROPIC_API_KEY
2744";
2745 let cfg: CompactConfig = serde_yaml::from_str(yaml).unwrap();
2746 let extractive = cfg
2747 .extractive_backend
2748 .as_ref()
2749 .expect("override should parse");
2750 assert_eq!(extractive.provider, "anthropic");
2751 assert_eq!(extractive.model, "claude-haiku-4-5");
2752 assert!(cfg.abstractive_backend.is_none());
2753 }
2754
2755 #[test]
2756 fn ask_rewriter_backend_can_override_to_local_while_answer_is_cloud() {
2757 let yaml = "\
2758backend:
2759 provider: anthropic
2760 model: claude-sonnet-5
2761 api_key_env: ANTHROPIC_API_KEY
2762rewriter_backend:
2763 provider: ollama
2764 model: llama3.2:3b
2765";
2766 let cfg: AskConfig = serde_yaml::from_str(yaml).unwrap();
2767 assert_eq!(cfg.backend.as_ref().unwrap().provider, "anthropic");
2768 assert_eq!(cfg.rewriter_backend.as_ref().unwrap().provider, "ollama");
2769 }
2770
2771 #[test]
2772 fn rewriter_falls_through_to_answer_stage_backend_before_the_smart_slot() {
2773 let cfg = AskConfig {
2784 backend: Some(BackendConfig {
2785 provider: "anthropic".into(),
2786 model: "claude-sonnet-5".into(),
2787 endpoint: None,
2788 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2789 api_key_ref: None,
2790 timeout_secs: None,
2791 }),
2792 ..Default::default()
2793 };
2794 let rewriter = cfg.effective_rewriter_backend(&omlx_llm());
2795 assert_eq!(rewriter.provider, "anthropic");
2796 assert_eq!(rewriter.model, "claude-sonnet-5");
2797 assert_eq!(
2798 rewriter.timeout_secs,
2799 Some(cfg.rewriter_timeout_secs as u64),
2800 "rewriter must keep its own tighter timeout even while following the answer stage's backend"
2801 );
2802 }
2803
2804 #[test]
2805 fn rewriter_explicit_override_timeout_wins_over_rewriter_timeout_secs() {
2806 let mut cfg = AskConfig {
2807 rewriter_timeout_secs: 8,
2808 ..AskConfig::default()
2809 };
2810 cfg.rewriter_backend = Some(BackendConfig {
2811 provider: "anthropic".into(),
2812 model: "claude-haiku-4-5".into(),
2813 endpoint: None,
2814 api_key_env: Some("ANTHROPIC_API_KEY".into()),
2815 api_key_ref: None,
2816 timeout_secs: Some(30),
2817 });
2818 let b = cfg.effective_rewriter_backend(&omlx_llm());
2819 assert_eq!(
2820 b.timeout_secs,
2821 Some(30),
2822 "explicit per-stage rewriter_backend override must NOT be overridden by ask.rewriter_timeout_secs"
2823 );
2824 }
2825
2826 fn omlx_llm() -> LlmConfig {
2827 LlmConfig {
2828 provider: "omlx".into(),
2829 model: "Qwen3.5-4B-MLX-4bit".into(),
2830 api_key_env: None,
2831 api_key_ref: Some("env:OMLX_API_KEY".into()),
2832 openai_url: Some("http://127.0.0.1:8000/v1".into()),
2833 }
2834 }
2835
2836 #[test]
2837 fn ask_without_override_inherits_smart_slot_and_maps_omlx_to_openai() {
2838 let ask = AskConfig::default();
2839 let b = ask.effective_backend(&omlx_llm());
2840 assert_eq!(b.provider, "openai");
2841 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
2842 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2843 assert_eq!(b.api_key_ref.as_deref(), Some("env:OMLX_API_KEY"));
2844 assert_eq!(b.timeout_secs, Some(ask.timeout_secs as u64));
2846 }
2847
2848 #[test]
2849 fn ask_rewriter_inherits_its_own_shorter_timeout_not_the_answer_one() {
2850 let ask = AskConfig::default();
2851 let b = ask.effective_rewriter_backend(&omlx_llm());
2852 assert_eq!(b.timeout_secs, Some(ask.rewriter_timeout_secs as u64));
2853 assert_ne!(b.timeout_secs, Some(ask.timeout_secs as u64));
2854 }
2855
2856 #[test]
2857 fn explicit_override_wins_over_the_smart_slot() {
2858 let ask = AskConfig {
2859 backend: Some(BackendConfig {
2860 provider: "anthropic".into(),
2861 model: "claude-haiku-4-5".into(),
2862 endpoint: None,
2863 api_key_env: None,
2864 api_key_ref: None,
2865 timeout_secs: Some(42),
2866 }),
2867 ..Default::default()
2868 };
2869 let b = ask.effective_backend(&omlx_llm());
2870 assert_eq!(b.provider, "anthropic");
2871 assert_eq!(b.timeout_secs, Some(42));
2872 }
2873
2874 #[test]
2875 fn compact_and_rollup_inherit_smart_slot_with_the_120s_budget() {
2876 let llm = omlx_llm();
2877 for b in [
2878 CompactConfig::default().effective_extractive_backend(&llm),
2879 CompactConfig::default().effective_abstractive_backend(&llm),
2880 RollupConfig::default().effective_extractive_backend(&llm),
2881 RollupConfig::default().effective_abstractive_backend(&llm),
2882 ] {
2883 assert_eq!(b.provider, "openai");
2884 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
2885 assert_eq!(b.timeout_secs, Some(120));
2886 }
2887 }
2888
2889 #[test]
2890 fn rollup_override_is_honored() {
2891 let r = RollupConfig {
2892 abstractive_backend: Some(BackendConfig {
2893 provider: "ollama".into(),
2894 model: "qwen3:4b".into(),
2895 endpoint: Some("http://box.local:11434".into()),
2896 api_key_env: None,
2897 api_key_ref: None,
2898 timeout_secs: None,
2899 }),
2900 ..Default::default()
2901 };
2902 let b = r.effective_abstractive_backend(&omlx_llm());
2903 assert_eq!(b.provider, "ollama");
2904 assert_eq!(b.endpoint.as_deref(), Some("http://box.local:11434"));
2905 }
2906}
2907
2908#[cfg(test)]
2909mod skills_config_tests {
2910 use super::*;
2911
2912 #[test]
2913 fn empty_yaml_hydrates_defaults() {
2914 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
2915 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2916 assert_eq!(cfg.skills.max_total_tokens, 2000);
2917 assert!(cfg.skills.adaptive.is_some());
2918 }
2919
2920 #[test]
2921 fn load_or_default_missing_file_returns_default() {
2922 let cfg = Config::load_or_default(std::path::Path::new("/nonexistent/config.yaml"));
2923 assert_eq!(cfg.skills.max_skills_in_prompt, 5);
2924 }
2925
2926 #[test]
2927 fn dev_discipline_index_defaults_auto_and_parses() {
2928 use crate::config::DevDisciplineIndex;
2929 let cfg: Config = serde_yaml_ng::from_str("").unwrap_or_default();
2930 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Auto);
2931 let cfg: Config =
2932 serde_yaml_ng::from_str("skills:\n dev_discipline_index: never\n").unwrap();
2933 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Never);
2934 let cfg: Config =
2935 serde_yaml_ng::from_str("skills:\n dev_discipline_index: always\n").unwrap();
2936 assert_eq!(cfg.skills.dev_discipline_index, DevDisciplineIndex::Always);
2937 }
2938}
2939
2940#[cfg(test)]
2941mod runs_config_tests {
2942 use super::*;
2943
2944 #[test]
2950 fn zero_heartbeat_values_clamp_to_defaults_at_load() {
2951 let tmp = tempfile::tempdir().unwrap();
2952 let path = tmp.path().join("config.yaml");
2953 std::fs::write(
2954 &path,
2955 "runs:\n heartbeat_interval_secs: 0\n heartbeat_stale_after_intervals: 0\n",
2956 )
2957 .unwrap();
2958
2959 let cfg = Config::load_or_default(&path);
2960 assert_eq!(
2961 cfg.runs.heartbeat_interval_secs, 10,
2962 "a zero interval must load as the default, not 0"
2963 );
2964 assert_eq!(
2965 cfg.runs.heartbeat_stale_after_intervals, 3,
2966 "a zero interval count must load as the default, not 0"
2967 );
2968 }
2969
2970 #[test]
2972 fn positive_heartbeat_values_survive_the_load() {
2973 let tmp = tempfile::tempdir().unwrap();
2974 let path = tmp.path().join("config.yaml");
2975 std::fs::write(
2976 &path,
2977 "runs:\n heartbeat_interval_secs: 60\n heartbeat_stale_after_intervals: 2\n",
2978 )
2979 .unwrap();
2980
2981 let cfg = Config::load_or_default(&path);
2982 assert_eq!(cfg.runs.heartbeat_interval_secs, 60);
2983 assert_eq!(cfg.runs.heartbeat_stale_after_intervals, 2);
2984 }
2985}
2986
2987#[cfg(test)]
2988mod ambient_capture_cfg_tests {
2989 use super::*;
2990
2991 #[test]
2992 fn session_and_harvest_defaults() {
2993 let cfg: Config = serde_yaml::from_str("{}").unwrap();
2994 assert_eq!(cfg.session.capture, "ambient");
2995 assert_eq!(cfg.session.retention_days, 14);
2996 assert!(cfg.harvest.auto_gate);
2997 assert_eq!(cfg.harvest.llm, "local-first");
2998 assert_eq!(cfg.harvest.min_events, 5);
2999 assert_eq!(cfg.harvest.min_user_turns, 2);
3000 assert_eq!(cfg.harvest.min_duration_secs, 120);
3001 assert_eq!(cfg.harvest.idle_minutes, 30);
3002 assert_eq!(cfg.harvest.max_llm_calls_per_day, 10);
3003 assert_eq!(cfg.harvest.max_extract_input_tokens, 12000);
3004 assert!(cfg.harvest.session_start_hint);
3005 assert!((cfg.harvest.similarity_merge_threshold - 0.6).abs() < f32::EPSILON);
3006 }
3007
3008 #[test]
3009 fn session_capture_override_parses() {
3010 let cfg: Config =
3011 serde_yaml::from_str("session:\n capture: off\n retention_days: 3\n").unwrap();
3012 assert_eq!(cfg.session.capture, "off");
3013 assert_eq!(cfg.session.retention_days, 3);
3014 }
3015}
3016
3017#[cfg(test)]
3018mod cc_proxy_cfg_tests {
3019 use super::*;
3020
3021 #[test]
3022 fn defaults_to_local_cc_proxy_enabled() {
3023 let cfg: Config = serde_yaml_ng::from_str("{}").unwrap();
3024 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
3025 assert!(cfg.cc_proxy.enabled);
3026 }
3027
3028 #[test]
3029 fn url_and_enabled_override_parse() {
3030 let cfg: Config =
3031 serde_yaml_ng::from_str("cc_proxy:\n url: http://127.0.0.1:9999\n enabled: false\n")
3032 .unwrap();
3033 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:9999");
3034 assert!(!cfg.cc_proxy.enabled);
3035 }
3036
3037 #[test]
3038 fn partial_section_keeps_other_default() {
3039 let cfg: Config = serde_yaml_ng::from_str("cc_proxy:\n enabled: false\n").unwrap();
3041 assert_eq!(cfg.cc_proxy.url, "http://127.0.0.1:8088");
3042 assert!(!cfg.cc_proxy.enabled);
3043 }
3044}
3045
3046#[cfg(test)]
3047mod model_switch_config_tests {
3048 use super::*;
3049
3050 #[test]
3051 fn model_switch_config_defaults_and_omitted_block() {
3052 let cfg: Config = serde_yaml::from_str("{}").unwrap();
3054 assert_eq!(cfg.models.default, None);
3055 assert!(cfg.models.fallback_chain.is_empty());
3056 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
3057 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
3058 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
3059 assert!(!cfg.models.routing.enabled);
3060
3061 let yaml = "models:\n default: claude_sonnet\n fallback_chain: [claude_sonnet, deepseek_v4_pro]\n routing:\n enabled: true\n cheap: deepseek_v4_flash\n frontier: claude_opus\n threshold_input_tokens: 1500\n";
3063 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
3064 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
3065 assert_eq!(
3066 cfg.models.fallback_chain,
3067 vec!["claude_sonnet", "deepseek_v4_pro"]
3068 );
3069 assert!(cfg.models.routing.enabled);
3070 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
3071 }
3072
3073 #[test]
3074 fn smart_config_defaults_off_with_autopick() {
3075 let cfg: Config = serde_yaml::from_str("{}").unwrap();
3076 assert!(
3077 !cfg.models.smart.enabled,
3078 "Smart background routing is opt-in (capability-gate spec §2)"
3079 );
3080 assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
3082 cfg.models.smart.max_escalations,
3083 DEFAULT_SMART_MAX_ESCALATIONS
3084 );
3085 }
3086
3087 #[test]
3088 fn smart_override_inherits_field_by_field() {
3089 let global = SmartConfig {
3090 enabled: true,
3091 cheap: Some("g".into()),
3092 max_escalations: 3,
3093 };
3094 assert_eq!(global.merged(None), global);
3095 assert_eq!(global.merged(Some(&SmartOverride::default())), global);
3096 let only_cheap = SmartOverride {
3098 cheap: Some("a".into()),
3099 ..Default::default()
3100 };
3101 let m = global.merged(Some(&only_cheap));
3102 assert!(m.enabled, "overriding cheap must not disable Smart");
3103 assert_eq!(m.cheap.as_deref(), Some("a"));
3104 assert_eq!(m.max_escalations, 3);
3105 let off = SmartOverride {
3107 enabled: Some(false),
3108 ..Default::default()
3109 };
3110 assert!(!global.merged(Some(&off)).enabled);
3111 }
3112
3113 #[test]
3114 fn routing_override_inherits_field_by_field() {
3115 let global = RoutingConfig {
3116 enabled: true,
3117 cheap: Some("c".into()),
3118 frontier: Some("f".into()),
3119 threshold_input_tokens: Some(9),
3120 };
3121 let only_cheap = RoutingOverride {
3122 cheap: Some("a".into()),
3123 ..Default::default()
3124 };
3125 let m = global.merged(Some(&only_cheap));
3126 assert!(m.enabled, "overriding cheap must not disable routing");
3127 assert_eq!(m.cheap.as_deref(), Some("a"));
3128 assert_eq!(m.frontier.as_deref(), Some("f"));
3129 assert_eq!(m.threshold_input_tokens, Some(9));
3130 }
3131}
3132
3133#[cfg(test)]
3134mod notifications_config_tests {
3135 use super::*;
3136
3137 #[test]
3138 fn notifications_default_to_log_only() {
3139 let c: Config = serde_yaml::from_str("{}").unwrap();
3140 assert!(!c.notifications.desktop, "desktop must be opt-in");
3141 }
3142
3143 #[test]
3144 fn an_existing_config_without_the_block_still_parses() {
3145 let c: Config = serde_yaml::from_str("retrieval:\n min_score: 0.42\n").unwrap();
3147 assert!(!c.notifications.desktop);
3148 }
3149
3150 #[test]
3155 fn the_monitor_resolver_is_off_until_a_user_turns_it_on() {
3156 let empty: Config = serde_yaml::from_str("{}").unwrap();
3157 assert!(
3158 !empty.monitor_resolver.enabled,
3159 "asking a model must be opt-in"
3160 );
3161 assert!(empty.monitor_resolver.model.is_none());
3162
3163 let upgraded: Config = serde_yaml::from_str(
3164 "retrieval:\n min_score: 0.42\nnotifications:\n desktop: true\n",
3165 )
3166 .unwrap();
3167 assert!(
3168 !upgraded.monitor_resolver.enabled,
3169 "a config written before this feature existed must not enable it"
3170 );
3171 }
3172
3173 #[test]
3174 fn the_monitor_resolver_reads_back_what_a_user_wrote() {
3175 let c: Config =
3176 serde_yaml::from_str("monitor_resolver:\n enabled: true\n model: claude_haiku\n")
3177 .unwrap();
3178 assert!(c.monitor_resolver.enabled);
3179 assert_eq!(c.monitor_resolver.model.as_deref(), Some("claude_haiku"));
3180 }
3181
3182 #[test]
3185 fn triage_is_off_and_non_binding_by_default() {
3186 let c: Config = serde_yaml::from_str("retrieval:\n min_score: 0.42\n").unwrap();
3187 assert!(!c.triage.enabled, "triage must not run unasked");
3188 assert!(!c.triage.enforce);
3189 assert!(!c.triage.enforces());
3190 }
3191
3192 #[test]
3195 fn enforce_without_enabled_binds_nothing() {
3196 let c: Config = serde_yaml::from_str("triage:\n enforce: true\n").unwrap();
3197 assert!(c.triage.enforce, "the user's word is kept verbatim");
3198 assert!(
3199 !c.triage.enforces(),
3200 "but it binds nothing while triage does not run"
3201 );
3202 }
3203
3204 #[test]
3205 fn triage_binds_only_when_both_knobs_are_set() {
3206 let c: Config =
3207 serde_yaml::from_str("triage:\n enabled: true\n enforce: true\n").unwrap();
3208 assert!(c.triage.enforces());
3209 }
3210
3211 #[test]
3214 fn enabled_alone_is_shadow_mode() {
3215 let c: Config = serde_yaml::from_str("triage:\n enabled: true\n").unwrap();
3216 assert!(c.triage.enabled);
3217 assert!(!c.triage.enforces());
3218 }
3219}