nexo-brain 5.3.1 → 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.1",
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,6 +87,8 @@ 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
+
90
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.
91
93
 
92
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.
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)
@@ -2359,6 +2387,7 @@ async function main() {
2359
2387
  });
2360
2388
  log(" Hooks installed.");
2361
2389
  }
2390
+ writeRuntimeCoreArtifactsManifest(NEXO_HOME, srcDir);
2362
2391
 
2363
2392
  // Generate personality
2364
2393
  const personality = `# ${operatorName} — Personality
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.1",
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",
@@ -359,6 +359,10 @@ def _cleanup_retired_runtime_files():
359
359
  """Remove retired core files that should not survive updates."""
360
360
  retired = [
361
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",
362
366
  ]
363
367
  for target in retired:
364
368
  try:
@@ -349,6 +349,12 @@ CORE_HOOK_SPECS = [
349
349
  "timeout": 5,
350
350
  "script": "protocol-pretool-guardrail.sh",
351
351
  },
352
+ {
353
+ "event": "UserPromptSubmit",
354
+ "identity": "heartbeat-user-msg.sh",
355
+ "timeout": 3,
356
+ "script": "heartbeat-user-msg.sh",
357
+ },
352
358
  {
353
359
  "event": "PostToolUse",
354
360
  "identity": "capture-tool-logs.sh",
@@ -373,6 +379,12 @@ CORE_HOOK_SPECS = [
373
379
  "timeout": 5,
374
380
  "script": "protocol-guardrail.sh",
375
381
  },
382
+ {
383
+ "event": "PostToolUse",
384
+ "identity": "heartbeat-posttool.sh",
385
+ "timeout": 3,
386
+ "script": "heartbeat-posttool.sh",
387
+ },
376
388
  {
377
389
  "event": "PreCompact",
378
390
  "identity": "pre-compact.sh",
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env python3
2
+ """Heartbeat enforcement for NEXO sessions.
3
+
4
+ Tracks user messages vs heartbeat calls. Emits a warning when more than two
5
+ user messages pass without a heartbeat call.
6
+
7
+ Modes:
8
+ - HEARTBEAT_MODE=user_msg: increment counter on UserPromptSubmit
9
+ - HEARTBEAT_MODE=post_tool: inspect PostToolUse payload, reset on heartbeat,
10
+ warn when other tools keep running without one
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import os
17
+ import sys
18
+ import time
19
+ from pathlib import Path
20
+
21
+ STATE_FILE = Path(os.environ.get("NEXO_HOME", Path.home() / ".nexo")) / "operations" / ".heartbeat-state.json"
22
+ THRESHOLD = 2
23
+ HEARTBEAT_TOOL = "nexo_heartbeat"
24
+ SKIP_TOOLS = {"nexo_startup", "nexo_stop", "nexo_smart_startup"}
25
+
26
+
27
+ def _read_state() -> dict:
28
+ try:
29
+ return json.loads(STATE_FILE.read_text())
30
+ except Exception:
31
+ return {"user_msgs": 0, "last_heartbeat_ts": 0.0, "last_user_msg_ts": 0.0}
32
+
33
+
34
+ def _write_state(state: dict) -> None:
35
+ try:
36
+ STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
37
+ STATE_FILE.write_text(json.dumps(state))
38
+ except Exception:
39
+ pass
40
+
41
+
42
+ def handle_user_message() -> int:
43
+ state = _read_state()
44
+ state["user_msgs"] = state.get("user_msgs", 0) + 1
45
+ state["last_user_msg_ts"] = time.time()
46
+ _write_state(state)
47
+ return 0
48
+
49
+
50
+ def handle_post_tool(payload: dict) -> int:
51
+ tool_name = str(payload.get("tool_name", "")).strip()
52
+ short_name = tool_name.rsplit("__", 1)[-1] if "__" in tool_name else tool_name
53
+ state = _read_state()
54
+
55
+ if short_name == HEARTBEAT_TOOL:
56
+ state["user_msgs"] = 0
57
+ state["last_heartbeat_ts"] = time.time()
58
+ _write_state(state)
59
+ return 0
60
+
61
+ if short_name in SKIP_TOOLS:
62
+ return 0
63
+
64
+ user_msgs = state.get("user_msgs", 0)
65
+ if user_msgs > THRESHOLD:
66
+ print(
67
+ f"\nWARNING: HEARTBEAT OVERDUE ({user_msgs} user messages without nexo_heartbeat). "
68
+ "Call nexo_heartbeat(sid=SID, task='...') before continuing."
69
+ )
70
+ return 0
71
+
72
+
73
+ def main() -> int:
74
+ mode = os.environ.get("HEARTBEAT_MODE", "").strip()
75
+ if mode == "user_msg":
76
+ return handle_user_message()
77
+ if mode == "post_tool":
78
+ raw = sys.stdin.read()
79
+ if not raw.strip():
80
+ return 0
81
+ try:
82
+ payload = json.loads(raw)
83
+ except Exception:
84
+ return 0
85
+ return handle_post_tool(payload)
86
+ return 0
87
+
88
+
89
+ if __name__ == "__main__":
90
+ raise SystemExit(main())
@@ -0,0 +1,18 @@
1
+ #!/bin/bash
2
+ # NEXO PostToolUse hook — heartbeat enforcement checker
3
+ set -uo pipefail
4
+
5
+ INPUT=$(cat || true)
6
+ [ -z "$INPUT" ] && exit 0
7
+
8
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
9
+ HELPER=""
10
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
11
+ HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
12
+ elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
13
+ HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
14
+ fi
15
+
16
+ [ -z "$HELPER" ] && exit 0
17
+ HEARTBEAT_MODE=post_tool python3 "$HELPER" <<< "$INPUT" 2>/dev/null || true
18
+ exit 0
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+ # NEXO UserPromptSubmit hook — track user messages for heartbeat enforcement
3
+ set -uo pipefail
4
+
5
+ NEXO_HOME="${NEXO_HOME:-$HOME/.nexo}"
6
+ HELPER=""
7
+ if [ -n "${NEXO_CODE:-}" ] && [ -f "${NEXO_CODE%/}/hooks/heartbeat-enforcement.py" ]; then
8
+ HELPER="${NEXO_CODE%/}/hooks/heartbeat-enforcement.py"
9
+ elif [ -f "$NEXO_HOME/hooks/heartbeat-enforcement.py" ]; then
10
+ HELPER="$NEXO_HOME/hooks/heartbeat-enforcement.py"
11
+ fi
12
+
13
+ [ -z "$HELPER" ] && exit 0
14
+ HEARTBEAT_MODE=user_msg python3 "$HELPER" 2>/dev/null || true
15
+ exit 0
@@ -81,7 +81,7 @@ def _is_git_repo() -> bool:
81
81
 
82
82
 
83
83
  def _refresh_installed_manifest():
84
- """Copy source crons/ to NEXO_HOME/crons/ so catchup & watchdog stay current."""
84
+ """Refresh packaged crons and persist the runtime core-artifacts manifest."""
85
85
  try:
86
86
  src_crons = SRC_DIR / "crons"
87
87
  dst_crons = NEXO_HOME / "crons"
@@ -90,10 +90,45 @@ def _refresh_installed_manifest():
90
90
  for f in src_crons.iterdir():
91
91
  if f.is_file():
92
92
  shutil.copy2(str(f), str(dst_crons / f.name))
93
+ config_dir = NEXO_HOME / "config"
94
+ config_dir.mkdir(parents=True, exist_ok=True)
95
+ payload = {
96
+ "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
97
+ "script_names": sorted(
98
+ f.name for f in (SRC_DIR / "scripts").iterdir()
99
+ if f.is_file()
100
+ ) if (SRC_DIR / "scripts").is_dir() else [],
101
+ "hook_names": sorted(
102
+ f.name for f in (SRC_DIR / "hooks").iterdir()
103
+ if f.is_file()
104
+ ) if (SRC_DIR / "hooks").is_dir() else [],
105
+ }
106
+ (config_dir / "runtime-core-artifacts.json").write_text(
107
+ json.dumps(payload, indent=2, ensure_ascii=False) + "\n"
108
+ )
93
109
  except Exception:
94
110
  pass
95
111
 
96
112
 
113
+ def _cleanup_retired_runtime_files() -> list[str]:
114
+ removed: list[str] = []
115
+ retired_paths = [
116
+ NEXO_HOME / "scripts" / "heartbeat-enforcement.py",
117
+ NEXO_HOME / "scripts" / "heartbeat-posttool.sh",
118
+ NEXO_HOME / "scripts" / "heartbeat-user-msg.sh",
119
+ NEXO_HOME / "hooks" / "heartbeat-guard.sh",
120
+ ]
121
+ for path in retired_paths:
122
+ if not path.exists():
123
+ continue
124
+ try:
125
+ path.unlink()
126
+ removed.append(str(path))
127
+ except Exception:
128
+ continue
129
+ return removed
130
+
131
+
97
132
  def _read_version() -> str:
98
133
  """Read the installed/runtime version."""
99
134
  if _PACKAGED_INSTALL:
@@ -546,10 +581,12 @@ def _handle_packaged_update(progress_fn=None) -> str:
546
581
  errors.append(f"verification: {verify_err}")
547
582
 
548
583
  hook_sync_warning = None
584
+ retired_runtime_files: list[str] = []
549
585
  try:
550
586
  _emit_progress(progress_fn, "Refreshing installed hooks and manifests...")
551
587
  _refresh_installed_manifest()
552
588
  _sync_hooks_to_home()
589
+ retired_runtime_files = _cleanup_retired_runtime_files()
553
590
  except Exception as e:
554
591
  hook_sync_warning = f"{e}"
555
592
 
@@ -599,6 +636,8 @@ def _handle_packaged_update(progress_fn=None) -> str:
599
636
  lines.append(" Hooks: synced to NEXO_HOME")
600
637
  else:
601
638
  lines.append(f" WARNING: hook sync: {hook_sync_warning}")
639
+ if retired_runtime_files:
640
+ lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
602
641
  if not client_sync_warning:
603
642
  lines.append(" Clients: configured client targets synced")
604
643
  else:
@@ -714,9 +753,11 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
714
753
  cron_sync_result = f"Cron sync warning: {e}"
715
754
 
716
755
  # Step 9: Sync hooks to NEXO_HOME
756
+ retired_runtime_files: list[str] = []
717
757
  try:
718
758
  _emit_progress(progress_fn, "Syncing core Claude hooks...")
719
759
  _sync_hooks_to_home()
760
+ retired_runtime_files = _cleanup_retired_runtime_files()
720
761
  steps_done.append("hook-sync")
721
762
  except Exception as e:
722
763
  pass # Non-critical, log in function
@@ -768,6 +809,8 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
768
809
  lines.append(" Crons: synced with manifest")
769
810
  if "hook-sync" in steps_done:
770
811
  lines.append(" Hooks: synced to NEXO_HOME")
812
+ if retired_runtime_files:
813
+ lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
771
814
  if "client-sync" in steps_done:
772
815
  lines.append(" Clients: configured client targets synced")
773
816
  lines.append("")
@@ -44,6 +44,14 @@ _LEGACY_WAKE_RECOVERY_METADATA = [
44
44
  "# nexo: run_on_boot=true",
45
45
  ]
46
46
 
47
+ _LEGACY_CORE_RUNTIME_FILES = {
48
+ "capture-tool-logs.sh",
49
+ "daily-briefing-check.sh",
50
+ "heartbeat-enforcement.py",
51
+ "heartbeat-posttool.sh",
52
+ "heartbeat-user-msg.sh",
53
+ }
54
+
47
55
  # Forbidden patterns — direct DB access from personal scripts
48
56
  _FORBIDDEN_PATTERNS = [
49
57
  re.compile(r"\bsqlite3\b"),
@@ -119,7 +127,7 @@ def _apply_legacy_personal_script_backfills() -> None:
119
127
 
120
128
 
121
129
  def load_core_script_names() -> set[str]:
122
- """Load script names from crons/manifest.json (these are core, not personal)."""
130
+ """Load runtime-managed script names (core, not personal)."""
123
131
  names: set[str] = set()
124
132
  for manifest_path in [NEXO_CODE / "crons" / "manifest.json", NEXO_HOME / "crons" / "manifest.json"]:
125
133
  if manifest_path.exists():
@@ -132,6 +140,26 @@ def load_core_script_names() -> set[str]:
132
140
  break
133
141
  except Exception:
134
142
  continue
143
+ runtime_manifest = NEXO_HOME / "config" / "runtime-core-artifacts.json"
144
+ if runtime_manifest.exists():
145
+ try:
146
+ data = json.loads(runtime_manifest.read_text())
147
+ for key in ("script_names", "hook_names"):
148
+ for name in data.get(key, []):
149
+ clean = Path(str(name)).name
150
+ if clean:
151
+ names.add(clean)
152
+ except Exception:
153
+ pass
154
+ hooks_dir = NEXO_HOME / "hooks"
155
+ if hooks_dir.is_dir():
156
+ try:
157
+ for item in hooks_dir.iterdir():
158
+ if item.is_file():
159
+ names.add(item.name)
160
+ except Exception:
161
+ pass
162
+ names.update(_LEGACY_CORE_RUNTIME_FILES)
135
163
  return names
136
164
 
137
165
 
@@ -112,7 +112,6 @@ These agents power NEXO's learning and memory systems. Strongly recommended.
112
112
  | File | Schedule | What it does |
113
113
  |------|----------|-------------|
114
114
  | `com.nexo.dashboard.plist` | Persistent (KeepAlive) | Runs the NEXO web dashboard on `http://localhost:6174`. Provides a browser-based view of sessions, reminders, followups, and system health. Only needed if you want the dashboard UI. |
