nexo-brain 5.3.6 → 5.3.8

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.6",
3
+ "version": "5.3.8",
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,7 @@
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.4` closes the last packaged-runtime core/personal leak from the hook migration work: legacy hook aliases stay out of the personal bucket, `nexo update` removes those retired aliases when the canonical hooks already exist, and both `nexo` and `nexo chat` now show latest vs installed version at a glance.
21
+ Version `5.3.8` is the current packaged-runtime line: packaged installs now self-heal more of the update path, portable user-data export/import is explicit, tracked Codex drift no longer leaves runtime doctor red after cleanup, and the packaged migrator copies newly added root runtime modules into `~/.nexo` instead of leaving them stranded in the npm tarball.
22
22
 
23
23
  Start here:
24
24
  - [5-minute quickstart](docs/quickstart-5-minutes.md)
@@ -89,7 +89,7 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
89
89
  - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
90
90
  - NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
91
91
 
92
- Version `5.3.6` hardens the Claude Code bootstrap path and related runtime hygiene: managed client sync now writes the NEXO MCP server where current Claude Code actually reads it (`~/.claude.json`), script classification is stricter about core-vs-personal runtime artifacts, schedule status distinguishes genuinely running jobs from broken ones, and retroactive learnings stop opening keyword-only false positives outside their declared `applies_to` scope. Version `5.3.5` already keeps CLI version visibility honest right after `nexo update`: if the cached npm version lags behind the runtime you just installed, `nexo` / `nexo chat` now clamp `Latest` to the installed version and refresh the cache instead of showing a stale older release. Version `5.3.4` already cleaned up legacy core alias leakage and added the version-status banner. Version `5.3.3` closed the remaining packaged-runtime doctor mismatch: the built-in hourly backup helper is now inventoried as a core LaunchAgent, so clean installs no longer get a false unknown-LaunchAgent warning. Version `5.3.2` already hardened the runtime boundary by persisting which runtime scripts/hooks are core product artifacts, keeping `nexo scripts` from mixing those into the personal bucket, and migrating the legacy Claude Code heartbeat wrappers into managed core hooks.
92
+ Version `5.3.8` is the immediate packaged-migration hotfix for `5.3.7`: the installer/runtime migrator now discovers all top-level runtime Python modules from `src/` dynamically instead of relying on a manual allowlist, so new product surfaces like `nexo export` / `nexo import` actually arrive in `~/.nexo` after update instead of being present only in the published npm tarball. Version `5.3.7` closed the remaining packaged-runtime happy-path gap and finally exposed portable user-data migration commands: packaged `nexo update` now self-heals cron definitions and LaunchAgents after a successful npm bump, new `nexo export` / `nexo import` commands move operator data as a safe bundle instead of leaving that flow implicit, and runtime doctor now distinguishes tracked historical Codex drift from an actually broken runtime so cleaned installs stop staying red for stale transcript debt alone. Version `5.3.6` hardened the Claude Code bootstrap path and related runtime hygiene: managed client sync now writes the NEXO MCP server where current Claude Code actually reads it (`~/.claude.json`), script classification is stricter about core-vs-personal runtime artifacts, schedule status distinguishes genuinely running jobs from broken ones, and retroactive learnings stop opening keyword-only false positives outside their declared `applies_to` scope. Version `5.3.5` already keeps CLI version visibility honest right after `nexo update`: if the cached npm version lags behind the runtime you just installed, `nexo` / `nexo chat` now clamp `Latest` to the installed version and refresh the cache instead of showing a stale older release. Version `5.3.4` already cleaned up legacy core alias leakage and added the version-status banner. Version `5.3.3` closed the remaining packaged-runtime doctor mismatch: the built-in hourly backup helper is now inventoried as a core LaunchAgent, so clean installs no longer get a false unknown-LaunchAgent warning. Version `5.3.2` already hardened the runtime boundary by persisting which runtime scripts/hooks are core product artifacts, keeping `nexo scripts` from mixing those into the personal bucket, and migrating the legacy Claude Code heartbeat wrappers into managed core hooks.
93
93
 
94
94
  Version `5.3.1` normalizes packaged npm installs so they behave like packaged npm installs: `nexo update` now keeps the runtime anchored to `~/.nexo`, refreshes packaged bootstrap/client artifacts after upgrade, avoids repo-only release-artifact drift in installed runtimes, and keeps personal scripts on the canonical packaged path.
95
95
 
package/bin/nexo-brain.js CHANGED
@@ -120,8 +120,8 @@ function writeRuntimeCoreArtifactsManifest(nexoHome, srcDir) {
120
120
  }
121
121
  }
122
122
 
123
- function getCoreRuntimeFlatFiles() {
124
- return [
123
+ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
124
+ const staticFiles = [
125
125
  "server.py",
126
126
  "plugin_loader.py",
127
127
  "knowledge_graph.py",
@@ -155,6 +155,11 @@ function getCoreRuntimeFlatFiles() {
155
155
  "runtime_power.py",
156
156
  "requirements.txt",
157
157
  ];
158
+ const discoveredRootModules = fs.existsSync(srcDir)
159
+ ? fs.readdirSync(srcDir)
160
+ .filter((name) => name.endsWith(".py") && fs.statSync(path.join(srcDir, name)).isFile())
161
+ : [];
162
+ return [...new Set([...staticFiles, ...discoveredRootModules])];
158
163
  }
159
164
 
160
165
  function getCoreRuntimePackages() {
@@ -1480,7 +1485,7 @@ async function main() {
1480
1485
  log(" Hooks updated.");
1481
1486
 
1482
1487
  // Update core Python files (flat .py files in src/)
1483
- const coreFlatFiles = getCoreRuntimeFlatFiles();
1488
+ const coreFlatFiles = getCoreRuntimeFlatFiles(srcDir);
1484
1489
  coreFlatFiles.forEach((f) => {
1485
1490
  const src = path.join(srcDir, f);
1486
1491
  if (fs.existsSync(src)) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.6",
3
+ "version": "5.3.8",
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",
package/src/cli.py CHANGED
@@ -3,6 +3,8 @@
3
3
 
4
4
  Entry points:
5
5
  nexo chat [PATH]
6
+ nexo export [PATH] [--json]
7
+ nexo import PATH [--json]
6
8
  nexo scripts list [--all] [--json]
7
9
  nexo scripts create NAME [--runtime python|shell] [--description TEXT]
8
10
  nexo scripts classify [--json]
@@ -558,6 +560,43 @@ def _scripts_doctor(args):
558
560
  return 0
559
561
 
560
562
 
563
+ def _export_bundle(args):
564
+ from user_data_portability import export_user_bundle
565
+
566
+ result = export_user_bundle(args.path or "")
567
+ if args.json:
568
+ print(json.dumps(result, indent=2, ensure_ascii=False))
569
+ else:
570
+ if not result.get("ok"):
571
+ print(result.get("error", "Export failed"), file=sys.stderr)
572
+ return 1
573
+ sections = result.get("sections", {})
574
+ script_count = sections.get("personal_scripts", {}).get("files", 0)
575
+ print(f"User data export written to {result['path']}")
576
+ print(f" Personal scripts: {script_count}")
577
+ print(f" Sections: {', '.join(sorted(sections))}")
578
+ return 0 if result.get("ok") else 1
579
+
580
+
581
+ def _import_bundle(args):
582
+ from user_data_portability import import_user_bundle
583
+
584
+ result = import_user_bundle(args.path)
585
+ if args.json:
586
+ print(json.dumps(result, indent=2, ensure_ascii=False))
587
+ else:
588
+ if not result.get("ok"):
589
+ print(result.get("error", "Import failed"), file=sys.stderr)
590
+ return 1
591
+ restored = result.get("restored", {})
592
+ script_count = restored.get("personal_scripts", {}).get("files", 0)
593
+ print(f"User data imported from {result['path']}")
594
+ print(f" Safety backup: {result['safety_backup']}")
595
+ print(f" Personal scripts restored: {script_count}")
596
+ print(f" Sections: {', '.join(sorted(restored))}")
597
+ return 0 if result.get("ok") else 1
598
+
599
+
561
600
  def _runtime_python_candidates() -> list[str]:
562
601
  candidates: list[str] = []
563
602
  seen: set[str] = set()
@@ -1588,6 +1627,8 @@ def _print_help():
1588
1627
 
1589
1628
  Commands:
1590
1629
  nexo chat [path] [--client claude_code|codex] Launch a NEXO terminal client
1630
+ nexo export [path] Export a portable user-data bundle
1631
+ nexo import PATH Import a portable user-data bundle
1591
1632
  nexo doctor [--tier boot|runtime|deep|all] [--fix] System diagnostics
1592
1633
  nexo scripts list|create|classify|sync|reconcile|ensure-schedules|schedules|run|doctor|call|unschedule|remove
1593
1634
  Personal scripts
@@ -1618,6 +1659,16 @@ def main():
1618
1659
  help="Override the chat picker and launch a specific terminal client",
1619
1660
  )
1620
1661
 
1662
+ # -- export --
1663
+ export_parser = sub.add_parser("export", help="Export a portable user-data bundle")
1664
+ export_parser.add_argument("path", nargs="?", default="", help="Output bundle path (default: NEXO_HOME/exports/...)")
1665
+ export_parser.add_argument("--json", action="store_true", help="JSON output")
1666
+
1667
+ # -- import --
1668
+ import_parser = sub.add_parser("import", help="Import a portable user-data bundle")
1669
+ import_parser.add_argument("path", help="Bundle path created by `nexo export`")
1670
+ import_parser.add_argument("--json", action="store_true", help="JSON output")
1671
+
1621
1672
  # -- scripts --
1622
1673
  scripts_parser = sub.add_parser("scripts", help="Manage personal scripts")
1623
1674
  scripts_sub = scripts_parser.add_subparsers(dest="scripts_command")
@@ -1828,6 +1879,10 @@ def main():
1828
1879
  return 0
1829
1880
  elif args.command == "chat":
1830
1881
  return _chat(args)
1882
+ elif args.command == "export":
1883
+ return _export_bundle(args)
1884
+ elif args.command == "import":
1885
+ return _import_bundle(args)
1831
1886
  elif args.command == "update":
1832
1887
  return _update(args)
1833
1888
  elif args.command == "clients":
@@ -2157,10 +2157,8 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2157
2157
  and audit["delete_without_protocol"] == 0
2158
2158
  and audit["delete_without_guard_ack"] == 0
2159
2159
  )
2160
- historical_write_drift = (
2160
+ tracked_write_without_open_debt = (
2161
2161
  no_open_conditioned_debt
2162
- and audit.get("latest_violation_age_seconds") is not None
2163
- and float(audit["latest_violation_age_seconds"]) >= 172800
2164
2162
  and audit["write_without_protocol"] > 0
2165
2163
  and audit["write_without_guard_ack"] == 0
2166
2164
  and audit["delete_without_protocol"] == 0
@@ -2168,7 +2166,7 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2168
2166
  )
2169
2167
 
2170
2168
  if audit["write_without_protocol"] or audit["write_without_guard_ack"]:
2171
- if historical_write_drift:
2169
+ if tracked_write_without_open_debt:
2172
2170
  status = "healthy"
2173
2171
  severity = "info"
2174
2172
  else:
@@ -2191,7 +2189,9 @@ def check_codex_conditioned_file_discipline() -> DoctorCheck:
2191
2189
  severity=severity,
2192
2190
  summary=(
2193
2191
  "Historical Codex conditioned-file drift has no open protocol debt"
2194
- if historical_read_only or historical_write_drift
2192
+ if historical_read_only
2193
+ else "Tracked Codex conditioned-file drift has no open protocol debt"
2194
+ if tracked_write_without_open_debt
2195
2195
  else "Recent Codex sessions respect conditioned-file discipline"
2196
2196
  if status == "healthy"
2197
2197
  else "Recent Codex sessions are bypassing conditioned-file discipline"
@@ -89,7 +89,10 @@ def _refresh_installed_manifest():
89
89
  dst_crons.mkdir(parents=True, exist_ok=True)
90
90
  for f in src_crons.iterdir():
91
91
  if f.is_file():
92
- shutil.copy2(str(f), str(dst_crons / f.name))
92
+ dest = dst_crons / f.name
93
+ if _paths_match(f, dest):
94
+ continue
95
+ shutil.copy2(str(f), str(dest))
93
96
  config_dir = NEXO_HOME / "config"
94
97
  config_dir.mkdir(parents=True, exist_ok=True)
95
98
  payload = {
@@ -355,7 +358,8 @@ def _sync_hooks_to_home():
355
358
  for f in hooks_src.iterdir():
356
359
  if f.is_file() and f.suffix == ".sh":
357
360
  dest = hooks_dest / f.name
358
- shutil.copy2(str(f), str(dest))
361
+ if not _paths_match(f, dest):
362
+ shutil.copy2(str(f), str(dest))
359
363
  os.chmod(str(dest), 0o755)
360
364
  synced += 1
361
365
  if synced:
@@ -499,6 +503,93 @@ def _emit_progress(progress_fn, message: str) -> None:
499
503
  pass
500
504
 
501
505
 
506
+ def _paths_match(src: Path, dest: Path) -> bool:
507
+ try:
508
+ return src.exists() and dest.exists() and src.samefile(dest)
509
+ except Exception:
510
+ return False
511
+
512
+
513
+ def _sync_packaged_crons(progress_fn=None) -> tuple[bool, str | None]:
514
+ sync_path = NEXO_HOME / "crons" / "sync.py"
515
+ if not sync_path.is_file():
516
+ _refresh_installed_manifest()
517
+ return True, None
518
+ try:
519
+ _emit_progress(progress_fn, "Syncing core cron definitions...")
520
+ result = subprocess.run(
521
+ [sys.executable, str(sync_path)],
522
+ cwd=str(NEXO_HOME),
523
+ capture_output=True,
524
+ text=True,
525
+ timeout=30,
526
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_HOME)},
527
+ )
528
+ if result.returncode != 0:
529
+ return False, result.stderr.strip() or result.stdout.strip() or "cron sync failed"
530
+ _refresh_installed_manifest()
531
+ return True, None
532
+ except Exception as e:
533
+ return False, f"cron sync error: {e}"
534
+
535
+
536
+ def _reload_launch_agents_after_bump() -> dict:
537
+ result: dict = {
538
+ "scanned": 0,
539
+ "reloaded": 0,
540
+ "skipped_missing": 0,
541
+ "errors": [],
542
+ "platform": sys.platform,
543
+ }
544
+
545
+ if sys.platform != "darwin":
546
+ return result
547
+
548
+ launch_agents_dir = Path.home() / "Library" / "LaunchAgents"
549
+ if not launch_agents_dir.is_dir():
550
+ return result
551
+
552
+ try:
553
+ plists = sorted(launch_agents_dir.glob("com.nexo.*.plist"))
554
+ except Exception as e:
555
+ result["errors"].append({"plist": "*", "stderr": f"glob failed: {e}"})
556
+ return result
557
+
558
+ result["scanned"] = len(plists)
559
+ for plist in plists:
560
+ try:
561
+ if not plist.is_file():
562
+ result["skipped_missing"] += 1
563
+ continue
564
+ subprocess.run(
565
+ ["launchctl", "unload", str(plist)],
566
+ capture_output=True,
567
+ text=True,
568
+ timeout=10,
569
+ )
570
+ load_proc = subprocess.run(
571
+ ["launchctl", "load", "-w", str(plist)],
572
+ capture_output=True,
573
+ text=True,
574
+ timeout=10,
575
+ )
576
+ if load_proc.returncode == 0:
577
+ result["reloaded"] += 1
578
+ else:
579
+ result["errors"].append(
580
+ {
581
+ "plist": plist.name,
582
+ "stderr": (load_proc.stderr or load_proc.stdout or "load failed")[:300],
583
+ }
584
+ )
585
+ except subprocess.TimeoutExpired:
586
+ result["errors"].append({"plist": plist.name, "stderr": "launchctl timeout"})
587
+ except Exception as e:
588
+ result["errors"].append({"plist": plist.name, "stderr": str(e)[:300]})
589
+
590
+ return result
591
+
592
+
502
593
  def _handle_packaged_update(progress_fn=None) -> str:
503
594
  """Update a packaged (npm) install — no git repo available."""
504
595
  old_version = _read_version()
@@ -581,10 +672,16 @@ def _handle_packaged_update(progress_fn=None) -> str:
581
672
  errors.append(f"verification: {verify_err}")
582
673
 
583
674
  hook_sync_warning = None
675
+ cron_sync_warning = None
584
676
  retired_runtime_files: list[str] = []
677
+ launchagent_reload_warning = None
678
+ launchagent_reload_summary = None
679
+ cron_sync_ok, cron_sync_error = _sync_packaged_crons(progress_fn=progress_fn)
680
+ if not cron_sync_ok:
681
+ errors.append(f"cron sync: {cron_sync_error}")
682
+ cron_sync_warning = cron_sync_error
585
683
  try:
586
684
  _emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
587
- _refresh_installed_manifest()
588
685
  _sync_hooks_to_home()
589
686
  retired_runtime_files = _cleanup_retired_runtime_files()
590
687
  except Exception as e:
@@ -596,6 +693,19 @@ def _handle_packaged_update(progress_fn=None) -> str:
596
693
  if not clients_ok:
597
694
  client_sync_warning = client_sync_error or "unknown client sync error"
598
695
 
696
+ if old_version != new_version:
697
+ _emit_progress(progress_fn, "Reloading LaunchAgents after version bump...")
698
+ try:
699
+ launchagent_reload_summary = _reload_launch_agents_after_bump()
700
+ if launchagent_reload_summary.get("errors"):
701
+ launchagent_reload_warning = (
702
+ f"reloaded {launchagent_reload_summary['reloaded']}/"
703
+ f"{launchagent_reload_summary['scanned']} with "
704
+ f"{len(launchagent_reload_summary['errors'])} error(s)"
705
+ )
706
+ except Exception as e:
707
+ launchagent_reload_warning = f"launchagent reload error: {e}"
708
+
599
709
  if errors:
600
710
  # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
601
711
  if code_backup_dir:
@@ -632,6 +742,10 @@ def _handle_packaged_update(progress_fn=None) -> str:
632
742
  lines = ["UPDATE SUCCESSFUL (packaged install)"]
633
743
  lines.append(f" Version: {old_version} -> {new_version}")
634
744
  lines.append(f" Backup: {backup_dir}")
745
+ if not cron_sync_warning:
746
+ lines.append(" Crons: synced with manifest")
747
+ else:
748
+ lines.append(f" WARNING: cron sync: {cron_sync_warning}")
635
749
  if not hook_sync_warning:
636
750
  lines.append(" Hooks: synced to NEXO_HOME")
637
751
  else:
@@ -642,6 +756,15 @@ def _handle_packaged_update(progress_fn=None) -> str:
642
756
  lines.append(" Clients: configured client targets synced")
643
757
  else:
644
758
  lines.append(f" WARNING: client sync: {client_sync_warning}")
759
+ if launchagent_reload_summary and launchagent_reload_summary.get("scanned"):
760
+ if not launchagent_reload_warning:
761
+ lines.append(
762
+ " LaunchAgents: reloaded "
763
+ f"{launchagent_reload_summary['reloaded']}/"
764
+ f"{launchagent_reload_summary['scanned']}"
765
+ )
766
+ else:
767
+ lines.append(f" WARNING: launchagent reload: {launchagent_reload_warning}")
645
768
  lines.append("")
646
769
  lines.append("MCP server restart needed to load new code.")
647
770
  return "\n".join(lines)
@@ -0,0 +1,328 @@
1
+ """Portable export/import helpers for operator user data."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import shutil
8
+ import sqlite3
9
+ import tarfile
10
+ import tempfile
11
+ from datetime import datetime, timezone
12
+ from pathlib import Path
13
+
14
+ from runtime_home import export_resolved_nexo_home
15
+
16
+ NEXO_HOME = export_resolved_nexo_home()
17
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
18
+ EXPORTS_DIR = NEXO_HOME / "exports"
19
+ STAGING_DIR = EXPORTS_DIR / ".staging"
20
+ USER_DIRS = ("brain", "coordination", "nexo-email", "assets")
21
+ USER_CONFIG_FILES = ("schedule.json",)
22
+ IGNORED_FILENAMES = {".DS_Store"}
23
+ IGNORED_DIRS = {"__pycache__"}
24
+ IGNORED_SUFFIXES = {".pyc", ".pyo"}
25
+
26
+
27
+ def _now_stamp() -> str:
28
+ return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
29
+
30
+
31
+ def _runtime_version() -> str:
32
+ for candidate, key in (
33
+ (NEXO_HOME / "version.json", "version"),
34
+ (NEXO_CODE.parent / "version.json", "version"),
35
+ (NEXO_CODE.parent / "package.json", "version"),
36
+ (NEXO_HOME / "package.json", "version"),
37
+ ):
38
+ try:
39
+ if candidate.is_file():
40
+ return str(json.loads(candidate.read_text()).get(key, "?"))
41
+ except Exception:
42
+ continue
43
+ return "?"
44
+
45
+
46
+ def _sqlite_backup(src: Path, dest: Path) -> None:
47
+ dest.parent.mkdir(parents=True, exist_ok=True)
48
+ src_conn = sqlite3.connect(str(src))
49
+ try:
50
+ dst_conn = sqlite3.connect(str(dest))
51
+ try:
52
+ src_conn.backup(dst_conn)
53
+ finally:
54
+ dst_conn.close()
55
+ finally:
56
+ src_conn.close()
57
+
58
+
59
+ def _should_skip_file(path: Path, exclude_names: set[str] | None = None) -> bool:
60
+ exclude = exclude_names or set()
61
+ if path.name in exclude:
62
+ return True
63
+ if path.name in IGNORED_FILENAMES:
64
+ return True
65
+ if path.suffix in IGNORED_SUFFIXES:
66
+ return True
67
+ return False
68
+
69
+
70
+ def _copy_tree_filtered(src: Path, dest: Path, *, exclude_names: set[str] | None = None) -> int:
71
+ if not src.is_dir():
72
+ return 0
73
+ copied = 0
74
+ for root, dirs, files in os.walk(src):
75
+ root_path = Path(root)
76
+ rel = root_path.relative_to(src)
77
+ dirs[:] = [item for item in dirs if item not in IGNORED_DIRS]
78
+ target_root = dest / rel
79
+ target_root.mkdir(parents=True, exist_ok=True)
80
+ for name in files:
81
+ file_path = root_path / name
82
+ if _should_skip_file(file_path, exclude_names=exclude_names):
83
+ continue
84
+ shutil.copy2(str(file_path), str(target_root / name))
85
+ copied += 1
86
+ return copied
87
+
88
+
89
+ def _copy_file_if_present(src: Path, dest: Path) -> bool:
90
+ if not src.is_file():
91
+ return False
92
+ dest.parent.mkdir(parents=True, exist_ok=True)
93
+ shutil.copy2(str(src), str(dest))
94
+ return True
95
+
96
+
97
+ def _safe_extract(archive_path: Path, dest_dir: Path) -> None:
98
+ resolved_dest = dest_dir.resolve()
99
+ with tarfile.open(archive_path, "r:*") as tar:
100
+ members = tar.getmembers()
101
+ for member in members:
102
+ target = (dest_dir / member.name).resolve()
103
+ if target != resolved_dest and resolved_dest not in target.parents:
104
+ raise ValueError(f"archive path escapes destination: {member.name}")
105
+ if member.issym() or member.islnk():
106
+ raise ValueError(f"archive contains unsupported link member: {member.name}")
107
+
108
+ for member in members:
109
+ target = (dest_dir / member.name).resolve()
110
+ if member.isdir():
111
+ target.mkdir(parents=True, exist_ok=True)
112
+ target.chmod(member.mode & 0o777)
113
+ continue
114
+ if not member.isfile():
115
+ raise ValueError(f"archive contains unsupported member type: {member.name}")
116
+
117
+ target.parent.mkdir(parents=True, exist_ok=True)
118
+ extracted = tar.extractfile(member)
119
+ if extracted is None:
120
+ raise ValueError(f"archive member could not be read: {member.name}")
121
+ with extracted, target.open("wb") as handle:
122
+ shutil.copyfileobj(extracted, handle)
123
+ target.chmod(member.mode & 0o777)
124
+
125
+
126
+ def _load_personal_scripts() -> tuple[list[dict], list[dict]]:
127
+ from script_registry import classify_scripts_dir, discover_personal_schedules
128
+
129
+ classification = classify_scripts_dir()
130
+ scripts = [entry for entry in classification.get("entries", []) if entry.get("classification") == "personal"]
131
+ script_paths = {entry["path"] for entry in scripts}
132
+ schedules = [
133
+ schedule for schedule in discover_personal_schedules()
134
+ if schedule.get("script_path") in script_paths
135
+ ]
136
+ return scripts, schedules
137
+
138
+
139
+ def export_user_bundle(output_path: str = "") -> dict:
140
+ output = Path(output_path).expanduser() if output_path.strip() else (EXPORTS_DIR / f"nexo-user-data-{_now_stamp()}.tar.gz")
141
+ output.parent.mkdir(parents=True, exist_ok=True)
142
+ STAGING_DIR.mkdir(parents=True, exist_ok=True)
143
+ stage_dir = Path(tempfile.mkdtemp(prefix="nexo-export-", dir=str(STAGING_DIR)))
144
+ bundle_root = stage_dir / "bundle"
145
+ bundle_root.mkdir(parents=True, exist_ok=True)
146
+
147
+ sections: dict[str, dict] = {}
148
+
149
+ try:
150
+ data_db = NEXO_HOME / "data" / "nexo.db"
151
+ if data_db.is_file():
152
+ _sqlite_backup(data_db, bundle_root / "data" / "nexo.db")
153
+ sections["data_db"] = {"path": "data/nexo.db"}
154
+
155
+ brain_dir = NEXO_HOME / "brain"
156
+ if brain_dir.is_dir():
157
+ copied = _copy_tree_filtered(brain_dir, bundle_root / "brain", exclude_names={"nexo.db"})
158
+ brain_db = brain_dir / "nexo.db"
159
+ if brain_db.is_file():
160
+ _sqlite_backup(brain_db, bundle_root / "brain" / "nexo.db")
161
+ copied += 1
162
+ sections["brain"] = {"path": "brain", "files": copied}
163
+
164
+ for dirname in USER_DIRS[1:]:
165
+ src_dir = NEXO_HOME / dirname
166
+ if not src_dir.is_dir():
167
+ continue
168
+ copied = _copy_tree_filtered(src_dir, bundle_root / dirname)
169
+ sections[dirname] = {"path": dirname, "files": copied}
170
+
171
+ copied_configs: list[str] = []
172
+ for filename in USER_CONFIG_FILES:
173
+ if _copy_file_if_present(NEXO_HOME / "config" / filename, bundle_root / "config" / filename):
174
+ copied_configs.append(filename)
175
+ if copied_configs:
176
+ sections["config"] = {"path": "config", "files": copied_configs}
177
+
178
+ scripts, schedules = _load_personal_scripts()
179
+ exported_scripts: list[dict] = []
180
+ scripts_dir = bundle_root / "personal-scripts"
181
+ scripts_dir.mkdir(parents=True, exist_ok=True)
182
+ for entry in scripts:
183
+ src_path = Path(entry["path"])
184
+ if not src_path.is_file():
185
+ continue
186
+ shutil.copy2(str(src_path), str(scripts_dir / src_path.name))
187
+ exported_scripts.append(
188
+ {
189
+ "name": entry.get("name", src_path.stem),
190
+ "path": f"personal-scripts/{src_path.name}",
191
+ "runtime": entry.get("runtime", "unknown"),
192
+ "description": entry.get("description", ""),
193
+ }
194
+ )
195
+ (scripts_dir / "manifest.json").write_text(
196
+ json.dumps(
197
+ {
198
+ "scripts": exported_scripts,
199
+ "schedules": schedules,
200
+ },
201
+ indent=2,
202
+ ensure_ascii=False,
203
+ ) + "\n"
204
+ )
205
+ sections["personal_scripts"] = {
206
+ "path": "personal-scripts",
207
+ "files": len(exported_scripts),
208
+ "schedules": len(schedules),
209
+ }
210
+
211
+ manifest = {
212
+ "kind": "nexo-user-data-bundle",
213
+ "version": _runtime_version(),
214
+ "created_at": datetime.now(timezone.utc).isoformat(),
215
+ "nexo_home": str(NEXO_HOME),
216
+ "sections": sections,
217
+ }
218
+ (bundle_root / "manifest.json").write_text(json.dumps(manifest, indent=2, ensure_ascii=False) + "\n")
219
+
220
+ with tarfile.open(output, "w:gz") as tar:
221
+ tar.add(bundle_root, arcname="bundle")
222
+
223
+ return {
224
+ "ok": True,
225
+ "path": str(output),
226
+ "kind": manifest["kind"],
227
+ "version": manifest["version"],
228
+ "sections": sections,
229
+ }
230
+ finally:
231
+ shutil.rmtree(stage_dir, ignore_errors=True)
232
+
233
+
234
+ def import_user_bundle(bundle_path: str) -> dict:
235
+ archive_path = Path(bundle_path).expanduser()
236
+ if not archive_path.is_file():
237
+ return {"ok": False, "error": f"bundle not found: {archive_path}"}
238
+
239
+ backups_dir = NEXO_HOME / "backups"
240
+ backups_dir.mkdir(parents=True, exist_ok=True)
241
+ safety_backup = backups_dir / f"pre-import-user-data-{_now_stamp()}.tar.gz"
242
+ safety_result = export_user_bundle(str(safety_backup))
243
+ if not safety_result.get("ok"):
244
+ return {"ok": False, "error": "failed to create safety backup", "safety_backup": str(safety_backup)}
245
+
246
+ STAGING_DIR.mkdir(parents=True, exist_ok=True)
247
+ stage_dir = Path(tempfile.mkdtemp(prefix="nexo-import-", dir=str(STAGING_DIR)))
248
+
249
+ try:
250
+ _safe_extract(archive_path, stage_dir)
251
+ bundle_root = stage_dir / "bundle"
252
+ manifest_path = bundle_root / "manifest.json"
253
+ if not manifest_path.is_file():
254
+ return {"ok": False, "error": "bundle manifest missing", "safety_backup": str(safety_backup)}
255
+
256
+ manifest = json.loads(manifest_path.read_text())
257
+ if manifest.get("kind") != "nexo-user-data-bundle":
258
+ return {
259
+ "ok": False,
260
+ "error": f"unsupported bundle kind: {manifest.get('kind', 'unknown')}",
261
+ "safety_backup": str(safety_backup),
262
+ }
263
+
264
+ restored: dict[str, dict] = {}
265
+
266
+ data_db = bundle_root / "data" / "nexo.db"
267
+ if data_db.is_file():
268
+ _sqlite_backup(data_db, NEXO_HOME / "data" / "nexo.db")
269
+ restored["data_db"] = {"path": "data/nexo.db"}
270
+
271
+ brain_dir = bundle_root / "brain"
272
+ if brain_dir.is_dir():
273
+ copied = _copy_tree_filtered(brain_dir, NEXO_HOME / "brain", exclude_names={"nexo.db"})
274
+ brain_db = brain_dir / "nexo.db"
275
+ if brain_db.is_file():
276
+ _sqlite_backup(brain_db, NEXO_HOME / "brain" / "nexo.db")
277
+ copied += 1
278
+ restored["brain"] = {"path": "brain", "files": copied}
279
+
280
+ for dirname in USER_DIRS[1:]:
281
+ src_dir = bundle_root / dirname
282
+ if not src_dir.is_dir():
283
+ continue
284
+ copied = _copy_tree_filtered(src_dir, NEXO_HOME / dirname)
285
+ restored[dirname] = {"path": dirname, "files": copied}
286
+
287
+ restored_configs: list[str] = []
288
+ for filename in USER_CONFIG_FILES:
289
+ if _copy_file_if_present(bundle_root / "config" / filename, NEXO_HOME / "config" / filename):
290
+ restored_configs.append(filename)
291
+ if restored_configs:
292
+ restored["config"] = {"path": "config", "files": restored_configs}
293
+
294
+ imported_scripts = 0
295
+ scripts_dir = bundle_root / "personal-scripts"
296
+ target_scripts_dir = NEXO_HOME / "scripts"
297
+ target_scripts_dir.mkdir(parents=True, exist_ok=True)
298
+ if scripts_dir.is_dir():
299
+ for script_path in sorted(scripts_dir.iterdir()):
300
+ if not script_path.is_file() or script_path.name == "manifest.json":
301
+ continue
302
+ shutil.copy2(str(script_path), str(target_scripts_dir / script_path.name))
303
+ imported_scripts += 1
304
+ restored["personal_scripts"] = {"path": "personal-scripts", "files": imported_scripts}
305
+
306
+ from db import init_db
307
+ from script_registry import reconcile_personal_scripts
308
+
309
+ init_db()
310
+ reconcile_result = reconcile_personal_scripts(dry_run=False)
311
+
312
+ return {
313
+ "ok": True,
314
+ "path": str(archive_path),
315
+ "kind": manifest.get("kind"),
316
+ "bundle_version": manifest.get("version"),
317
+ "safety_backup": str(safety_backup),
318
+ "restored": restored,
319
+ "reconciled": reconcile_result,
320
+ }
321
+ except Exception as exc:
322
+ return {
323
+ "ok": False,
324
+ "error": str(exc),
325
+ "safety_backup": str(safety_backup),
326
+ }
327
+ finally:
328
+ shutil.rmtree(stage_dir, ignore_errors=True)