1use regex::Regex;
11use toml_edit::{Array, DocumentMut, Item, Table, Value};
12
13mod features;
15mod infra;
16mod llm;
17mod mcp;
18mod memory;
19mod serve;
20mod session;
21mod tools;
22
23pub use features::{
24 migrate_autodream_config, migrate_caveman_config, migrate_compression_predictor_config,
25 migrate_deep_link_config, migrate_five_signal_config, migrate_goals_config,
26 migrate_knowledge_config, migrate_magic_docs_config, migrate_microcompact_config,
27 migrate_orchestration_asset_sensitivity, migrate_orchestration_persistence,
28 migrate_tui_delights, migrate_tui_mouse, migrate_tui_theme_config, migrate_tui_theme_defaults,
29};
30pub use infra::*;
31pub(crate) use llm::migrate_gonkagate_to_gonka;
33pub use llm::*;
34pub use mcp::*;
35pub use memory::*;
36pub use serve::migrate_serve_config;
37pub use session::*;
38pub use tools::*;
39
40#[must_use]
52pub fn section_header_present(src: &str, name: &str) -> bool {
53 let escaped = regex::escape(name);
55 let pattern = format!(r"^\[{escaped}(?:\.[^\]]+)?\](?:\s*#.*)?$");
58 let re = Regex::new(&pattern).expect("regex::escape always produces a valid pattern");
59 src.lines()
60 .filter(|line| !line.trim_start().starts_with('#'))
61 .any(|line| re.is_match(line.trim()))
62}
63
64static CANONICAL_ORDER: &[&str] = &[
66 "agent",
67 "llm",
68 "skills",
69 "memory",
70 "index",
71 "tools",
72 "mcp",
73 "telegram",
74 "discord",
75 "slack",
76 "a2a",
77 "acp",
78 "gateway",
79 "metrics",
80 "daemon",
81 "scheduler",
82 "orchestration",
83 "classifiers",
84 "security",
85 "vault",
86 "timeouts",
87 "cost",
88 "debug",
89 "logging",
90 "notifications",
91 "tui",
92 "agents",
93 "experiments",
94 "lsp",
95 "telemetry",
96 "session",
97 "deep_link",
98];
99
100#[derive(Debug, thiserror::Error)]
102#[non_exhaustive]
103pub enum MigrateError {
104 #[error("failed to parse input config: {0}")]
106 Parse(#[from] toml_edit::TomlError),
107 #[error("failed to parse reference config: {0}")]
109 Reference(toml_edit::TomlError),
110 #[error("migration failed: invalid TOML structure — {0}")]
113 InvalidStructure(&'static str),
114}
115
116#[derive(Debug)]
118pub struct MigrationResult {
119 pub output: String,
121 pub changed_count: usize,
123 pub sections_changed: Vec<String>,
125}
126
127pub struct ConfigMigrator {
132 reference_src: &'static str,
133}
134
135impl Default for ConfigMigrator {
136 fn default() -> Self {
137 Self::new()
138 }
139}
140
141impl ConfigMigrator {
142 #[must_use]
144 pub fn new() -> Self {
145 Self {
146 reference_src: include_str!("../../config/default.toml"),
147 }
148 }
149
150 pub fn migrate(&self, user_toml: &str) -> Result<MigrationResult, MigrateError> {
162 let reference_doc = self
163 .reference_src
164 .parse::<DocumentMut>()
165 .map_err(MigrateError::Reference)?;
166 let mut user_doc = user_toml.parse::<DocumentMut>()?;
167
168 let mut changed_count = 0usize;
169 let mut sections_changed: Vec<String> = Vec::new();
170 let mut pending_comments: Vec<(String, String)> = Vec::new();
173
174 for (key, ref_item) in reference_doc.as_table() {
176 if ref_item.is_table() {
177 let ref_table = ref_item.as_table().expect("is_table checked above");
178 if user_doc.contains_key(key) {
179 if let Some(user_table) = user_doc.get_mut(key).and_then(Item::as_table_mut) {
181 let (n, comments) =
182 merge_table_commented(user_table, ref_table, key, user_toml);
183 changed_count += n;
184 pending_comments.extend(comments);
185 }
186 } else {
187 if user_toml.contains(&format!("# [{key}]")) {
190 continue;
191 }
192 let commented = commented_table_block(key, ref_table);
193 if !commented.is_empty() {
194 sections_changed.push(key.to_owned());
195 }
196 changed_count += 1;
197 }
198 } else {
199 if !user_doc.contains_key(key) {
201 let raw = format_commented_item(key, ref_item);
202 if !raw.is_empty() {
203 sections_changed.push(format!("__scalar__{key}"));
204 changed_count += 1;
205 }
206 }
207 }
208 }
209
210 let user_str = user_doc.to_string();
212
213 let mut output = user_str;
216 for (section_key, comment_line) in &pending_comments {
217 if !section_body(&output, section_key).contains(comment_line.trim()) {
218 output = insert_after_section(&output, section_key, comment_line);
219 }
220 }
221
222 for key in §ions_changed {
224 if let Some(scalar_key) = key.strip_prefix("__scalar__") {
225 if let Some(ref_item) = reference_doc.get(scalar_key) {
226 let raw = format_commented_item(scalar_key, ref_item);
227 if !raw.is_empty() {
228 output.push('\n');
229 output.push_str(&raw);
230 output.push('\n');
231 }
232 }
233 } else if let Some(ref_table) = reference_doc.get(key.as_str()).and_then(Item::as_table)
234 {
235 let block = commented_table_block(key, ref_table);
236 if !block.is_empty() {
237 output.push('\n');
238 output.push_str(&block);
239 }
240 }
241 }
242
243 output = reorder_sections(&output, CANONICAL_ORDER);
245
246 let sections_changed_clean: Vec<String> = sections_changed
248 .into_iter()
249 .filter(|k| !k.starts_with("__scalar__"))
250 .collect();
251
252 Ok(MigrationResult {
253 output,
254 changed_count,
255 sections_changed: sections_changed_clean,
256 })
257 }
258}
259
260fn merge_table_commented(
266 user_table: &mut Table,
267 ref_table: &Table,
268 section_key: &str,
269 user_toml: &str,
270) -> (usize, Vec<(String, String)>) {
271 let mut count = 0usize;
272 let mut comments: Vec<(String, String)> = Vec::new();
273 for (key, ref_item) in ref_table {
274 if ref_item.is_table() {
275 if user_table.contains_key(key) {
276 let pair = (
277 user_table.get_mut(key).and_then(Item::as_table_mut),
278 ref_item.as_table(),
279 );
280 if let (Some(user_sub_table), Some(ref_sub_table)) = pair {
281 let sub_key = format!("{section_key}.{key}");
282 let (n, c) =
283 merge_table_commented(user_sub_table, ref_sub_table, &sub_key, user_toml);
284 count += n;
285 comments.extend(c);
286 }
287 } else if let Some(ref_sub_table) = ref_item.as_table() {
288 let dotted = format!("{section_key}.{key}");
290 let marker = format!("# [{dotted}]");
291 if !user_toml.contains(&marker) {
292 let block = commented_table_block(&dotted, ref_sub_table);
293 if !block.is_empty() {
294 comments.push((section_key.to_owned(), format!("\n{block}")));
295 count += 1;
296 }
297 }
298 }
299 } else if ref_item.is_array_of_tables() {
300 } else {
302 if !user_table.contains_key(key) {
304 let raw_value = ref_item
305 .as_value()
306 .map(value_to_toml_string)
307 .unwrap_or_default();
308 if !raw_value.is_empty() {
309 let comment_line = format!("# {key} = {raw_value}\n");
310 if !section_body(user_toml, section_key).contains(comment_line.trim()) {
313 comments.push((section_key.to_owned(), comment_line));
314 count += 1;
315 }
316 }
317 }
318 }
319 }
320 (count, comments)
321}
322
323fn section_body<'a>(doc: &'a str, section: &str) -> &'a str {
329 let header = format!("[{section}]");
330 let Some(section_start) = doc.find(&header) else {
331 return "";
332 };
333 let body_start = section_start + header.len();
334 let body_end = doc[body_start..]
335 .find("\n[")
336 .map_or(doc.len(), |r| body_start + r);
337 &doc[body_start..body_end]
338}
339
340fn insert_after_section(raw: &str, section_name: &str, text: &str) -> String {
346 let header = format!("[{section_name}]");
347 let Some(section_start) = raw.find(&header) else {
348 return format!("{raw}{text}");
349 };
350 let search_from = section_start + header.len();
352 let insert_pos = raw[search_from..]
354 .find("\n[")
355 .map_or(raw.len(), |rel| search_from + rel + 1);
356 let mut out = String::with_capacity(raw.len() + text.len());
357 out.push_str(&raw[..insert_pos]);
358 out.push_str(text);
359 out.push_str(&raw[insert_pos..]);
360 out
361}
362
363fn format_commented_item(key: &str, item: &Item) -> String {
365 if let Some(val) = item.as_value() {
366 let raw = value_to_toml_string(val);
367 if !raw.is_empty() {
368 return format!("# {key} = {raw}\n");
369 }
370 }
371 String::new()
372}
373
374fn commented_table_block(section_name: &str, table: &Table) -> String {
379 use std::fmt::Write as _;
380
381 let mut lines = format!("# [{section_name}]\n");
382
383 for (key, item) in table {
384 if item.is_table() {
385 if let Some(sub_table) = item.as_table() {
386 let sub_name = format!("{section_name}.{key}");
387 let sub_block = commented_table_block(&sub_name, sub_table);
388 if !sub_block.is_empty() {
389 lines.push('\n');
390 lines.push_str(&sub_block);
391 }
392 }
393 } else if item.is_array_of_tables() {
394 } else if let Some(val) = item.as_value() {
396 let raw = value_to_toml_string(val);
397 if !raw.is_empty() {
398 let _ = writeln!(lines, "# {key} = {raw}");
399 }
400 }
401 }
402
403 if lines.trim() == format!("[{section_name}]") {
405 return String::new();
406 }
407 lines
408}
409
410fn value_to_toml_string(val: &Value) -> String {
412 match val {
413 Value::String(s) => {
414 let inner = s.value();
415 format!("\"{inner}\"")
416 }
417 Value::Integer(i) => i.value().to_string(),
418 Value::Float(f) => {
419 let v = f.value();
420 if v.fract() == 0.0 {
422 format!("{v:.1}")
423 } else {
424 format!("{v}")
425 }
426 }
427 Value::Boolean(b) => b.value().to_string(),
428 Value::Array(arr) => format_array(arr),
429 Value::InlineTable(t) => {
430 let pairs: Vec<String> = t
431 .iter()
432 .map(|(k, v)| format!("{k} = {}", value_to_toml_string(v)))
433 .collect();
434 format!("{{ {} }}", pairs.join(", "))
435 }
436 Value::Datetime(dt) => dt.value().to_string(),
437 }
438}
439
440fn format_array(arr: &Array) -> String {
441 if arr.is_empty() {
442 return "[]".to_owned();
443 }
444 let items: Vec<String> = arr.iter().map(value_to_toml_string).collect();
445 format!("[{}]", items.join(", "))
446}
447
448fn reorder_sections(toml_str: &str, canonical_order: &[&str]) -> String {
454 let sections = split_into_sections(toml_str);
455 if sections.is_empty() {
456 return toml_str.to_owned();
457 }
458
459 let preamble_block = sections
461 .iter()
462 .find(|(h, _)| h.is_empty())
463 .map_or("", |(_, c)| c.as_str());
464
465 let section_map: Vec<(&str, &str)> = sections
466 .iter()
467 .filter(|(h, _)| !h.is_empty())
468 .map(|(h, c)| (h.as_str(), c.as_str()))
469 .collect();
470
471 let mut out = String::new();
472 if !preamble_block.is_empty() {
473 out.push_str(preamble_block);
474 }
475
476 let mut emitted: Vec<bool> = vec![false; section_map.len()];
477
478 for &canon in canonical_order {
479 for (idx, &(header, content)) in section_map.iter().enumerate() {
480 let section_name = extract_section_name(header);
481 let top_level = section_name
482 .split('.')
483 .next()
484 .unwrap_or("")
485 .trim_start_matches('#')
486 .trim();
487 if top_level == canon && !emitted[idx] {
488 out.push_str(content);
489 emitted[idx] = true;
490 }
491 }
492 }
493
494 for (idx, &(_, content)) in section_map.iter().enumerate() {
496 if !emitted[idx] {
497 out.push_str(content);
498 }
499 }
500
501 out
502}
503
504fn extract_section_name(header: &str) -> &str {
506 let trimmed = header.trim().trim_start_matches("# ");
508 if trimmed.starts_with('[') && trimmed.contains(']') {
510 let inner = &trimmed[1..];
511 if let Some(end) = inner.find(']') {
512 return &inner[..end];
513 }
514 }
515 trimmed
516}
517
518fn split_into_sections(toml_str: &str) -> Vec<(String, String)> {
522 let mut sections: Vec<(String, String)> = Vec::new();
523 let mut current_header = String::new();
524 let mut current_content = String::new();
525
526 for line in toml_str.lines() {
527 let trimmed = line.trim();
528 if is_top_level_section_header(trimmed) {
529 sections.push((current_header.clone(), current_content.clone()));
530 trimmed.clone_into(&mut current_header);
531 line.clone_into(&mut current_content);
532 current_content.push('\n');
533 } else {
534 current_content.push_str(line);
535 current_content.push('\n');
536 }
537 }
538
539 if !current_header.is_empty() || !current_content.is_empty() {
541 sections.push((current_header, current_content));
542 }
543
544 sections
545}
546
547fn is_top_level_section_header(line: &str) -> bool {
552 if line.starts_with('[')
553 && !line.starts_with("[[")
554 && let Some(end) = line.find(']')
555 {
556 return !line[1..end].contains('.');
557 }
558 false
559}
560
561pub trait Migration: Send + Sync {
586 fn name(&self) -> &'static str;
588
589 fn apply(&self, toml_src: &str) -> Result<MigrationResult, MigrateError>;
595}
596
597mod steps;
598use steps::{
599 MigrateAcpSubagentsConfig, MigrateAgentBudgetHint, MigrateAgentRetryToToolsRetry,
600 MigrateAutodreamConfig, MigrateCavemanConfig, MigrateCocoonProviderNotice,
601 MigrateCocoonShowBalance, MigrateCompressionPredictorConfig, MigrateDatabaseUrl,
602 MigrateDeepLinkConfig, MigrateDurableConfig, MigrateEgressConfig, MigrateEmbedProviderRename,
603 MigrateEvalModelToProvider, MigrateFidelityTimeoutDefaults, MigrateFiveSignalConfig,
604 MigrateFocusAutoConsolidateMinWindow, MigrateForgettingConfig, MigrateGoalsConfig,
605 MigrateGonkagateToGonka, MigrateHooksPermissionDeniedConfig, MigrateHooksTurnComplete,
606 MigrateKnowledgeConfig, MigrateLlmStreamLimits, MigrateMagicDocsConfig,
607 MigrateMcpElicitationConfig, MigrateMcpMaxConnectAttempts, MigrateMcpRetryAndToolTimeout,
608 MigrateMcpTrustLevels, MigrateMemoryGraph, MigrateMemoryGraphRecallIncludeImported,
609 MigrateMemoryHebbian, MigrateMemoryHebbianConsolidation, MigrateMemoryHebbianSpread,
610 MigrateMemoryPersonaConfig, MigrateMemoryReasoning, MigrateMemoryReasoningJudge,
611 MigrateMemoryRetrieval, MigrateMemoryRetrievalQueryBias, MigrateMicrocompactConfig,
612 MigrateNliConfig, MigrateOrchestrationAssetSensitivity, MigrateOrchestrationPersistence,
613 MigrateOrchestratorProvider, MigrateOtelFilter, MigratePiiFilterNames,
614 MigratePlannerModelToProvider, MigratePolicyProviderAndUtilityWindow,
615 MigrateProviderMaxConcurrent, MigrateQdrantApiKey, MigrateQdrantTimeoutSecs,
616 MigrateQualityConfig, MigrateSandboxConfig, MigrateSandboxEgressFilter, MigrateSchedulerDaemon,
617 MigrateSecretMaskingConfig, MigrateServeConfig, MigrateSessionPersistProviderOverrides,
618 MigrateSessionPersistenceConfig, MigrateSessionProviderPersistence, MigrateSessionRecapConfig,
619 MigrateShellCheckpointsConfig, MigrateShellTransactional, MigrateSttToProvider,
620 MigrateSupervisorConfig, MigrateTelemetryConfig, MigrateToolsCompressionConfig,
621 MigrateTraceMetadata, MigrateTuiDelights, MigrateTuiMouse, MigrateTuiThemeConfig,
622 MigrateTuiThemeDefaults, MigrateUtilityHighGainTools, MigrateVigilConfig,
623 MigrateWorktreeConfig, MigrateWorktreeGitTimeout,
624};
625
626pub static MIGRATIONS: std::sync::LazyLock<Vec<Box<dyn Migration + Send + Sync>>> =
643 std::sync::LazyLock::new(|| {
644 vec![
645 Box::new(MigrateSttToProvider) as Box<dyn Migration + Send + Sync>,
647 Box::new(MigratePlannerModelToProvider),
648 Box::new(MigrateMcpTrustLevels),
649 Box::new(MigrateAgentRetryToToolsRetry),
650 Box::new(MigrateDatabaseUrl),
651 Box::new(MigrateShellTransactional),
652 Box::new(MigrateAgentBudgetHint),
653 Box::new(MigrateForgettingConfig),
654 Box::new(MigrateCompressionPredictorConfig),
655 Box::new(MigrateMicrocompactConfig),
656 Box::new(MigrateAutodreamConfig),
657 Box::new(MigrateMagicDocsConfig),
658 Box::new(MigrateTelemetryConfig),
659 Box::new(MigrateSupervisorConfig),
660 Box::new(MigrateOtelFilter),
661 Box::new(MigrateEgressConfig),
662 Box::new(MigrateVigilConfig),
663 Box::new(MigrateSandboxConfig),
664 Box::new(MigrateSandboxEgressFilter),
665 Box::new(MigrateOrchestrationPersistence),
666 Box::new(MigrateSessionRecapConfig),
667 Box::new(MigrateMcpElicitationConfig),
668 Box::new(MigrateQualityConfig),
669 Box::new(MigrateAcpSubagentsConfig),
670 Box::new(MigrateHooksPermissionDeniedConfig),
671 Box::new(MigrateMemoryGraph),
673 Box::new(MigrateSchedulerDaemon),
674 Box::new(MigrateMemoryRetrieval),
675 Box::new(MigrateMemoryReasoning),
676 Box::new(MigrateMemoryReasoningJudge),
677 Box::new(MigrateMemoryHebbian),
678 Box::new(MigrateMemoryHebbianConsolidation),
679 Box::new(MigrateMemoryHebbianSpread),
680 Box::new(MigrateHooksTurnComplete),
681 Box::new(MigrateFocusAutoConsolidateMinWindow),
682 Box::new(MigrateSessionProviderPersistence),
684 Box::new(MigrateMemoryRetrievalQueryBias),
685 Box::new(MigrateMemoryPersonaConfig),
686 Box::new(MigrateQdrantApiKey),
688 Box::new(MigrateMcpMaxConnectAttempts),
690 Box::new(MigrateGoalsConfig),
692 Box::new(MigrateToolsCompressionConfig),
693 Box::new(MigrateOrchestratorProvider),
695 Box::new(MigrateProviderMaxConcurrent),
697 Box::new(MigrateGonkagateToGonka),
699 Box::new(MigrateCocoonProviderNotice),
701 Box::new(MigrateTraceMetadata),
703 Box::new(MigrateFiveSignalConfig),
705 Box::new(MigrateEmbedProviderRename),
707 Box::new(MigrateMcpRetryAndToolTimeout),
709 Box::new(MigrateFidelityTimeoutDefaults),
711 Box::new(MigrateSessionPersistProviderOverrides),
713 Box::new(MigrateCocoonShowBalance),
715 Box::new(MigrateWorktreeConfig),
717 Box::new(MigrateWorktreeGitTimeout),
719 Box::new(MigrateLlmStreamLimits),
721 Box::new(MigrateDurableConfig),
723 Box::new(MigrateEvalModelToProvider),
725 Box::new(MigrateCavemanConfig),
727 Box::new(MigrateShellCheckpointsConfig),
729 Box::new(MigrateKnowledgeConfig),
731 Box::new(MigrateDeepLinkConfig),
733 Box::new(MigrateMemoryGraphRecallIncludeImported),
735 Box::new(MigratePolicyProviderAndUtilityWindow),
737 Box::new(MigrateTuiThemeConfig),
739 Box::new(MigrateTuiThemeDefaults),
741 Box::new(MigrateTuiDelights),
743 Box::new(MigrateTuiMouse),
745 Box::new(MigrateOrchestrationAssetSensitivity),
747 Box::new(MigrateSessionPersistenceConfig),
749 Box::new(MigrateServeConfig),
751 Box::new(MigrateNliConfig),
753 Box::new(MigrateSecretMaskingConfig),
755 Box::new(MigratePiiFilterNames),
758 Box::new(MigrateQdrantTimeoutSecs),
760 Box::new(MigrateUtilityHighGainTools),
762 ]
763 });
764
765#[cfg(test)]
767fn make_formatted_str(s: &str) -> Value {
768 use toml_edit::Formatted;
769 Value::String(Formatted::new(s.to_owned()))
770}
771
772#[cfg(test)]
773mod tests;