115
- | `com.nexo.github-monitor.plist` | Daily 08:00 | Checks the NEXO public GitHub repository for open issues, pull requests, and pending releases. Writes results to `~/.nexo/github-status.json` for NEXO to read at startup. Only relevant if you maintain the public NEXO repository. |
116
115
 
117
116
  ---
118
117
 
@@ -1,45 +0,0 @@
1
- <?xml version="1.0" encoding="UTF-8"?>
2
- <!-- com.nexo.github-monitor
3
- Runs nexo-github-monitor.py every day at 08:00 to check the NEXO
4
- public GitHub repository for new issues, pull requests, and pending
5
- releases. Writes results to ~/.nexo/github-status.json. At the next
6
- session startup, NEXO reads this file and responds to open issues,
7
- reviews PRs, and proposes a release if enough commits have
8
- accumulated since the last tag.
9
- -->
10
- <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
11
- <plist version="1.0">
12
- <dict>
13
- <key>Label</key>
14
- <string>com.nexo.github-monitor</string>
15
- <key>ProgramArguments</key>
16
- <array>
17
- <string>/usr/bin/python3</string>
18
- <string>{{NEXO_HOME}}/scripts/nexo-github-monitor.py</string>
19
- </array>
20
- <key>StartCalendarInterval</key>
21
- <dict>
22
- <key>Hour</key>
23
- <integer>8</integer>
24
- <key>Minute</key>
25
- <integer>0</integer>
26
- </dict>
27
- <key>StandardOutPath</key>
28
- <string>{{NEXO_HOME}}/logs/github-monitor-stdout.log</string>
29
- <key>StandardErrorPath</key>
30
- <string>{{NEXO_HOME}}/logs/github-monitor-stderr.log</string>
31
- <key>EnvironmentVariables</key>
32
- <dict>
33
- <key>HOME</key>
34
- <string>{{HOME}}</string>
35
- <key>NEXO_HOME</key>
36
- <string>{{NEXO_HOME}}</string>
37
- <key>NEXO_CODE</key>
38
- <string>{{NEXO_CODE}}</string>
39
- <key>PATH</key>
40
- <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
41
- </dict>
42
- <key>RunAtLoad</key>
43
- <false/>
44
- </dict>
45
- </plist>