1use super::*;
2
3#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
10#[serde(rename_all = "snake_case")]
11pub enum VoiceId {
12 #[default]
14 AfHeart,
15 AfBella,
16 AfNicole,
17 AmAdam,
18 AmMichael,
19}
20
21impl VoiceId {
22 pub fn style_index(&self) -> usize {
24 match self {
25 VoiceId::AfHeart => 0,
26 VoiceId::AfBella => 1,
27 VoiceId::AfNicole => 2,
28 VoiceId::AmAdam => 3,
29 VoiceId::AmMichael => 4,
30 }
31 }
32
33 pub fn as_str(&self) -> &'static str {
35 match self {
36 VoiceId::AfHeart => "af_heart",
37 VoiceId::AfBella => "af_bella",
38 VoiceId::AfNicole => "af_nicole",
39 VoiceId::AmAdam => "am_adam",
40 VoiceId::AmMichael => "am_michael",
41 }
42 }
43}
44
45impl std::str::FromStr for VoiceId {
46 type Err = anyhow::Error;
47
48 fn from_str(s: &str) -> anyhow::Result<Self> {
49 match s {
50 "af_heart" => Ok(VoiceId::AfHeart),
51 "af_bella" => Ok(VoiceId::AfBella),
52 "af_nicole" => Ok(VoiceId::AfNicole),
53 "am_adam" => Ok(VoiceId::AmAdam),
54 "am_michael" => Ok(VoiceId::AmMichael),
55 other => anyhow::bail!(
56 "unknown voice ID '{other}' \
57 (valid: af_heart, af_bella, af_nicole, am_adam, am_michael)"
58 ),
59 }
60 }
61}
62
63#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
66pub struct VoiceConfig {
67 #[serde(default)]
69 pub enabled: bool,
70 #[serde(default)]
72 pub voice_id: VoiceId,
73 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub input_device: Option<String>,
77}
78
79#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct HitlConfig {
85 #[serde(default = "default_hitl_timeout_secs")]
86 pub timeout_secs: u32,
87 #[serde(default)]
90 pub max_iterations: Option<u32>,
91 #[serde(default)]
94 pub max_tokens: Option<u64>,
95 #[serde(default, skip_serializing_if = "Option::is_none")]
104 pub autonomy: Option<crate::hitl::Autonomy>,
105}
106
107fn default_hitl_timeout_secs() -> u32 {
108 300
109}
110
111impl Default for HitlConfig {
112 fn default() -> Self {
113 Self {
114 timeout_secs: default_hitl_timeout_secs(),
115 max_iterations: None,
116 max_tokens: None,
117 autonomy: None,
118 }
119 }
120}
121
122#[cfg(test)]
123mod hitl_tests {
124 use super::*;
125
126 #[test]
129 fn hitl_config_autonomy_absent_means_inherit_not_continue() {
130 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60").unwrap();
131 assert_eq!(cfg.autonomy, None);
132 assert_eq!(cfg.autonomy.unwrap_or_default(), crate::hitl::Autonomy::Ask);
133 }
134
135 #[test]
136 fn hitl_config_autonomy_parses_all_three_modes() {
137 for (yaml, want) in [
138 ("continue", crate::hitl::Autonomy::Continue),
139 ("review", crate::hitl::Autonomy::Review),
140 ("ask", crate::hitl::Autonomy::Ask),
141 ] {
142 let cfg: HitlConfig =
143 serde_yaml::from_str(&format!("timeout_secs: 60\nautonomy: {yaml}")).unwrap();
144 assert_eq!(cfg.autonomy, Some(want), "yaml={yaml}");
145 }
146 }
147
148 #[test]
149 fn hitl_config_default_max_iterations_is_none() {
150 let cfg = HitlConfig::default();
151 assert!(cfg.max_iterations.is_none());
152 }
153
154 #[test]
155 fn hitl_config_max_iterations_explicit() {
156 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_iterations: 5").unwrap();
157 assert_eq!(cfg.max_iterations, Some(5));
158 }
159
160 #[test]
161 fn hitl_config_default_max_tokens_is_none() {
162 let cfg = HitlConfig::default();
163 assert!(cfg.max_tokens.is_none());
164 }
165
166 #[test]
167 fn hitl_config_max_tokens_explicit() {
168 let cfg: HitlConfig = serde_yaml::from_str("timeout_secs: 60\nmax_tokens: 250000").unwrap();
169 assert_eq!(cfg.max_tokens, Some(250_000));
170 }
171}
172
173#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
179pub struct CompanionConfig {
180 #[serde(default)]
181 pub enabled: bool,
182 #[serde(default = "default_locale")]
183 pub locale: String,
184 #[serde(default)]
185 pub relationship: Relationship,
186 #[serde(default)]
187 pub voice_overrides: VoiceOverrides,
188 #[serde(default)]
189 pub onboarding: OnboardingState,
190 #[serde(default)]
191 pub rhythm: RhythmConfig,
192 #[serde(default)]
193 pub proactive: ProactiveConfig,
194}
195
196pub fn default_locale() -> String {
205 sys_locale::get_locale()
206 .filter(|l| !l.is_empty())
207 .or_else(|| std::env::var("LANG").ok().and_then(|v| normalize_lang(&v)))
208 .unwrap_or_else(|| "en-US".into())
209}
210
211fn normalize_lang(v: &str) -> Option<String> {
213 v.split('.')
214 .next()
215 .map(|s| s.replace('_', "-"))
216 .filter(|s| !s.is_empty())
217}
218
219#[cfg(test)]
220mod locale_tests {
221 use super::normalize_lang;
222
223 #[test]
224 fn lang_with_encoding_and_region_normalizes() {
225 assert_eq!(normalize_lang("zh_TW.UTF-8").as_deref(), Some("zh-TW"));
226 }
227
228 #[test]
229 fn lang_without_encoding_normalizes() {
230 assert_eq!(normalize_lang("en_US").as_deref(), Some("en-US"));
231 }
232
233 #[test]
234 fn lang_with_script_keeps_script() {
235 assert_eq!(
236 normalize_lang("zh_Hant_TW.UTF-8").as_deref(),
237 Some("zh-Hant-TW")
238 );
239 }
240
241 #[test]
242 fn empty_lang_yields_none() {
243 assert_eq!(normalize_lang(""), None);
244 }
245}
246
247#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
248pub struct VoiceOverrides {
249 #[serde(default, skip_serializing_if = "Option::is_none")]
250 pub name_for_user: Option<String>,
251 #[serde(default, skip_serializing_if = "Option::is_none")]
252 pub formality: Option<Formality>,
253 #[serde(default, skip_serializing_if = "Option::is_none")]
254 pub extra_instructions: Option<String>,
255}
256
257#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
258pub struct FirstMemory {
259 pub text: String,
260 pub established_at: chrono::DateTime<chrono::Utc>,
261}
262
263#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
264pub struct OnboardingState {
265 #[serde(default, skip_serializing_if = "Option::is_none")]
266 pub completed_at: Option<chrono::DateTime<chrono::Utc>>,
267 #[serde(default)]
268 pub version: u32,
269 #[serde(default, skip_serializing_if = "Option::is_none")]
270 pub agent_display_name: Option<String>,
271 #[serde(default, skip_serializing_if = "Option::is_none")]
272 pub first_memory: Option<FirstMemory>,
273}
274
275#[derive(Debug, Default, Clone, PartialEq, Serialize, Deserialize)]
278pub struct RhythmConfig {
279 #[serde(default)]
280 pub enabled: bool,
281}
282
283#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
284pub struct ProactiveConfig {
285 #[serde(default)]
286 pub enabled: bool,
287 #[serde(default, skip_serializing_if = "Option::is_none")]
289 pub learning_until: Option<chrono::DateTime<chrono::Utc>>,
290 #[serde(default, skip_serializing_if = "Option::is_none")]
291 pub quiet_hours: Option<QuietHours>,
292 #[serde(default, skip_serializing_if = "Option::is_none")]
293 pub active_hours: Option<ActiveHours>,
294 #[serde(default = "default_daily_cap")]
295 pub daily_cap: u8,
296 #[serde(default = "default_channels")]
297 pub channels: Vec<String>,
298 #[serde(default, skip_serializing_if = "Option::is_none")]
299 pub paused_until: Option<chrono::DateTime<chrono::Utc>>,
300}
301
302impl Default for ProactiveConfig {
303 fn default() -> Self {
304 Self {
305 enabled: false,
306 learning_until: None,
307 quiet_hours: None,
308 active_hours: None,
309 daily_cap: default_daily_cap(),
310 channels: default_channels(),
311 paused_until: None,
312 }
313 }
314}
315
316fn default_daily_cap() -> u8 {
317 3
318}
319fn default_channels() -> Vec<String> {
320 vec!["stdout".into()]
321}
322
323#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
324pub struct QuietHours {
325 pub start: String,
326 pub end: String,
327}
328
329#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
330pub struct ActiveHours {
331 pub start: String,
332 pub end: String,
333}
334
335#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
340pub struct AgentAppearance {
341 #[serde(default = "default_style_preset")]
343 pub style_preset: String,
344 #[serde(default)]
345 pub behavior_preset: BehaviorPreset,
346 #[serde(default, skip_serializing_if = "Option::is_none")]
348 pub source_image_path: Option<std::path::PathBuf>,
349 #[serde(default = "default_expressions_dir")]
351 pub expressions_dir: std::path::PathBuf,
352 #[serde(default, skip_serializing_if = "Option::is_none")]
353 pub last_rendered_at: Option<chrono::DateTime<chrono::Utc>>,
354 #[serde(default)]
355 pub render_status: RenderStatus,
356}
357
358fn default_style_preset() -> String {
359 "default-blob".into()
360}
361
362fn default_expressions_dir() -> std::path::PathBuf {
363 std::path::PathBuf::from("expressions")
364}
365
366impl Default for AgentAppearance {
367 fn default() -> Self {
368 Self {
369 style_preset: default_style_preset(),
370 behavior_preset: BehaviorPreset::Normal,
371 source_image_path: None,
372 expressions_dir: default_expressions_dir(),
373 last_rendered_at: None,
374 render_status: RenderStatus::Pending,
375 }
376 }
377}
378
379#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
380#[serde(rename_all = "snake_case")]
381pub enum BehaviorPreset {
382 Quiet,
383 #[default]
384 Normal,
385 Lively,
386}
387
388#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
389#[serde(tag = "status", rename_all = "snake_case")]
390pub enum RenderStatus {
391 #[default]
392 Pending,
393 Rendering {
394 done: u8,
395 total: u8,
396 },
397 Ready,
398 Failed {
399 reason: String,
400 },
401}
402
403#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
409#[serde(rename_all = "kebab-case")]
410pub enum SnapshotPolicy {
411 #[default]
412 PullOnStart,
413 PullPeriodic,
414 Manual,
415}
416
417#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
419pub struct PatternFilter {
420 #[serde(default, skip_serializing_if = "Vec::is_empty")]
421 pub applies_in: Vec<String>,
422 #[serde(default, skip_serializing_if = "Vec::is_empty")]
423 pub tier: Vec<String>,
424 #[serde(default, skip_serializing_if = "Vec::is_empty")]
425 pub maturity: Vec<String>,
426 #[serde(default)]
427 pub importance_min: f64,
428 #[serde(default = "default_max_snapshot_count")]
429 pub max_count: usize,
430 #[serde(default)]
431 pub snapshot_policy: SnapshotPolicy,
432}
433
434fn default_max_snapshot_count() -> usize {
435 200
436}
437
438impl Default for PatternFilter {
439 fn default() -> Self {
440 Self {
441 applies_in: vec![],
442 tier: vec![],
443 maturity: vec![],
444 importance_min: 0.0,
445 max_count: 200,
446 snapshot_policy: SnapshotPolicy::default(),
447 }
448 }
449}
450
451#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
453pub struct SnapshotRef {
454 pub knowledge_commit: String,
455 pub taken_at: String,
456 pub filter: PatternFilter,
457}
458
459#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
461pub struct FederationConfig {
462 #[serde(default)]
463 pub filter: PatternFilter,
464 #[serde(default, skip_serializing_if = "Option::is_none")]
465 pub snapshot_ref: Option<SnapshotRef>,
466 #[serde(default)]
467 pub evidence_flush_interval_minutes: u32,
468}
469
470#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
478#[serde(rename_all = "snake_case")]
479pub enum ProactiveTier {
480 Off,
481 WarmOnly,
482 WarmAndBehavior,
483 All,
484}
485
486impl ProactiveTier {
487 pub fn from_config(c: &CompanionConfig) -> Self {
488 match (c.enabled, c.rhythm.enabled, c.proactive.enabled) {
489 (false, _, _) => Self::Off,
490 (true, false, false) => Self::WarmOnly,
491 (true, true, false) => Self::WarmAndBehavior,
492 (true, _, true) => Self::All,
493 }
494 }
495
496 pub fn apply(&self, c: &mut CompanionConfig) {
497 match self {
498 Self::Off => {
499 c.enabled = false;
500 c.rhythm.enabled = false;
501 c.proactive.enabled = false;
502 }
503 Self::WarmOnly => {
504 c.enabled = true;
505 c.rhythm.enabled = false;
506 c.proactive.enabled = false;
507 }
508 Self::WarmAndBehavior => {
509 c.enabled = true;
510 c.rhythm.enabled = true;
511 c.proactive.enabled = false;
512 }
513 Self::All => {
514 c.enabled = true;
515 c.rhythm.enabled = true;
516 c.proactive.enabled = true;
517 }
518 }
519 }
520}
521
522#[cfg(test)]
523mod voice_tests {
524 use super::*;
525 use std::str::FromStr;
526
527 #[test]
528 fn voice_config_round_trips() {
529 let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
531 let yaml = format!("{base}voice:\n enabled: true\n voice_id: af_bella\n");
532
533 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with voice");
534 assert!(profile.voice.enabled);
535 assert_eq!(profile.voice.voice_id, VoiceId::AfBella);
536
537 let legacy: AgentProfile = serde_yaml_ng::from_str(base).expect("parse without voice");
539 assert!(!legacy.voice.enabled);
540 assert_eq!(legacy.voice.voice_id, VoiceId::AfHeart);
541 }
542
543 #[test]
544 fn voice_id_from_str_roundtrips() {
545 let cases = [
546 ("af_heart", VoiceId::AfHeart),
547 ("af_bella", VoiceId::AfBella),
548 ("af_nicole", VoiceId::AfNicole),
549 ("am_adam", VoiceId::AmAdam),
550 ("am_michael", VoiceId::AmMichael),
551 ];
552 for (s, expected) in cases {
553 assert_eq!(VoiceId::from_str(s).unwrap(), expected);
554 assert_eq!(expected.as_str(), s);
555 }
556 }
557
558 #[test]
559 fn voice_id_from_str_rejects_unknown() {
560 assert!(VoiceId::from_str("bogus").is_err());
561 }
562}
563
564#[cfg(test)]
565mod appearance_tests {
566 use super::*;
567
568 #[test]
569 fn appearance_default_style_preset_is_default_blob() {
570 assert_eq!(AgentAppearance::default().style_preset, "default-blob");
571 }
572
573 #[test]
574 fn appearance_default_behavior_is_normal() {
575 assert_eq!(
576 AgentAppearance::default().behavior_preset,
577 BehaviorPreset::Normal
578 );
579 }
580
581 #[test]
582 fn appearance_default_render_status_is_pending() {
583 assert_eq!(
584 AgentAppearance::default().render_status,
585 RenderStatus::Pending
586 );
587 }
588
589 #[test]
590 fn render_status_serde_round_trip() {
591 let cases = [
592 RenderStatus::Pending,
593 RenderStatus::Rendering { done: 3, total: 12 },
594 RenderStatus::Ready,
595 RenderStatus::Failed {
596 reason: "out of quota".into(),
597 },
598 ];
599 for status in cases {
600 let yaml = serde_yaml_ng::to_string(&status).expect("serialize");
601 let back: RenderStatus = serde_yaml_ng::from_str(&yaml).expect("deserialize");
602 assert_eq!(status, back);
603 }
604 }
605
606 #[test]
607 fn agent_profile_with_appearance_round_trips() {
608 let base = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
609 let yaml = format!(
610 "{base}appearance:\n style_preset: chiikawa\n render_status:\n status: ready\n"
611 );
612 let profile: AgentProfile = serde_yaml_ng::from_str(&yaml).expect("parse with appearance");
613 assert_eq!(profile.appearance.style_preset, "chiikawa");
614 assert_eq!(profile.appearance.render_status, RenderStatus::Ready);
615
616 let out = serde_yaml_ng::to_string(&profile).expect("serialize");
617 let back: AgentProfile = serde_yaml_ng::from_str(&out).expect("re-parse");
618 assert_eq!(profile.appearance, back.appearance);
619 }
620
621 #[test]
622 fn legacy_profile_without_appearance_uses_default() {
623 let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
624 let profile: AgentProfile = serde_yaml_ng::from_str(yaml).expect("parse legacy");
625 assert_eq!(profile.appearance.style_preset, "default-blob");
626 assert_eq!(profile.appearance.behavior_preset, BehaviorPreset::Normal);
627 assert_eq!(profile.appearance.render_status, RenderStatus::Pending);
628 }
629
630 #[test]
631 fn legacy_profile_without_file_actions_or_action_pipeline_loads() {
632 let yaml = include_str!("../../tests/fixtures/profile_p0a_minimal.yaml");
633 let p: AgentProfile = serde_yaml_ng::from_str(yaml).unwrap();
634 assert!(p.file_actions.is_empty());
635 assert_eq!(p.action_pipeline.deletion.cancel_window_minutes, 10);
636 assert_eq!(p.action_pipeline.queue.max_concurrent, 3);
637 }
638}
639
640#[cfg(test)]
641mod federation_tests {
642 use super::*;
643
644 #[test]
645 fn test_pattern_filter_default() {
646 let f = PatternFilter::default();
647 assert_eq!(f.max_count, 200);
648 assert_eq!(f.importance_min, 0.0);
649 assert!(f.tier.is_empty());
650 }
651
652 #[test]
653 fn test_federation_config_roundtrip() {
654 let cfg = FederationConfig {
655 filter: PatternFilter {
656 tier: vec!["core".into()],
657 max_count: 50,
658 ..Default::default()
659 },
660 snapshot_ref: Some(SnapshotRef {
661 knowledge_commit: "abc123def456".into(),
662 taken_at: "2026-05-19T00:00:00Z".into(),
663 filter: PatternFilter::default(),
664 }),
665 evidence_flush_interval_minutes: 15,
666 };
667 let yaml = serde_yaml_ng::to_string(&cfg).unwrap();
668 let back: FederationConfig = serde_yaml_ng::from_str(&yaml).unwrap();
669 assert_eq!(cfg, back);
670 }
671
672 #[test]
673 fn test_agent_profile_federation_defaults() {
674 let cfg = FederationConfig::default();
678 assert_eq!(cfg.evidence_flush_interval_minutes, 0);
679 assert!(cfg.snapshot_ref.is_none());
680 }
681}