nexo-brain 7.20.21 → 7.20.23
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 +3 -1
- package/package.json +1 -1
- package/src/auto_update.py +49 -19
- package/src/cli.py +21 -5
- package/src/db_guard.py +27 -9
- package/src/doctor/providers/boot.py +91 -2
- package/src/interactive_db.py +59 -0
- package/src/local_context/api.py +274 -81
- package/src/local_context/db.py +336 -0
- package/src/local_context/logging.py +3 -4
- package/src/mcp_required_tools.py +31 -0
- package/src/plugins/episodic_memory.py +18 -0
- package/src/plugins/recover.py +7 -4
- package/src/plugins/skills.py +14 -3
- package/src/plugins/update.py +37 -12
- package/src/scripts/nexo-backup.sh +131 -7
- package/src/server.py +97 -7
- package/src/tools_reminders.py +37 -8
- package/src/tools_sessions.py +11 -19
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.23",
|
|
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 `7.20.
|
|
21
|
+
Version `7.20.23` is the current packaged-runtime line. Patch release over v7.20.22 — Local Memory status reads the real split sidecar database read-only, reports retryable keyed failures without false zeroes, and keeps Desktop Spanish/English copy localized.
|
|
22
|
+
|
|
23
|
+
Previously in `7.20.22`: patch release over v7.20.19 — Local Memory moved out of the main Brain database, MCP readiness verifies required tools, and split-aware Desktop backups validate the main DB and Local Memory sidecar separately.
|
|
22
24
|
|
|
23
25
|
Previously in `7.20.18`: patch release over v7.20.17 — Desktop-managed setup now preserves a completed onboarding flag when Brain is later invoked with the non-interactive `--skip` bootstrap path.
|
|
24
26
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.23",
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -89,6 +89,7 @@ LOCAL_CONTEXT_BACKUP_TABLES = (
|
|
|
89
89
|
"local_index_checkpoints",
|
|
90
90
|
"local_index_state",
|
|
91
91
|
"local_index_errors",
|
|
92
|
+
"local_index_logs",
|
|
92
93
|
"local_assets",
|
|
93
94
|
"local_asset_versions",
|
|
94
95
|
"local_chunks",
|
|
@@ -99,6 +100,14 @@ LOCAL_CONTEXT_BACKUP_TABLES = (
|
|
|
99
100
|
"local_index_dirs",
|
|
100
101
|
)
|
|
101
102
|
PROTECTED_BACKUP_TABLES = CRITICAL_BACKUP_TABLES + LOCAL_CONTEXT_BACKUP_TABLES
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _backup_validation_tables(db_file: Path) -> tuple[str, ...]:
|
|
106
|
+
if db_file.name == "local-context.db":
|
|
107
|
+
return LOCAL_CONTEXT_BACKUP_TABLES
|
|
108
|
+
return PROTECTED_BACKUP_TABLES
|
|
109
|
+
|
|
110
|
+
|
|
102
111
|
CLASSIFIER_INSTALL_TIMEOUT_SECONDS = 1800
|
|
103
112
|
CLASSIFIER_INSTALL_JOIN_SECONDS = 1500
|
|
104
113
|
CLASSIFIER_INSTALL_LOG = paths.logs_dir() / "classifier-install.log"
|
|
@@ -338,7 +347,7 @@ def _validate_db_backup(source_db: Path, backup_db: Path) -> dict:
|
|
|
338
347
|
report["errors"].append(f"backup db missing: {backup_db}")
|
|
339
348
|
return report
|
|
340
349
|
|
|
341
|
-
for table in
|
|
350
|
+
for table in _backup_validation_tables(source_db):
|
|
342
351
|
source_count = _critical_table_count(source_db, table)
|
|
343
352
|
backup_count = _critical_table_count(backup_db, table)
|
|
344
353
|
report["source_counts"][table] = source_count
|
|
@@ -373,18 +382,29 @@ def _create_validated_db_backup() -> tuple[str | None, dict | None]:
|
|
|
373
382
|
if not backup_dir:
|
|
374
383
|
return None, None
|
|
375
384
|
|
|
376
|
-
|
|
377
|
-
|
|
385
|
+
source_dbs: list[Path] = []
|
|
386
|
+
primary_db = _find_primary_db_path()
|
|
387
|
+
if primary_db is not None:
|
|
388
|
+
source_dbs.append(primary_db)
|
|
389
|
+
local_context_db = paths.memory_dir() / "local-context.db"
|
|
390
|
+
if local_context_db.is_file():
|
|
391
|
+
source_dbs.append(local_context_db)
|
|
392
|
+
if not source_dbs:
|
|
378
393
|
return backup_dir, None
|
|
379
394
|
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
395
|
+
reports = []
|
|
396
|
+
ok = True
|
|
397
|
+
for source_db in source_dbs:
|
|
398
|
+
report = _validate_db_backup(source_db, Path(backup_dir) / source_db.name)
|
|
399
|
+
reports.append(report)
|
|
400
|
+
if not report["ok"]:
|
|
401
|
+
ok = False
|
|
402
|
+
details = ", ".join(
|
|
403
|
+
f"{item['table']} {item['source']}->{item['backup']}"
|
|
404
|
+
for item in report["regressions"]
|
|
405
|
+
) or "; ".join(report["errors"])
|
|
406
|
+
_log(f"DB backup validation failed ({source_db.name}): {details}")
|
|
407
|
+
return backup_dir, {"ok": ok, "reports": reports}
|
|
388
408
|
|
|
389
409
|
|
|
390
410
|
def _read_last_check() -> dict:
|
|
@@ -1425,6 +1445,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1425
1445
|
from db_guard import (
|
|
1426
1446
|
CRITICAL_TABLES,
|
|
1427
1447
|
HOURLY_BACKUP_MAX_AGE,
|
|
1448
|
+
LOCAL_CONTEXT_TABLES,
|
|
1428
1449
|
MIN_REFERENCE_ROWS,
|
|
1429
1450
|
PROTECTED_TABLES,
|
|
1430
1451
|
db_looks_wiped,
|
|
@@ -1445,14 +1466,15 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1445
1466
|
return None
|
|
1446
1467
|
if not db_looks_wiped(primary, PROTECTED_TABLES):
|
|
1447
1468
|
return None
|
|
1469
|
+
recovery_tables = PROTECTED_TABLES + LOCAL_CONTEXT_TABLES
|
|
1448
1470
|
reference = find_best_hourly_backup(
|
|
1449
1471
|
paths.backups_dir(),
|
|
1450
1472
|
max_age_seconds=HOURLY_BACKUP_MAX_AGE,
|
|
1451
|
-
tables=
|
|
1473
|
+
tables=recovery_tables,
|
|
1452
1474
|
) or find_latest_hourly_backup(
|
|
1453
1475
|
paths.backups_dir(),
|
|
1454
1476
|
max_age_seconds=HOURLY_BACKUP_MAX_AGE,
|
|
1455
|
-
tables=
|
|
1477
|
+
tables=recovery_tables,
|
|
1456
1478
|
)
|
|
1457
1479
|
if reference is None:
|
|
1458
1480
|
_log("self-heal: nexo.db looks wiped but no usable hourly backup found — skipping.")
|
|
@@ -1461,7 +1483,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1461
1483
|
"reason": "no_usable_hourly_backup",
|
|
1462
1484
|
"primary_db": str(primary),
|
|
1463
1485
|
}
|
|
1464
|
-
ref_counts = db_row_counts(reference,
|
|
1486
|
+
ref_counts = db_row_counts(reference, recovery_tables)
|
|
1465
1487
|
ref_total = sum(v for v in ref_counts.values() if isinstance(v, int))
|
|
1466
1488
|
if ref_total < MIN_REFERENCE_ROWS:
|
|
1467
1489
|
_log(f"self-heal: reference backup {reference.name} has {ref_total} rows, below floor {MIN_REFERENCE_ROWS}")
|
|
@@ -1550,7 +1572,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1550
1572
|
"quiesce": quiesce_report,
|
|
1551
1573
|
"resume": resume_report,
|
|
1552
1574
|
}
|
|
1553
|
-
valid, valid_err = validate_backup_matches_source(reference, primary,
|
|
1575
|
+
valid, valid_err = validate_backup_matches_source(reference, primary, recovery_tables)
|
|
1554
1576
|
if not valid:
|
|
1555
1577
|
_log(f"self-heal: post-restore validation failed: {valid_err}")
|
|
1556
1578
|
resume_report = _resume_quiesced()
|
|
@@ -1564,7 +1586,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1564
1586
|
"resume": resume_report,
|
|
1565
1587
|
}
|
|
1566
1588
|
|
|
1567
|
-
final_counts = db_row_counts(primary,
|
|
1589
|
+
final_counts = db_row_counts(primary, recovery_tables)
|
|
1568
1590
|
final_total = sum(v for v in final_counts.values() if isinstance(v, int))
|
|
1569
1591
|
_log(f"self-heal: restored {final_total} critical rows from {reference.name}.")
|
|
1570
1592
|
resume_report = _resume_quiesced()
|
|
@@ -2047,6 +2069,9 @@ def _backup_dbs() -> str | None:
|
|
|
2047
2069
|
backup_dir = paths.backups_dir() / f"pre-autoupdate-{timestamp}"
|
|
2048
2070
|
|
|
2049
2071
|
db_files = list(DATA_DIR.glob("*.db")) if DATA_DIR.is_dir() else []
|
|
2072
|
+
local_context_db = paths.memory_dir() / "local-context.db"
|
|
2073
|
+
if local_context_db.is_file():
|
|
2074
|
+
db_files.append(local_context_db)
|
|
2050
2075
|
db_files += [f for f in NEXO_HOME.glob("*.db") if f.is_file()]
|
|
2051
2076
|
src_db = SRC_DIR / "nexo.db"
|
|
2052
2077
|
if src_db.is_file() and src_db not in db_files:
|
|
@@ -2090,11 +2115,16 @@ def _restore_dbs(backup_dir: str):
|
|
|
2090
2115
|
if not bdir.is_dir():
|
|
2091
2116
|
return
|
|
2092
2117
|
for db_backup in bdir.glob("*.db"):
|
|
2093
|
-
|
|
2094
|
-
|
|
2118
|
+
if db_backup.name == "local-context.db":
|
|
2119
|
+
candidates = [paths.memory_dir() / db_backup.name]
|
|
2120
|
+
else:
|
|
2121
|
+
candidates = [DATA_DIR / db_backup.name, NEXO_HOME / db_backup.name, SRC_DIR / db_backup.name]
|
|
2122
|
+
for candidate in candidates:
|
|
2123
|
+
if candidate.is_file() or db_backup.name == "local-context.db":
|
|
2095
2124
|
src_conn = None
|
|
2096
2125
|
dst_conn = None
|
|
2097
2126
|
try:
|
|
2127
|
+
candidate.parent.mkdir(parents=True, exist_ok=True)
|
|
2098
2128
|
src_conn = sqlite3.connect(str(db_backup))
|
|
2099
2129
|
dst_conn = sqlite3.connect(str(candidate))
|
|
2100
2130
|
src_conn.backup(dst_conn)
|
|
@@ -3958,7 +3988,7 @@ def _auto_update_check_locked() -> dict:
|
|
|
3958
3988
|
|
|
3959
3989
|
# Backfill runtime CLI modules for existing installs
|
|
3960
3990
|
try:
|
|
3961
|
-
for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py", "client_preferences.py", "claude_cli.py", "agent_runner.py", "bootstrap_docs.py"):
|
|
3991
|
+
for fname in ("cli.py", "script_registry.py", "skills_runtime.py", "cron_recovery.py", "client_preferences.py", "claude_cli.py", "agent_runner.py", "bootstrap_docs.py", "mcp_required_tools.py"):
|
|
3962
3992
|
src_file = SRC_DIR / fname
|
|
3963
3993
|
dest_file = NEXO_HOME / fname
|
|
3964
3994
|
if src_file.is_file() and (not dest_file.exists() or src_file.stat().st_mtime > dest_file.stat().st_mtime):
|
package/src/cli.py
CHANGED
|
@@ -52,6 +52,17 @@ from pathlib import Path
|
|
|
52
52
|
|
|
53
53
|
from runtime_home import export_resolved_nexo_home
|
|
54
54
|
from runtime_versioning import build_mcp_status, clear_restart_required_marker
|
|
55
|
+
try:
|
|
56
|
+
from mcp_required_tools import BOOTSTRAP_REQUIRED_MCP_TOOLS, missing_required_tools
|
|
57
|
+
except ModuleNotFoundError as exc:
|
|
58
|
+
if getattr(exc, "name", "") != "mcp_required_tools":
|
|
59
|
+
raise
|
|
60
|
+
# Older installed runtimes can be missing modules added after their CLI was
|
|
61
|
+
# copied. Keep `nexo update` bootable so the sync can repair the runtime.
|
|
62
|
+
BOOTSTRAP_REQUIRED_MCP_TOOLS: tuple[str, ...] = ()
|
|
63
|
+
|
|
64
|
+
def missing_required_tools(_tool_names):
|
|
65
|
+
return []
|
|
55
66
|
|
|
56
67
|
NEXO_HOME = export_resolved_nexo_home()
|
|
57
68
|
NEXO_CODE = Path(os.environ.get("NEXO_CODE", str(Path(__file__).resolve().parent)))
|
|
@@ -200,8 +211,11 @@ def _mcp_probe(args) -> int:
|
|
|
200
211
|
server_path = NEXO_CODE / "server.py"
|
|
201
212
|
env = os.environ.copy()
|
|
202
213
|
env["NEXO_MCP_PROBE"] = "1"
|
|
203
|
-
env
|
|
204
|
-
|
|
214
|
+
# Probe must be deterministic: inherited env from an older Desktop/runtime
|
|
215
|
+
# must not force plugin loading or startup preflight and turn this into a
|
|
216
|
+
# slow/false-negative health check.
|
|
217
|
+
env["NEXO_MCP_PLUGIN_MODE"] = getattr(args, "plugin_mode", None) or "none"
|
|
218
|
+
env["NEXO_MCP_RUN_STARTUP_PREFLIGHT"] = "0"
|
|
205
219
|
client = str(getattr(args, "client", "") or "").strip()
|
|
206
220
|
if client:
|
|
207
221
|
env["NEXO_MCP_CLIENT"] = client
|
|
@@ -256,8 +270,8 @@ def _mcp_probe(args) -> int:
|
|
|
256
270
|
for tool in tools
|
|
257
271
|
if isinstance(tool, dict) and tool.get("name")
|
|
258
272
|
]
|
|
259
|
-
required =
|
|
260
|
-
missing =
|
|
273
|
+
required = list(BOOTSTRAP_REQUIRED_MCP_TOOLS)
|
|
274
|
+
missing = missing_required_tools(tool_names)
|
|
261
275
|
ok = not missing and len(tool_names) > 0
|
|
262
276
|
payload = {
|
|
263
277
|
"ok": ok,
|
|
@@ -265,6 +279,8 @@ def _mcp_probe(args) -> int:
|
|
|
265
279
|
"probe_ok": ok,
|
|
266
280
|
"tools_available": len(tool_names) > 0,
|
|
267
281
|
"tool_count": len(tool_names),
|
|
282
|
+
"required_tools": required,
|
|
283
|
+
"required_tool_count": len(required),
|
|
268
284
|
"required_tools_present": not missing,
|
|
269
285
|
"missing_required_tools": missing,
|
|
270
286
|
"client": client,
|
|
@@ -3383,7 +3399,7 @@ def main():
|
|
|
3383
3399
|
|
|
3384
3400
|
local_context_query_p = local_context_sub.add_parser("query", help="Query local memory evidence")
|
|
3385
3401
|
local_context_query_p.add_argument("query", help="Question or search phrase")
|
|
3386
|
-
local_context_query_p.add_argument("--intent", default="answer", help="Intent label
|
|
3402
|
+
local_context_query_p.add_argument("--intent", default="answer", help="Intent label included with the query result")
|
|
3387
3403
|
local_context_query_p.add_argument("--limit", type=int, default=12, help="Maximum evidence rows")
|
|
3388
3404
|
local_context_query_p.add_argument("--current-context", default="", help="Optional current conversation/task context")
|
|
3389
3405
|
local_context_query_p.add_argument("--mode", choices=["compact", "full"], default="compact", help="Payload shape. Compact is safe for chat clients; full is for debugging.")
|
package/src/db_guard.py
CHANGED
|
@@ -75,6 +75,7 @@ LOCAL_CONTEXT_TABLES: tuple[str, ...] = (
|
|
|
75
75
|
"local_index_checkpoints",
|
|
76
76
|
"local_index_state",
|
|
77
77
|
"local_index_errors",
|
|
78
|
+
"local_index_logs",
|
|
78
79
|
"local_assets",
|
|
79
80
|
"local_asset_versions",
|
|
80
81
|
"local_chunks",
|
|
@@ -85,10 +86,16 @@ LOCAL_CONTEXT_TABLES: tuple[str, ...] = (
|
|
|
85
86
|
"local_index_dirs",
|
|
86
87
|
)
|
|
87
88
|
|
|
88
|
-
#
|
|
89
|
-
#
|
|
90
|
-
#
|
|
91
|
-
|
|
89
|
+
# Tables protected inside the operational Brain DB. Keep this core-only:
|
|
90
|
+
# callers that need local memory checks must request LOCAL_CONTEXT_TABLES or
|
|
91
|
+
# RECOVERY_TABLES explicitly so normal Brain wipe detection is not skewed by
|
|
92
|
+
# split local-memory databases.
|
|
93
|
+
PROTECTED_TABLES: tuple[str, ...] = CRITICAL_TABLES
|
|
94
|
+
|
|
95
|
+
# Recovery must still inspect legacy backups that carried local memory tables
|
|
96
|
+
# inside nexo.db. This wider set is safe for row counts and restore validation,
|
|
97
|
+
# but should not replace PROTECTED_TABLES in core wipe-diff callers.
|
|
98
|
+
RECOVERY_TABLES: tuple[str, ...] = CRITICAL_TABLES + LOCAL_CONTEXT_TABLES
|
|
92
99
|
|
|
93
100
|
# A reference backup must contain at least this many rows (summed across
|
|
94
101
|
# CRITICAL_TABLES) before we will treat it as "proof the user has real data".
|
|
@@ -209,7 +216,7 @@ def _table_count(conn: sqlite3.Connection, table: str) -> int | None:
|
|
|
209
216
|
return int(result[0]) if result is not None else 0
|
|
210
217
|
|
|
211
218
|
|
|
212
|
-
def db_row_counts(path: str | Path, tables: tuple[str, ...] =
|
|
219
|
+
def db_row_counts(path: str | Path, tables: tuple[str, ...] = RECOVERY_TABLES) -> dict[str, int | None]:
|
|
213
220
|
"""Return {table: count} for a SQLite DB. Missing DB / missing tables map to None."""
|
|
214
221
|
p = Path(path)
|
|
215
222
|
counts: dict[str, int | None] = {t: None for t in tables}
|
|
@@ -251,11 +258,22 @@ def db_looks_wiped(
|
|
|
251
258
|
size = p.stat().st_size
|
|
252
259
|
except OSError:
|
|
253
260
|
return False
|
|
261
|
+
counts = db_row_counts(p, tables)
|
|
254
262
|
if size <= EMPTY_DB_SIZE_BYTES:
|
|
255
263
|
# Small but not necessarily wiped — confirm via row counts.
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
264
|
+
return _counts_look_wiped(counts)
|
|
265
|
+
return _counts_look_wiped(counts)
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _counts_look_wiped(counts: dict[str, int | None]) -> bool:
|
|
269
|
+
"""Treat Brain table loss as a wipe even if legacy local tables remain."""
|
|
270
|
+
critical_present = {
|
|
271
|
+
table: counts.get(table)
|
|
272
|
+
for table in CRITICAL_TABLES
|
|
273
|
+
if counts.get(table) is not None
|
|
274
|
+
}
|
|
275
|
+
if critical_present:
|
|
276
|
+
return _all_tables_empty_or_missing(critical_present)
|
|
259
277
|
return _all_tables_empty_or_missing(counts)
|
|
260
278
|
|
|
261
279
|
|
|
@@ -478,7 +496,7 @@ def _backup_drift_is_safe(source_count: int, backup_count: int) -> bool:
|
|
|
478
496
|
|
|
479
497
|
|
|
480
498
|
def _quote_identifier(identifier: str) -> str:
|
|
481
|
-
if identifier not in PROTECTED_TABLES:
|
|
499
|
+
if identifier not in PROTECTED_TABLES and identifier not in LOCAL_CONTEXT_TABLES:
|
|
482
500
|
raise ValueError(f"refusing unsafe table identifier: {identifier!r}")
|
|
483
501
|
return '"' + identifier.replace('"', '""') + '"'
|
|
484
502
|
|
|
@@ -44,6 +44,7 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
44
44
|
from db_guard import (
|
|
45
45
|
CRITICAL_TABLES,
|
|
46
46
|
EMPTY_DB_SIZE_BYTES,
|
|
47
|
+
LOCAL_CONTEXT_TABLES,
|
|
47
48
|
MIN_REFERENCE_ROWS,
|
|
48
49
|
PROTECTED_TABLES,
|
|
49
50
|
db_looks_wiped,
|
|
@@ -98,6 +99,47 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
98
99
|
reference_counts = db_row_counts(reference, PROTECTED_TABLES) if reference else {}
|
|
99
100
|
reference_rows = sum(v for v in reference_counts.values() if isinstance(v, int))
|
|
100
101
|
protected_regression = diff_row_counts(db_path, reference, PROTECTED_TABLES) if reference else None
|
|
102
|
+
local_context_db = paths.memory_dir() / "local-context.db"
|
|
103
|
+
local_needs_reference = (
|
|
104
|
+
not local_context_db.is_file()
|
|
105
|
+
or db_looks_wiped(local_context_db, LOCAL_CONTEXT_TABLES)
|
|
106
|
+
)
|
|
107
|
+
local_reference = None
|
|
108
|
+
if local_needs_reference:
|
|
109
|
+
local_reference = (
|
|
110
|
+
find_best_hourly_backup(
|
|
111
|
+
paths.backups_dir(),
|
|
112
|
+
glob="local-context-*.db",
|
|
113
|
+
min_critical_rows=MIN_REFERENCE_ROWS,
|
|
114
|
+
tables=LOCAL_CONTEXT_TABLES,
|
|
115
|
+
)
|
|
116
|
+
or find_latest_hourly_backup(
|
|
117
|
+
paths.backups_dir(),
|
|
118
|
+
glob="local-context-*.db",
|
|
119
|
+
min_critical_rows=MIN_REFERENCE_ROWS,
|
|
120
|
+
tables=LOCAL_CONTEXT_TABLES,
|
|
121
|
+
)
|
|
122
|
+
or find_best_hourly_backup(
|
|
123
|
+
paths.backups_dir(),
|
|
124
|
+
glob="nexo-*.db",
|
|
125
|
+
min_critical_rows=MIN_REFERENCE_ROWS,
|
|
126
|
+
tables=LOCAL_CONTEXT_TABLES,
|
|
127
|
+
)
|
|
128
|
+
or find_latest_hourly_backup(
|
|
129
|
+
paths.backups_dir(),
|
|
130
|
+
glob="nexo-*.db",
|
|
131
|
+
min_critical_rows=MIN_REFERENCE_ROWS,
|
|
132
|
+
tables=LOCAL_CONTEXT_TABLES,
|
|
133
|
+
)
|
|
134
|
+
)
|
|
135
|
+
local_reference_counts = db_row_counts(local_reference, LOCAL_CONTEXT_TABLES) if local_reference else {}
|
|
136
|
+
local_reference_rows = sum(v for v in local_reference_counts.values() if isinstance(v, int))
|
|
137
|
+
local_regression = diff_row_counts(local_context_db, local_reference, LOCAL_CONTEXT_TABLES) if local_reference else None
|
|
138
|
+
recoverable_local_regression = bool(
|
|
139
|
+
local_reference
|
|
140
|
+
and local_regression
|
|
141
|
+
and local_regression.is_wipe()
|
|
142
|
+
)
|
|
101
143
|
recoverable_regression = bool(
|
|
102
144
|
reference
|
|
103
145
|
and protected_regression
|
|
@@ -121,7 +163,7 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
121
163
|
)
|
|
122
164
|
)
|
|
123
165
|
|
|
124
|
-
if quick_ok and not recoverable_wipe and not recoverable_regression:
|
|
166
|
+
if quick_ok and not recoverable_wipe and not recoverable_regression and not recoverable_local_regression:
|
|
125
167
|
if looks_wiped and not reference:
|
|
126
168
|
return DoctorCheck(
|
|
127
169
|
id="boot.db_integrity",
|
|
@@ -150,6 +192,11 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
150
192
|
evidence.append(f"Reference backup: {reference} ({reference_rows} protected rows)")
|
|
151
193
|
if protected_regression:
|
|
152
194
|
evidence.extend(protected_regression.summary_lines())
|
|
195
|
+
if local_reference:
|
|
196
|
+
evidence.append(f"Local memory DB: {local_context_db}")
|
|
197
|
+
evidence.append(f"Local memory reference: {local_reference} ({local_reference_rows} rows)")
|
|
198
|
+
if local_regression:
|
|
199
|
+
evidence.extend(["Local memory regression:", *local_regression.summary_lines()])
|
|
153
200
|
|
|
154
201
|
if fix and recoverable_regression:
|
|
155
202
|
report = restore_tables_from_backup(reference, db_path)
|
|
@@ -205,7 +252,49 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
205
252
|
repair_plan=["Run nexo recover --force --yes, then restart Desktop"],
|
|
206
253
|
)
|
|
207
254
|
|
|
208
|
-
if
|
|
255
|
+
if fix and recoverable_local_regression:
|
|
256
|
+
try:
|
|
257
|
+
local_context_db.parent.mkdir(parents=True, exist_ok=True)
|
|
258
|
+
if not local_context_db.exists():
|
|
259
|
+
sqlite3.connect(str(local_context_db)).close()
|
|
260
|
+
except Exception as exc:
|
|
261
|
+
return DoctorCheck(
|
|
262
|
+
id="boot.db_integrity",
|
|
263
|
+
tier="boot",
|
|
264
|
+
status="critical",
|
|
265
|
+
severity="error",
|
|
266
|
+
summary="Local memory sidecar repair failed",
|
|
267
|
+
evidence=evidence + [f"Cannot create local memory DB: {type(exc).__name__}: {exc}"],
|
|
268
|
+
repair_plan=["Close NEXO Desktop and run nexo doctor --tier boot --plane database_real --fix"],
|
|
269
|
+
)
|
|
270
|
+
report = restore_tables_from_backup(local_reference, local_context_db, tables=LOCAL_CONTEXT_TABLES)
|
|
271
|
+
if report.get("ok"):
|
|
272
|
+
restored = {
|
|
273
|
+
table: payload
|
|
274
|
+
for table, payload in (report.get("tables") or {}).items()
|
|
275
|
+
if isinstance(payload, dict) and payload.get("status") == "restored"
|
|
276
|
+
}
|
|
277
|
+
restored_rows = sum(int(payload.get("after") or 0) for payload in restored.values())
|
|
278
|
+
return DoctorCheck(
|
|
279
|
+
id="boot.db_integrity",
|
|
280
|
+
tier="boot",
|
|
281
|
+
status="healthy",
|
|
282
|
+
severity="info",
|
|
283
|
+
summary=f"Local memory sidecar restored from backup ({restored_rows} rows)",
|
|
284
|
+
evidence=evidence + [f"Restored local-memory sidecar tables: {len(restored)}"],
|
|
285
|
+
fixed=True,
|
|
286
|
+
)
|
|
287
|
+
return DoctorCheck(
|
|
288
|
+
id="boot.db_integrity",
|
|
289
|
+
tier="boot",
|
|
290
|
+
status="critical",
|
|
291
|
+
severity="error",
|
|
292
|
+
summary="Local memory sidecar repair failed",
|
|
293
|
+
evidence=evidence + [f"Restore errors: {report.get('errors') or []}"],
|
|
294
|
+
repair_plan=["Close NEXO Desktop and run nexo doctor --tier boot --plane database_real --fix"],
|
|
295
|
+
)
|
|
296
|
+
|
|
297
|
+
if recoverable_wipe or recoverable_regression or recoverable_local_regression:
|
|
209
298
|
return DoctorCheck(
|
|
210
299
|
id="boot.db_integrity",
|
|
211
300
|
tier="boot",
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""Helpers for interactive MCP tools that must not block on SQLite locks."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import sqlite3
|
|
7
|
+
from contextlib import contextmanager
|
|
8
|
+
|
|
9
|
+
from db import get_db
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def interactive_db_timeout_ms() -> int:
|
|
13
|
+
"""Return the max SQLite busy wait for user-facing MCP calls."""
|
|
14
|
+
try:
|
|
15
|
+
return max(50, min(int(os.environ.get("NEXO_MCP_DB_BUSY_TIMEOUT_MS", "250")), 10000))
|
|
16
|
+
except Exception:
|
|
17
|
+
return 250
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def set_interactive_db_timeout() -> None:
|
|
21
|
+
"""Make the shared connection fail fast when a background writer owns the DB."""
|
|
22
|
+
try:
|
|
23
|
+
get_db().execute(f"PRAGMA busy_timeout={interactive_db_timeout_ms()}")
|
|
24
|
+
except Exception:
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@contextmanager
|
|
29
|
+
def interactive_db_timeout():
|
|
30
|
+
"""Temporarily reduce SQLite busy wait for a user-facing MCP call."""
|
|
31
|
+
conn = None
|
|
32
|
+
previous = None
|
|
33
|
+
try:
|
|
34
|
+
conn = get_db()
|
|
35
|
+
row = conn.execute("PRAGMA busy_timeout").fetchone()
|
|
36
|
+
previous = int(row[0]) if row and row[0] is not None else None
|
|
37
|
+
conn.execute(f"PRAGMA busy_timeout={interactive_db_timeout_ms()}")
|
|
38
|
+
except Exception:
|
|
39
|
+
conn = None
|
|
40
|
+
try:
|
|
41
|
+
yield
|
|
42
|
+
finally:
|
|
43
|
+
if conn is not None and previous is not None:
|
|
44
|
+
try:
|
|
45
|
+
conn.execute(f"PRAGMA busy_timeout={previous}")
|
|
46
|
+
except Exception:
|
|
47
|
+
pass
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def is_db_busy(exc: BaseException) -> bool:
|
|
51
|
+
msg = str(exc).lower()
|
|
52
|
+
return (
|
|
53
|
+
isinstance(exc, sqlite3.OperationalError)
|
|
54
|
+
and (
|
|
55
|
+
"database is locked" in msg
|
|
56
|
+
or "database is busy" in msg
|
|
57
|
+
or "database table is locked" in msg
|
|
58
|
+
)
|
|
59
|
+
) or "database is locked" in msg or "database is busy" in msg
|