nexo-brain 5.3.28 → 5.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.28",
3
+ "version": "5.4.0",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `5.3.11` is the current packaged-runtime line: protocol and Cortex now reject malformed `outcome`, `task_type`, and `impact_level` values explicitly instead of silently coercing them into other valid states, so task history, debt, hot context, and decision telemetry stay trustworthy even when a caller passes a bad contract payload.
21
+ Version `5.4.0` is the current packaged-runtime line: runtime event bus at `~/.nexo/runtime/events.ndjson`, `nexo notify` for one-shot proactive events, `nexo health --json` for a rolled-up subsystem snapshot, `nexo logs --tail --json` for structured log access, and a safe flat→nested migration for `calibration.json` on older installs.
22
+
23
+ Previously in `5.3.30`: four read-only CLI commands (`nexo schema`, `nexo identity`, `nexo onboard`, `nexo scan-profile`) let external UIs auto-adapt to the editable schema, identity, onboarding wizard, and profile heuristics.
22
24
 
23
25
  Start here:
24
26
  - [5-minute quickstart](docs/quickstart-5-minutes.md)
@@ -488,7 +490,9 @@ NEXO Brain doesn't just respond — it runs 13 core recovery-aware background jo
488
490
  | **followup-hygiene** | Weekly (Sun) | Normalizes statuses, flags stale followups, cleans orphans |
489
491
  | **learning-housekeep** | 03:15 daily | Dedup learnings, adjust weights by usage, process overdue reviews, reconcile decision outcomes |
490
492
  | **immune** | Every 30 min | Quarantine processing, memory promotion/rejection, synaptic pruning |
493
+ | **impact-scorer** | 05:45 daily | Scores active followups so queues can prioritize by expected impact |
491
494
  | **synthesis** | 06:00 daily | Memory synthesis — discovers cross-memory patterns |
495
+ | **outcome-checker** | 08:00 daily | Verifies tracked outcomes and marks them met, pending, or missed |
492
496
  | **watchdog** | Every 30 min | Monitors services, LaunchAgents, and infrastructure health |
493
497
  | **auto-close-sessions** | Every 5 min | Cleans stale sessions |
494
498
 
package/bin/nexo-brain.js CHANGED
@@ -88,6 +88,20 @@ function log(msg) {
88
88
  console.log(` ${msg}`);
89
89
  }
90
90
 
91
+ function duplicateArtifactCanonicalName(name) {
92
+ const ext = path.extname(name);
93
+ const stem = ext ? name.slice(0, -ext.length) : name;
94
+ const match = stem.match(/^(.*) ([2-9]\d*)$/);
95
+ if (!match) return null;
96
+ return `${match[1]}${ext}`;
97
+ }
98
+
99
+ function isDuplicateArtifactName(name, dirPath = "") {
100
+ const canonical = duplicateArtifactCanonicalName(name);
101
+ if (!canonical || !dirPath) return false;
102
+ return fs.existsSync(path.join(dirPath, canonical));
103
+ }
104
+
91
105
  function syncWatchdogHashRegistry(nexoHome) {
92
106
  try {
93
107
  const watchdogPath = path.join(nexoHome, "scripts", "nexo-watchdog.sh");
@@ -122,7 +136,7 @@ function writeRuntimeCoreArtifactsManifest(nexoHome, srcDir) {
122
136
  return fs.readdirSync(dirPath)
123
137
  .filter((name) => {
124
138
  const full = path.join(dirPath, name);
125
- return fs.existsSync(full) && fs.statSync(full).isFile();
139
+ return fs.existsSync(full) && fs.statSync(full).isFile() && !isDuplicateArtifactName(name, dirPath);
126
140
  })
127
141
  .sort();
128
142
  };
@@ -192,6 +206,7 @@ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
192
206
  const discoveredRootModules = fs.existsSync(srcDir)
193
207
  ? fs.readdirSync(srcDir)
194
208
  .filter((name) => {
209
+ if (isDuplicateArtifactName(name, srcDir)) return false;
195
210
  const stat = fs.statSync(path.join(srcDir, name));
196
211
  if (!stat.isFile()) return false;
197
212
  // Include Python modules and any flat JSON config the Python runtime
@@ -1551,7 +1566,7 @@ async function main() {
1551
1566
  const copyDirRec = (src, dest) => {
1552
1567
  fs.mkdirSync(dest, { recursive: true });
1553
1568
  fs.readdirSync(src).forEach(item => {
1554
- if (item === "__pycache__" || item.endsWith(".pyc") || item.endsWith(".db")) return;
1569
+ if (item === "__pycache__" || item.endsWith(".pyc") || item.endsWith(".db") || isDuplicateArtifactName(item, src)) return;
1555
1570
  const srcPath = path.join(src, item);
1556
1571
  const destPath = path.join(dest, item);
1557
1572
  if (fs.statSync(srcPath).isDirectory()) {
@@ -1620,7 +1635,7 @@ async function main() {
1620
1635
  const pluginsDest = path.join(NEXO_HOME, "plugins");
1621
1636
  fs.mkdirSync(pluginsDest, { recursive: true });
1622
1637
  if (fs.existsSync(pluginsSrc)) {
1623
- fs.readdirSync(pluginsSrc).filter(f => f.endsWith(".py")).forEach((f) => {
1638
+ fs.readdirSync(pluginsSrc).filter(f => f.endsWith(".py") && !isDuplicateArtifactName(f, pluginsSrc)).forEach((f) => {
1624
1639
  fs.copyFileSync(path.join(pluginsSrc, f), path.join(pluginsDest, f));
1625
1640
  });
1626
1641
  }
@@ -1772,7 +1787,7 @@ async function main() {
1772
1787
  const copyDirRec2 = (src, dest) => {
1773
1788
  fs.mkdirSync(dest, { recursive: true });
1774
1789
  fs.readdirSync(src).forEach(item => {
1775
- if (item === "__pycache__" || item.endsWith(".pyc") || item.endsWith(".db")) return;
1790
+ if (item === "__pycache__" || item.endsWith(".pyc") || item.endsWith(".db") || isDuplicateArtifactName(item, src)) return;
1776
1791
  const srcP = path.join(src, item);
1777
1792
  const destP = path.join(dest, item);
1778
1793
  if (fs.statSync(srcP).isDirectory()) copyDirRec2(srcP, destP);
@@ -1806,7 +1821,7 @@ async function main() {
1806
1821
  const copyDirRec3 = (src, dest) => {
1807
1822
  fs.mkdirSync(dest, { recursive: true });
1808
1823
  fs.readdirSync(src).forEach(item => {
1809
- if (item === "__pycache__" || item.endsWith(".pyc")) return;
1824
+ if (item === "__pycache__" || item.endsWith(".pyc") || isDuplicateArtifactName(item, src)) return;
1810
1825
  const srcP = path.join(src, item);
1811
1826
  const destP = path.join(dest, item);
1812
1827
  if (fs.statSync(srcP).isDirectory()) copyDirRec3(srcP, destP);
@@ -1831,6 +1846,7 @@ async function main() {
1831
1846
  if (fs.existsSync(templatesSrc)) {
1832
1847
  fs.mkdirSync(templatesDest, { recursive: true });
1833
1848
  for (const f of fs.readdirSync(templatesSrc)) {
1849
+ if (isDuplicateArtifactName(f, templatesSrc)) continue;
1834
1850
  const src = path.join(templatesSrc, f);
1835
1851
  const dest = path.join(templatesDest, f);
1836
1852
  if (fs.statSync(src).isFile()) {
@@ -1838,6 +1854,7 @@ async function main() {
1838
1854
  } else if (fs.statSync(src).isDirectory()) {
1839
1855
  fs.mkdirSync(dest, { recursive: true });
1840
1856
  for (const sf of fs.readdirSync(src)) {
1857
+ if (isDuplicateArtifactName(sf, src)) continue;
1841
1858
  const ssrc = path.join(src, sf);
1842
1859
  if (fs.statSync(ssrc).isFile()) {
1843
1860
  fs.copyFileSync(ssrc, path.join(dest, sf));
@@ -2354,7 +2371,7 @@ async function main() {
2354
2371
  const copyDirRecursive = (src, dest) => {
2355
2372
  fs.mkdirSync(dest, { recursive: true });
2356
2373
  fs.readdirSync(src).forEach(item => {
2357
- if (item === "__pycache__" || item.endsWith(".pyc") || item.endsWith(".db")) return;
2374
+ if (item === "__pycache__" || item.endsWith(".pyc") || item.endsWith(".db") || isDuplicateArtifactName(item, src)) return;
2358
2375
  const srcPath = path.join(src, item);
2359
2376
  const destPath = path.join(dest, item);
2360
2377
  if (fs.statSync(srcPath).isDirectory()) {
@@ -2461,7 +2478,7 @@ async function main() {
2461
2478
  // Plugins (all .py files in plugins/)
2462
2479
  fs.mkdirSync(path.join(NEXO_HOME, "plugins"), { recursive: true });
2463
2480
  if (fs.existsSync(pluginsSrcDir)) {
2464
- fs.readdirSync(pluginsSrcDir).filter(f => f.endsWith(".py")).forEach((f) => {
2481
+ fs.readdirSync(pluginsSrcDir).filter(f => f.endsWith(".py") && !isDuplicateArtifactName(f, pluginsSrcDir)).forEach((f) => {
2465
2482
  fs.copyFileSync(path.join(pluginsSrcDir, f), path.join(NEXO_HOME, "plugins", f));
2466
2483
  });
2467
2484
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.28",
3
+ "version": "5.4.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
@@ -18,6 +18,7 @@ import time
18
18
  from pathlib import Path
19
19
 
20
20
  from runtime_home import export_resolved_nexo_home, managed_nexo_home
21
+ from tree_hygiene import is_duplicate_artifact_name
21
22
 
22
23
  NEXO_HOME = export_resolved_nexo_home()
23
24
  DATA_DIR = NEXO_HOME / "data"
@@ -63,6 +64,20 @@ def _log(msg: str):
63
64
  print(f"[NEXO auto-update] {msg}", file=sys.stderr)
64
65
 
65
66
 
67
+ def _runtime_copy_ignore(*extra_patterns: str):
68
+ base_ignore = shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.db", *extra_patterns)
69
+
70
+ def _ignore(dir_name: str, names: list[str]) -> set[str]:
71
+ ignored = set(base_ignore(dir_name, names))
72
+ ignored.update(
73
+ name for name in names
74
+ if is_duplicate_artifact_name(Path(dir_name) / name)
75
+ )
76
+ return ignored
77
+
78
+ return _ignore
79
+
80
+
66
81
  def _critical_table_count(db_path: Path, table: str) -> int | None:
67
82
  """Return COUNT(*) for a critical table when it exists, otherwise None."""
68
83
  import sqlite3
@@ -470,7 +485,7 @@ def _refresh_installed_manifest():
470
485
  if src_crons.exists():
471
486
  dst_crons.mkdir(parents=True, exist_ok=True)
472
487
  for f in src_crons.iterdir():
473
- if f.is_file():
488
+ if f.is_file() and not is_duplicate_artifact_name(f):
474
489
  shutil.copy2(str(f), str(dst_crons / f.name))
475
490
  _log("Refreshed installed crons manifest")
476
491
  except Exception as e:
@@ -746,7 +761,7 @@ def _sync_hooks():
746
761
  hooks_dest.mkdir(parents=True, exist_ok=True)
747
762
  synced = 0
748
763
  for f in hooks_src.iterdir():
749
- if f.is_file() and f.suffix == ".sh":
764
+ if f.is_file() and f.suffix == ".sh" and not is_duplicate_artifact_name(f):
750
765
  dest = hooks_dest / f.name
751
766
  shutil.copy2(str(f), str(dest))
752
767
  os.chmod(str(dest), 0o755)
@@ -1441,7 +1456,7 @@ def _auto_update_check_locked() -> dict:
1441
1456
  import shutil
1442
1457
  scripts_dest.mkdir(parents=True, exist_ok=True)
1443
1458
  for f in scripts_src.iterdir():
1444
- if f.name.startswith('.') or f.name == '__pycache__':
1459
+ if f.name.startswith('.') or f.name == '__pycache__' or is_duplicate_artifact_name(f):
1445
1460
  continue
1446
1461
  dest = scripts_dest / f.name
1447
1462
  if f.is_file() and not dest.exists():
@@ -1475,12 +1490,12 @@ def _auto_update_check_locked() -> dict:
1475
1490
  if doctor_src.is_dir():
1476
1491
  import shutil
1477
1492
  if not doctor_dest.is_dir():
1478
- shutil.copytree(str(doctor_src), str(doctor_dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
1493
+ shutil.copytree(str(doctor_src), str(doctor_dest), ignore=_runtime_copy_ignore())
1479
1494
  _log("Backfilled doctor package")
1480
1495
  else:
1481
1496
  # Update existing files
1482
1497
  for root, dirs, files in os.walk(str(doctor_src)):
1483
- dirs[:] = [d for d in dirs if d != "__pycache__"]
1498
+ dirs[:] = [d for d in dirs if d != "__pycache__" and not is_duplicate_artifact_name(Path(root) / d)]
1484
1499
  rel = os.path.relpath(root, str(doctor_src))
1485
1500
  dest_dir = doctor_dest / rel
1486
1501
  dest_dir.mkdir(parents=True, exist_ok=True)
@@ -1488,6 +1503,8 @@ def _auto_update_check_locked() -> dict:
1488
1503
  if f.endswith(".pyc"):
1489
1504
  continue
1490
1505
  src_f = Path(root) / f
1506
+ if is_duplicate_artifact_name(src_f):
1507
+ continue
1491
1508
  dst_f = dest_dir / f
1492
1509
  if not dst_f.exists() or src_f.stat().st_mtime > dst_f.stat().st_mtime:
1493
1510
  shutil.copy2(str(src_f), str(dst_f))
@@ -1501,11 +1518,11 @@ def _auto_update_check_locked() -> dict:
1501
1518
  if skills_src.is_dir():
1502
1519
  import shutil
1503
1520
  if not skills_dest.is_dir():
1504
- shutil.copytree(str(skills_src), str(skills_dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
1521
+ shutil.copytree(str(skills_src), str(skills_dest), ignore=_runtime_copy_ignore())
1505
1522
  _log("Backfilled skills-core")
1506
1523
  else:
1507
1524
  for root, dirs, files in os.walk(str(skills_src)):
1508
- dirs[:] = [d for d in dirs if d != "__pycache__"]
1525
+ dirs[:] = [d for d in dirs if d != "__pycache__" and not is_duplicate_artifact_name(Path(root) / d)]
1509
1526
  rel = os.path.relpath(root, str(skills_src))
1510
1527
  dest_dir = skills_dest / rel
1511
1528
  dest_dir.mkdir(parents=True, exist_ok=True)
@@ -1513,6 +1530,8 @@ def _auto_update_check_locked() -> dict:
1513
1530
  if f.endswith(".pyc"):
1514
1531
  continue
1515
1532
  src_f = Path(root) / f
1533
+ if is_duplicate_artifact_name(src_f):
1534
+ continue
1516
1535
  dst_f = dest_dir / f
1517
1536
  if not dst_f.exists() or src_f.stat().st_mtime > dst_f.stat().st_mtime:
1518
1537
  shutil.copy2(str(src_f), str(dst_f))
@@ -1539,7 +1558,7 @@ def _auto_update_check_locked() -> dict:
1539
1558
  import shutil
1540
1559
  if templates_src.is_dir():
1541
1560
  for item in templates_src.iterdir():
1542
- if item.name == "__pycache__":
1561
+ if item.name == "__pycache__" or is_duplicate_artifact_name(item):
1543
1562
  continue
1544
1563
  dest_item = templates_dest / item.name
1545
1564
  if item.is_file():
@@ -1548,7 +1567,7 @@ def _auto_update_check_locked() -> dict:
1548
1567
  elif item.is_dir():
1549
1568
  dest_item.mkdir(parents=True, exist_ok=True)
1550
1569
  for sub in item.iterdir():
1551
- if sub.is_file():
1570
+ if sub.is_file() and not is_duplicate_artifact_name(sub):
1552
1571
  dest_sub = dest_item / sub.name
1553
1572
  if not dest_sub.exists() or sub.stat().st_mtime > dest_sub.stat().st_mtime:
1554
1573
  shutil.copy2(str(sub), str(dest_sub))
@@ -1735,6 +1754,8 @@ def _discover_runtime_root_python_modules(base_dir: Path) -> list[str]:
1735
1754
  continue
1736
1755
  if item.name.startswith(".") or item.name == "__init__.py":
1737
1756
  continue
1757
+ if is_duplicate_artifact_name(item):
1758
+ continue
1738
1759
  modules.append(item.name)
1739
1760
  return modules
1740
1761
 
@@ -1857,7 +1878,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1857
1878
  shutil.copytree(
1858
1879
  str(pkg_src),
1859
1880
  str(pkg_dest),
1860
- ignore=shutil.ignore_patterns("__pycache__", "*.pyc", "*.pyo", "*.db"),
1881
+ ignore=_runtime_copy_ignore(),
1861
1882
  )
1862
1883
  copied_packages += 1
1863
1884
 
@@ -1874,7 +1895,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1874
1895
  if plugins_src.is_dir():
1875
1896
  plugins_dest.mkdir(parents=True, exist_ok=True)
1876
1897
  for item in plugins_src.iterdir():
1877
- if item.is_file() and item.suffix == ".py":
1898
+ if item.is_file() and item.suffix == ".py" and not is_duplicate_artifact_name(item):
1878
1899
  shutil.copy2(str(item), str(plugins_dest / item.name))
1879
1900
 
1880
1901
  _emit_progress(progress_fn, "Copying scripts...")
@@ -1883,13 +1904,13 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1883
1904
  if scripts_src.is_dir():
1884
1905
  scripts_dest.mkdir(parents=True, exist_ok=True)
1885
1906
  for item in scripts_src.iterdir():
1886
- if item.name == "__pycache__" or item.name.startswith("."):
1907
+ if item.name == "__pycache__" or item.name.startswith(".") or is_duplicate_artifact_name(item):
1887
1908
  continue
1888
1909
  dst = scripts_dest / item.name
1889
1910
  if item.is_dir():
1890
1911
  if dst.exists():
1891
1912
  shutil.rmtree(str(dst), ignore_errors=True)
1892
- shutil.copytree(str(item), str(dst), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
1913
+ shutil.copytree(str(item), str(dst), ignore=_runtime_copy_ignore())
1893
1914
  elif item.is_file():
1894
1915
  existing_class = installed_script_classes.get(item.name, "")
1895
1916
  if dst.exists() and existing_class in {"personal", "non-script"}:
@@ -1919,7 +1940,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1919
1940
  if templates_src.is_dir():
1920
1941
  templates_dest.mkdir(parents=True, exist_ok=True)
1921
1942
  for item in templates_src.iterdir():
1922
- if item.name == "__pycache__":
1943
+ if item.name == "__pycache__" or is_duplicate_artifact_name(item):
1923
1944
  continue
1924
1945
  if item.is_file():
1925
1946
  shutil.copy2(str(item), str(templates_dest / item.name))
@@ -1927,7 +1948,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1927
1948
  sub_dest = templates_dest / item.name
1928
1949
  sub_dest.mkdir(parents=True, exist_ok=True)
1929
1950
  for sub in item.iterdir():
1930
- if sub.is_file():
1951
+ if sub.is_file() and not is_duplicate_artifact_name(sub):
1931
1952
  shutil.copy2(str(sub), str(sub_dest / sub.name))
1932
1953
 
1933
1954
  package_json = repo_dir / "package.json"
@@ -1948,7 +1969,7 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1948
1969
  if skills_src.is_dir():
1949
1970
  if skills_dest.exists():
1950
1971
  shutil.rmtree(str(skills_dest), ignore_errors=True)
1951
- shutil.copytree(str(skills_src), str(skills_dest), ignore=shutil.ignore_patterns("__pycache__", "*.pyc"))
1972
+ shutil.copytree(str(skills_src), str(skills_dest), ignore=_runtime_copy_ignore())
1952
1973
 
1953
1974
  bin_dir = dest / "bin"
1954
1975
  bin_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,242 @@
1
+ """Calibration migration — flat → nested schema for calibration.json.
2
+
3
+ Older NEXO installations wrote calibration.json with flat top-level keys
4
+ (user_name, autonomy, role…). The canonical shape is nested
5
+ (user.name, personality.autonomy, meta.role). This module detects the
6
+ flat shape and migrates to nested with a backup.
7
+
8
+ Design:
9
+ - Backup lives at calibration.json.pre-migrate-<version>
10
+ - Unknown fields are preserved verbatim under `legacy_unmapped`
11
+ - Revert is just: cp backup → calibration.json
12
+ - Idempotent: if already nested, returns OK without touching the file
13
+
14
+ No network, no DB. Pure file I/O so it can run from any runtime.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import json
19
+ import os
20
+ import shutil
21
+ import time
22
+ from pathlib import Path
23
+ from typing import Any
24
+
25
+
26
+ FLAT_TO_NESTED = {
27
+ # user.*
28
+ "user_name": ("user", "name"),
29
+ "name": ("user", "name"),
30
+ "language": ("user", "language"),
31
+ "lang": ("user", "language"),
32
+ "timezone": ("user", "timezone"),
33
+ "tz": ("user", "timezone"),
34
+ "assistant_name": ("user", "assistant_name"),
35
+ # personality.*
36
+ "autonomy": ("personality", "autonomy"),
37
+ "communication": ("personality", "communication"),
38
+ "honesty": ("personality", "honesty"),
39
+ "proactivity": ("personality", "proactivity"),
40
+ "error_handling": ("personality", "error_handling"),
41
+ # preferences.*
42
+ "menu_on_demand": ("preferences", "menu_on_demand"),
43
+ "show_pending_items": ("preferences", "show_pending_items"),
44
+ "execution_first": ("preferences", "execution_first"),
45
+ "report_style": ("preferences", "report_style"),
46
+ # meta.*
47
+ "role": ("meta", "role"),
48
+ "technical_level": ("meta", "technical_level"),
49
+ }
50
+
51
+ # Keys that always live at the top level regardless of shape
52
+ TOP_LEVEL_KEYS = {"version", "created", "mood_history", "operator_name"}
53
+
54
+
55
+ def _calibration_path(nexo_home: Path | None = None) -> Path:
56
+ home = nexo_home or Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
57
+ return home / "brain" / "calibration.json"
58
+
59
+
60
+ def _is_nested(cal: dict) -> bool:
61
+ """Nested shape has at least one of {user, personality, preferences, meta} as dict."""
62
+ for key in ("user", "personality", "preferences", "meta"):
63
+ if isinstance(cal.get(key), dict):
64
+ return True
65
+ return False
66
+
67
+
68
+ def _has_flat_markers(cal: dict) -> bool:
69
+ """Flat shape has top-level keys that normally belong inside nested groups."""
70
+ for flat_key in FLAT_TO_NESTED:
71
+ if flat_key in cal:
72
+ return True
73
+ return False
74
+
75
+
76
+ def detect(cal: dict | None = None, *, path: Path | None = None) -> dict:
77
+ """Return {'shape': 'nested'|'flat'|'mixed'|'empty', 'reason': str}."""
78
+ if cal is None:
79
+ target = path or _calibration_path()
80
+ if not target.is_file():
81
+ return {"shape": "empty", "reason": "file does not exist"}
82
+ try:
83
+ cal = json.loads(target.read_text())
84
+ except Exception as exc:
85
+ return {"shape": "empty", "reason": f"unreadable: {exc}"}
86
+ if not isinstance(cal, dict) or not cal:
87
+ return {"shape": "empty", "reason": "empty or not an object"}
88
+
89
+ nested = _is_nested(cal)
90
+ flat = _has_flat_markers(cal)
91
+ if nested and flat:
92
+ return {"shape": "mixed", "reason": "both nested groups and flat keys present"}
93
+ if nested:
94
+ return {"shape": "nested", "reason": "already canonical"}
95
+ if flat:
96
+ return {"shape": "flat", "reason": "top-level flat keys, no nested groups"}
97
+ return {"shape": "empty", "reason": "no recognizable keys"}
98
+
99
+
100
+ def migrate(
101
+ cal: dict,
102
+ *,
103
+ preserve_unmapped: bool = True,
104
+ ) -> dict:
105
+ """Convert a flat calibration payload to nested. Returns a new dict."""
106
+ if _is_nested(cal) and not _has_flat_markers(cal):
107
+ return dict(cal)
108
+
109
+ result: dict[str, Any] = {}
110
+ legacy_unmapped: dict[str, Any] = {}
111
+
112
+ # Preserve nested groups already present
113
+ for group in ("user", "personality", "preferences", "meta"):
114
+ if isinstance(cal.get(group), dict):
115
+ result[group] = dict(cal[group])
116
+
117
+ # Preserve top-level metadata
118
+ for key in TOP_LEVEL_KEYS:
119
+ if key in cal:
120
+ result[key] = cal[key]
121
+
122
+ # Walk flat keys
123
+ for key, value in cal.items():
124
+ if key in ("user", "personality", "preferences", "meta"):
125
+ continue
126
+ if key in TOP_LEVEL_KEYS:
127
+ continue
128
+ if key in FLAT_TO_NESTED:
129
+ group, leaf = FLAT_TO_NESTED[key]
130
+ result.setdefault(group, {})
131
+ # Nested value wins over flat if both exist
132
+ if leaf not in result[group]:
133
+ result[group][leaf] = value
134
+ else:
135
+ legacy_unmapped[key] = value
136
+
137
+ if legacy_unmapped and preserve_unmapped:
138
+ result["legacy_unmapped"] = legacy_unmapped
139
+
140
+ # Bump version marker if present
141
+ if "version" not in result:
142
+ result["version"] = 1
143
+
144
+ return result
145
+
146
+
147
+ def backup_path(path: Path, version: str = "5.4.0") -> Path:
148
+ return path.with_name(path.name + f".pre-migrate-{version}")
149
+
150
+
151
+ def apply_migration(
152
+ path: Path | None = None,
153
+ *,
154
+ version: str = "5.4.0",
155
+ dry_run: bool = False,
156
+ ) -> dict:
157
+ """Migrate calibration.json on disk. Returns a status dict."""
158
+ target = path or _calibration_path()
159
+ if not target.is_file():
160
+ return {"status": "skipped", "reason": "calibration.json not found", "path": str(target)}
161
+
162
+ try:
163
+ original = json.loads(target.read_text())
164
+ except Exception as exc:
165
+ return {"status": "error", "reason": f"unreadable: {exc}", "path": str(target)}
166
+
167
+ shape = detect(original)
168
+ if shape["shape"] in ("nested", "empty"):
169
+ return {
170
+ "status": "noop",
171
+ "reason": f"already {shape['shape']}",
172
+ "path": str(target),
173
+ "shape": shape["shape"],
174
+ }
175
+
176
+ migrated = migrate(original)
177
+ if dry_run:
178
+ return {
179
+ "status": "preview",
180
+ "reason": "dry run",
181
+ "path": str(target),
182
+ "shape": shape["shape"],
183
+ "original": original,
184
+ "migrated": migrated,
185
+ "backup_would_be": str(backup_path(target, version)),
186
+ }
187
+
188
+ backup = backup_path(target, version)
189
+ try:
190
+ shutil.copy2(target, backup)
191
+ except Exception as exc:
192
+ return {"status": "error", "reason": f"backup failed: {exc}", "path": str(target)}
193
+
194
+ try:
195
+ target.write_text(json.dumps(migrated, ensure_ascii=False, indent=2))
196
+ except Exception as exc:
197
+ # Attempt revert
198
+ try:
199
+ shutil.copy2(backup, target)
200
+ except Exception:
201
+ pass
202
+ return {"status": "error", "reason": f"write failed: {exc}", "path": str(target)}
203
+
204
+ # Re-detect to confirm
205
+ post = detect(migrated)
206
+ if post["shape"] != "nested":
207
+ # Revert
208
+ try:
209
+ shutil.copy2(backup, target)
210
+ except Exception:
211
+ pass
212
+ return {
213
+ "status": "error",
214
+ "reason": f"post-migration shape is {post['shape']}",
215
+ "path": str(target),
216
+ }
217
+
218
+ return {
219
+ "status": "migrated",
220
+ "reason": "flat → nested",
221
+ "path": str(target),
222
+ "backup": str(backup),
223
+ "shape": post["shape"],
224
+ "migrated_at": time.time(),
225
+ }
226
+
227
+
228
+ def revert(
229
+ path: Path | None = None,
230
+ *,
231
+ version: str = "5.4.0",
232
+ ) -> dict:
233
+ """Revert calibration.json to the most recent pre-migrate backup."""
234
+ target = path or _calibration_path()
235
+ backup = backup_path(target, version)
236
+ if not backup.is_file():
237
+ return {"status": "error", "reason": f"no backup found at {backup}", "path": str(target)}
238
+ try:
239
+ shutil.copy2(backup, target)
240
+ except Exception as exc:
241
+ return {"status": "error", "reason": f"copy failed: {exc}", "path": str(target)}
242
+ return {"status": "reverted", "from": str(backup), "path": str(target)}