nexo-brain 5.5.5 → 5.5.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.5.5",
3
+ "version": "5.5.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,7 +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.5.5` is the current packaged-runtime line: data-loss guardrails + automatic self-heal. The updater now refuses to capture an already-wiped `nexo.db` into a `pre-update-*` snapshot (validated `sqlite3.backup` + pre-flight wipe guard + post-migration row-count gate), and an auto-heal restores `data/nexo.db` from the newest hourly backup on the next server boot when a wipe is detected. New `nexo recover` CLI + `nexo_recover` MCP tool.
21
+ Version `5.5.6` is the current packaged-runtime line: same-day follow-up to v5.5.5 that adds in-process rate-limits to `nexo_backup_now` (30 s), `nexo_backup_restore` (60 s), and `export_user_bundle` (120 s) so a runaway MCP client can no longer hammer `sqlite3.Connection.backup()` from a tool-use loop closing the cause of the 2026-04-16 incident the same day v5.5.5 closed its consequences.
22
+
23
+ Previously in `5.5.5`: data-loss guardrails + automatic self-heal. The updater now refuses to capture an already-wiped `nexo.db` into a `pre-update-*` snapshot (validated `sqlite3.backup` + pre-flight wipe guard + post-migration row-count gate), and an auto-heal restores `data/nexo.db` from the newest hourly backup on the next server boot when a wipe is detected. New `nexo recover` CLI + `nexo_recover` MCP tool.
22
24
 
23
25
  Previously in `5.5.4`: Deep Sleep no longer blocks on unparseable sessions — reduced retries, added a JSON escape hatch, and unified the automation subprocess timeout to 3h across all scripts via a single shared constant.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.5.5",
3
+ "version": "5.5.6",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain \u2014 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",
@@ -1,8 +1,20 @@
1
- """Backup plugin — hourly SQLite backups with 7-day retention."""
1
+ """Backup plugin — hourly SQLite backups with 7-day retention.
2
+
3
+ v5.5.6: all three tools are rate-limited in-process so that a runaway MCP
4
+ client (tool-use loop in Claude Code, buggy Desktop handler, etc.) cannot
5
+ hammer ``sqlite3.Connection.backup()`` hundreds of times in minutes. The
6
+ v5.5.4 incident where an external loop caused ~8.5 GB of file-backed writes
7
+ in 37 minutes and corrupted nexo.db when the OS finally killed the process
8
+ is the exact scenario this limit prevents at the tool boundary — in addition
9
+ to the v5.5.5 self-heal that recovers from that class of wipe.
10
+ """
11
+ import glob
2
12
  import os
3
13
  import shutil
14
+ import sqlite3
15
+ import threading
4
16
  import time
5
- import glob
17
+
6
18
  from db import get_db
7
19
 
8
20
  NEXO_HOME = os.environ.get("NEXO_HOME", os.path.expanduser("~/.nexo"))
@@ -11,15 +23,63 @@ BACKUP_DIR = os.path.join(NEXO_HOME, "backups")
11
23
 
12
24
  RETENTION_DAYS = 7
13
25
 
26
+ # ── Rate limits (v5.5.6) ────────────────────────────────────────────
27
+ # Minimum seconds between successive calls to each destructive/expensive
28
+ # backup tool. Overridable per-tool via env var for tests or deliberate
29
+ # recovery scenarios (NEXO_BACKUP_MIN_INTERVAL_SECS, etc.).
30
+ BACKUP_NOW_MIN_INTERVAL_SECS = int(
31
+ os.environ.get("NEXO_BACKUP_MIN_INTERVAL_SECS", "30")
32
+ )
33
+ BACKUP_RESTORE_MIN_INTERVAL_SECS = int(
34
+ os.environ.get("NEXO_BACKUP_RESTORE_MIN_INTERVAL_SECS", "60")
35
+ )
36
+
37
+ _rate_limit_lock = threading.Lock()
38
+ _last_call_ts: dict[str, float] = {
39
+ "backup_now": 0.0,
40
+ "backup_restore": 0.0,
41
+ }
42
+
43
+
44
+ def _check_rate_limit(tool: str, min_interval: int) -> str | None:
45
+ """Return a rate-limit error string if the tool is called too soon, else None."""
46
+ now = time.time()
47
+ with _rate_limit_lock:
48
+ last = _last_call_ts.get(tool, 0.0)
49
+ elapsed = now - last
50
+ if last > 0 and elapsed < min_interval:
51
+ remaining = int(min_interval - elapsed)
52
+ return (
53
+ f"Rate-limited: {tool} called {int(elapsed)}s ago "
54
+ f"(min {min_interval}s between calls). Wait {remaining}s. "
55
+ "If you are seeing this message repeatedly, a client may be stuck in a "
56
+ "tool-use loop — check NEXO transcripts and kill the runaway session."
57
+ )
58
+ _last_call_ts[tool] = now
59
+ return None
60
+
61
+
62
+ def _reset_rate_limit_state_for_tests() -> None:
63
+ """Test hook: clear all tracked call timestamps."""
64
+ with _rate_limit_lock:
65
+ for key in _last_call_ts:
66
+ _last_call_ts[key] = 0.0
67
+
14
68
 
15
69
  def handle_backup_now() -> str:
16
- """Create an immediate backup of the NEXO database."""
70
+ """Create an immediate backup of the NEXO database.
71
+
72
+ Rate-limited to one call every BACKUP_NOW_MIN_INTERVAL_SECS (default 30 s).
73
+ """
74
+ err = _check_rate_limit("backup_now", BACKUP_NOW_MIN_INTERVAL_SECS)
75
+ if err is not None:
76
+ return err
77
+
17
78
  os.makedirs(BACKUP_DIR, exist_ok=True)
18
79
  timestamp = time.strftime("%Y-%m-%d-%H%M")
19
80
  dest = os.path.join(BACKUP_DIR, f"nexo-{timestamp}.db")
20
81
 
21
82
  # Use SQLite backup API for consistency
22
- import sqlite3
23
83
  src_conn = sqlite3.connect(DB_PATH)
24
84
  try:
25
85
  dst_conn = sqlite3.connect(dest)
@@ -56,16 +116,23 @@ def handle_backup_list() -> str:
56
116
  def handle_backup_restore(filename: str) -> str:
57
117
  """Restore database from a backup file. DESTRUCTIVE — replaces current DB.
58
118
 
119
+ Rate-limited to one call every BACKUP_RESTORE_MIN_INTERVAL_SECS (default
120
+ 60 s). A client hammering restore in a loop is the exact shape of the
121
+ v5.5.4 incident.
122
+
59
123
  Args:
60
124
  filename: Backup filename (e.g., 'nexo-2026-03-11-1200.db')
61
125
  """
126
+ err = _check_rate_limit("backup_restore", BACKUP_RESTORE_MIN_INTERVAL_SECS)
127
+ if err is not None:
128
+ return err
129
+
62
130
  src = os.path.join(BACKUP_DIR, filename)
63
131
  if not os.path.isfile(src):
64
132
  return f"Backup not found: {filename}"
65
133
 
66
134
  # Create safety backup first
67
135
  safety = os.path.join(BACKUP_DIR, f"nexo-pre-restore-{time.strftime('%Y%m%d%H%M%S')}.db")
68
- import sqlite3
69
136
  src_conn = sqlite3.connect(DB_PATH)
70
137
  try:
71
138
  dst_conn = sqlite3.connect(safety)
@@ -8,6 +8,8 @@ import shutil
8
8
  import sqlite3
9
9
  import tarfile
10
10
  import tempfile
11
+ import threading
12
+ import time
11
13
  from datetime import datetime, timezone
12
14
  from pathlib import Path
13
15
 
@@ -23,6 +25,36 @@ IGNORED_FILENAMES = {".DS_Store"}
23
25
  IGNORED_DIRS = {"__pycache__"}
24
26
  IGNORED_SUFFIXES = {".pyc", ".pyo"}
25
27
 
28
+ # v5.5.6: rate-limit the whole-bundle export so a runaway MCP client cannot
29
+ # loop this tool. Each export snapshots the entire NEXO state through
30
+ # sqlite3.Connection.backup() plus a tree copy — in the v5.5.4 incident a
31
+ # similar loop wrote 8.5 GB in 37 minutes. Overridable for tests / deliberate
32
+ # batch exports via NEXO_EXPORT_MIN_INTERVAL_SECS.
33
+ EXPORT_MIN_INTERVAL_SECS = int(os.environ.get("NEXO_EXPORT_MIN_INTERVAL_SECS", "120"))
34
+ _export_rate_lock = threading.Lock()
35
+ _export_last_call_ts = [0.0]
36
+
37
+
38
+ def _check_export_rate_limit() -> str | None:
39
+ now = time.time()
40
+ with _export_rate_lock:
41
+ last = _export_last_call_ts[0]
42
+ elapsed = now - last
43
+ if last > 0 and elapsed < EXPORT_MIN_INTERVAL_SECS:
44
+ remaining = int(EXPORT_MIN_INTERVAL_SECS - elapsed)
45
+ return (
46
+ f"Rate-limited: export_user_bundle called {int(elapsed)}s ago "
47
+ f"(min {EXPORT_MIN_INTERVAL_SECS}s between calls). Wait {remaining}s. "
48
+ "If you see this repeatedly, a client may be stuck in a tool-use loop."
49
+ )
50
+ _export_last_call_ts[0] = now
51
+ return None
52
+
53
+
54
+ def _reset_export_rate_limit_state_for_tests() -> None:
55
+ with _export_rate_lock:
56
+ _export_last_call_ts[0] = 0.0
57
+
26
58
 
27
59
  def _now_stamp() -> str:
28
60
  return datetime.now(timezone.utc).strftime("%Y%m%d-%H%M%S")
@@ -137,6 +169,9 @@ def _load_personal_scripts() -> tuple[list[dict], list[dict]]:
137
169
 
138
170
 
139
171
  def export_user_bundle(output_path: str = "") -> dict:
172
+ err = _check_export_rate_limit()
173
+ if err is not None:
174
+ return {"ok": False, "error": err, "rate_limited": True}
140
175
  output = Path(output_path).expanduser() if output_path.strip() else (EXPORTS_DIR / f"nexo-user-data-{_now_stamp()}.tar.gz")
141
176
  output.parent.mkdir(parents=True, exist_ok=True)
142
177
  STAGING_DIR.mkdir(parents=True, exist_ok=True)