nexo-brain 5.3.0 → 5.3.2

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.0",
3
+ "version": "5.3.2",
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
@@ -87,9 +87,18 @@ Versions `3.1.7` through `3.2.0` close the recent-memory gap:
87
87
  - when even that misses, NEXO now exposes raw transcript fallback tools for Claude Code and Codex session stores
88
88
  - NEXO can now inspect itself through a live system catalog derived from canonical sources instead of relying only on stale docs or operator memory
89
89
 
90
+ Version `5.3.2` hardens the packaged runtime boundary: NEXO now persists which runtime scripts/hooks are core product artifacts, `nexo scripts` no longer mixes those into the personal bucket, and `nexo update` migrates the legacy Claude Code heartbeat wrappers into managed core hooks.
91
+
92
+ 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.
93
+
90
94
  Version `5.3.0` adds `nexo uninstall` — a CLI command that cleanly separates runtime from user data. It stops all crons, removes the MCP server config, and preserves databases, learnings, and personal scripts for safe reinstall.
91
95
 
92
- Version `5.2.1` closes two focused gaps in the Cortex layer that were left open by the v5.1 audit — the high-stakes response-contract detector was English-only, and the `nexo-cortex-cycle` cron was writing a quality snapshot that no reader ever consumed:
96
+ Version `5.2.1` fixes the Deep Sleep datetime regression and closes the decision-to-outcome feedback gap:
97
+
98
+ - `_parse_any_datetime` in `apply_findings.py` now strips timezone info before comparison, fixing the offset-aware/offset-naive crash that was breaking Deep Sleep verification work.
99
+ - `cortex_decide()` now auto-creates a `decision_outcome` when none is linked yet, so the outcome-checker cron can verify real decisions instead of leaving the loop open.
100
+
101
+ Version `5.2.0` closes two focused gaps in the Cortex layer that were left open by the v5.1 audit — the high-stakes response-contract detector was English-only, and the `nexo-cortex-cycle` cron was writing a quality snapshot that no reader ever consumed:
93
102
 
94
103
  - `HIGH_STAKES_KEYWORDS_ES` adds ~45 Spanish keywords to the high-stakes detector with accented and unaccented variants, so a goal written in Spanish (`migrar la base de datos de producción`) trips the same gate as its English twin.
95
104
  - `NEGATION_PATTERNS` suppresses false positives when the user explicitly disclaims touching the sensitive area (`sin afectar producción`, `no tocar prod`, `without touching production`, `don't modify`). The raw keyword being present is no longer enough to flag the task.
package/bin/nexo-brain.js CHANGED
@@ -93,6 +93,33 @@ function syncWatchdogHashRegistry(nexoHome) {
93
93
  }
94
94
  }
95
95
 
96
+ function writeRuntimeCoreArtifactsManifest(nexoHome, srcDir) {
97
+ try {
98
+ const listTopLevelFiles = (dirPath) => {
99
+ if (!fs.existsSync(dirPath)) return [];
100
+ return fs.readdirSync(dirPath)
101
+ .filter((name) => {
102
+ const full = path.join(dirPath, name);
103
+ return fs.existsSync(full) && fs.statSync(full).isFile();
104
+ })
105
+ .sort();
106
+ };
107
+ const configDir = path.join(nexoHome, "config");
108
+ fs.mkdirSync(configDir, { recursive: true });
109
+ const payload = {
110
+ generated_at: new Date().toISOString(),
111
+ script_names: listTopLevelFiles(path.join(srcDir, "scripts")),
112
+ hook_names: listTopLevelFiles(path.join(srcDir, "hooks")),
113
+ };
114
+ fs.writeFileSync(
115
+ path.join(configDir, "runtime-core-artifacts.json"),
116
+ `${JSON.stringify(payload, null, 2)}\n`
117
+ );
118
+ } catch (err) {
119
+ log(`WARN: could not write runtime core-artifacts manifest: ${err.message}`);
120
+ }
121
+ }
122
+
96
123
  function getCoreRuntimeFlatFiles() {
97
124
  return [
98
125
  "server.py",
@@ -1538,6 +1565,7 @@ async function main() {
1538
1565
  fs.chmodSync(path.join(scriptsDest, f), "755");
1539
1566
  });
1540
1567
  }
