nexo-brain 6.5.0 → 7.0.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": "6.5.0",
3
+ "version": "7.0.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 `6.5.0` is the current packaged-runtime line — Plan Consolidado fase F0.2: operators can now `nexo scripts enable|disable|status <name>` any personal automation. The cron wrapper honours the flag at every tick (`exit 0` with `summary='[disabled]'` while the LaunchAgent stays loaded). The companion NEXO Desktop client (a closed-source product, distributed separately) wires the same toggle into its Automatizaciones panel. See [CHANGELOG](CHANGELOG.md) for the full diff.
21
+ Version `7.0.0` is the current packaged-runtime line — **BREAKING — Plan Consolidado fase F0.6**: physical separation of the runtime tree into `~/.nexo/{core,personal,runtime}/`. The flat layout (`~/.nexo/scripts/`, `brain/`, `data/`, `operations/`, ...) is gone. Operators on v6.x are auto-migrated on first `nexo update`; fresh installs land directly in the new tree. New `paths.py` helpers are transition-aware. The companion NEXO Desktop client (v0.21.0, closed-source distributed separately) updates its hardcoded paths so auto-update keeps working.
22
+
23
+ Previously in `6.5.0`: Plan Consolidado fase F0.2: operators can now `nexo scripts enable|disable|status <name>` any personal automation. The cron wrapper honours the flag at every tick (`exit 0` with `summary='[disabled]'` while the LaunchAgent stays loaded). The companion NEXO Desktop client (a closed-source product, distributed separately) wires the same toggle into its Automatizaciones panel. See [CHANGELOG](CHANGELOG.md) for the full diff.
22
24
 
23
25
  > **About NEXO Desktop.** NEXO Desktop is a separate closed-source companion app distributed at [systeam.es/nexo-desktop](https://systeam.es/nexo-desktop) — its source does not live in this repo. When release notes mention Desktop they describe a coordinated client release that consumes the Brain's CLI / MCP contract; the Brain itself is fully usable on its own (terminal, Codex, Claude Code, or any MCP client).
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "6.5.0",
3
+ "version": "7.0.0",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 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",
@@ -92,4 +92,4 @@
92
92
  "README.md",
93
93
  "LICENSE"
94
94
  ]
95
- }
95
+ }
@@ -10,6 +10,7 @@ This is separate from plugins/update.py which handles MANUAL updates with rollba
10
10
  import json
11
11
  import hashlib
12
12
  import os
13
+ import paths
13
14
  import re
14
15
  import shutil
15
16
  import subprocess
@@ -32,7 +33,7 @@ except ModuleNotFoundError as exc:
32
33
  return False
33
34
 
34
35
  NEXO_HOME = export_resolved_nexo_home()
35
- DATA_DIR = NEXO_HOME / "data"
36
+ DATA_DIR = paths.data_dir()
36
37
  DATA_DIR.mkdir(parents=True, exist_ok=True)
37
38
 
38
39
  # Repo root: go up from src/
@@ -212,10 +213,10 @@ def _write_last_check(data: dict):
212
213
  def _sync_watchdog_hash_registry():
213
214
  """Keep the immutable-hash registry aligned with the installed watchdog script."""
214
215
  try:
215
- watchdog_file = NEXO_HOME / "scripts" / "nexo-watchdog.sh"
216
+ watchdog_file = paths.core_scripts_dir() / "nexo-watchdog.sh"
216
217
  if not watchdog_file.exists():
217
218
  return
218
- registry_file = NEXO_HOME / "scripts" / ".watchdog-hashes"
219
+ registry_file = paths.core_scripts_dir() / ".watchdog-hashes"
219
220
  entries: dict[str, str] = {}
220
221
  if registry_file.exists():
221
222
  for line in registry_file.read_text().splitlines():
@@ -523,7 +524,7 @@ def _refresh_installed_manifest():
523
524
  try:
524
525
  import shutil
525
526
  src_crons = SRC_DIR / "crons"
526
- dst_crons = NEXO_HOME / "crons"
527
+ dst_crons = paths.crons_dir()
527
528
  if src_crons.exists():
528
529
  dst_crons.mkdir(parents=True, exist_ok=True)
529
530
  for f in src_crons.iterdir():
@@ -537,17 +538,17 @@ def _refresh_installed_manifest():
537
538
  def _cleanup_retired_runtime_files():
538
539
  """Remove retired core files that should not survive updates."""
