nexo-brain 5.4.5 → 5.4.7

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.4.5",
3
+ "version": "5.4.7",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,9 +18,9 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `5.4.5` is the current packaged-runtime line: test isolation for tree_hygiene module + fake venv to prevent CI timeout completes the publish workflow fix from v5.4.3.
21
+ Version `5.4.7` is the current packaged-runtime line: tool-enforcement-map.json for Protocol Enforcer canonical map of all 247 tools with enforcement metadata, plus verify script.
22
22
 
23
- Previously in `5.4.0`: runtime event bus at `~/.nexo/runtime/events.ndjson`, `nexo notify`, `nexo health --json`, `nexo logs --tail --json`, and a safe flat→nested migration for `calibration.json`.
23
+ Previously in `5.4.6`: runtime dependency management in `nexo update` + daily auto-update cron.
24
24
 
25
25
  Start here:
26
26
  - [5-minute quickstart](docs/quickstart-5-minutes.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.4.5",
3
+ "version": "5.4.7",
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",
@@ -60,6 +60,9 @@
60
60
  "scripts": {
61
61
  "postinstall": "node bin/postinstall.js"
62
62
  },
63
+ "runtimeDependencies": [
64
+ {"name": "@anthropic-ai/claude-code", "type": "npm-global", "optional": false}
65
+ ],
63
66
  "engines": {
64
67
  "node": ">=18"
65
68
  },
@@ -2267,6 +2267,14 @@ def manual_sync_update(*, interactive: bool = False, allow_source_pull: bool = T
2267
2267
  sync_result["warnings"].append(
2268
2268
  f"Preserved {len(copy_stats['script_conflicts'])} personal runtime script collision(s) in NEXO_HOME/scripts"
2269
2269
  )
2270
+ # Update runtime dependencies (best-effort)
2271
+ try:
2272
+ from plugins.update import _update_runtime_dependencies, _format_dep_results
2273
+ dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
2274
+ sync_result["runtime_dependencies"] = dep_results
2275
+ except Exception:
2276
+ pass # Non-critical
2277
+
2270
2278
  _emit_progress(progress_fn, "Runtime update completed.")
2271
2279
  except Exception as e:
2272
2280
  _emit_progress(progress_fn, "Update failed; restoring previous runtime state...")
package/src/cli.py CHANGED
@@ -903,6 +903,17 @@ def _update(args):
903
903
  print(f" Personal schedules: {invalid} declarations need review")
904
904
  if result.get("pulled_source"):
905
905
  print(" Source repo: pulled latest fast-forward before sync")
906
+ for dep in result.get("runtime_dependencies") or []:
907
+ dep_name = dep.get("name", "")
908
+ dep_status = dep.get("status", "")
909
+ if dep_status == "updated":
910
+ print(f" Dependencies: {dep_name} {dep.get('old_version')} -> {dep.get('new_version')}")
911
+ elif dep_status == "installed":
912
+ print(f" Dependencies: {dep_name} installed ({dep.get('new_version')})")
913
+ elif dep_status == "already_latest":
914
+ print(f" Dependencies: {dep_name} {dep.get('old_version')} (latest)")
915
+ elif dep_status == "failed":
916
+ print(f" WARNING: {dep_name} update failed: {dep.get('error', 'unknown')}")
906
917
  if choice.get("prompted"):
907
918
  print(f" Power policy: {runtime_power['format_power_policy_label'](choice.get('policy'))}")
908
919
  if power_result.get("message"):
@@ -203,6 +203,18 @@
203
203
  "max_catchup_age": 0,
204
204
  "run_on_boot": true,
205
205
  "run_on_wake": true
206
+ },
207
+ {
208
+ "id": "auto-update",
209
+ "script": "scripts/nexo-auto-update.py",
210
+ "schedule": {"hour": 2, "minute": 0},
211
+ "description": "Daily auto-update — Brain + runtime dependencies",
212
+ "core": true,
213
+ "recovery_policy": "catchup",
214
+ "idempotent": true,
215
+ "max_catchup_age": 172800,
216
+ "run_on_boot": true,
217
+ "run_on_wake": true
206
218
  }
207
219
  ]
208
220
  }
@@ -2,6 +2,7 @@ from __future__ import annotations
2
2
  """Update plugin — pull latest code, backup DBs, run migrations, verify."""
3
3
  import json
4
4
  import os
5
+ import re
5
6
  import shutil
6
7
  import sqlite3
7
8
  import subprocess
