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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +4 -2
- package/bin/nexo-brain.js +1 -0
- package/package.json +1 -1
- package/src/auto_update.py +169 -0
- package/src/cli.py +38 -0
- package/src/constants.py +9 -0
- package/src/db_guard.py +449 -0
- package/src/plugins/recover.py +403 -0
- package/src/plugins/update.py +162 -16
- package/src/scripts/deep-sleep/extract.py +13 -4
- package/src/scripts/deep-sleep/synthesize.py +2 -1
- package/src/scripts/nexo-agent-run.py +2 -1
- package/src/scripts/nexo-catchup.py +4 -3
- package/src/scripts/nexo-daily-self-audit.py +2 -1
- package/src/scripts/nexo-evolution-run.py +2 -1
- package/src/scripts/nexo-immune.py +2 -1
- package/src/scripts/nexo-postmortem-consolidator.py +2 -1
- package/src/scripts/nexo-sleep.py +2 -1
- package/src/scripts/nexo-synthesis.py +2 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.5.
|
|
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.
|
|
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
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "5.5.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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)
|
package/src/constants.py
ADDED
|
@@ -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
|