1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4mod attribution;
5mod backend;
6mod conversations;
7mod ops;
8mod scratch;
9mod search;
10mod skills;
11
12pub use attribution::*;
13pub use backend::*;
14pub use conversations::*;
15pub use ops::*;
16pub use scratch::*;
17pub use search::*;
18pub use skills::*;
19
20use ops::{
21 default_heartbeat_interval_secs, default_heartbeat_stale_after_intervals, default_rotate_at_mb,
22};
23
24pub const DEFAULT_LOCAL_LLM_MODEL: &str = "qwen3.5:4b";
25
26pub const DEFAULT_BUNDLED_MODEL_ID: &str = "Qwen3.5-2B-MLX-4bit";
31
32pub const DEFAULT_MAX_RETRIES: u32 = 1;
33pub const DEFAULT_BACKOFF_BASE_MS: u64 = 500;
34pub const DEFAULT_COOLDOWN_SECS: u64 = 60;
35pub const DEFAULT_ROUTING_THRESHOLD: u32 = 2000;
36pub const DEFAULT_SMART_MAX_ESCALATIONS: u32 = 1;
37
38pub const DEFAULT_SMART_ENABLED: bool = false;
42
43pub const DEFAULT_OLLAMA_ENDPOINT: &str = "http://localhost:11434";
48
49fn default_max_retries() -> u32 {
50 DEFAULT_MAX_RETRIES
51}
52fn default_backoff_base_ms() -> u64 {
53 DEFAULT_BACKOFF_BASE_MS
54}
55fn default_cooldown_secs() -> u64 {
56 DEFAULT_COOLDOWN_SECS
57}
58fn default_smart_max_escalations() -> u32 {
59 DEFAULT_SMART_MAX_ESCALATIONS
60}
61fn default_smart_enabled() -> bool {
62 DEFAULT_SMART_ENABLED
63}
64
65#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
71pub struct SmartConfig {
72 #[serde(default = "default_smart_enabled")]
73 pub enabled: bool,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub cheap: Option<String>,
76 #[serde(default = "default_smart_max_escalations")]
77 pub max_escalations: u32,
78}
79
80impl Default for SmartConfig {
81 fn default() -> Self {
82 Self {
83 enabled: DEFAULT_SMART_ENABLED,
84 cheap: None,
85 max_escalations: DEFAULT_SMART_MAX_ESCALATIONS,
86 }
87 }
88}
89
90#[derive(Debug, Clone, Serialize, Deserialize, Default)]
93pub struct ModelSwitchConfig {
94 #[serde(default, skip_serializing_if = "Option::is_none")]
96 pub default: Option<String>,
97 #[serde(default, skip_serializing_if = "Vec::is_empty")]
99 pub fallback_chain: Vec<String>,
100 #[serde(default)]
101 pub retry: RetryConfig,
102 #[serde(default)]
103 pub routing: RoutingConfig,
104 #[serde(default)]
105 pub smart: SmartConfig,
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
109pub struct RetryConfig {
110 #[serde(default = "default_max_retries")]
111 pub max_retries: u32,
112 #[serde(default = "default_backoff_base_ms")]
113 pub backoff_base_ms: u64,
114 #[serde(default = "default_cooldown_secs")]
115 pub cooldown_secs: u64,
116}
117
118impl Default for RetryConfig {
119 fn default() -> Self {
120 Self {
121 max_retries: DEFAULT_MAX_RETRIES,
122 backoff_base_ms: DEFAULT_BACKOFF_BASE_MS,
123 cooldown_secs: DEFAULT_COOLDOWN_SECS,
124 }
125 }
126}
127
128#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
129pub struct RoutingConfig {
130 #[serde(default)]
131 pub enabled: bool,
132 #[serde(default, skip_serializing_if = "Option::is_none")]
133 pub cheap: Option<String>,
134 #[serde(default, skip_serializing_if = "Option::is_none")]
135 pub frontier: Option<String>,
136 #[serde(default, skip_serializing_if = "Option::is_none")]
137 pub threshold_input_tokens: Option<u32>,
138}
139
140#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
145pub struct SmartOverride {
146 #[serde(default, skip_serializing_if = "Option::is_none")]
147 pub enabled: Option<bool>,
148 #[serde(default, skip_serializing_if = "Option::is_none")]
149 pub cheap: Option<String>,
150 #[serde(default, skip_serializing_if = "Option::is_none")]
151 pub max_escalations: Option<u32>,
152}
153
154#[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq)]
157pub struct RoutingOverride {
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub enabled: Option<bool>,
160 #[serde(default, skip_serializing_if = "Option::is_none")]
161 pub cheap: Option<String>,
162 #[serde(default, skip_serializing_if = "Option::is_none")]
163 pub frontier: Option<String>,
164 #[serde(default, skip_serializing_if = "Option::is_none")]
165 pub threshold_input_tokens: Option<u32>,
166 #[serde(default, skip_serializing_if = "Option::is_none")]
171 pub smart: Option<SmartOverride>,
172}
173
174impl SmartConfig {
175 pub fn merged(&self, ov: Option<&SmartOverride>) -> SmartConfig {
177 let Some(o) = ov else { return self.clone() };
178 SmartConfig {
179 enabled: o.enabled.unwrap_or(self.enabled),
180 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
181 max_escalations: o.max_escalations.unwrap_or(self.max_escalations),
182 }
183 }
184}
185
186impl RoutingConfig {
187 pub fn merged(&self, ov: Option<&RoutingOverride>) -> RoutingConfig {
189 let Some(o) = ov else { return self.clone() };
190 RoutingConfig {
191 enabled: o.enabled.unwrap_or(self.enabled),
192 cheap: o.cheap.clone().or_else(|| self.cheap.clone()),
193 frontier: o.frontier.clone().or_else(|| self.frontier.clone()),
194 threshold_input_tokens: o.threshold_input_tokens.or(self.threshold_input_tokens),
195 }
196 }
197}
198
199#[derive(Debug, Clone, Serialize, Deserialize, Default)]
201pub struct Config {
202 #[serde(default)]
203 pub embedding: EmbeddingConfig,
204
205 #[serde(default)]
206 pub llm: LlmConfig,
207
208 #[serde(default)]
209 pub models: ModelSwitchConfig,
210
211 #[serde(default)]
212 pub retrieval: RetrievalConfig,
213
214 #[serde(default)]
215 pub paths: PathConfig,
216
217 #[serde(default)]
218 pub server: ServerConfig,
219
220 #[serde(default)]
221 pub community: CommunityConfig,
222
223 #[serde(default)]
224 pub conversations: ConversationsConfig,
225
226 #[serde(default)]
227 pub sync: SyncConfig,
228
229 #[serde(default)]
231 pub storage: StorageConfig,
232
233 #[serde(default)]
234 pub sources_global: SourcesGlobalConfig,
235
236 #[serde(default)]
238 pub sleep_cycle: SleepCycleConfig,
239
240 #[serde(default)]
242 pub skills: SkillsConfig,
243
244 #[serde(default)]
246 pub skill_llm: SkillLlmConfig,
247
248 #[serde(default)]
250 pub cross_agent: CrossAgentConfig,
251
252 #[serde(default)]
254 pub nudge: NudgeConfig,
255
256 #[serde(default)]
258 pub mobile_relay: MobileRelayConfig,
259
260 #[serde(default)]
262 pub session: SessionCfg,
263
264 #[serde(default)]
265 pub harvest: HarvestCfg,
266
267 #[serde(default)]
269 pub cc_proxy: CcProxyConfig,
270
271 #[serde(default)]
273 pub cli: CliConfig,
274
275 #[serde(default)]
277 pub parallel_jobs: ParallelJobsConfig,
278
279 #[serde(default)]
281 pub federation_snapshot: SnapshotConfig,
282
283 #[serde(default)]
285 pub memory: MemoryConfig,
286
287 #[serde(default)]
289 pub fleet_run: FleetRunConfig,
290
291 #[serde(default)]
293 pub open_items: OpenItemsConfig,
294
295 #[serde(default)]
297 pub fleet: FleetConfig,
298
299 #[serde(default)]
301 pub update: UpdateConfig,
302
303 #[serde(default)]
305 pub runs: RunsConfig,
306
307 #[serde(default)]
309 pub capture: CaptureConfig,
310
311 #[serde(default)]
315 pub limits: crate::limits::Limits,
316
317 #[serde(default)]
320 pub notifications: NotificationsConfig,
321
322 #[serde(default)]
325 pub monitor_resolver: MonitorResolverConfig,
326
327 #[serde(default)]
330 pub triage: TriageConfig,
331
332 #[serde(default)]
334 pub attribution: AttributionConfig,
335
336 #[serde(default)]
338 pub scratch: ScratchConfig,
339
340 #[serde(default)]
342 pub search: SearchConfig,
343}
344
345impl Config {
346 pub fn load_or_default(path: &std::path::Path) -> Self {
351 let Ok(text) = std::fs::read_to_string(path) else {
352 return Self::default();
353 };
354 let text = crate::config_migrate::migrate_conversations_yaml(&text).unwrap_or(text);
355 let mut cfg: Self = serde_yaml_ng::from_str(&text).unwrap_or_default();
356 cfg.sanitize();
357 cfg
358 }
359
360 fn sanitize(&mut self) {
365 if self.runs.heartbeat_interval_secs == 0 {
369 self.runs.heartbeat_interval_secs = default_heartbeat_interval_secs();
370 }
371 if self.capture.rotate_at_mb == 0 {
373 self.capture.rotate_at_mb = default_rotate_at_mb();
374 }
375 if self.runs.heartbeat_stale_after_intervals == 0 {
376 self.runs.heartbeat_stale_after_intervals = default_heartbeat_stale_after_intervals();
377 }
378 }
379}
380
381#[cfg(test)]
382mod tests {
383 #[test]
384 fn update_defaults_apply_when_block_absent_and_fields_parse() {
385 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
386 assert_eq!(c.update, super::UpdateConfig::default());
387 assert!(c.update.codesign_identity.is_none());
388 assert!(c.update.restart_exclude.is_empty());
389
390 let c: super::Config = serde_yaml_ng::from_str(
391 "update:\n codesign_identity: \"Developer ID Application: X (TEAM)\"\n restart_exclude: [dr_worker_1]\n",
392 )
393 .unwrap();
394 assert_eq!(
395 c.update.codesign_identity.as_deref(),
396 Some("Developer ID Application: X (TEAM)")
397 );
398 assert_eq!(c.update.restart_exclude, vec!["dr_worker_1".to_string()]);
399 }
400
401 #[test]
402 fn federation_snapshot_defaults_apply_when_block_absent() {
403 let c: super::Config = serde_yaml_ng::from_str("{}").unwrap();
404 assert_eq!(c.federation_snapshot.poll_secs, 30);
405 assert_eq!(c.federation_snapshot.request_max_age_secs, 600);
406 assert_eq!(
407 c.federation_snapshot.min_lifecycle,
408 crate::skill::stats::LifecycleState::Stable
409 );
410 assert_eq!(c.memory.capture, super::CaptureMode::AutoAnnounce);
412 let c: super::Config = serde_yaml_ng::from_str("memory:\n capture: off\n").unwrap();
413 assert_eq!(c.memory.capture, super::CaptureMode::Off);
414 }
415
416 use super::*;
417
418 #[test]
419 fn default_bundled_model_id_is_qwen35_2b() {
420 assert_eq!(
421 crate::config::DEFAULT_BUNDLED_MODEL_ID,
422 "Qwen3.5-2B-MLX-4bit"
423 );
424 }
425
426 #[test]
427 fn nudge_config_defaults() {
428 let c = NudgeConfig::default();
429 assert!(c.enabled);
430 assert_eq!(c.daily_cap, 3);
431 assert_eq!(c.snooze_days, 7);
432 assert_eq!(c.threshold, 3);
433 }
434
435 #[test]
436 fn config_has_nudge_section_with_defaults() {
437 let c: Config = serde_yaml_ng::from_str("{}").unwrap();
438 assert_eq!(c.nudge.daily_cap, 3);
439 }
440
441 #[test]
442 fn storage_config_default_is_lancedb() {
443 let c = StorageConfig::default();
444 assert_eq!(c.vector_backend, "lancedb");
445 assert_eq!(c.qdrant_url, None);
446 assert_eq!(c.qdrant_api_key_ref, None);
447 }
448
449 #[test]
450 fn sources_global_config_has_sensible_defaults() {
451 let c = SourcesGlobalConfig::default();
452 assert_eq!(c.poll_interval_secs, 600);
453 assert_eq!(c.max_chunks_per_sync, 10_000);
454 assert_eq!(c.max_parallel_sources, 3);
455 assert_eq!(c.default_weight, 1.0);
456 assert_eq!(c.embedding_batch_size, 32);
457 }
458
459 #[test]
460 fn config_default_has_storage_and_sources_global() {
461 let c = Config::default();
462 assert_eq!(c.storage.vector_backend, "lancedb");
463 assert_eq!(c.sources_global.default_weight, 1.0);
464 }
465
466 #[test]
467 fn config_loads_yaml_without_new_fields() {
468 let yaml = r#"
471embedding:
472 provider: ollama
473 model: test-model
474 dimensions: 512
475 ollama_endpoint: http://localhost:11434
476"#;
477 let c: Config = serde_yaml::from_str(yaml).expect("parses");
478 assert_eq!(c.storage.vector_backend, "lancedb");
479 assert_eq!(c.sources_global.max_parallel_sources, 3);
480 }
481
482 #[test]
483 fn llm_config_to_backend_config_anthropic_passthrough() {
484 let cfg = LlmConfig {
485 provider: "anthropic".into(),
486 model: "claude-haiku-4-5".into(),
487 api_key_env: Some("ANTHROPIC_API_KEY".into()),
488 api_key_ref: None,
489 openai_url: None,
490 };
491 let b = cfg.to_backend_config();
492 assert_eq!(b.provider, "anthropic");
493 assert_eq!(b.model, "claude-haiku-4-5");
494 assert_eq!(b.api_key_env.as_deref(), Some("ANTHROPIC_API_KEY"));
495 assert_eq!(b.endpoint, None);
496 assert_eq!(b.timeout_secs, None);
497 }
498
499 #[test]
500 fn llm_config_to_backend_config_openai_url_maps_to_endpoint() {
501 let cfg = LlmConfig {
502 provider: "openai".into(),
503 model: "gpt-4o-mini".into(),
504 api_key_env: None,
505 api_key_ref: None,
506 openai_url: Some("https://api.together.xyz/v1".into()),
507 };
508 let b = cfg.to_backend_config();
509 assert_eq!(b.provider, "openai");
510 assert_eq!(b.endpoint.as_deref(), Some("https://api.together.xyz/v1"));
511 assert_eq!(b.api_key_env, None); }
513
514 #[test]
515 fn llm_config_to_backend_config_ollama_openai_url_maps_to_endpoint() {
516 let cfg = LlmConfig {
517 provider: "ollama".into(),
518 model: "qwen3:14b".into(),
519 api_key_env: None,
520 api_key_ref: None,
521 openai_url: Some("http://192.168.1.10:11434".into()),
522 };
523 let b = cfg.to_backend_config();
524 assert_eq!(b.provider, "ollama");
525 assert_eq!(b.endpoint.as_deref(), Some("http://192.168.1.10:11434"));
526 }
527
528 #[test]
529 fn llm_config_to_backend_config_unknown_with_openai_url_aliases_to_openai() {
530 let cfg = LlmConfig {
534 provider: "custom-name".into(),
535 model: "some-model".into(),
536 api_key_env: Some("CUSTOM_KEY".into()),
537 api_key_ref: None,
538 openai_url: Some("https://my-proxy.local/v1".into()),
539 };
540 let b = cfg.to_backend_config();
541 assert_eq!(
542 b.provider, "openai",
543 "unknown provider + openai_url should alias to openai"
544 );
545 assert_eq!(b.endpoint.as_deref(), Some("https://my-proxy.local/v1"));
546 }
547
548 #[test]
549 fn api_key_ref_roundtrips_and_defaults_none() {
550 let b: BackendConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
552 assert_eq!(b.api_key_ref, None);
553 let l: LlmConfig = serde_yaml_ng::from_str("provider: anthropic\nmodel: m\n").unwrap();
554 assert_eq!(l.api_key_ref, None);
555 let e: EmbeddingConfig = serde_yaml_ng::from_str("provider: ollama\nmodel: m\n").unwrap();
556 assert_eq!(e.api_key_ref, None);
557
558 let l2 = LlmConfig {
560 api_key_ref: Some("keychain:mur/anthropic".into()),
561 ..Default::default()
562 };
563 let y = serde_yaml_ng::to_string(&l2).unwrap();
564 let l3: LlmConfig = serde_yaml_ng::from_str(&y).unwrap();
565 assert_eq!(l3.api_key_ref.as_deref(), Some("keychain:mur/anthropic"));
566 assert_eq!(
567 l3.to_backend_config().api_key_ref.as_deref(),
568 Some("keychain:mur/anthropic")
569 );
570 }
571
572 #[test]
573 fn open_items_muted_parses_and_defaults_empty() {
574 let c: Config = serde_yaml::from_str("open_items:\n muted:\n - inbox\n").unwrap();
575 assert_eq!(c.open_items.muted, vec!["inbox".to_string()]);
576
577 let d: Config = serde_yaml::from_str("llm:\n model: x\n").unwrap();
578 assert!(d.open_items.muted.is_empty(), "must default to no mutes");
579 }
580
581 #[test]
584 fn unreadable_config_yields_no_mutes() {
585 let tmp = tempfile::tempdir().unwrap();
586 let path = tmp.path().join("config.yaml");
587 std::fs::write(&path, "this: is: not: valid: yaml: [[[\n").unwrap();
588 let cfg = Config::load_or_default(&path);
589 assert!(
590 cfg.open_items.muted.is_empty(),
591 "a broken config must hide nothing"
592 );
593
594 let missing = Config::load_or_default(&tmp.path().join("nope.yaml"));
596 assert!(missing.open_items.muted.is_empty());
597 }
598
599 #[test]
600 fn rollup_config_accepts_backend_overrides() {
601 let yaml = r#"
602enabled: true
603extractive_backend:
604 provider: openai
605 model: Qwen3.5-4B-MLX-4bit
606 endpoint: http://127.0.0.1:8000/v1
607"#;
608 let c: RollupConfig = serde_yaml_ng::from_str(yaml).expect("parses");
609 let b = c.extractive_backend.expect("override present");
610 assert_eq!(b.provider, "openai");
611 assert_eq!(b.model, "Qwen3.5-4B-MLX-4bit");
612 assert_eq!(b.endpoint.as_deref(), Some("http://127.0.0.1:8000/v1"));
613 assert!(c.abstractive_backend.is_none());
614 }
615
616 #[test]
617 fn legacy_conversation_fields_are_gone_from_serialized_output() {
618 let cfg = Config::default();
619 let yaml = serde_yaml_ng::to_string(&cfg.conversations).expect("serializes");
625 for key in ["extractive_model", "abstractive_model", "ollama_endpoint"] {
626 assert!(
627 !yaml.contains(key),
628 "legacy key {key} still serialized:\n{yaml}"
629 );
630 }
631 }
632
633 #[test]
634 fn embedding_ollama_endpoint_is_omitted_when_unset() {
635 let mut cfg = Config::default();
636 cfg.embedding.provider = "omlx".into();
637 cfg.embedding.openai_url = Some("http://127.0.0.1:8000/v1".into());
638 cfg.embedding.ollama_endpoint = None;
639 let yaml = serde_yaml_ng::to_string(&cfg).expect("serializes");
640 assert!(
641 !yaml.contains("ollama_endpoint"),
642 "dead field re-emitted:\n{yaml}"
643 );
644 }
645
646 #[test]
647 fn embedding_ollama_endpoint_still_round_trips_when_set() {
648 let yaml =
649 "provider: ollama\nmodel: nomic-embed-text\nollama_endpoint: http://box.local:11434\n";
650 let e: EmbeddingConfig = serde_yaml_ng::from_str(yaml).expect("parses");
651 assert_eq!(e.ollama_endpoint.as_deref(), Some("http://box.local:11434"));
652 }
653}
654
655#[cfg(test)]
656mod model_switch_config_tests {
657 use super::*;
658
659 #[test]
660 fn model_switch_config_defaults_and_omitted_block() {
661 let cfg: Config = serde_yaml::from_str("{}").unwrap();
663 assert_eq!(cfg.models.default, None);
664 assert!(cfg.models.fallback_chain.is_empty());
665 assert_eq!(cfg.models.retry.max_retries, DEFAULT_MAX_RETRIES);
666 assert_eq!(cfg.models.retry.backoff_base_ms, DEFAULT_BACKOFF_BASE_MS);
667 assert_eq!(cfg.models.retry.cooldown_secs, DEFAULT_COOLDOWN_SECS);
668 assert!(!cfg.models.routing.enabled);
669
670 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";
672 let cfg: Config = serde_yaml::from_str(yaml).unwrap();
673 assert_eq!(cfg.models.default.as_deref(), Some("claude_sonnet"));
674 assert_eq!(
675 cfg.models.fallback_chain,
676 vec!["claude_sonnet", "deepseek_v4_pro"]
677 );
678 assert!(cfg.models.routing.enabled);
679 assert_eq!(cfg.models.routing.threshold_input_tokens, Some(1500));
680 }
681
682 #[test]
683 fn smart_config_defaults_off_with_autopick() {
684 let cfg: Config = serde_yaml::from_str("{}").unwrap();
685 assert!(
686 !cfg.models.smart.enabled,
687 "Smart background routing is opt-in (capability-gate spec §2)"
688 );
689 assert_eq!(cfg.models.smart.cheap, None); assert_eq!(
691 cfg.models.smart.max_escalations,
692 DEFAULT_SMART_MAX_ESCALATIONS
693 );
694 }
695
696 #[test]
697 fn smart_override_inherits_field_by_field() {
698 let global = SmartConfig {
699 enabled: true,
700 cheap: Some("g".into()),
701 max_escalations: 3,
702 };
703 assert_eq!(global.merged(None), global);
704 assert_eq!(global.merged(Some(&SmartOverride::default())), global);
705 let only_cheap = SmartOverride {
707 cheap: Some("a".into()),
708 ..Default::default()
709 };
710 let m = global.merged(Some(&only_cheap));
711 assert!(m.enabled, "overriding cheap must not disable Smart");
712 assert_eq!(m.cheap.as_deref(), Some("a"));
713 assert_eq!(m.max_escalations, 3);
714 let off = SmartOverride {
716 enabled: Some(false),
717 ..Default::default()
718 };
719 assert!(!global.merged(Some(&off)).enabled);
720 }
721
722 #[test]
723 fn routing_override_inherits_field_by_field() {
724 let global = RoutingConfig {
725 enabled: true,
726 cheap: Some("c".into()),
727 frontier: Some("f".into()),
728 threshold_input_tokens: Some(9),
729 };
730 let only_cheap = RoutingOverride {
731 cheap: Some("a".into()),
732 ..Default::default()
733 };
734 let m = global.merged(Some(&only_cheap));
735 assert!(m.enabled, "overriding cheap must not disable routing");
736 assert_eq!(m.cheap.as_deref(), Some("a"));
737 assert_eq!(m.frontier.as_deref(), Some("f"));
738 assert_eq!(m.threshold_input_tokens, Some(9));
739 }
740}