@@ -321,6 +322,237 @@ def _reinstall_pip_deps() -> str | None:
321
322
  return None
322
323
 
323
324
 
325
+ def _read_runtime_dependencies() -> list[dict]:
326
+ """Read runtimeDependencies from package.json."""
327
+ for candidate in (PACKAGE_JSON, NEXO_HOME / "package.json"):
328
+ try:
329
+ if candidate.is_file():
330
+ data = json.loads(candidate.read_text())
331
+ deps = data.get("runtimeDependencies")
332
+ if isinstance(deps, list):
333
+ return deps
334
+ except Exception:
335
+ continue
336
+ # Fallback: check the npm-installed package's package.json
337
+ npm_src = _find_npm_pkg_src()
338
+ if npm_src:
339
+ pkg = npm_src.parent / "package.json"
340
+ try:
341
+ if pkg.is_file():
342
+ data = json.loads(pkg.read_text())
343
+ deps = data.get("runtimeDependencies")
344
+ if isinstance(deps, list):
345
+ return deps
346
+ except Exception:
347
+ pass
348
+ return []
349
+
350
+
351
+ _VALID_NPM_NAME = re.compile(r'^(@[a-z0-9\-_.]+/)?[a-z0-9\-_.]+$')
352
+
353
+
354
+ def _validate_npm_name(name: str) -> bool:
355
+ """Validate that a package name follows npm naming conventions."""
356
+ return bool(name) and bool(_VALID_NPM_NAME.match(name)) and ".." not in name
357
+
358
+
359
+ def _get_npm_global_version(package_name: str) -> str | None:
360
+ """Return the currently installed global npm package version, or None."""
361
+ try:
362
+ result = subprocess.run(
363
+ ["npm", "list", "-g", package_name, "--json", "--depth=0"],
364
+ capture_output=True, text=True, timeout=15,
365
+ )
366
+ # npm list returns exit 1 with valid JSON for peer dep issues;
367
+ # always try to parse the output regardless of returncode.
368
+ if result.stdout.strip():
369
+ data = json.loads(result.stdout)
370
+ deps = data.get("dependencies", {})
371
+ info = deps.get(package_name)
372
+ if info and isinstance(info, dict):
373
+ return info.get("version")
374
+ except subprocess.TimeoutExpired:
375
+ pass
376
+ except FileNotFoundError:
377
+ pass # npm not installed
378
+ except Exception:
379
+ pass
380
+ return None
381
+
382
+
383
+ def _get_npm_registry_version(package_name: str) -> str | None:
384
+ """Return the latest version of a package from the npm registry."""
385
+ try:
386
+ result = subprocess.run(
387
+ ["npm", "view", package_name, "version"],
388
+ capture_output=True, text=True, timeout=15,
389
+ )
390
+ if result.returncode == 0 and result.stdout.strip():
391
+ return result.stdout.strip()
392
+ except subprocess.TimeoutExpired:
393
+ pass
394
+ except FileNotFoundError:
395
+ pass # npm not installed
396
+ except Exception:
397
+ pass
398
+ return None
399
+
400
+
401
+ def _update_runtime_dependencies(progress_fn=None) -> list[dict]:
402
+ """Update all declared runtimeDependencies. Returns a list of result dicts.
403
+
404
+ Each result dict contains:
405
+ name: package name
406
+ old_version: version before update (or None if not installed)
407
+ new_version: version after update (or None on failure)
408
+ status: "updated" | "already_latest" | "installed" | "failed" | "skipped"
409
+ error: error message (only when status == "failed")
410
+ """
411
+ deps = _read_runtime_dependencies()
412
+ if not deps:
413
+ return []
414
+
415
+ results = []
416
+ for dep in deps:
417
+ name = dep.get("name", "")
418
+ dep_type = dep.get("type", "")
419
+ optional = dep.get("optional", False)
420
+
421
+ if not name or dep_type != "npm-global":
422
+ results.append({
423
+ "name": name or "(unknown)",
424
+ "old_version": None,
425
+ "new_version": None,
426
+ "status": "skipped",
427
+ })
428
+ continue
429
+
430
+ if not _validate_npm_name(name):
431
+ results.append({
432
+ "name": name,
433
+ "old_version": None,
434
+ "new_version": None,
435
+ "status": "failed",
436
+ "error": f"invalid npm package name: {name!r}",
437
+ })
438
+ continue
439
+
440
+ _emit_progress(progress_fn, f"Checking runtime dependency: {name}...")
441
+
442
+ old_version = _get_npm_global_version(name)
443
+ latest_version = _get_npm_registry_version(name)
444
+
445
+ if old_version is None:
446
+ # Not installed
447
+ if optional:
448
+ results.append({
449
+ "name": name,
450
+ "old_version": None,
451
+ "new_version": None,
452
+ "status": "skipped",
453
+ })
454
+ continue
455
+ # Install it
456
+ _emit_progress(progress_fn, f"Installing {name}...")
457
+ try:
458
+ r = subprocess.run(
459
+ ["npm", "install", "-g", name],
460
+ capture_output=True, text=True, timeout=120,
461
+ )
462
+ if r.returncode == 0:
463
+ new_version = _get_npm_global_version(name)
464
+ results.append({
465
+ "name": name,
466
+ "old_version": None,
467
+ "new_version": new_version,
468
+ "status": "installed",
469
+ })
470
+ else:
471
+ results.append({
472
+ "name": name,
473
+ "old_version": None,
474
+ "new_version": None,
475
+ "status": "failed",
476
+ "error": r.stderr or r.stdout or "npm install failed",
477
+ })
478
+ except subprocess.TimeoutExpired:
479
+ results.append({
480
+ "name": name, "old_version": None, "new_version": None,
481
+ "status": "failed", "error": "npm install timed out (120s)",
482
+ })
483
+ except Exception as e:
484
+ results.append({
485
+ "name": name, "old_version": None, "new_version": None,
486
+ "status": "failed", "error": str(e),
487
+ })
488
+ continue
489
+
490
+ # Already installed — check if update needed
491
+ if latest_version and old_version == latest_version:
492
+ results.append({
493
+ "name": name,
494
+ "old_version": old_version,
495
+ "new_version": old_version,
496
+ "status": "already_latest",
497
+ })
498
+ continue
499
+
500
+ # Update
501
+ _emit_progress(progress_fn, f"Updating {name} {old_version} -> {latest_version or 'latest'}...")
502
+ try:
503
+ r = subprocess.run(
504
+ ["npm", "update", "-g", name],
505
+ capture_output=True, text=True, timeout=120,
506
+ )
507
+ if r.returncode == 0:
508
+ new_version = _get_npm_global_version(name) or latest_version
509
+ results.append({
510
+ "name": name,
511
+ "old_version": old_version,
512
+ "new_version": new_version,
513
+ "status": "updated" if new_version != old_version else "already_latest",
514
+ })
515
+ else:
516
+ results.append({
517
+ "name": name,
518
+ "old_version": old_version,
519
+ "new_version": old_version,
520
+ "status": "failed",
521
+ "error": r.stderr or r.stdout or "npm update failed",
522
+ })
523
+ except subprocess.TimeoutExpired:
524
+ results.append({
525
+ "name": name, "old_version": old_version, "new_version": old_version,
526
+ "status": "failed", "error": "npm update timed out (120s)",
527
+ })
528
+ except Exception as e:
529
+ results.append({
530
+ "name": name, "old_version": old_version, "new_version": old_version,
531
+ "status": "failed", "error": str(e),
532
+ })
533
+
534
+ return results
535
+
536
+
537
+ def _format_dep_results(dep_results: list[dict]) -> list[str]:
538
+ """Format runtime dependency results as human-readable lines."""
539
+ lines = []
540
+ for dep in dep_results:
541
+ name = dep.get("name", "")
542
+ status = dep.get("status", "")
543
+ old_v = dep.get("old_version")
544
+ new_v = dep.get("new_version")
545
+ if status == "updated":
546
+ lines.append(f" Dependencies: {name} {old_v} -> {new_v}")
547
+ elif status == "installed":
548
+ lines.append(f" Dependencies: {name} installed ({new_v})")
549
+ elif status == "already_latest":
550
+ lines.append(f" Dependencies: {name} {old_v} (latest)")
551
+ elif status == "failed":
552
+ lines.append(f" WARNING: {name} update failed: {dep.get('error', 'unknown')}")
553
+ return lines
554
+
555
+
324
556
  def _run_migrations() -> str | None:
325
557
  """Run init_db() to apply pending migrations. Returns error or None."""
326
558
  # In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
@@ -709,6 +941,13 @@ def _handle_packaged_update(progress_fn=None) -> str:
709
941
  except Exception as e:
710
942
  hook_sync_warning = f"{e}"
711
943
 
944
+ # Update runtime dependencies (best-effort, never aborts)
945
+ dep_results: list[dict] = []
946
+ try:
947
+ dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
948
+ except Exception:
949
+ pass # Non-critical
950
+
712
951
  client_sync_warning = None
713
952
  _emit_progress(progress_fn, "Refreshing shared client configs...")
714
953
  clients_ok, client_sync_error = _sync_packaged_clients()
@@ -774,6 +1013,7 @@ def _handle_packaged_update(progress_fn=None) -> str:
774
1013
  lines.append(f" WARNING: hook sync: {hook_sync_warning}")
775
1014
  if retired_runtime_files:
776
1015
  lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
1016
+ lines.extend(_format_dep_results(dep_results))
777
1017
  if not client_sync_warning:
778
1018
  lines.append(" Clients: configured client targets synced")
779
1019
  else:
@@ -907,7 +1147,16 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
907
1147
  except Exception as e:
908
1148
  pass # Non-critical, log in function
909
1149
 
910
- # Step 10: Sync shared client configs
1150
+ # Step 10: Update runtime dependencies (best-effort, never aborts)
1151
+ dep_results: list[dict] = []
1152
+ try:
1153
+ dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
1154
+ if dep_results:
1155
+ steps_done.append("runtime-deps")
1156
+ except Exception:
1157
+ pass # Non-critical
1158
+
1159
+ # Step 11: Sync shared client configs
911
1160
  try:
912
1161
  _emit_progress(progress_fn, "Refreshing shared client configs...")
913
1162
  from client_sync import sync_all_clients
@@ -947,8 +1196,12 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
947
1196
  pass # Non-critical, configs can be re-synced later
948
1197
 
949
1198
  # Build result
1199
+ dep_summary_lines = _format_dep_results(dep_results)
950
1200
  if pull_out == "Already up to date.":
951
- return f"Already up to date (v{old_version}). No changes pulled."
1201
+ msg = f"Already up to date (v{old_version}). No changes pulled."
1202
+ if dep_summary_lines:
1203
+ msg += "\n" + "\n".join(dep_summary_lines)
1204
+ return msg
952
1205
 
953
1206
  lines = ["UPDATE SUCCESSFUL"]
954
1207
  if version_changed:
@@ -967,6 +1220,7 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
967
1220
  lines.append(" Hooks: synced to NEXO_HOME")
968
1221
  if retired_runtime_files:
969
1222
  lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
1223
+ lines.extend(dep_summary_lines)
970
1224
  if "client-sync" in steps_done:
971
1225
  lines.append(" Clients: configured client targets synced")
972
1226
  lines.append("")
@@ -1,6 +1,151 @@
1
1
  #!/usr/bin/env python3
2
- """DEPRECATED: Updates are handled automatically by NEXO on startup."""
2
+ """
3
+ NEXO Auto-Update — Daily automatic update of Brain + runtime dependencies.
4
+
5
+ Runs once daily via LaunchAgent. Executes the same update flow as `nexo update`:
6
+ - Brain itself (git pull or npm update)
7
+ - Runtime dependencies declared in package.json runtimeDependencies
8
+
9
+ Zero interaction required. Logs results to NEXO_HOME/logs/auto-update.log.
10
+ Idempotent — safe to run multiple times.
11
+ """
12
+
13
+ import fcntl
14
+ import json
15
+ import os
16
+ import subprocess
3
17
  import sys