539
540
  retired = [
540
- NEXO_HOME / "scripts" / "nexo-day-orchestrator.sh",
541
- NEXO_HOME / "scripts" / "heartbeat-enforcement.py",
542
- NEXO_HOME / "scripts" / "heartbeat-posttool.sh",
543
- NEXO_HOME / "scripts" / "heartbeat-user-msg.sh",
544
- NEXO_HOME / "hooks" / "heartbeat-guard.sh",
541
+ paths.core_scripts_dir() / "nexo-day-orchestrator.sh",
542
+ paths.core_scripts_dir() / "heartbeat-enforcement.py",
543
+ paths.core_scripts_dir() / "heartbeat-posttool.sh",
544
+ paths.core_scripts_dir() / "heartbeat-user-msg.sh",
545
+ paths.core_hooks_dir() / "heartbeat-guard.sh",
545
546
  ]
546
547
  conditional_retired = [
547
- (NEXO_HOME / "scripts" / "nexo-postcompact.sh", NEXO_HOME / "hooks" / "post-compact.sh"),
548
- (NEXO_HOME / "scripts" / "nexo-memory-precompact.sh", NEXO_HOME / "hooks" / "pre-compact.sh"),
549
- (NEXO_HOME / "scripts" / "nexo-memory-stop.sh", NEXO_HOME / "hooks" / "session-stop.sh"),
550
- (NEXO_HOME / "scripts" / "nexo-session-briefing.sh", NEXO_HOME / "hooks" / "session-start.sh"),
548
+ (paths.core_scripts_dir() / "nexo-postcompact.sh", paths.core_hooks_dir() / "post-compact.sh"),
549
+ (paths.core_scripts_dir() / "nexo-memory-precompact.sh", paths.core_hooks_dir() / "pre-compact.sh"),
550
+ (paths.core_scripts_dir() / "nexo-memory-stop.sh", paths.core_hooks_dir() / "session-stop.sh"),
551
+ (paths.core_scripts_dir() / "nexo-session-briefing.sh", paths.core_hooks_dir() / "session-start.sh"),
551
552
  ]
552
553
  for target in retired:
553
554
  try:
