nexo-brain 5.4.5 → 5.4.6

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.6",
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.6` is the current packaged-runtime line: runtime dependency management in `nexo update` + daily auto-update cron. `nexo update` now manages external dependencies declared in `runtimeDependencies` (starting with Claude Code).
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.5`: test isolation for tree_hygiene module + fake venv to prevent CI timeout.
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.6",
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": 3, "minute": 45},
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
  }
@@ -321,6 +321,213 @@ def _reinstall_pip_deps() -> str | None:
321
321
  return None
322
322
 
323
323
 
324
+ def _read_runtime_dependencies() -> list[dict]:
325
+ """Read runtimeDependencies from package.json."""
326
+ for candidate in (PACKAGE_JSON, NEXO_HOME / "package.json"):
327
+ try:
328
+ if candidate.is_file():
329
+ data = json.loads(candidate.read_text())
330
+ deps = data.get("runtimeDependencies")
331
+ if isinstance(deps, list):
332
+ return deps
333
+ except Exception:
334
+ continue
335
+ # Fallback: check the npm-installed package's package.json
336
+ npm_src = _find_npm_pkg_src()
337
+ if npm_src:
338
+ pkg = npm_src.parent / "package.json"
339
+ try:
340
+ if pkg.is_file():
341
+ data = json.loads(pkg.read_text())
342
+ deps = data.get("runtimeDependencies")
343
+ if isinstance(deps, list):
344
+ return deps
345
+ except Exception:
346
+ pass
347
+ return []
348
+
349
+
350
+ def _get_npm_global_version(package_name: str) -> str | None:
351
+ """Return the currently installed global npm package version, or None."""
352
+ try:
353
+ result = subprocess.run(
354
+ ["npm", "list", "-g", package_name, "--json", "--depth=0"],
355
+ capture_output=True, text=True, timeout=15,
356
+ )
357
+ if result.returncode == 0:
358
+ data = json.loads(result.stdout)
359
+ deps = data.get("dependencies", {})
360
+ info = deps.get(package_name)
361
+ if info and isinstance(info, dict):
362
+ return info.get("version")
363
+ except subprocess.TimeoutExpired:
364
+ pass
365
+ except Exception:
366
+ pass
367
+ return None
368
+
369
+
370
+ def _get_npm_registry_version(package_name: str) -> str | None:
371
+ """Return the latest version of a package from the npm registry."""
372
+ try:
373
+ result = subprocess.run(
374
+ ["npm", "view", package_name, "version"],
375
+ capture_output=True, text=True, timeout=15,
376
+ )
377
+ if result.returncode == 0 and result.stdout.strip():
378
+ return result.stdout.strip()
379
+ except subprocess.TimeoutExpired:
380
+ pass
381
+ except Exception:
382
+ pass
383
+ return None
384
+
385
+
386
+ def _update_runtime_dependencies(progress_fn=None) -> list[dict]:
387
+ """Update all declared runtimeDependencies. Returns a list of result dicts.
388
+
389
+ Each result dict contains:
390
+ name: package name
391
+ old_version: version before update (or None if not installed)
392
+ new_version: version after update (or None on failure)
393
+ status: "updated" | "already_latest" | "installed" | "failed" | "skipped"
394
+ error: error message (only when status == "failed")
395
+ """
396
+ deps = _read_runtime_dependencies()
397
+ if not deps:
398
+ return []
399
+
400
+ results = []
401
+ for dep in deps:
402
+ name = dep.get("name", "")
403
+ dep_type = dep.get("type", "")
404
+ optional = dep.get("optional", True)
405
+
406
+ if not name or dep_type != "npm-global":
407
+ results.append({
408
+ "name": name or "(unknown)",
409
+ "old_version": None,
410
+ "new_version": None,
411
+ "status": "skipped",
412
+ })
413
+ continue
414
+
415
+ _emit_progress(progress_fn, f"Checking runtime dependency: {name}...")
416
+
417
+ old_version = _get_npm_global_version(name)
418
+ latest_version = _get_npm_registry_version(name)
419
+
420
+ if old_version is None:
421
+ # Not installed
422
+ if optional:
423
+ results.append({
424
+ "name": name,
425
+ "old_version": None,
426
+ "new_version": None,
427
+ "status": "skipped",
428
+ })
429
+ continue
430
+ # Install it
431
+ _emit_progress(progress_fn, f"Installing {name}...")
432
+ try:
433
+ r = subprocess.run(
434
+ ["npm", "install", "-g", name],
435
+ capture_output=True, text=True, timeout=120,
436
+ )
437
+ if r.returncode == 0:
438
+ new_version = _get_npm_global_version(name)
439
+ results.append({
440
+ "name": name,
441
+ "old_version": None,
442
+ "new_version": new_version,
443
+ "status": "installed",
444
+ })
445
+ else:
446
+ results.append({
447
+ "name": name,
448
+ "old_version": None,
449
+ "new_version": None,
450
+ "status": "failed",
451
+ "error": r.stderr or r.stdout or "npm install failed",
452
+ })
453
+ except subprocess.TimeoutExpired:
454
+ results.append({
455
+ "name": name, "old_version": None, "new_version": None,
456
+ "status": "failed", "error": "npm install timed out (120s)",
457
+ })
458
+ except Exception as e:
459
+ results.append({
460
+ "name": name, "old_version": None, "new_version": None,
461
+ "status": "failed", "error": str(e),
462
+ })
463
+ continue
464
+
465
+ # Already installed — check if update needed
466
+ if latest_version and old_version == latest_version:
467
+ results.append({
468
+ "name": name,
469
+ "old_version": old_version,
470
+ "new_version": old_version,
471
+ "status": "already_latest",
472
+ })
473
+ continue
474
+
475
+ # Update
476
+ _emit_progress(progress_fn, f"Updating {name} {old_version} -> {latest_version or 'latest'}...")
477
+ try:
478
+ r = subprocess.run(
479
+ ["npm", "update", "-g", name],
480
+ capture_output=True, text=True, timeout=120,
481
+ )
482
+ if r.returncode == 0:
483
+ new_version = _get_npm_global_version(name) or latest_version
484
+ results.append({
485
+ "name": name,
486
+ "old_version": old_version,
487
+ "new_version": new_version,
488
+ "status": "updated" if new_version != old_version else "already_latest",
489
+ })
490
+ else:
491
+ results.append({
492
+ "name": name,
493
+ "old_version": old_version,
494
+ "new_version": old_version,
495
+ "status": "failed",
496
+ "error": r.stderr or r.stdout or "npm update failed",
497
+ })
498
+ except subprocess.TimeoutExpired:
499
+ results.append({
500
+ "name": name, "old_version": old_version, "new_version": old_version,
501
+ "status": "failed", "error": "npm update timed out (120s)",
502
+ })
503
+ except Exception as e:
504
+ results.append({
505
+ "name": name, "old_version": old_version, "new_version": old_version,
506
+ "status": "failed", "error": str(e),
507
+ })
508
+
509
+ return results
510
+
511
+
512
+ def _format_dep_results(dep_results: list[dict]) -> list[str]:
513
+ """Format runtime dependency results as human-readable lines."""
514
+ lines = []
515
+ for dep in dep_results:
516
+ name = dep.get("name", "")
517
+ status = dep.get("status", "")
518
+ old_v = dep.get("old_version")
519
+ new_v = dep.get("new_version")
520
+ if status == "updated":
521
+ lines.append(f" Dependencies: {name} {old_v} -> {new_v}")
522
+ elif status == "installed":
523
+ lines.append(f" Dependencies: {name} installed ({new_v})")
524
+ elif status == "already_latest":
525
+ lines.append(f" Dependencies: {name} {old_v} (latest)")
526
+ elif status == "failed":
527
+ lines.append(f" WARNING: {name} update failed: {dep.get('error', 'unknown')}")
528
+ return lines
529
+
530
+
324
531
  def _run_migrations() -> str | None:
325
532
  """Run init_db() to apply pending migrations. Returns error or None."""
326
533
  # In packaged mode, db/ lives in NEXO_HOME; in dev mode, in SRC_DIR
@@ -709,6 +916,13 @@ def _handle_packaged_update(progress_fn=None) -> str:
709
916
  except Exception as e:
710
917
  hook_sync_warning = f"{e}"
711
918
 
919
+ # Update runtime dependencies (best-effort, never aborts)
920
+ dep_results: list[dict] = []
921
+ try:
922
+ dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
923
+ except Exception:
924
+ pass # Non-critical
925
+
712
926
  client_sync_warning = None
713
927
  _emit_progress(progress_fn, "Refreshing shared client configs...")
714
928
  clients_ok, client_sync_error = _sync_packaged_clients()
@@ -774,6 +988,7 @@ def _handle_packaged_update(progress_fn=None) -> str:
774
988
  lines.append(f" WARNING: hook sync: {hook_sync_warning}")
775
989
  if retired_runtime_files:
776
990
  lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
991
+ lines.extend(_format_dep_results(dep_results))
777
992
  if not client_sync_warning:
778
993
  lines.append(" Clients: configured client targets synced")
779
994
  else:
@@ -907,7 +1122,16 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
907
1122
  except Exception as e:
908
1123
  pass # Non-critical, log in function
909
1124
 
910
- # Step 10: Sync shared client configs
1125
+ # Step 10: Update runtime dependencies (best-effort, never aborts)
1126
+ dep_results: list[dict] = []
1127
+ try:
1128
+ dep_results = _update_runtime_dependencies(progress_fn=progress_fn)
1129
+ if dep_results:
1130
+ steps_done.append("runtime-deps")
1131
+ except Exception:
1132
+ pass # Non-critical
1133
+
1134
+ # Step 11: Sync shared client configs
911
1135
  try:
912
1136
  _emit_progress(progress_fn, "Refreshing shared client configs...")
913
1137
  from client_sync import sync_all_clients
@@ -947,8 +1171,12 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
947
1171
  pass # Non-critical, configs can be re-synced later
948
1172
 
949
1173
  # Build result
1174
+ dep_summary_lines = _format_dep_results(dep_results)
950
1175
  if pull_out == "Already up to date.":
951
- return f"Already up to date (v{old_version}). No changes pulled."
1176
+ msg = f"Already up to date (v{old_version}). No changes pulled."
1177
+ if dep_summary_lines:
1178
+ msg += "\n" + "\n".join(dep_summary_lines)
1179
+ return msg
952
1180
 
953
1181
  lines = ["UPDATE SUCCESSFUL"]
954
1182
  if version_changed:
@@ -967,6 +1195,7 @@ def handle_update(remote: str = "origin", branch: str = "main", progress_fn=None
967
1195
  lines.append(" Hooks: synced to NEXO_HOME")
968
1196
  if retired_runtime_files:
969
1197
  lines.append(f" Cleanup: removed {len(retired_runtime_files)} retired runtime file(s)")
1198
+ lines.extend(dep_summary_lines)
970
1199
  if "client-sync" in steps_done:
971
1200
  lines.append(" Clients: configured client targets synced")
972
1201
  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())