1568
+ writeRuntimeCoreArtifactsManifest(NEXO_HOME, srcDir);
1541
1569
  log(" Scripts updated.");
1542
1570
 
1543
1571
  // Register ALL 8 core hooks in settings.json (additive — don't remove user's custom hooks)
@@ -2210,15 +2238,31 @@ async function main() {
2210
2238
  "#!/usr/bin/env bash",
2211
2239
  "set -euo pipefail",
2212
2240
  "",
2213
- `NEXO_HOME="${NEXO_HOME}"`,
2241
+ `RUNTIME_HOME="${NEXO_HOME}"`,
2242
+ 'NEXO_HOME="$RUNTIME_HOME"',
2214
2243
  'export NEXO_HOME',
2215
- 'export NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"',
2244
+ 'resolve_code_dir() {',
2245
+ ' if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/cli.py" ]; then',
2246
+ ' printf \'%s\\n\' "${NEXO_CODE%/}"',
2247
+ ' return 0',
2248
+ ' fi',
2249
+ ' if [ -f "$NEXO_HOME/cli.py" ]; then',
2250
+ ' printf \'%s\\n\' "$NEXO_HOME"',
2251
+ ' return 0',
2252
+ ' fi',
2253
+ ' printf \'%s\\n\' "$NEXO_HOME"',
2254
+ '}',
2255
+ 'NEXO_CODE="$(resolve_code_dir)"',
2256
+ 'export NEXO_CODE',
2216
2257
  'resolve_python() {',
2217
2258
  ' local candidates=()',
2218
2259
  ' local candidate=""',
2219
2260
  ' if [ -n "${NEXO_RUNTIME_PYTHON:-}" ]; then candidates+=("$NEXO_RUNTIME_PYTHON"); fi',
2220
2261
  ' if [ -n "${NEXO_PYTHON:-}" ]; then candidates+=("$NEXO_PYTHON"); fi',
2221
- ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")',
2262
+ ' candidates+=("$NEXO_CODE/.venv/bin/python3" "$NEXO_CODE/.venv/bin/python")',
2263
+ ' if [ "$NEXO_CODE" != "$NEXO_HOME" ]; then',
2264
+ ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")',
2265
+ ' fi',
2222
2266
  ' case "$(uname -s)" in',
2223
2267
  ' Darwin) candidates+=("/opt/homebrew/bin/python3" "/usr/local/bin/python3") ;;',
2224
2268
  ' *) candidates+=("/usr/local/bin/python3" "/usr/bin/python3") ;;',
@@ -2246,7 +2290,17 @@ async function main() {
2246
2290
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2',
2247
2291
  ' exit 1',
2248
2292
  'fi',
2249
- 'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"',
2293
+ 'CLI_PY="$NEXO_CODE/cli.py"',
2294
+ 'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/cli.py" ]; then',
2295
+ ' NEXO_CODE="$NEXO_HOME"',
2296
+ ' export NEXO_CODE',
2297
+ ' CLI_PY="$NEXO_HOME/cli.py"',
2298
+ 'fi',
2299
+ 'if [ ! -f "$CLI_PY" ]; then',
2300
+ ' echo "NEXO CLI not found under $NEXO_HOME. Run nexo-brain or nexo update to repair the installation." >&2',
2301
+ ' exit 1',
2302
+ 'fi',
2303
+ 'exec "$PYTHON" "$CLI_PY" "$@"',
2250
2304
  "",
2251
2305
  ].join("\n");
2252
2306
  const runtimeCliPath = path.join(NEXO_HOME, "bin", "nexo");
@@ -2333,6 +2387,7 @@ async function main() {
2333
2387
  });
2334
2388
  log(" Hooks installed.");
2335
2389
  }