@@ -692,7 +693,7 @@ def _rotate_auto_update_backups(prefix: str, keep: int = AUTO_UPDATE_BACKUP_KEEP
692
693
  """
693
694
  if keep <= 0:
694
695
  return 0
695
- base = NEXO_HOME / "backups"
696
+ base = paths.backups_dir()
696
697
  if not base.is_dir():
697
698
  return 0
698
699
  try:
@@ -721,7 +722,7 @@ def _rotate_auto_update_backups(prefix: str, keep: int = AUTO_UPDATE_BACKUP_KEEP
721
722
  return removed
722
723
 
723
724
 
724
- SELF_HEAL_STATE_FILE = NEXO_HOME / "operations" / ".self-heal-state.json"
725
+ SELF_HEAL_STATE_FILE = paths.operations_dir() / ".self-heal-state.json"
725
726
  SELF_HEAL_COOLDOWN_SECONDS = 6 * 3600 # Never auto-heal twice within 6 h.
726
727
 
727
728
 
@@ -765,7 +766,7 @@ def _self_heal_if_wiped() -> dict | None:
765
766
  if not db_looks_wiped(primary, CRITICAL_TABLES):
766
767
  return None
767
768
  reference = find_latest_hourly_backup(
768
- NEXO_HOME / "backups",
769
+ paths.backups_dir(),
769
770
  max_age_seconds=HOURLY_BACKUP_MAX_AGE,
770
771
  )
771
772
  if reference is None:
@@ -813,7 +814,7 @@ def _self_heal_if_wiped() -> dict | None:
813
814
  time.sleep(0.5)
814
815
 
815
816
  # Snapshot the current (wiped) state so the heal is reversible.
816
- pre_heal_dir = NEXO_HOME / "backups" / f"pre-heal-{time.strftime('%Y-%m-%d-%H%M%S')}"
817
+ pre_heal_dir = paths.backups_dir() / f"pre-heal-{time.strftime('%Y-%m-%d-%H%M%S')}"
817
818
  try:
818
819
  import shutil as _shutil
819
820
  pre_heal_dir.mkdir(parents=True, exist_ok=True)
@@ -1282,7 +1283,7 @@ def _backup_dbs() -> str | None:
1282
1283
  # validation and rollback paths. Safe no-op when there are none.
1283
1284
  _purge_zero_byte_db_files()
1284
1285
  timestamp = _time.strftime("%Y-%m-%d-%H%M%S")
1285
- backup_dir = NEXO_HOME / "backups" / f"pre-autoupdate-{timestamp}"
1286
+ backup_dir = paths.backups_dir() / f"pre-autoupdate-{timestamp}"
1286
1287
 
1287
1288
  db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
1288
1289
  db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
@@ -1353,7 +1354,7 @@ def _sync_hooks():
1353
1354
  """Copy hook scripts from src/hooks/ to NEXO_HOME/hooks/ after a git pull."""
1354
1355
  import shutil
1355
1356
  hooks_src = SRC_DIR / "hooks"
1356
- hooks_dest = NEXO_HOME / "hooks"
1357
+ hooks_dest = paths.core_hooks_dir()
1357
1358
  if not hooks_src.is_dir():
1358
1359
  return
1359
1360
  hooks_dest.mkdir(parents=True, exist_ok=True)
@@ -1526,6 +1527,12 @@ def _run_db_migrations() -> bool:
1526
1527
  # over automatically so scripts pick up the new source on the
1527
1528
  # next cron without the operator running anything manually.
1528
1529
  _maybe_migrate_legacy_email_config()
1530
+ # Plan Consolidado F0.6 — one-shot physical layout migration.
1531
+ # If ~/.nexo/.structure-version is absent or pre-F0.6, move the
1532
+ # flat layout into ~/.nexo/{core,personal,runtime}/<X>/, rewrite
1533
+ # personal_scripts.path + LaunchAgent plists, then write the
1534
+ # F0.6 marker. Idempotent: returns immediately when already F0.6.
1535
+ _maybe_migrate_to_f06_layout()
1529
1536
  return True
1530
1537
  except Exception as e:
1531
1538
  _log(f"DB migration error: {e}")
@@ -1540,7 +1547,7 @@ def _maybe_migrate_legacy_email_config() -> None:
1540
1547
  except Exception:
1541
1548
  return # m46 not applied yet (older runtime), nothing to do
1542
1549
  try:
1543
- legacy = NEXO_HOME / "nexo-email" / "config.json"
1550
+ legacy = paths.nexo_email_dir() / "config.json"
1544
1551
  if not legacy.exists():
1545
1552
  return
1546
1553
  if get_email_account("primary"):
@@ -1571,6 +1578,240 @@ def _maybe_migrate_legacy_email_config() -> None:
1571
1578
 
1572
1579
  # ── npm version check (notify only) ─────────────────────────────────
1573
1580
 
1581
+
1582
+ def _maybe_migrate_to_f06_layout() -> None:
1583
+ """Plan F0.6 — one-shot physical layout migration. Idempotent.
1584
+
1585
+ Pre-condition: ``~/.nexo/.structure-version`` is absent or its content
1586
+ is not ``F0.6``. After running, every dir from the flat layout
1587
+ (``~/.nexo/scripts/``, ``brain/``, ``data/``, ``operations/``, ...)
1588
+ has been moved to its post-F0.6 home (``~/.nexo/{core,personal,runtime}/<X>/``),
1589
+ ``personal_scripts.path`` rows point at the new locations, every
1590
+ ``com.nexo.*.plist`` LaunchAgent has been rewritten to use the new
1591
+ paths, and ``~/.nexo/.structure-version`` is set to ``F0.6``.
1592
+
1593
+ A snapshot is taken before the move at ``~/.nexo-pre-f06-snapshot/``
1594
+ so a complete rollback is ``mv ~/.nexo-pre-f06-snapshot ~/.nexo``.
1595
+
1596
+ Failures are non-fatal — the helper logs and returns; the operator
1597
+ can re-run ``nexo update`` later.
1598
+ """
1599
+ try:
1600
+ marker = NEXO_HOME / ".structure-version"
1601
+ try:
1602
+ current = marker.read_text(encoding="utf-8").strip()
1603
+ except Exception:
1604
+ current = ""
1605
+ if current == "F0.6":
1606
+ return # already migrated
1607
+ # Refuse to migrate from a non-NEXO_HOME or a fresh install
1608
+ # without legacy dirs to move.
1609
+ legacy_anchors = [NEXO_HOME / sub for sub in
1610
+ ("scripts", "brain", "data", "operations", "logs", "backups",
1611
+ "memory", "cognitive", "coordination", "exports",
1612
+ "nexo-email", "doctor", "snapshots", "crons",
1613
+ "skills", "plugins", "hooks", "rules")]
1614
+ present = [p for p in legacy_anchors if p.exists() and not p.is_symlink()]
1615
+ if not present:
1616
+ # Fresh install — write marker and return.
1617
+ try:
1618
+ marker.write_text("F0.6\n", encoding="utf-8")
1619
+ except Exception:
1620
+ pass
1621
+ return
1622
+
1623
+ _log("[F0.6] starting layout migration...")
1624
+ import shutil
1625
+ import sqlite3
1626
+ import re as _re
1627
+ # 1. Snapshot
1628
+ snapshot = Path(str(NEXO_HOME) + "-pre-f06-snapshot")
1629
+ if not snapshot.exists():
1630
+ try:
1631
+ shutil.copytree(NEXO_HOME, snapshot, symlinks=True, ignore_dangling_symlinks=True)
1632
+ _log(f"[F0.6] snapshot at {snapshot}")
1633
+ except Exception as e:
1634
+ _log(f"[F0.6] snapshot failed (non-fatal): {e}")
1635
+
1636
+ # 2. Ensure target dirs
1637
+ for sub in ("core/scripts", "core-dev/scripts", "personal/scripts",
1638
+ "personal/brain", "personal/skills",
1639
+ "core/plugins", "core/hooks", "core/rules",
1640
+ "runtime/data", "runtime/logs", "runtime/operations",
1641
+ "runtime/backups", "runtime/memory", "runtime/cognitive",
1642
+ "runtime/coordination", "runtime/exports",
1643
+ "runtime/nexo-email", "runtime/doctor",
1644
+ "runtime/snapshots", "runtime/crons"):
1645
+ (NEXO_HOME / sub).mkdir(parents=True, exist_ok=True)
1646
+
1647
+ # 3. Classify scripts: load core names from packaged manifest
1648
+ core_names: set[str] = set()
1649
+ artifacts = NEXO_HOME / "config" / "runtime-core-artifacts.json"
1650
+ try:
1651
+ data = json.loads(artifacts.read_text(encoding="utf-8"))
1652
+ for name in data.get("script_names", []):
1653
+ core_names.add(name)
1654
+ except Exception:
1655
+ pass
1656
+ for f in (NEXO_HOME / "core" / "scripts").iterdir() if (NEXO_HOME / "core" / "scripts").is_dir() else []:
1657
+ core_names.add(f.name)
1658
+ dev_names: set[str] = set()
1659
+ if (NEXO_HOME / "core-dev" / "scripts").is_dir():
1660
+ for f in (NEXO_HOME / "core-dev" / "scripts").iterdir():
1661
+ dev_names.add(f.name)
1662
+
1663
+ def _classify(name: str) -> str:
1664
+ if name in dev_names:
1665
+ return "core-dev"
1666
+ if name in core_names:
1667
+ return "core"
1668
+ return "personal"
1669
+
1670
+ # 4. Move script files (handle existing F0.3/0.4 symlinks)
1671
+ legacy_scripts = NEXO_HOME / "scripts"
1672
+ if legacy_scripts.is_dir() and not legacy_scripts.is_symlink():
1673
+ for f in sorted(legacy_scripts.iterdir()):
1674
+ if not f.is_file():
1675
+ continue
1676
+ cls = _classify(f.name)
1677
+ root = {
1678
+ "core": NEXO_HOME / "core" / "scripts",
1679
+ "personal": NEXO_HOME / "personal" / "scripts",
1680
+ "core-dev": NEXO_HOME / "core-dev" / "scripts",
1681
+ }[cls]
1682
+ dst = root / f.name
1683
+ if dst.is_symlink():
1684
+ target = dst.resolve()
1685
+ if target == f.resolve():
1686
+ dst.unlink()
1687
+ if dst.exists():
1688
+ if dst.is_file() and f.is_file() and dst.read_bytes() == f.read_bytes():
1689
+ f.unlink()
1690
+ continue
1691
+ dst.rename(dst.with_suffix(dst.suffix + ".f06bak"))
1692
+ shutil.move(str(f), str(dst))
1693
+
1694
+ # 5. Move tree dirs (brain, data, logs, ...)
1695
+ TREE_MAP = [
1696
+ ("brain", NEXO_HOME / "personal" / "brain"),
1697
+ ("data", NEXO_HOME / "runtime" / "data"),
1698
+ ("logs", NEXO_HOME / "runtime" / "logs"),
1699
+ ("operations", NEXO_HOME / "runtime" / "operations"),
1700
+ ("backups", NEXO_HOME / "runtime" / "backups"),
1701
+ ("memory", NEXO_HOME / "runtime" / "memory"),
1702
+ ("cognitive", NEXO_HOME / "runtime" / "cognitive"),
1703
+ ("coordination", NEXO_HOME / "runtime" / "coordination"),
1704
+ ("exports", NEXO_HOME / "runtime" / "exports"),
1705
+ ("nexo-email", NEXO_HOME / "runtime" / "nexo-email"),
1706
+ ("doctor", NEXO_HOME / "runtime" / "doctor"),
1707
+ ("snapshots", NEXO_HOME / "runtime" / "snapshots"),
1708
+ ("crons", NEXO_HOME / "runtime" / "crons"),
1709
+ ("skills", NEXO_HOME / "personal" / "skills"),
1710
+ ("plugins", NEXO_HOME / "core" / "plugins"),
1711
+ ("hooks", NEXO_HOME / "core" / "hooks"),
1712
+ ("rules", NEXO_HOME / "core" / "rules"),
1713
+ ]
1714
+ for legacy_name, new_dir in TREE_MAP:
1715
+ legacy = NEXO_HOME / legacy_name
1716
+ if not legacy.is_dir() or legacy.is_symlink():
1717
+ continue
1718
+ new_dir.parent.mkdir(parents=True, exist_ok=True)
1719
+ # Per-item move so we don't kill files via symlink-loop.
1720
+ for item in list(legacy.iterdir()):
1721
+ target = new_dir / item.name
1722
+ if target.is_symlink() and target.resolve() == item.resolve():
1723
+ target.unlink()
1724
+ if target.exists():
1725
+ if item.is_file() and target.is_file() and item.stat().st_size == target.stat().st_size:
1726
+ try:
1727
+ item.unlink()
1728
+ continue
1729
+ except Exception:
1730
+ pass
1731
+ # Backup conflict and proceed
1732
+ try:
1733
+ target.rename(target.with_suffix(target.suffix + ".f06bak"))
1734
+ except Exception:
1735
+ continue
1736
+ try:
1737
+ shutil.move(str(item), str(target))
1738
+ except Exception as e:
1739
+ _log(f"[F0.6] move {item} -> {target} failed: {e}")
1740
+ try:
1741
+ legacy.rmdir()
1742
+ except OSError:
1743
+ pass
1744
+
1745
+ # 6. UPDATE personal_scripts.path
1746
+ try:
1747
+ db = NEXO_HOME / "runtime" / "data" / "nexo.db"
1748
+ if not db.is_file():
1749
+ db = NEXO_HOME / "data" / "nexo.db"
1750
+ if db.is_file():
1751
+ conn = sqlite3.connect(str(db))
1752
+ rows = conn.execute("SELECT id, path FROM personal_scripts").fetchall()
1753
+ updates = []
1754
+ legacy_root = str(NEXO_HOME / "scripts") + "/"
1755
+ for row_id, old_path in rows:
1756
+ if not old_path or not old_path.startswith(legacy_root):
1757
+ continue
1758
+ name = Path(old_path).name
1759
+ cls = _classify(name)
1760
+ new_root = {
1761
+ "core": NEXO_HOME / "core" / "scripts",
1762
+ "personal": NEXO_HOME / "personal" / "scripts",
1763
+ "core-dev": NEXO_HOME / "core-dev" / "scripts",
1764
+ }[cls]
1765
+ updates.append((str(new_root / name), row_id))
1766
+ if updates:
1767
+ conn.executemany("UPDATE personal_scripts SET path = ? WHERE id = ?", updates)
1768
+ conn.commit()
1769
+ _log(f"[F0.6] UPDATEd {len(updates)} personal_scripts.path rows")
1770
+ conn.close()
1771
+ except Exception as e:
1772
+ _log(f"[F0.6] DB UPDATE failed (non-fatal): {e}")
1773
+
1774
+ # 7. Rewrite LaunchAgent plists
1775
+ try:
1776
+ la_dir = Path.home() / "Library" / "LaunchAgents"
1777
+ count = 0
1778
+ if la_dir.is_dir():
1779
+ for plist in sorted(la_dir.glob("com.nexo.*.plist")):
1780
+ try:
1781
+ text = plist.read_text(encoding="utf-8")
1782
+ except Exception:
1783
+ continue
1784
+ original = text
1785
+ def _repl_script(m):
1786
+ full = m.group(0)
1787
+ name = Path(full).name
1788
+ cls = _classify(name)
1789
+ new_root = {
1790
+ "core": NEXO_HOME / "core" / "scripts",
1791
+ "personal": NEXO_HOME / "personal" / "scripts",
1792
+ "core-dev": NEXO_HOME / "core-dev" / "scripts",
1793
+ }[cls]
1794
+ return str(new_root / name)
1795
+ text = _re.sub(rf"{_re.escape(str(NEXO_HOME))}/scripts/[\w\-\.]+\.(py|sh|js)", _repl_script, text)
1796
+ text = text.replace(f"{NEXO_HOME}/logs/", f"{NEXO_HOME}/runtime/logs/")
1797
+ if text != original:
1798
+ plist.write_text(text, encoding="utf-8")
1799
+ count += 1
1800
+ _log(f"[F0.6] rewrote {count} LaunchAgent plists")
1801
+ except Exception as e:
1802
+ _log(f"[F0.6] plist rewrite failed (non-fatal): {e}")
1803
+
1804
+ # 8. Write marker
1805
+ try:
1806
+ marker.write_text("F0.6\n", encoding="utf-8")
1807
+ _log("[F0.6] marker written: ~/.nexo/.structure-version = F0.6")
1808
+ except Exception as e:
1809
+ _log(f"[F0.6] marker write failed: {e}")
1810
+ _log("[F0.6] migration done")
1811
+ except Exception as e:
1812
+ _log(f"[F0.6] unexpected error (non-fatal, runtime keeps working): {e}")
1813
+
1814
+
1574
1815
  def _check_npm_version() -> str | None:
1575
1816
  """For non-git installs: check npm registry for a newer version. Returns notification or None."""
1576
1817
  current = _read_package_version()
@@ -2067,7 +2308,7 @@ def _sync_client_bootstraps(preferences: dict | None = None) -> list[str]:
2067
2308
 
2068
2309
  # ── Main entry point ─────────────────────────────────────────────────
2069
2310
 
2070
- _AUTO_UPDATE_LOCK_FILE = NEXO_HOME / "operations" / ".auto_update.lock"
2311
+ _AUTO_UPDATE_LOCK_FILE = paths.operations_dir() / ".auto_update.lock"
2071
2312
  _AUTO_UPDATE_LOCK_STALE_SECONDS = 600 # 10 minutes
2072
2313
 
2073
2314
 
@@ -2265,10 +2506,10 @@ def _auto_update_check_locked() -> dict:
2265
2506
 
2266
2507
  # Backfill evolution-objective.json for existing installs
2267
2508
  try:
2268
- evo_obj_path = NEXO_HOME / "brain" / "evolution-objective.json"
2509
+ evo_obj_path = paths.brain_dir() / "evolution-objective.json"
2269
2510
  from evolution_cycle import normalize_objective
2270
2511
  if not evo_obj_path.exists():
2271
- (NEXO_HOME / "brain").mkdir(parents=True, exist_ok=True)
2512
+ (paths.brain_dir()).mkdir(parents=True, exist_ok=True)
2272
2513
  default_objective = {
2273
2514
  "objective": "Improve operational excellence and reduce repeated errors",
2274
2515
  "focus_areas": ["error_prevention", "proactivity", "memory_quality"],
@@ -2298,7 +2539,7 @@ def _auto_update_check_locked() -> dict:
2298
2539
 
2299
2540
  # Backfill NEXO_HOME/scripts/ for existing installs
2300
2541
  try:
2301
- scripts_dest = NEXO_HOME / "scripts"
2542
+ scripts_dest = paths.core_scripts_dir()
2302
2543
  # Deduce NEXO_CODE: env var first, then from __file__ (auto_update.py is in src/)
2303
2544
  nexo_code = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
2304
2545
  scripts_src = nexo_code / "scripts" if (nexo_code / "scripts").is_dir() else None
@@ -2336,7 +2577,7 @@ def _auto_update_check_locked() -> dict:
2336
2577
  # Backfill doctor package for existing installs
2337
2578
  try:
2338
2579
  doctor_src = SRC_DIR / "doctor"
2339
- doctor_dest = NEXO_HOME / "doctor"
2580
+ doctor_dest = paths.doctor_dir()
2340
2581
  if doctor_src.is_dir():
2341
2582
  import shutil
2342
2583
  if not doctor_dest.is_dir():
@@ -2391,7 +2632,7 @@ def _auto_update_check_locked() -> dict:
2391
2632
  # Backfill MCP doctor plugin so existing installs expose nexo_doctor.
2392
2633
  try:
2393
2634
  plugin_src = SRC_DIR / "plugins" / "doctor.py"
2394
- plugin_dest = NEXO_HOME / "plugins" / "doctor.py"
2635
+ plugin_dest = paths.core_plugins_dir() / "doctor.py"
2395
2636
  plugin_dest.parent.mkdir(parents=True, exist_ok=True)
2396
2637
  if plugin_src.is_file() and (not plugin_dest.exists() or plugin_src.stat().st_mtime > plugin_dest.stat().st_mtime):
2397
2638
  import shutil
@@ -2478,8 +2719,8 @@ def _auto_update_check_locked() -> dict:
2478
2719
  return result
2479
2720
 
2480
2721
 
2481
- UPDATE_SUMMARY_FILE = NEXO_HOME / "logs" / "update-last-summary.json"
2482
- UPDATE_HISTORY_FILE = NEXO_HOME / "logs" / "update-history.jsonl"
2722
+ UPDATE_SUMMARY_FILE = paths.logs_dir() / "update-last-summary.json"
2723
+ UPDATE_HISTORY_FILE = paths.logs_dir() / "update-history.jsonl"
2483
2724
 
2484
2725
 
2485
2726
  def _resolve_sync_source() -> tuple[Path | None, Path | None]:
@@ -2645,7 +2886,7 @@ def _installed_scripts_classification(dest: Path) -> dict[str, str]:
2645
2886
 
2646
2887
  def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
2647
2888
  timestamp = time.strftime("%Y-%m-%d-%H%M%S")
2648
- backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
2889
+ backup_dir = paths.backups_dir() / f"runtime-tree-{timestamp}"
2649
2890
  backup_dir.mkdir(parents=True, exist_ok=True)
2650
2891
 
2651
2892
  code_dirs = [
@@ -3090,7 +3331,7 @@ def _runtime_busy_reason() -> str | None:
3090
3331
 
3091
3332
  def _write_update_summary(summary: dict):
3092
3333
  try:
3093
- logs_dir = NEXO_HOME / "logs"
3334
+ logs_dir = paths.logs_dir()
3094
3335
  logs_dir.mkdir(parents=True, exist_ok=True)
3095
3336
  payload = dict(summary)
3096
3337
  payload.setdefault("timestamp", time.strftime("%Y-%m-%dT%H:%M:%S"))
@@ -193,7 +193,8 @@ def _target_path(client: str, *, user_home: Path | None = None) -> Path:
193
193
 
194
194
 
195
195
  def _version_tracker_path(nexo_home: Path, client: str) -> Path:
196
- return nexo_home / "data" / BOOTSTRAP_SPECS[client]["version_file"]
196
+ import paths
197
+ return paths.data_dir() / BOOTSTRAP_SPECS[client]["version_file"]
197
198
 
198
199
 
199
200
  def render_bootstrap_template(
@@ -54,7 +54,8 @@ TOP_LEVEL_KEYS = {"version", "created", "mood_history", "operator_name"}
54
54
 
55
55
  def _calibration_path(nexo_home: Path | None = None) -> Path:
56
56
  home = nexo_home or Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
57
- return home / "brain" / "calibration.json"
57
+ import paths
58
+ return paths.brain_dir() / "calibration.json"
58
59
 
59
60
 
60
61
  def _is_nested(cal: dict) -> bool:
@@ -342,7 +343,9 @@ def apply_v6_purge(
342
343
  "seeded_default_resonance": False,
343
344
  }
344
345
 
345
- cal_path = home / "brain" / "calibration.json"
346
+ _brain_new = home / "personal" / "brain"
347
+ _brain_legacy = home / "brain"
348
+ cal_path = (_brain_new if _brain_new.is_dir() else _brain_legacy) / "calibration.json"
346
349
  sched_path = home / "config" / "schedule.json"
347
350
 
348
351
  # --- calibration.json ---
package/src/cli.py CHANGED
@@ -37,6 +37,7 @@ import contextlib
37
37
  import io
38
38
  import json
39
39
  import os
40
+ import paths
40
41
  import shutil
41
42
  import subprocess
42
43
  import sys
@@ -303,7 +304,7 @@ def _scripts_list(args):
303
304
  print(json.dumps(scripts, indent=2))
304
305
  else:
305
306
  if not scripts:
306
- print("No personal scripts found in", NEXO_HOME / "scripts")
307
+ print("No personal scripts found in", paths.core_scripts_dir())
307
308
  return 0
308
309
  # Table output
309
310
  name_w = max(len(s["name"]) for s in scripts)
@@ -347,7 +348,7 @@ def _scripts_classify(args):
347
348
 
348
349
  entries = report.get("entries", [])
349
350
  if not entries:
350
- print("No scripts directory found:", report.get("scripts_dir", NEXO_HOME / "scripts"))
351
+ print("No scripts directory found:", report.get("scripts_dir", paths.core_scripts_dir()))
351
352
  return 0
352
353
 
353
354
  path_w = max(len(Path(entry["path"]).name) for entry in entries)
@@ -538,8 +539,8 @@ def _scripts_run(args):
538
539
 
539
540
  # Only inject DB paths for core scripts
540
541
  if is_core:
541
- env["NEXO_DB"] = str(NEXO_HOME / "data" / "nexo.db")
542
- env["NEXO_COGNITIVE_DB"] = str(NEXO_HOME / "data" / "cognitive.db")
542
+ env["NEXO_DB"] = str(paths.db_path())
543
+ env["NEXO_COGNITIVE_DB"] = str(paths.data_dir() / "cognitive.db")
543
544
 
544
545
  # Timeout
545
546
  timeout = None
@@ -1031,7 +1032,7 @@ def _update(args):
1031
1032
  # the sensory-register buffer. Keeps a .pre-v5.4.1.bak backup the first
1032
1033
  # time it runs on a given host.
1033
1034
  try:
1034
- buf = NEXO_HOME / "brain" / "session_buffer.jsonl"
1035
+ buf = paths.brain_dir() / "session_buffer.jsonl"
1035
1036
  marker = buf.with_suffix(".jsonl.pre-v5.4.1.bak")
1036
1037
  if buf.is_file() and not marker.is_file():
1037
1038
  raw = buf.read_text(errors="ignore").splitlines()
@@ -1163,7 +1164,7 @@ def _write_calibration_default_resonance(tier: str) -> None:
1163
1164
  …). This helper keeps the CLI path writing to both calibration.json
1164
1165
  AND schedule.json so the two surfaces never disagree.
1165
1166
  """
1166
- cal_path = NEXO_HOME / "brain" / "calibration.json"
1167
+ cal_path = paths.brain_dir() / "calibration.json"
1167
1168
  try:
1168
1169
  cal_path.parent.mkdir(parents=True, exist_ok=True)
1169
1170
  if cal_path.exists():
@@ -3,6 +3,7 @@ from __future__ import annotations
3
3
 
4
4
  import json
5
5
  import os
6
+ import paths
6
7
  import plistlib
7
8
  import sqlite3
8
9
  import contextlib
@@ -16,8 +17,8 @@ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent
16
17
  LAUNCH_AGENTS_DIR = Path.home() / "Library" / "LaunchAgents"
17
18
  OPTIONALS_FILE = NEXO_HOME / "config" / "optionals.json"
18
19
  SCHEDULE_FILE = NEXO_HOME / "config" / "schedule.json"
19
- DB_PATH = NEXO_HOME / "data" / "nexo.db"
20
- STATE_FILE = NEXO_HOME / "operations" / ".catchup-state.json"
20
+ DB_PATH = paths.db_path()
21
+ STATE_FILE = paths.operations_dir() / ".catchup-state.json"
21
22
 
22
23
 
23
24
  def _local_timezone():
@@ -84,7 +85,7 @@ def resolve_declared_schedule(cron: dict) -> dict:
84
85
 
85
86
  def load_enabled_crons() -> list[dict]:
86
87
  manifest_candidates = [
87
- NEXO_HOME / "crons" / "manifest.json",
88
+ paths.crons_dir() / "manifest.json",
88
89
  NEXO_CODE / "crons" / "manifest.json",
89
90
  ]
90
91
  optionals = _load_json(OPTIONALS_FILE, {})
package/src/db/_skills.py CHANGED
@@ -11,6 +11,7 @@ Executable skills are indexed in SQLite but sourced from filesystem definitions.
11
11
  import datetime
12
12
  import json
13
13
  import os
14
+ import paths
14
15
  import re
15
16
  import shutil
16
17
  from pathlib import Path
@@ -25,7 +26,7 @@ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
25
26
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parents[1])))
26
27
 
27
28
  NEXO_ROOT = NEXO_CODE.parent
28
- PERSONAL_SKILLS_DIR = NEXO_HOME / "skills"
29
+ PERSONAL_SKILLS_DIR = paths.personal_skills_dir()
29
30
 
30
31
 
31
32
  def _resolve_core_skills_dir() -> Path:
@@ -27,11 +27,13 @@ def _nexo_home() -> Path:
27
27
 
28
28
 
29
29
  def _calibration_path() -> Path:
30
- return _nexo_home() / "brain" / "calibration.json"
30
+ import paths
31
+ return paths.brain_dir() / "calibration.json"
31
32
 
32
33
 
33
34
  def _profile_path() -> Path:
34
- return _nexo_home() / "brain" / "profile.json"
35
+ import paths
36
+ return paths.brain_dir() / "profile.json"
35
37
 
36
38
 
37
39
  def _read_json(path: Path) -> dict: