nexo-brain 5.3.0 → 5.3.1

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.1",
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,16 @@ 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.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.
91
+
90
92
  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
93
 
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:
94
+ Version `5.2.1` fixes the Deep Sleep datetime regression and closes the decision-to-outcome feedback gap:
95
+
96
+ - `_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.
97
+ - `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.
98
+
99
+ 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
100
 
94
101
  - `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
102
  - `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
@@ -2210,15 +2210,31 @@ async function main() {
2210
2210
  "#!/usr/bin/env bash",
2211
2211
  "set -euo pipefail",
2212
2212
  "",
2213
- `NEXO_HOME="${NEXO_HOME}"`,
2213
+ `RUNTIME_HOME="${NEXO_HOME}"`,
2214
+ 'NEXO_HOME="$RUNTIME_HOME"',
2214
2215
  'export NEXO_HOME',
2215
- 'export NEXO_CODE="${NEXO_CODE:-$NEXO_HOME}"',
2216
+ 'resolve_code_dir() {',
2217
+ ' if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/cli.py" ]; then',
2218
+ ' printf \'%s\\n\' "${NEXO_CODE%/}"',
2219
+ ' return 0',
2220
+ ' fi',
2221
+ ' if [ -f "$NEXO_HOME/cli.py" ]; then',
2222
+ ' printf \'%s\\n\' "$NEXO_HOME"',
2223
+ ' return 0',
2224
+ ' fi',
2225
+ ' printf \'%s\\n\' "$NEXO_HOME"',
2226
+ '}',
2227
+ 'NEXO_CODE="$(resolve_code_dir)"',
2228
+ 'export NEXO_CODE',
2216
2229
  'resolve_python() {',
2217
2230
  ' local candidates=()',
2218
2231
  ' local candidate=""',
2219
2232
  ' if [ -n "${NEXO_RUNTIME_PYTHON:-}" ]; then candidates+=("$NEXO_RUNTIME_PYTHON"); fi',
2220
2233
  ' if [ -n "${NEXO_PYTHON:-}" ]; then candidates+=("$NEXO_PYTHON"); fi',
2221
- ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")',
2234
+ ' candidates+=("$NEXO_CODE/.venv/bin/python3" "$NEXO_CODE/.venv/bin/python")',
2235
+ ' if [ "$NEXO_CODE" != "$NEXO_HOME" ]; then',
2236
+ ' candidates+=("$NEXO_HOME/.venv/bin/python3" "$NEXO_HOME/.venv/bin/python")',
2237
+ ' fi',
2222
2238
  ' case "$(uname -s)" in',
2223
2239
  ' Darwin) candidates+=("/opt/homebrew/bin/python3" "/usr/local/bin/python3") ;;',
2224
2240
  ' *) candidates+=("/usr/local/bin/python3" "/usr/bin/python3") ;;',
@@ -2246,7 +2262,17 @@ async function main() {
2246
2262
  ' echo "NEXO runtime Python not found. Run nexo-brain or nexo update to repair the installation." >&2',
2247
2263
  ' exit 1',
2248
2264
  'fi',
2249
- 'exec "$PYTHON" "$NEXO_HOME/cli.py" "$@"',
2265
+ 'CLI_PY="$NEXO_CODE/cli.py"',
2266
+ 'if [ ! -f "$CLI_PY" ] && [ -f "$NEXO_HOME/cli.py" ]; then',
2267
+ ' NEXO_CODE="$NEXO_HOME"',
2268
+ ' export NEXO_CODE',
2269
+ ' CLI_PY="$NEXO_HOME/cli.py"',
2270
+ 'fi',
2271
+ 'if [ ! -f "$CLI_PY" ]; then',
2272
+ ' echo "NEXO CLI not found under $NEXO_HOME. Run nexo-brain or nexo update to repair the installation." >&2',
2273
+ ' exit 1',
2274
+ 'fi',
2275
+ 'exec "$PYTHON" "$CLI_PY" "$@"',
2250
2276
  "",
2251
2277
  ].join("\n");
2252
2278
  const runtimeCliPath = path.join(NEXO_HOME, "bin", "nexo");
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.3.0",
3
+ "version": "5.3.1",
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
 
@@ -1451,9 +1490,11 @@ def _resolve_sync_source() -> tuple[Path | None, Path | None]:
1451
1490
  if (
1452
1491
  not same_as_runtime
1453
1492
  and (NEXO_CODE / "db").is_dir()
1454
- and (NEXO_CODE.parent / "package.json").is_file()
1455
1493
  ):
1456
- return NEXO_CODE, NEXO_CODE.parent
1494
+ if (NEXO_CODE.parent / "package.json").is_file():
1495
+ return NEXO_CODE, NEXO_CODE.parent
1496
+ if (NEXO_CODE / "package.json").is_file():
1497
+ return NEXO_CODE, NEXO_CODE
1457
1498
 
1458
1499
  version_source = _runtime_version_source()
1459
1500
  if version_source:
@@ -1583,7 +1624,21 @@ def _backup_runtime_tree(dest: Path = NEXO_HOME) -> str:
1583
1624
  backup_dir = NEXO_HOME / "backups" / f"runtime-tree-{timestamp}"
1584
1625
  backup_dir.mkdir(parents=True, exist_ok=True)
1585
1626
 
1586
- code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts", "doctor", "skills-core"]
1627
+ code_dirs = [
1628
+ "hooks",
1629
+ "plugins",
1630
+ "db",
1631
+ "cognitive",
1632
+ "dashboard",
1633
+ "rules",
1634
+ "crons",
1635
+ "scripts",
1636
+ "doctor",
1637
+ "skills",
1638
+ "skills-core",
1639
+ "skills-runtime",
1640
+ "templates",
1641
+ ]
1587
1642
  flat_files = _runtime_flat_files(dest)
1588
1643
  for name in code_dirs:
1589
1644
  src = dest / name
@@ -1636,6 +1691,9 @@ def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_H
1636
1691
  script_conflicts: list[dict[str, str]] = []
1637
1692
  installed_script_classes = _installed_scripts_classification(dest)
1638
1693
 
1694
+ for dirname in ("bin", "skills", "skills-core", "skills-runtime", "templates"):
1695
+ (dest / dirname).mkdir(parents=True, exist_ok=True)
1696
+
1639
1697
  _emit_progress(progress_fn, "Copying core packages...")
1640
1698
  for pkg in packages:
1641
1699
  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",
@@ -18,6 +18,7 @@ except ModuleNotFoundError: # Python < 3.11
18
18
  import tomli as tomllib
19
19
 
20
20
  from bootstrap_docs import sync_client_bootstrap
21
+ from runtime_home import resolve_nexo_home
21
22
 
22
23
  try:
23
24
  from client_preferences import (
@@ -73,7 +74,7 @@ def _user_home() -> Path:
73
74
 
74
75
 
75
76
  def _default_nexo_home() -> Path:
76
- return Path(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo"))).expanduser()
77
+ return resolve_nexo_home(os.environ.get("NEXO_HOME", str(_user_home() / ".nexo")))
77
78
 
78
79
 
79
80
  def _resolve_operator_name(nexo_home: Path, explicit: str = "") -> str:
@@ -12,15 +12,30 @@ import os
12
12
  from pathlib import Path
13
13
 
14
14
  from db._core import get_db
15
+ from runtime_home import resolve_nexo_home
15
16
 
16
17
 
17
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
18
+ NEXO_HOME = resolve_nexo_home()
18
19
 
19
20
 
20
21
  def _now_text() -> str:
21
22
  return datetime.datetime.now().isoformat(timespec="seconds")
22
23
 
23
24
 
25
+ def _canonical_scripts_dir() -> Path:
26
+ home = resolve_nexo_home(os.environ.get("NEXO_HOME", str(NEXO_HOME)))
27
+ return home / "scripts"
28
+
29
+
30
+ def _normalize_script_path(path: str | Path) -> str:
31
+ candidate = Path(path).expanduser()
32
+ try:
33
+ relative = candidate.resolve(strict=False).relative_to(_canonical_scripts_dir().resolve(strict=False))
34
+ except Exception:
35
+ return str(candidate)
36
+ return str(_canonical_scripts_dir() / relative)
37
+
38
+
24
39
  def _row_to_dict(row) -> dict:
25
40
  return dict(row) if row is not None else {}
26
41
 
@@ -61,6 +76,7 @@ def _safe_slug(value: str) -> str:
61
76
 
62
77
 
63
78
  def _ensure_script_id(conn, name: str, path: str) -> str:
79
+ path = _normalize_script_path(path)
64
80
  existing = conn.execute(
65
81
  "SELECT id FROM personal_scripts WHERE path = ? LIMIT 1",
66
82
  (path,),
@@ -90,6 +106,7 @@ def upsert_personal_script(
90
106
  has_inline_metadata: bool = False,
91
107
  ) -> dict:
92
108
  conn = get_db()
109
+ path = _normalize_script_path(path)
93
110
  script_id = _ensure_script_id(conn, name, path)
94
111
  now = _now_text()
95
112
  conn.execute(
@@ -132,11 +149,12 @@ def upsert_personal_script(
132
149
 
133
150
  def delete_missing_personal_scripts(active_paths: list[str]) -> int:
134
151
  conn = get_db()
135
- if active_paths:
136
- placeholders = ",".join("?" for _ in active_paths)
152
+ normalized_paths = [_normalize_script_path(path) for path in active_paths]
153
+ if normalized_paths:
154
+ placeholders = ",".join("?" for _ in normalized_paths)
137
155
  rows = conn.execute(
138
156
  f"SELECT id FROM personal_scripts WHERE path NOT IN ({placeholders})",
139
- tuple(active_paths),
157
+ tuple(normalized_paths),
140
158
  ).fetchall()
141
159
  else:
142
160
  rows = conn.execute("SELECT id FROM personal_scripts").fetchall()
@@ -160,6 +178,7 @@ def register_personal_script_schedule(
160
178
  enabled: bool = True,
161
179
  ) -> dict | None:
162
180
  conn = get_db()
181
+ script_path = _normalize_script_path(script_path)
163
182
  script = conn.execute(
164
183
  "SELECT id FROM personal_scripts WHERE path = ?",
165
184
  (script_path,),
@@ -342,6 +361,7 @@ def list_personal_scripts(include_disabled: bool = True) -> list[dict]:
342
361
 
343
362
  def get_personal_script(name_or_path: str) -> dict | None:
344
363
  conn = get_db()
364
+ normalized_path = _normalize_script_path(name_or_path)
345
365
  row = conn.execute(
346
366
  """
347
367
  SELECT * FROM personal_scripts
@@ -349,7 +369,7 @@ def get_personal_script(name_or_path: str) -> dict | None:
349
369
  ORDER BY path = ? DESC
350
370
  LIMIT 1
351
371
  """,
352
- (name_or_path, name_or_path, name_or_path),
372
+ (normalized_path, name_or_path, normalized_path),
353
373
  ).fetchone()
354
374
  if not row:
355
375
  return None
@@ -361,9 +381,10 @@ def get_personal_script(name_or_path: str) -> dict | None:
361
381
 
362
382
  def delete_personal_script(name_or_path: str) -> int:
363
383
  conn = get_db()
384
+ normalized_path = _normalize_script_path(name_or_path)
364
385
  result = conn.execute(
365
386
  "DELETE FROM personal_scripts WHERE path = ? OR name = ? OR id = ?",
366
- (name_or_path, name_or_path, name_or_path),
387
+ (normalized_path, name_or_path, name_or_path),
367
388
  )
368
389
  return int(result.rowcount or 0)
369
390
 
@@ -371,13 +392,14 @@ def delete_personal_script(name_or_path: str) -> int:
371
392
  def record_personal_script_run(name_or_path: str, exit_code: int, run_at: str | None = None) -> None:
372
393
  conn = get_db()
373
394
  run_at = run_at or _now_text()
395
+ normalized_path = _normalize_script_path(name_or_path)
374
396
  conn.execute(
375
397
  """
376
398
  UPDATE personal_scripts
377
399
  SET last_run_at = ?, last_exit_code = ?, updated_at = ?
378
400
  WHERE path = ? OR name = ?
379
401
  """,
380
- (run_at, exit_code, _now_text(), name_or_path, name_or_path),
402
+ (run_at, exit_code, _now_text(), normalized_path, name_or_path),
381
403
  )
382
404
 
383
405
 
@@ -393,7 +415,7 @@ def sync_personal_scripts_registry(
393
415
  scheduled = 0
394
416
 
395
417
  for record in script_records:
396
- path = str(record["path"])
418
+ path = _normalize_script_path(record["path"])
397
419
  active_paths.append(path)
398
420
  upsert_personal_script(
399
421
  name=record.get("name") or Path(path).stem,
@@ -84,6 +84,28 @@ def _release_root() -> Path:
84
84
  return Path(NEXO_CODE)
85
85
 
86
86
 
87
+ def _has_release_publish_context(release_root: Path) -> bool:
88
+ git_dir = release_root / ".git"
89
+ if git_dir.exists() or git_dir.is_file():
90
+ return True
91
+ if _recorded_source_root() is not None:
92
+ return True
93
+ try:
94
+ release_root_resolved = release_root.resolve()
95
+ nexo_home_resolved = NEXO_HOME.resolve()
96
+ except Exception:
97
+ release_root_resolved = release_root
98
+ nexo_home_resolved = NEXO_HOME
99
+ if release_root_resolved == nexo_home_resolved:
100
+ return False
101
+ has_release_files = (
102
+ (release_root / "package.json").is_file()
103
+ and (release_root / "CHANGELOG.md").is_file()
104
+ and (release_root / "scripts" / "sync_release_artifacts.py").is_file()
105
+ )
106
+ return has_release_files
107
+
108
+
87
109
  def _package_json_path() -> Path:
88
110
  if PACKAGE_JSON.is_file():
89
111
  return PACKAGE_JSON
@@ -2660,13 +2682,27 @@ def check_release_artifact_sync() -> DoctorCheck:
2660
2682
  if changelog_version:
2661
2683
  evidence.append(f"top changelog version: {changelog_version}")
2662
2684
 
2685
+ release_root = _release_root()
2686
+ if not _has_release_publish_context(release_root):
2687
+ if version:
2688
+ evidence.append(f"package version: {version}")
2689
+ evidence.append(f"release root: {release_root}")
2690
+ evidence.append("packaged runtime without source repo; release artifact audit skipped")
2691
+ return DoctorCheck(
2692
+ id="runtime.release_artifacts",
2693
+ tier="runtime",
2694
+ status="healthy",
2695
+ severity="info",
2696
+ summary="Release artifact audit skipped for packaged runtime",
2697
+ evidence=evidence,
2698
+ )
2699
+
2663
2700
  if version and changelog_version and version != changelog_version:
2664
2701
  status = "critical"
2665
2702
  severity = "error"
2666
2703
  evidence.append("package/changelog release version mismatch")
2667
2704
  repair_plan.append("Bump or align CHANGELOG.md before publishing")
2668
2705
 
2669
- release_root = _release_root()
2670
2706
  sync_script = release_root / "scripts" / "sync_release_artifacts.py"
2671
2707
  if not sync_script.is_file():
2672
2708
  status = "critical"
@@ -4,6 +4,8 @@
4
4
  # Caches output for 1 hour to avoid regenerating on rapid successive sessions.
5
5
  set -uo pipefail
6
6
 
7
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
8
+
7
9
  # Fase 3 item 7: hook lifecycle observability — record duration + exit code
8
10
  # in hook_runs on EXIT. Best-effort: a failure here must not break the hook.
9
11
  NEXO_HOOK_START_MS=$(python3 -c "import time; print(int(time.time()*1000))" 2>/dev/null || echo 0)
@@ -18,8 +20,12 @@ _nexo_record_hook_run() {
18
20
  duration_ms=$((now_ms - NEXO_HOOK_START_MS))
19
21
  fi
20
22
  fi
21
- local recorder
22
- recorder="${NEXO_CODE:-/Users/franciscoc/Documents/_PhpstormProjects/nexo/src}/scripts/nexo-hook-record.py"
23
+ local recorder=""
24
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/scripts/nexo-hook-record.py" ]; then
25
+ recorder="${NEXO_CODE%/}/scripts/nexo-hook-record.py"
26
+ elif [ -f "$NEXO_HOME/scripts/nexo-hook-record.py" ]; then
27
+ recorder="$NEXO_HOME/scripts/nexo-hook-record.py"
28
+ fi
23
29
  if [ -f "$recorder" ]; then
24
30
  python3 "$recorder" record \
25
31
  --hook "$NEXO_HOOK_NAME" \
@@ -30,8 +36,6 @@ _nexo_record_hook_run() {
30
36
  fi
31
37
  }
32
38
  trap _nexo_record_hook_run EXIT
33
-
34
- NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
35
39
  BRIEFING_FILE="$NEXO_HOME/coordination/session-briefing.txt"
36
40
  MAX_AGE_SECONDS=3600 # 1 hour cache
37
41
 
@@ -9,6 +9,8 @@ import sys
9
9
  import time
10
10
  from pathlib import Path
11
11
 
12
+ from runtime_home import export_resolved_nexo_home
13
+
12
14
  # Code root is the parent of plugins/:
13
15
  # - source checkout: <repo>/src
14
16
  # - packaged runtime: <NEXO_HOME>
@@ -16,7 +18,7 @@ _THIS_DIR = Path(__file__).resolve().parent
16
18
  CODE_ROOT = _THIS_DIR.parent
17
19
  _REPO_CANDIDATE = CODE_ROOT.parent
18
20
 
19
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
21
+ NEXO_HOME = export_resolved_nexo_home()
20
22
  DATA_DIR = NEXO_HOME / "data"
21
23
  BACKUP_BASE = NEXO_HOME / "backups"
22
24
 
@@ -330,8 +332,23 @@ def _backup_code_tree() -> tuple[str | None, str | None]:
330
332
  timestamp = time.strftime("%Y-%m-%d-%H%M%S")
331
333
  backup_dir = BACKUP_BASE / f"code-tree-{timestamp}"
332
334
  # Directories and flat files that postinstall copies into NEXO_HOME
333
- code_dirs = ["hooks", "plugins", "db", "cognitive", "dashboard", "rules", "crons", "scripts"]
334
- code_files_glob = ["*.py", "requirements.txt"]
335
+ code_dirs = [
336
+ "bin",
337
+ "hooks",
338
+ "plugins",
339
+ "db",
340
+ "cognitive",
341
+ "dashboard",
342
+ "rules",
343
+ "crons",
344
+ "scripts",
345
+ "doctor",
346
+ "skills",
347
+ "skills-core",
348
+ "skills-runtime",
349
+ "templates",
350
+ ]
351
+ code_files_glob = ["*.py", "requirements.txt", "package.json"]
335
352
  try:
336
353
  backup_dir.mkdir(parents=True, exist_ok=True)
337
354
  # Backup directories
@@ -372,6 +389,54 @@ def _restore_code_tree(backup_dir: str) -> str | None:
372
389
  return None
373
390
 
374
391
 
392
+ def _normalize_preferences_for_client_sync() -> dict:
393
+ from client_preferences import normalize_client_preferences
394
+
395
+ schedule_path = NEXO_HOME / "config" / "schedule.json"
396
+ schedule_payload = json.loads(schedule_path.read_text()) if schedule_path.exists() else {}
397
+ normalized_preferences = normalize_client_preferences(schedule_payload)
398
+ if normalized_preferences != {
399
+ key: schedule_payload.get(key)
400
+ for key in normalized_preferences
401
+ }:
402
+ merged_schedule = dict(schedule_payload)
403
+ merged_schedule.update(normalized_preferences)
404
+ schedule_path.parent.mkdir(parents=True, exist_ok=True)
405
+ schedule_path.write_text(json.dumps(merged_schedule, indent=2, ensure_ascii=False) + "\n")
406
+ return normalized_preferences
407
+
408
+
409
+ def _sync_packaged_clients() -> tuple[bool, str | None]:
410
+ try:
411
+ from client_sync import sync_all_clients
412
+ except Exception as e:
413
+ return False, f"client sync import failed: {e}"
414
+
415
+ try:
416
+ preferences = _normalize_preferences_for_client_sync()
417
+ result = sync_all_clients(
418
+ nexo_home=NEXO_HOME,
419
+ runtime_root=NEXO_HOME,
420
+ operator_name=os.environ.get("NEXO_NAME", ""),
421
+ preferences=preferences,
422
+ )
423
+ except Exception as e:
424
+ return False, f"client sync failed: {e}"
425
+
426
+ if result.get("ok"):
427
+ return True, None
428
+
429
+ clients = result.get("clients", {})
430
+ failures = []
431
+ for key, payload in clients.items():
432
+ if payload.get("ok") or payload.get("skipped"):
433
+ continue
434
+ failures.append(f"{key}: {payload.get('error', 'unknown error')}")
435
+ if not failures:
436
+ failures.append("unknown client sync failure")
437
+ return False, "; ".join(failures)
438
+
439
+
375
440
  def _rollback_npm_package(target_version: str) -> str | None:
376
441
  """Rollback nexo-brain npm package to a specific version.
377
442
 
@@ -480,6 +545,20 @@ def _handle_packaged_update(progress_fn=None) -> str:
480
545
  if verify_err:
481
546
  errors.append(f"verification: {verify_err}")
482
547
 
548
+ hook_sync_warning = None
549
+ try:
550
+ _emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
551
+ _refresh_installed_manifest()
552
+ _sync_hooks_to_home()
553
+ except Exception as e:
554
+ hook_sync_warning = f"{e}"
555
+
556
+ client_sync_warning = None
557
+ _emit_progress(progress_fn, "Refreshing shared client configs...")
558
+ clients_ok, client_sync_error = _sync_packaged_clients()
559
+ if not clients_ok:
560
+ client_sync_warning = client_sync_error or "unknown client sync error"
561
+
483
562
  if errors:
484
563
  # 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
485
564
  if code_backup_dir:
@@ -516,6 +595,14 @@ def _handle_packaged_update(progress_fn=None) -> str:
516
595
  lines = ["UPDATE SUCCESSFUL (packaged install)"]
517
596
  lines.append(f" Version: {old_version} -> {new_version}")
518
597
  lines.append(f" Backup: {backup_dir}")
598
+ if not hook_sync_warning:
599
+ lines.append(" Hooks: synced to NEXO_HOME")
600
+ else:
601
+ lines.append(f" WARNING: hook sync: {hook_sync_warning}")
602
+ if not client_sync_warning:
603
+ lines.append(" Clients: configured client targets synced")
604
+ else:
605
+ lines.append(f" WARNING: client sync: {client_sync_warning}")
519
606
  lines.append("")
520
607
  lines.append("MCP server restart needed to load new code.")
521
608
  return "\n".join(lines)
@@ -0,0 +1,46 @@
1
+ from __future__ import annotations
2
+
3
+ """Shared helpers to resolve the managed NEXO home path."""
4
+
5
+ import os
6
+ from pathlib import Path
7
+
8
+
9
+ def user_home() -> Path:
10
+ return Path(os.environ.get("HOME", str(Path.home()))).expanduser()
11
+
12
+
13
+ def managed_nexo_home(*, home: Path | None = None) -> Path:
14
+ return (home or user_home()) / ".nexo"
15
+
16
+
17
+ def legacy_nexo_home(*, home: Path | None = None) -> Path:
18
+ return (home or user_home()) / "claude"
19
+
20
+
21
+ def resolve_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
22
+ home = user_home()
23
+ managed = managed_nexo_home(home=home)
24
+ candidate = Path(value).expanduser() if value else Path(
25
+ os.environ.get("NEXO_HOME", str(managed))
26
+ ).expanduser()
27
+ legacy = legacy_nexo_home(home=home)
28
+
29
+ if candidate == managed:
30
+ return managed
31
+ if candidate == legacy:
32
+ return managed if managed.exists() or legacy.is_symlink() else candidate
33
+
34
+ try:
35
+ if managed.exists() and candidate.resolve(strict=False) == managed.resolve(strict=False):
36
+ return managed
37
+ except Exception:
38
+ pass
39
+
40
+ return candidate
41
+
42
+
43
+ def export_resolved_nexo_home(value: str | os.PathLike[str] | None = None) -> Path:
44
+ resolved = resolve_nexo_home(value)
45
+ os.environ["NEXO_HOME"] = str(resolved)
46
+ return resolved
@@ -16,7 +16,9 @@ import stat
16
16
  import subprocess
17
17
  from pathlib import Path
18
18
 
19
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
19
+ from runtime_home import export_resolved_nexo_home
20
+
21
+ NEXO_HOME = export_resolved_nexo_home()
20
22
  NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
21
23
 
22
24
  # Internal artifacts to always ignore
@@ -73,6 +75,7 @@ METADATA_KEYS = {
73
75
  "run_on_wake",
74
76
  "idempotent",
75
77
  "max_catchup_age",
78
+ "doctor_allow_db",
76
79
  }
77
80
  SUPPORTED_RUNTIMES = {"python", "shell", "node", "php", "unknown"}
78
81
  PERSONAL_SCHEDULE_MANAGED_ENV = "NEXO_MANAGED_PERSONAL_CRON"
@@ -213,7 +216,11 @@ def _is_ignored(path: Path) -> bool:
213
216
  return True
214
217
  if path.name.startswith("."):
215
218
  return True
216
- for parent in path.relative_to(get_scripts_dir()).parents:
219
+ try:
220
+ relative_path = path.resolve().relative_to(get_scripts_dir().resolve())
221
+ except Exception:
222
+ return False
223
+ for parent in relative_path.parents:
217
224
  if parent.name in _IGNORED_DIRS:
218
225
  return True
219
226
  return False
@@ -1425,14 +1432,19 @@ def doctor_script(path_or_name: str) -> dict:
1425
1432
  elif cmd:
1426
1433
  items.append({"level": "pass", "msg": f"Required command found: {cmd}"})
1427
1434
 
1435
+ allow_db_access = str(meta.get("doctor_allow_db", "")).strip().lower() in {"1", "true", "yes", "on"}
1436
+ if allow_db_access:
1437
+ items.append({"level": "pass", "msg": "Doctor DB access explicitly allowed"})
1438
+
1428
1439
  # Forbidden patterns (only for personal scripts)
1429
1440
  if not is_core:
1430
1441
  try:
1431
1442
  content = p.read_text(errors="ignore")
1432
- for pat in _FORBIDDEN_PATTERNS:
1433
- match = pat.search(content)
1434
- if match:
1435
- items.append({"level": "fail", "msg": f"Forbidden DB pattern found: {match.group()}"})
1443
+ if not allow_db_access:
1444
+ for pat in _FORBIDDEN_PATTERNS:
1445
+ match = pat.search(content)
1446
+ if match:
1447
+ items.append({"level": "fail", "msg": f"Forbidden DB pattern found: {match.group()}"})
1436
1448
  except Exception:
1437
1449
  pass
1438
1450
 
@@ -63,7 +63,7 @@ def _open_correction_fatigue_followup(fatigued: list) -> str:
63
63
  lines.append(f"... and {len(fatigued) - 10} more")
64
64
  description = "\n".join(lines)
65
65
  verification = (
66
- "sqlite3 ~/claude/data/cognitive.db \"SELECT id, content, strength, tags "
66
+ "sqlite3 ~/.nexo/data/cognitive.db \"SELECT id, content, strength, tags "
67
67
  "FROM ltm_memories WHERE tags LIKE '%under_review%' ORDER BY strength ASC LIMIT 50\""
68
68
  )
69
69
  now_epoch = datetime.now().timestamp()
@@ -12,7 +12,7 @@ recommendation pattern could persist indefinitely between user reports.
12
12
  What this script does (idempotent and best-effort):
13
13
 
14
14
  1. Loads cortex_evaluation_summary for the last 7 days and last 1 day.
15
- 2. Persists the snapshot to ~/claude/operations/cortex-quality-latest.json
15
+ 2. Persists the snapshot to ~/.nexo/operations/cortex-quality-latest.json
16
16
  so dashboards / morning briefings can read fresh metrics without
17
17
  re-running the SQL.
18
18
  3. Detects degradation signals on the 7-day window. The criteria are
@@ -25,7 +25,7 @@ What this script does (idempotent and best-effort):
25
25
  metrics when degradation is detected. Idempotent: if a non-PENDING /
26
26
  resolved followup of the same id already exists, it is updated in
27
27
  place rather than duplicated.
28
- 5. Logs every run to ~/claude/logs/cortex-cycle.log.
28
+ 5. Logs every run to ~/.nexo/logs/cortex-cycle.log.
29
29
 
30
30
  Catchup-friendly: a stale plist firing twice in quick succession is fine.
31
31
  The quality file is rewritten in place, the followup is upserted, no
@@ -3,7 +3,7 @@
3
3
 
4
4
  Closes Fase 3 item 7 of NEXO-AUDIT-2026-04-11. Bash hooks can call:
5
5
 
6
- python3 ~/Documents/_PhpstormProjects/nexo/src/scripts/nexo-hook-record.py \
6
+ NEXO_HOME=~/.nexo python3 ~/.nexo/scripts/nexo-hook-record.py \
7
7
  --hook session-start --duration-ms 142 --exit $? --session $SID
8
8
 
9
9
  This script is intentionally minimal so it adds <50ms of latency to the
@@ -7,7 +7,7 @@ Usage:
7
7
  python3 nexo-migrate.py --from 1.6.0 # override detected current version
8
8
 
9
9
  Reads current version from $NEXO_HOME/version.json.
10
- Reads target version from the repo's package.json.
10
+ Reads target version from the installed runtime package.json.
11
11
  Backs up NEXO_HOME/db/ before any migration.
12
12
  Runs DB schema migrations via the existing _schema.py system.
13
13
  """
@@ -16,13 +16,43 @@ import argparse
16
16
  import json
17
17
  import os
18
18
  import shutil
19
- import sqlite3
20
19
  import sys
21
20
  from datetime import datetime
22
21
  from pathlib import Path
23
22
 
24
- NEXO_HOME = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo"))
25
- REPO_ROOT = Path(__file__).resolve().parent.parent.parent # nexo/src/scripts -> nexo/
23
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo"))).expanduser()
24
+ SCRIPT_ROOT = Path(__file__).resolve().parent.parent
25
+
26
+
27
+ def _resolve_runtime_root() -> Path:
28
+ candidates: list[Path] = []
29
+
30
+ raw_code = os.environ.get("NEXO_CODE", "").strip()
31
+ if raw_code:
32
+ env_code = Path(raw_code).expanduser()
33
+ candidates.append(env_code)
34
+ if env_code.name == "src":
35
+ candidates.append(env_code.parent)
36
+
37
+ candidates.extend([SCRIPT_ROOT, NEXO_HOME])
38
+
39
+ seen: set[str] = set()
40
+ for candidate in candidates:
41
+ candidate = candidate if candidate.is_dir() else candidate.parent
42
+ key = str(candidate)
43
+ if key in seen:
44
+ continue
45
+ seen.add(key)
46
+ if (candidate / "package.json").is_file():
47
+ return candidate
48
+ if (candidate / "src" / "server.py").is_file() and (candidate / "package.json").is_file():
49
+ return candidate
50
+
51
+ return SCRIPT_ROOT
52
+
53
+
54
+ RUNTIME_ROOT = _resolve_runtime_root()
55
+ SOURCE_ROOT = RUNTIME_ROOT / "src" if (RUNTIME_ROOT / "src" / "db").is_dir() else RUNTIME_ROOT
26
56
 
27
57
 
28
58
  # ── Version helpers ──────────────────────────────────────────────
@@ -56,8 +86,8 @@ def get_current_version() -> str:
56
86
 
57
87
 
58
88
  def get_target_version() -> str:
59
- """Read target version from repo package.json."""
60
- pkg = REPO_ROOT / "package.json"
89
+ """Read target version from installed runtime package.json."""
90
+ pkg = RUNTIME_ROOT / "package.json"
61
91
  if not pkg.exists():
62
92
  print(f"ERROR: package.json not found at {pkg}", file=sys.stderr)
63
93
  sys.exit(1)
@@ -107,10 +137,8 @@ def ensure_nexo_home_dirs():
107
137
 
108
138
  def run_db_schema_migrations():
109
139
  """Run the formal DB schema migration system from _schema.py."""
110
- # Add src/ to path so we can import the db module
111
- src_dir = REPO_ROOT / "src"
112
- if str(src_dir) not in sys.path:
113
- sys.path.insert(0, str(src_dir))
140
+ if str(SOURCE_ROOT) not in sys.path:
141
+ sys.path.insert(0, str(SOURCE_ROOT))
114
142
 
115
143
  # Set NEXO_HOME env for the db module
116
144
  os.environ["NEXO_HOME"] = str(NEXO_HOME)
@@ -374,7 +374,7 @@ def _open_watcher_followup(result: dict) -> dict:
374
374
  )
375
375
  description = "\n".join(description_lines)
376
376
  verification = (
377
- f"sqlite3 ~/claude/data/nexo.db \"SELECT last_health, last_result "
377
+ f"sqlite3 ~/.nexo/data/nexo.db \"SELECT last_health, last_result "
378
378
  f"FROM state_watchers WHERE watcher_id = '{watcher_id}'\""
379
379
  )
380
380
 
@@ -1,4 +1,4 @@
1
- <!-- nexo-claude-md-version: 2.1.3 -->
1
+ <!-- nexo-claude-md-version: 2.1.4 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — Cognitive Co-Operator
@@ -35,6 +35,12 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
35
35
  - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
36
36
  - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first user-visible answer unless the user explicitly asked for it.
37
37
 
38
+ ## Communication Guardrail
39
+ - In the first answer to Francisco on any thread, lead with the direct recommendation, decision, or status.
40
+ - Keep each decision point to 2-3 short sentences maximum. Hold extra detail unless he explicitly asks for it.
41
+ - Do not use internal NEXO jargon in the first answer (`protocol debt`, `cortex evaluation`, `runtime check`, `guard_check`, `heartbeat`, etc.). Translate it into plain operational language.
42
+ - Prefer conclusion plus next action over option dumps, raw diagnostics, or internal process narration. Apply this equally to chat replies, emails, briefings, and headless reports intended for Francisco.
43
+
38
44
  <!-- nexo:start:profile -->
39
45
  ## User Profile
40
46
  - **Calibration:** `{{NEXO_HOME}}/brain/calibration.json` (personality settings + language + user name)
@@ -1,4 +1,4 @@
1
- <!-- nexo-codex-agents-version: 1.2.3 -->
1
+ <!-- nexo-codex-agents-version: 1.2.4 -->
2
2
  ******CORE******
3
3
  <!-- nexo:core:start -->
4
4
  # {{NAME}} — NEXO Shared Brain for Codex
@@ -32,6 +32,12 @@ NEXO updates may rewrite `CORE`, but they must preserve `USER` verbatim.
32
32
  - For single-artifact asks, the default cap before the first visible answer is one lookup plus one detail read. Do not keep chaining tools before answering unless the user explicitly asked for more depth.
33
33
  - After `nexo_email_read`, `nexo_session_diary_read`, `nexo_reminders`, `nexo_followups`, or equivalent single-artifact retrieval, answer immediately. Do not launch another search, another read, or background analysis before the first visible answer unless the user explicitly asked for it.
34
34
 
35
+ ## Communication Guardrail
36
+ - In the first answer to Francisco on any thread, lead with the direct recommendation, decision, or status.
37
+ - Keep each decision point to 2-3 short sentences maximum. Hold extra detail unless he explicitly asks for it.
38
+ - Do not use internal NEXO jargon in the first answer (`protocol debt`, `cortex evaluation`, `runtime check`, `guard_check`, `heartbeat`, etc.). Translate it into plain operational language.
39
+ - Prefer conclusion plus next action over option dumps, raw diagnostics, or internal process narration. Apply this equally to chat replies, emails, briefings, and headless reports intended for Francisco.
40
+
35
41
  ## Codex Runtime Notes
36
42
  - Codex does not provide Claude Code hooks, so protocol discipline must be explicit.
37
43
  - If a stable session token is useful, pass `session_token='codex-<task>-<date>'` and `session_client='codex'` to `nexo_startup`; otherwise leave them blank.
@@ -30,11 +30,15 @@ def _detect_nexo_home() -> Path:
30
30
  ):
31
31
  return inferred_home
32
32
 
33
+ default_home = Path.home() / ".nexo"
34
+ if default_home.is_dir():
35
+ return default_home
36
+
33
37
  claude_home = Path.home() / "claude"
34
38
  if claude_home.is_dir():
35
39
  return claude_home
36
40
 
37
- return Path.home() / ".nexo"
41
+ return default_home
38
42
 
39
43
 
40
44
  NEXO_HOME = _detect_nexo_home()