2390
+ writeRuntimeCoreArtifactsManifest(NEXO_HOME, srcDir);
2336
2391
 
2337
2392
  // Generate personality
2338
2393
  const personality = `# ${operatorName} — Personality
package/bin/nexo.js CHANGED
@@ -12,7 +12,44 @@ const fs = require("fs");
12
12
  const os = require("os");
13
13
  const path = require("path");
14
14
 
15
- const NEXO_HOME = process.env.NEXO_HOME || path.join(os.homedir(), ".nexo");
15
+ function resolveNexoHome(rawValue) {
16
+ const homeDir = os.homedir();
17
+ const managedHome = path.join(homeDir, ".nexo");
18
+ const legacyHome = path.join(homeDir, "claude");
19
+ const candidate = rawValue || managedHome;
20
+
21
+ if (candidate === managedHome) return managedHome;
22
+ if (candidate === legacyHome) return fs.existsSync(managedHome) ? managedHome : candidate;
23
+
24
+ try {
25
+ if (fs.existsSync(managedHome) && fs.realpathSync.native(candidate) === fs.realpathSync.native(managedHome)) {
26
+ return managedHome;
27
+ }
28
+ } catch {}
29
+
30
+ return candidate;
31
+ }
32
+
33
+ const NEXO_HOME = resolveNexoHome(process.env.NEXO_HOME);
34
+ const NEXO_CODE = resolveCodeDir();
35
+
36
+ function resolveCodeDir() {
37
+ const envCode = process.env.NEXO_CODE;
38
+ if (envCode && fs.existsSync(path.join(envCode, "cli.py"))) {
39
+ return envCode;
40
+ }
41
+ const repoCandidate = path.join(__dirname, "..", "src", "cli.py");
42
+ if (fs.existsSync(repoCandidate)) {
43
+ return path.join(__dirname, "..", "src");
44
+ }
45
+ if (fs.existsSync(path.join(NEXO_HOME, "cli.py"))) {
46
+ return NEXO_HOME;
47
+ }
48
+ if (fs.existsSync(path.join(NEXO_HOME, "claude", "cli.py"))) {
49
+ return path.join(NEXO_HOME, "claude");
50
+ }
51
+ return NEXO_HOME;
52
+ }
16
53
 
17
54
  function pythonSupportsModule(candidate, moduleName) {
18
55
  if (!candidate) return false;
@@ -23,7 +60,7 @@ function pythonSupportsModule(candidate, moduleName) {
23
60
  env: {
24
61
  ...process.env,
25
62
  NEXO_HOME,
26
- NEXO_CODE: path.join(__dirname, "..", "src"),
63
+ NEXO_CODE,
27
64
  },
28
65
  });
29
66
  return result.status === 0;
@@ -36,6 +73,8 @@ function findPython() {
36
73
  const candidates = [
37
74
  process.env.NEXO_RUNTIME_PYTHON,
38
75
  process.env.NEXO_PYTHON,
76
+ path.join(NEXO_CODE, ".venv", "bin", "python3"),
77
+ path.join(NEXO_CODE, ".venv", "bin", "python"),
39
78
  path.join(NEXO_HOME, ".venv", "bin", "python3"),
40
79
  path.join(NEXO_HOME, ".venv", "bin", "python"),
41
80
  process.platform === "darwin" ? "/opt/homebrew/bin/python3" : "",
@@ -55,10 +94,7 @@ function findPython() {
55
94
  }
56
95
 
57
96
  function findCliPy() {
58
- const repoCandidate = path.join(__dirname, "..", "src", "cli.py");
59
- const installedCandidate = path.join(NEXO_HOME, "cli.py");
60
- if (fs.existsSync(repoCandidate)) return repoCandidate;
61
- return installedCandidate;
97
+ return path.join(NEXO_CODE, "cli.py");
62
98
  }
63
99
 
64
100
  const python = findPython();
@@ -75,7 +111,7 @@ const result = spawnSync(python, [cliPy, ...process.argv.slice(2)], {
75
111
  env: {
76
112
  ...process.env,
77
113
  NEXO_HOME,
78
- NEXO_CODE: path.join(__dirname, "..", "src"),
114
+ NEXO_CODE,
79
115
  },
80
116
  });
81
117
 
package/hooks/hooks.json CHANGED
@@ -50,6 +50,18 @@
50
50
  ]
51
51
  }
52
52
  ],
53
+ "UserPromptSubmit": [
54
+ {
55
+ "matcher": "*",
56
+ "hooks": [
57
+ {
58
+ "type": "command",
59
+ "command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/heartbeat-user-msg.sh\"",
60
+ "timeout": 3
61
+ }
62
+ ]
63
+ }
64
+ ],
53
65
  "PostToolUse": [
54
66
  {
55
67
  "matcher": "*",
@@ -73,6 +85,11 @@
73
85
  "type": "command",
74
86
  "command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/protocol-guardrail.sh\"",
75
87
  "timeout": 5
88
+ },
89
+ {
90
+ "type": "command",
91
+ "command": "NEXO_HOME=\"${CLAUDE_PLUGIN_DATA}\" NEXO_CODE=\"${CLAUDE_PLUGIN_ROOT}/src\" bash \"${CLAUDE_PLUGIN_ROOT}/src/hooks/heartbeat-posttool.sh\"",
92
+ "timeout": 3
76
93
  }
77
94
  ]
78
95
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.0",
3
+ "version": "5.3.2",
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",
@@ -67,6 +67,7 @@
67
67
  "bin/nexo-brain.js",
68
68
  "bin/nexo.js",
69
69
  "bin/postinstall.js",
70
+ "scripts/sync_release_artifacts.py",
70
71
  "src/",
71
72
  "community/",
72
73
  "!src/**/__pycache__",
@@ -0,0 +1,128 @@
1
+ #!/usr/bin/env python3
2
+ from __future__ import annotations
3
+
4
+ import argparse
5
+ import json
6
+ import re
7
+ from pathlib import Path
8
+
9
+
10
+ ROOT = Path(__file__).resolve().parents[1]
11
+ ROOT_PACKAGE_JSON = ROOT / "package.json"
12
+ CLAUDE_PLUGIN_JSON = ROOT / ".claude-plugin" / "plugin.json"
13
+ CLAWHUB_SKILL_MD = ROOT / "clawhub-skill" / "SKILL.md"
14
+ OPENCLAW_PACKAGE_JSON = ROOT / "openclaw-plugin" / "package.json"
15
+ OPENCLAW_MCP_BRIDGE = ROOT / "openclaw-plugin" / "src" / "mcp-bridge.ts"
16
+
17
+
18
+ def fail(message: str) -> None:
19
+ raise SystemExit(f"[sync-release-artifacts] {message}")
20
+
21
+
22
+ def load_json(path: Path) -> dict:
23
+ return json.loads(path.read_text())
24
+
25
+
26
+ def dump_json(path: Path, payload: dict) -> None:
27
+ path.write_text(json.dumps(payload, indent=2) + "\n")
28
+
29
+
30
+ def sync_json_version(path: Path, expected_version: str, label: str) -> bool:
31
+ payload = load_json(path)
32
+ if payload.get("version") == expected_version:
33
+ return False
34
+ payload["version"] = expected_version
35
+ dump_json(path, payload)
36
+ print(f"[sync-release-artifacts] synced {label} version -> {expected_version}")
37
+ return True
38
+
39
+
40
+ def sync_clawhub_skill(skill_path: Path, expected_version: str) -> bool:
41
+ text = skill_path.read_text()
42
+ updated = text
43
+
44
+ updated = re.sub(
45
+ r"(?m)^version:\s*[^\n]+$",
46
+ f"version: {expected_version}",
47
+ updated,
48
+ count=1,
49
+ )
50
+ updated = updated.replace("~/.nexo/src/server.py", "~/.nexo/server.py")
51
+
52
+ if updated == text:
53
+ return False
54
+
55
+ skill_path.write_text(updated)
56
+ print(f"[sync-release-artifacts] synced ClawHub skill -> {expected_version}")
57
+ return True
58
+
59
+
60
+ def sync_openclaw_bridge(bridge_path: Path, expected_version: str) -> bool:
61
+ text = bridge_path.read_text()
62
+ updated = text
63
+
64
+ updated = updated.replace(
65
+ 'resolve(this.config.nexoHome, "src", "server.py")',
66
+ 'resolve(this.config.nexoHome, "server.py")',
67
+ )
68
+ updated = re.sub(
69
+ r'clientInfo:\s*\{\s*name:\s*"openclaw-memory-nexo-brain",\s*version:\s*"[^"]+"\s*\}',
70
+ f'clientInfo: {{ name: "openclaw-memory-nexo-brain", version: "{expected_version}" }}',
71
+ updated,
72
+ )
73
+
74
+ if updated == text:
75
+ return False
76
+
77
+ bridge_path.write_text(updated)
78
+ print(f"[sync-release-artifacts] synced OpenClaw bridge -> {expected_version}")
79
+ return True
80
+
81
+
82
+ def main() -> None:
83
+ parser = argparse.ArgumentParser(description="Keep release-facing integration artifacts in sync.")
84
+ parser.add_argument("--release-version", help="Expected release version (must match root package.json).")
85
+ parser.add_argument("--check", action="store_true", help="Fail if any artifact would change.")
86
+ args = parser.parse_args()
87
+
88
+ root_package = load_json(ROOT_PACKAGE_JSON)
89
+ root_version = root_package.get("version")
90
+ if not root_version:
91
+ fail("root package.json is missing version")
92
+
93
+ if args.release_version and args.release_version != root_version:
94
+ fail(
95
+ f"release version {args.release_version} does not match root package.json version {root_version}"
96
+ )
97
+
98
+ original_payloads = {
99
+ CLAUDE_PLUGIN_JSON: CLAUDE_PLUGIN_JSON.read_text(),
100
+ CLAWHUB_SKILL_MD: CLAWHUB_SKILL_MD.read_text(),
101
+ OPENCLAW_PACKAGE_JSON: OPENCLAW_PACKAGE_JSON.read_text(),
102
+ OPENCLAW_MCP_BRIDGE: OPENCLAW_MCP_BRIDGE.read_text(),
103
+ }
104
+
105
+ changed = []
106
+ if sync_json_version(CLAUDE_PLUGIN_JSON, root_version, "Claude plugin"):
107
+ changed.append(".claude-plugin/plugin.json")
108
+ if sync_clawhub_skill(CLAWHUB_SKILL_MD, root_version):
109
+ changed.append("clawhub-skill/SKILL.md")
110
+ if sync_json_version(OPENCLAW_PACKAGE_JSON, root_version, "OpenClaw package"):
111
+ changed.append("openclaw-plugin/package.json")
112
+ if sync_openclaw_bridge(OPENCLAW_MCP_BRIDGE, root_version):
113
+ changed.append("openclaw-plugin/src/mcp-bridge.ts")
114
+
115
+ if args.check:
116
+ for path, text in original_payloads.items():
117
+ path.write_text(text)
118
+ if changed:
119
+ fail("artifacts out of sync: " + ", ".join(changed))
120
+ print("[sync-release-artifacts] OK")
121
+ return
122
+
123
+ if not changed:
124
+ print("[sync-release-artifacts] already in sync")
125
+
126
+
127
+ if __name__ == "__main__":
128
+ main()
@@ -17,7 +17,9 @@ import sys
17
17
  import time
18
18
  from pathlib import Path
19
19
 
20
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
20
+ from runtime_home import export_resolved_nexo_home, managed_nexo_home
21
+
22
+ NEXO_HOME = export_resolved_nexo_home()
21
23
  DATA_DIR = NEXO_HOME / "data"
22
24
  DATA_DIR.mkdir(parents=True, exist_ok=True)
23
25
 
@@ -148,15 +150,42 @@ def _runtime_cli_wrapper_text() -> str:
148
150
  return (
149
151
  "#!/usr/bin/env bash\n"
150
152
  "set -euo pipefail\n\n"
151
- f'NEXO_HOME="{NEXO_HOME}"\n'
153
+ f'DEFAULT_NEXO_HOME="{managed_nexo_home()}"\n'
154
+ 'RUNTIME_HOME="${NEXO_HOME:-$DEFAULT_NEXO_HOME}"\n'
155
+ 'if [ "$RUNTIME_HOME" = "${HOME}/claude" ] && [ -e "$DEFAULT_NEXO_HOME" ]; then\n'
156
+ ' RUNTIME_HOME="$DEFAULT_NEXO_HOME"\n'
157
+ 'fi\n'
158
+ 'if [ -e "$RUNTIME_HOME" ] && [ -e "$DEFAULT_NEXO_HOME" ]; then\n'
159
+ ' RESOLVED_RUNTIME="$(cd "$RUNTIME_HOME" 2>/dev/null && pwd -P || true)"\n'
160
+ ' RESOLVED_DEFAULT="$(cd "$DEFAULT_NEXO_HOME" 2>/dev/null && pwd -P || true)"\n'
161
+ ' if [ -n "$RESOLVED_RUNTIME" ] && [ "$RESOLVED_RUNTIME" = "$RESOLVED_DEFAULT" ]; then\n'
162
+ ' RUNTIME_HOME="$DEFAULT_NEXO_HOME"\n'
163
+ ' fi\n'
164
+ 'fi\n'
165
+ 'NEXO_HOME="$RUNTIME_HOME"\n'
152
166
  'export NEXO_HOME\n'
153
- 'export NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"\n'
167
+ 'resolve_code_dir() {\n'
168
+ ' if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/cli.py" ]; then\n'
169
+ ' printf \'%s\\n\' "${NEXO_CODE%/}"\n'
170
+ ' return 0\n'
171
+ ' fi\n'
172
+ ' if [ -f "$NEXO_HOME/cli.py" ]; then\n'
173
+ ' printf \'%s\\n\' "$NEXO_HOME"\n'
174
+ ' return 0\n'
175
+ ' fi\n'
176
+ ' printf \'%s\\n\' "$NEXO_HOME"\n'
177
+ '}\n'
178
+ 'NEXO_CODE="$(resolve_code_dir)"\n'
179
+ 'export NEXO_CODE\n'
154
180
  'resolve_python() {\n'
155
181
  ' local candidates=()\n'
156
182
  ' local candidate=""\n'
157
183
  ' if [ -n "${NEXO_RUNTIME_PYTHON:-}" ]; then candidates+=("$NEXO_RUNTIME_PYTHON"); fi\n'
158
184
  ' if [ -n "${NEXO_PYTHON:-}" ]; then candidates+=("$NEXO_PYTHON"); fi\n'
159
- ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")\n'
185
+ ' candidates+=("$NEXO_CODE/.venv/bin/python3" "$NEXO_CODE/.venv/bin/python")\n'
186
+ ' if [ "$NEXO_CODE" != "$NEXO_HOME" ]; then\n'
187
+ ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")\n'
188
+ ' fi\n'
160
189
  ' case "$(uname -s)" in\n'
161
190
  ' Darwin) candidates+=("/opt/homebrew/bin/python3" "/usr/local/bin/python3") ;;\n'
162
191
  ' *) candidates+=("/usr/local/bin/python3" "/usr/bin/python3") ;;\n'
@@ -184,7 +213,17 @@ def _runtime_cli_wrapper_text() -> str:
184
213
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2\n'
185
214
  ' exit 1\n'
186
215
  'fi\n'
187
- 'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"\n'
216
+ 'CLI_PY="$NEXO_CODE/cli.py"\n'
217
+ 'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/cli.py" ]; then\n'
218
+ ' NEXO_CODE="$NEXO_HOME"\n'
219
+ ' export NEXO_CODE\n'
220
+ ' CLI_PY="$NEXO_HOME/cli.py"\n'
221
+ 'fi\n'
222
+ 'if [ ! -f "$CLI_PY" ]; then\n'
223
+ ' echo "NEXO CLI not found under $NEXO_HOME. Run nexo-brain or nexo update to repair the installation." >&2\n'
224
+ ' exit 1\n'
225
+ 'fi\n'
226
+ 'exec "$PYTHON" "$CLI_PY" "$@"\n'
188
227
  )
189
228
 
190
229
 
@@ -320,6 +359,10 @@ def _cleanup_retired_runtime_files():
320
359
  """Remove retired core files that should not survive updates."""
321
360
  retired = [
322
361
  NEXO_HOME / "scripts" / "nexo-day-orchestrator.sh",
362
+ NEXO_HOME / "scripts" / "heartbeat-enforcement.py",
363
+ NEXO_HOME / "scripts" / "heartbeat-posttool.sh",
364
+ NEXO_HOME / "scripts" / "heartbeat-user-msg.sh",
365
+ NEXO_HOME / "hooks" / "heartbeat-guard.sh",
323
366
  ]
324
367
  for target in retired:
325
368
  try:
@@ -1451,9 +1494,11 @@ def _resolve_sync_source() -> tuple[Path | None, Path | None]:
1451
1494
  if (
1452
1495
  not same_as_runtime
1453
1496
  and (NEXO_CODE / "db").is_dir()
1454
- and (NEXO_CODE.parent / "package.json").is_file()
1455
1497
  ):
1456
- return NEXO_CODE, NEXO_CODE.parent
1498
+ if (NEXO_CODE.parent / "package.json").is_file():
1499
+ return NEXO_CODE, NEXO_CODE.parent
1500
+ if (NEXO_CODE / "package.json").is_file():
1501
+ return NEXO_CODE, NEXO_CODE
1457
1502
 
1458
1503
  version_source = _runtime_version_source()
1459
1504
  if version_source:
@@ -1583,7 +1628,21 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1583
1628
  backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
1584
1629
  backup_dir.mkdir(parents=True, exist_ok=True)
1585
1630
 
1586
- code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts", "doctor", "skills-core"]
1631
+ code_dirs = [
1632
+ "hooks",
1633
+ "plugins",
1634
+ "db",
1635
+ "cognitive",
1636
+ "dashboard",
1637
+ "rules",
1638
+ "crons",
1639
+ "scripts",
1640
+ "doctor",
1641
+ "skills",
1642
+ "skills-core",
1643
+ "skills-runtime",
1644
+ "templates",
1645
+ ]
1587
1646
  flat_files = _runtime_flat_files(dest)
1588
1647
  for name in code_dirs:
1589
1648
  src = dest / name
@@ -1636,6 +1695,9 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1636
1695
  script_conflicts: list[dict[str, str]] = []
1637
1696
  installed_script_classes = _installed_scripts_classification(dest)
1638
1697
 
1698
+ for dirname in ("bin", "skills", "skills-core", "skills-runtime", "templates"):
1699
+ (dest / dirname).mkdir(parents=True, exist_ok=True)
1700
+
1639
1701
  _emit_progress(progress_fn, "Copying core packages...")
1640
1702
  for pkg in packages:
1641
1703
  pkg_src = src_dir / pkg
@@ -16,6 +16,7 @@ from client_preferences import (
16
16
  normalize_client_key,
17
17
  normalize_client_preferences,
18
18
  )
19
+ from runtime_home import resolve_nexo_home
19
20
 
20
21
  def _resolve_templates_dir(module_file: str | os.PathLike[str]) -> Path:
21
22
  module_dir = Path(module_file).resolve().parent
@@ -60,7 +61,7 @@ def _user_home() -> Path:
60
61
 
61
62
 
62
63
  def _default_nexo_home() -> Path:
63
- return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
64
+ return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
64
65
 
65
66
 
66
67
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
@@ -115,6 +116,18 @@ def _build_user_block(user_payload: str, template_text: str) -> str:
115
116
  return f"{USER_START}\n{user_payload.rstrip()}\n{USER_END}"
116
117
 
117
118
 
119
+ def _extract_user_payload(text: str) -> str:
120
+ block = _extract_block(text, USER_START, USER_END)
121
+ if not block:
122
+ return ""
123
+ payload = block
124
+ if payload.startswith(USER_START):
125
+ payload = payload[len(USER_START):]
126
+ if payload.endswith(USER_END):
127
+ payload = payload[: -len(USER_END)]
128
+ return payload.strip("\n")
129
+
130
+
118
131
  def _legacy_user_payload(existing_text: str) -> str:
119
132
  cleaned = re.sub(r"<!--\s*nexo-[^-]+-[^-]+-version:\s*[\d.]+\s*-->\s*", "", existing_text)
120
133
  if "<!-- nexo:start:" in cleaned:
@@ -146,6 +159,33 @@ def _legacy_user_payload(existing_text: str) -> str:
146
159
  return cleaned.strip()
147
160
 
148
161
 
162
+ def _home_alias(path: Path, user_home: Path) -> str:
163
+ try:
164
+ relative = path.expanduser().relative_to(user_home.expanduser())
165
+ except Exception:
166
+ return str(path.expanduser())
167
+ return "~/" + relative.as_posix()
168
+
169
+
170
+ def _migrate_legacy_nexo_home_references(user_payload: str, *, nexo_home: Path, user_home: Path) -> str:
171
+ if not user_payload.strip():
172
+ return user_payload
173
+ legacy_home = user_home.expanduser() / "claude"
174
+ target_home = nexo_home.expanduser()
175
+ if legacy_home == target_home:
176
+ return user_payload
177
+
178
+ replacements = [
179
+ (str(legacy_home), str(target_home)),
180
+ (_home_alias(legacy_home, user_home), _home_alias(target_home, user_home)),
181
+ ]
182
+ updated = user_payload
183
+ for old, new in replacements:
184
+ if old and new and old != new:
185
+ updated = updated.replace(old, new)
186
+ return updated
187
+
188
+
149
189
  def _target_path(client: str, *, user_home: Path | None = None) -> Path:
150
190
  spec = BOOTSTRAP_SPECS[client]
151
191
  home = user_home or _user_home()
@@ -243,12 +283,23 @@ def sync_client_bootstrap(
243
283
 
244
284
  existing = target_path.read_text()
245
285
  if CORE_START in existing and CORE_END in existing and USER_START in existing and USER_END in existing:
246
- user_block = _extract_block(existing, USER_START, USER_END) or _extract_block(rendered, USER_START, USER_END)
286
+ user_payload = _extract_user_payload(existing) or _extract_user_payload(rendered)
287
+ user_payload = _migrate_legacy_nexo_home_references(
288
+ user_payload,
289
+ nexo_home=nexo_home_path,
290
+ user_home=home_path,
291
+ )
292
+ user_block = _build_user_block(user_payload, rendered)
247
293
  updated = _replace_block(existing, CORE_START, CORE_END, rendered_core)
248
294
  updated = _replace_block(updated, USER_START, USER_END, user_block)
249
295
  action = "updated" if updated != existing else "unchanged"
250
296
  else:
251
297
  legacy_user = _legacy_user_payload(existing)
298
+ legacy_user = _migrate_legacy_nexo_home_references(
299
+ legacy_user,
300
+ nexo_home=nexo_home_path,
301
+ user_home=home_path,
302
+ )
252
303
  updated = _replace_block(rendered, USER_START, USER_END, _build_user_block(legacy_user, rendered))
253
304
  action = "migrated"
254
305
 
package/src/cli.py CHANGED
@@ -40,7 +40,9 @@ import subprocess
40
40
  import sys
41
41
  from pathlib import Path
42
42
 
43
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
43
+ from runtime_home import export_resolved_nexo_home
44
+
45
+ NEXO_HOME = export_resolved_nexo_home()
44
46
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
45
47
  TERMINAL_CLIENT_LABELS = {
46
48
  "claude_code": "Claude Code",