4
- print("This script is deprecated. NEXO auto-updates on startup.")
5
- print("To update manually, use the nexo_update MCP tool.")
6
- sys.exit(0)
18
+ import time
19
+ from pathlib import Path
20
+
21
+ NEXO_HOME = Path(os.environ.get("NEXO_HOME", str(Path.home() / ".nexo")))
22
+ _script_dir = Path(__file__).resolve().parent
23
+ _repo_src = _script_dir.parent
24
+ NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(_repo_src) if (_repo_src / "server.py").exists() else str(NEXO_HOME)))
25
+ if str(NEXO_CODE) not in sys.path:
26
+ sys.path.insert(0, str(NEXO_CODE))
27
+
28
+ LOG_DIR = NEXO_HOME / "logs"
29
+ LOG_FILE = LOG_DIR / "auto-update.log"
30
+ LOCK_FILE = NEXO_HOME / "data" / "auto-update.lock"
31
+ MAX_LOG_SIZE = 512 * 1024 # 512 KB
32
+
33
+
34
+ def _log(msg: str):
35
+ ts = time.strftime("%Y-%m-%d %H:%M:%S")
36
+ line = f"[{ts}] {msg}\n"
37
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
38
+ # Rotate if too large
39
+ if LOG_FILE.exists() and LOG_FILE.stat().st_size > MAX_LOG_SIZE:
40
+ rotated = LOG_FILE.with_suffix(".log.1")
41
+ try:
42
+ LOG_FILE.rename(rotated)
43
+ except Exception:
44
+ pass
45
+ with open(LOG_FILE, "a") as f:
46
+ f.write(line)
47
+
48
+
49
+ def _acquire_lock():
50
+ """Acquire an exclusive lock to prevent concurrent updates."""
51
+ LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
52
+ lock_fd = open(LOCK_FILE, "w")
53
+ try:
54
+ fcntl.flock(lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
55
+ return lock_fd
56
+ except (IOError, OSError):
57
+ lock_fd.close()
58
+ return None
59
+
60
+
61
+ def main():
62
+ lock_fd = _acquire_lock()
63
+ if lock_fd is None:
64
+ _log("Skipped: another auto-update is already running.")
65
+ return 0
66
+
67
+ try:
68
+ _log("Auto-update started.")
69
+
70
+ # Use `nexo update --json` via CLI for the full update flow
71
+ nexo_bin = NEXO_HOME / "bin" / "nexo"
72
+ if not nexo_bin.exists():
73
+ # Try the npm global bin
74
+ try:
75
+ result = subprocess.run(
76
+ ["which", "nexo"],
77
+ capture_output=True, text=True, timeout=5,
78
+ )
79
+ if result.returncode == 0 and result.stdout.strip():
80
+ nexo_bin = Path(result.stdout.strip())
81
+ except subprocess.TimeoutExpired:
82
+ pass
83
+ except Exception:
84
+ pass
85
+
86
+ if not nexo_bin.exists():
87
+ _log("ERROR: nexo CLI not found. Cannot auto-update.")
88
+ return 1
89
+
90
+ try:
91
+ result = subprocess.run(
92
+ [str(nexo_bin), "update", "--json"],
93
+ capture_output=True, text=True,
94
+ timeout=300, # 5 minutes max
95
+ env={**os.environ, "NEXO_HOME": str(NEXO_HOME), "NEXO_CODE": str(NEXO_CODE)},
96
+ )
97
+ except subprocess.TimeoutExpired:
98
+ _log("ERROR: Auto-update timed out after 300s.")
99
+ return 1
100
+
101
+ if result.returncode != 0:
102
+ _log(f"ERROR: nexo update failed (exit {result.returncode}): {result.stderr or result.stdout}")
103
+ return 1
104
+
105
+ # Parse and log results
106
+ try:
107
+ data = json.loads(result.stdout)
108
+ except (json.JSONDecodeError, ValueError):
109
+ _log(f"Update completed but output was not JSON: {result.stdout[:500]}")
110
+ return 0
111
+
112
+ # Log Brain update status
113
+ mode = data.get("mode", "unknown")
114
+ if "Already up to date" in str(data.get("message", "")):
115
+ _log(f"Brain: already up to date ({mode} mode).")
116
+ else:
117
+ version_info = ""
118
+ if data.get("version"):
119
+ version_info = f" v{data['version']}"
120
+ _log(f"Brain: updated{version_info} ({mode} mode).")
121
+
122
+ # Log runtime dependency results
123
+ deps = data.get("runtime_dependencies") or []
124
+ for dep in deps:
125
+ name = dep.get("name", "")
126
+ status = dep.get("status", "")
127
+ if status == "updated":
128
+ _log(f"Dependency: {name} {dep.get('old_version')} -> {dep.get('new_version')}")
129
+ elif status == "installed":
130
+ _log(f"Dependency: {name} installed ({dep.get('new_version')})")
131
+ elif status == "already_latest":
132
+ _log(f"Dependency: {name} {dep.get('old_version')} (latest)")
133
+ elif status == "failed":
134
+ _log(f"Dependency WARNING: {name} failed: {dep.get('error', 'unknown')}")
135
+
136
+ _log("Auto-update completed successfully.")
137
+ return 0
138
+
139
+ except Exception as e:
140
+ _log(f"ERROR: Unexpected error: {e}")
141
+ return 1
142
+ finally:
143
+ try:
144
+ fcntl.flock(lock_fd, fcntl.LOCK_UN)
145
+ lock_fd.close()
146
+ except Exception:
147
+ pass
148
+
149
+
150
+ if __name__ == "__main__":
151
+ sys.exit(main())