nexo-brain 5.5.3 → 5.5.5

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.3",
3
+ "version": "5.5.5",
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,11 @@
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.3` is the current packaged-runtime line: CLAUDE.md CORE now teaches the model to trust the Protocol Enforcer, so aligned backends stop rejecting heartbeat, diary, and checkpoint injections as suspected prompt injection.
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.
22
22
 
23
- Previously in `5.4.6`: runtime dependency management in `nexo update` + daily auto-update cron.
23
+ 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
+
25
+ Previously in `5.5.3`: CLAUDE.md CORE teaches the model to trust the Protocol Enforcer, so aligned backends stop rejecting heartbeat, diary, and checkpoint injections as suspected prompt injection.
24
26
 
25
27
  Start here:
26
28
  - [5-minute quickstart](docs/quickstart-5-minutes.md)
package/bin/nexo-brain.js CHANGED
@@ -185,6 +185,7 @@ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
185
185
  "agent_runner.py",
186
186
  "bootstrap_docs.py",
187
187
  "auto_update.py",
188
+ "db_guard.py",
188
189
  "tools_sessions.py",
189
190
  "tools_coordination.py",
190
191
  "tools_reminders.py",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "5.5.3",
3
+ "version": "5.5.5",
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",
@@ -679,6 +679,163 @@ def _rotate_auto_update_backups(prefix: str, keep: int = AUTO_UPDATE_BACKUP_KEEP
679
679
  return removed
680
680
 
681
681
 
682
+ SELF_HEAL_STATE_FILE = NEXO_HOME / "operations" / ".self-heal-state.json"
683
+ SELF_HEAL_COOLDOWN_SECONDS = 6 * 3600 # Never auto-heal twice within 6 h.
684
+
685
+
686
+ def _self_heal_if_wiped() -> dict | None:
687
+ """Detect a wiped nexo.db at server startup and restore from the newest
688
+ hourly backup without any user action required.
689
+
690
+ Guard conditions (ALL must be true to fire):
691
+ - ``NEXO_DISABLE_AUTO_HEAL`` env var is unset.
692
+ - ``data/nexo.db`` exists but looks wiped (empty critical tables, size
693
+ below the empty-schema threshold, or both).
694
+ - A hourly backup newer than 48 h exists AND contains >= 50 rows
695
+ across CRITICAL_TABLES.
696
+ - The self-heal cooldown has elapsed since the last successful heal.
697
+
698
+ On success, writes a marker to ``~/.nexo/operations/.self-heal-state.json``
699
+ and returns a report dict. Returns None when no heal happened (caller
700
+ treats that as "normal boot").
701
+ """
702
+ if os.environ.get("NEXO_DISABLE_AUTO_HEAL") == "1":
703
+ return None
704
+ try:
705
+ from db_guard import (
706
+ CRITICAL_TABLES,
707
+ HOURLY_BACKUP_MAX_AGE,
708
+ MIN_REFERENCE_ROWS,
709
+ db_looks_wiped,
710
+ db_row_counts,
711
+ find_latest_hourly_backup,
712
+ kill_nexo_mcp_servers,
713
+ safe_sqlite_backup,
714
+ validate_backup_matches_source,
715
+ )
716
+ except Exception as e:
717
+ _log(f"self-heal: db_guard import failed: {e}")
718
+ return None
719
+
720
+ primary = DATA_DIR / "nexo.db"
721
+ if not primary.is_file():
722
+ return None
723
+ if not db_looks_wiped(primary, CRITICAL_TABLES):
724
+ return None
725
+ reference = find_latest_hourly_backup(
726
+ NEXO_HOME / "backups",
727
+ max_age_seconds=HOURLY_BACKUP_MAX_AGE,
728
+ )
729
+ if reference is None:
730
+ _log("self-heal: nexo.db looks wiped but no usable hourly backup found — skipping.")
731
+ return {
732
+ "action": "skipped",
733
+ "reason": "no_usable_hourly_backup",
734
+ "primary_db": str(primary),
735
+ }
736
+ ref_counts = db_row_counts(reference, CRITICAL_TABLES)
737
+ ref_total = sum(v for v in ref_counts.values() if isinstance(v, int))
738
+ if ref_total < MIN_REFERENCE_ROWS:
739
+ _log(f"self-heal: reference backup {reference.name} has {ref_total} rows, below floor {MIN_REFERENCE_ROWS}")
740
+ return {
741
+ "action": "skipped",
742
+ "reason": "reference_below_floor",
743
+ "reference": str(reference),
744
+ "reference_rows": ref_total,
745
+ }
746
+
747
+ # Cooldown: don't loop-heal.
748
+ try:
749
+ if SELF_HEAL_STATE_FILE.is_file():
750
+ last = json.loads(SELF_HEAL_STATE_FILE.read_text())
751
+ last_ts = float(last.get("last_heal_ts", 0))
752
+ if time.time() - last_ts < SELF_HEAL_COOLDOWN_SECONDS:
753
+ _log(
754
+ f"self-heal: cooldown active "
755
+ f"({(time.time() - last_ts) / 60:.0f} min ago < "
756
+ f"{SELF_HEAL_COOLDOWN_SECONDS // 60} min) — skipping."
757
+ )
758
+ return {"action": "skipped", "reason": "cooldown"}
759
+ except Exception:
760
+ pass
761
+
762
+ _log(
763
+ "self-heal: detected wiped nexo.db "
764
+ f"(reference={reference.name}, {ref_total} critical rows). Restoring..."
765
+ )
766
+
767
+ # Kill any live MCP servers so they cannot overwrite the restored DB.
768
+ kill_report = kill_nexo_mcp_servers(dry_run=False)
769
+ if kill_report.get("terminated"):
770
+ _log(f"self-heal: terminated {kill_report['terminated']} live MCP server(s).")
771
+ time.sleep(0.5)
772
+
773
+ # Snapshot the current (wiped) state so the heal is reversible.
774
+ pre_heal_dir = NEXO_HOME / "backups" / f"pre-heal-{time.strftime('%Y-%m-%d-%H%M%S')}"
775
+ try:
776
+ import shutil as _shutil
777
+ pre_heal_dir.mkdir(parents=True, exist_ok=True)
778
+ for suffix in ("", "-wal", "-shm"):
779
+ sidecar = primary.parent / f"{primary.name}{suffix}"
780
+ if sidecar.exists():
781
+ _shutil.copy2(str(sidecar), str(pre_heal_dir / sidecar.name))
782
+ except Exception as e:
783
+ _log(f"self-heal: pre-heal snapshot warning: {e}")
784
+
785
+ # Clear stale WAL/SHM before the restore so the new DB starts clean.
786
+ for suffix in ("-wal", "-shm"):
787
+ sidecar = primary.parent / f"{primary.name}{suffix}"
788
+ if sidecar.exists():
789
+ try:
790
+ sidecar.unlink()
791
+ except Exception as e:
792
+ _log(f"self-heal: could not remove {sidecar.name}: {e}")
793
+
794
+ ok, err = safe_sqlite_backup(reference, primary)
795
+ if not ok:
796
+ _log(f"self-heal: restore copy failed: {err}")
797
+ return {
798
+ "action": "failed",
799
+ "reason": "restore_copy_failed",
800
+ "error": err,
801
+ "reference": str(reference),
802
+ "pre_heal_dir": str(pre_heal_dir),
803
+ }
804
+ valid, valid_err = validate_backup_matches_source(reference, primary, CRITICAL_TABLES)
805
+ if not valid:
806
+ _log(f"self-heal: post-restore validation failed: {valid_err}")
807
+ return {
808
+ "action": "failed",
809
+ "reason": "validation_failed",
810
+ "error": valid_err,
811
+ "reference": str(reference),
812
+ "pre_heal_dir": str(pre_heal_dir),
813
+ }
814
+
815
+ final_counts = db_row_counts(primary, CRITICAL_TABLES)
816
+ final_total = sum(v for v in final_counts.values() if isinstance(v, int))
817
+ _log(f"self-heal: restored {final_total} critical rows from {reference.name}.")
818
+ try:
819
+ SELF_HEAL_STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
820
+ SELF_HEAL_STATE_FILE.write_text(json.dumps({
821
+ "last_heal_ts": time.time(),
822
+ "reference": str(reference),
823
+ "critical_rows_restored": final_total,
824
+ "pre_heal_dir": str(pre_heal_dir),
825
+ }))
826
+ except Exception as e:
827
+ _log(f"self-heal: state write warning: {e}")
828
+
829
+ return {
830
+ "action": "restored",
831
+ "reference": str(reference),
832
+ "reference_rows": ref_total,
833
+ "restored_rows": final_total,
834
+ "pre_heal_dir": str(pre_heal_dir),
835
+ "terminated_servers": kill_report.get("terminated", 0),
836
+ }
837
+
838
+
682
839
  def _backup_dbs() -> str | None:
683
840
  """Snapshot all .db files before migration. Returns backup dir or None."""
684
841
  import sqlite3
@@ -1384,6 +1541,7 @@ def _auto_update_check_locked() -> dict:
1384
1541
  "client_bootstrap_updates": [],
1385
1542
  "migrations": [],
1386
1543
  "db_migrations": 0,
1544
+ "self_heal": None,
1387
1545
  "skipped_reason": None,
1388
1546
  "error": None,
1389
1547
  }
@@ -1398,6 +1556,17 @@ def _auto_update_check_locked() -> dict:
1398
1556
  except Exception:
1399
1557
  pass # Default to enabled on any read error
1400
1558
 
1559
+ # ── Phase 0: Data-loss self-heal (v5.5.5+) ─────────────────────
1560
+ # Runs BEFORE any migration/backfill so a wiped DB is restored from the
1561
+ # hourly backup stream instead of being schema-migrated in place. Caps
1562
+ # itself via a state file so we never loop-heal on a legitimate reset.
1563
+ try:
1564
+ heal_report = _self_heal_if_wiped()
1565
+ if heal_report is not None:
1566
+ result["self_heal"] = heal_report
1567
+ except Exception as e:
1568
+ _log(f"self-heal check error (continuing): {e}")
1569
+
1401
1570
  # ── Phase 1: Local migrations (safe, no network) ────────────────
1402
1571
  # These ALWAYS run, regardless of cooldown, network state, or auto_update flag.
1403
1572
 
package/src/cli.py CHANGED
@@ -784,6 +784,25 @@ def _scripts_call(args):
784
784
  return 1
785
785
 
786
786
 
787
+ def _recover(args):
788
+ """Delegate to plugins.recover.cli_main so the logic lives in one place."""
789
+ from plugins.recover import cli_main as _recover_cli_main
790
+ argv: list[str] = []
791
+ if getattr(args, "source", None):
792
+ argv.extend(["--from", args.source])
793
+ if getattr(args, "list", False):
794
+ argv.append("--list")
795
+ if getattr(args, "dry_run", False):
796
+ argv.append("--dry-run")
797
+ if getattr(args, "force", False):
798
+ argv.append("--force")
799
+ if getattr(args, "yes", False):
800
+ argv.append("--yes")
801
+ if getattr(args, "json", False):
802
+ argv.append("--json")
803
+ return _recover_cli_main(argv)
804
+
805
+
787
806
  def _update(args):
788
807
  """Update the installed runtime.
789
808
 
@@ -1997,6 +2016,23 @@ def main():
1997
2016
  update_parser = sub.add_parser("update", help="Update installed runtime")
1998
2017
  update_parser.add_argument("--json", action="store_true", help="JSON output")
1999
2018
 
2019
+ # -- recover --
2020
+ recover_parser = sub.add_parser(
2021
+ "recover",
2022
+ help="Restore ~/.nexo/data/nexo.db from a hourly backup (data-loss recovery)",
2023
+ )
2024
+ recover_parser.add_argument("--from", dest="source", default=None,
2025
+ help="Explicit backup path (file or snapshot directory)")
2026
+ recover_parser.add_argument("--list", action="store_true",
2027
+ help="List available backups and exit")
2028
+ recover_parser.add_argument("--dry-run", action="store_true",
2029
+ help="Report the plan but do not touch the DB")
2030
+ recover_parser.add_argument("--force", action="store_true",
2031
+ help="Overwrite the current DB even if it does not look wiped")
2032
+ recover_parser.add_argument("--yes", action="store_true",
2033
+ help="Skip the interactive confirmation prompt")
2034
+ recover_parser.add_argument("--json", action="store_true", help="JSON output")
2035
+
2000
2036
  # -- clients --
2001
2037
  clients_parser = sub.add_parser("clients", help="Shared client config management")
2002
2038
  clients_sub = clients_parser.add_subparsers(dest="clients_command")
@@ -2190,6 +2226,8 @@ def main():
2190
2226
  return _import_bundle(args)
2191
2227
  elif args.command == "update":
2192
2228
  return _update(args)
2229
+ elif args.command == "recover":
2230
+ return _recover(args)
2193
2231
  elif args.command == "clients":
2194
2232
  if args.clients_command == "sync":
2195
2233
  return _clients_sync(args)
@@ -0,0 +1,9 @@
1
+ """Shared constants for NEXO scripts and runtime."""
2
+ from __future__ import annotations
3
+
4
+ # Safety-net timeout (seconds) for Claude CLI / automation subprocess calls.
5
+ # Applied across deep-sleep, synthesis, immune, evolution, catchup, and other
6
+ # headless scripts that invoke the configured automation backend. Three hours
7
+ # is long enough for legitimate long runs but short enough to prevent zombie
8
+ # subprocesses from blocking the pipeline indefinitely.
9
+ AUTOMATION_SUBPROCESS_TIMEOUT = 10800