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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.19",
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.19` is the current packaged-runtime line. Patch release over v7.20.18 — Local Memory status and long first-indexing runs stay stable during Desktop-managed updates; stale macOS Full Disk Access denials are cleared after a live access probe succeeds.
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.21",
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",
@@ -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 PROTECTED_BACKUP_TABLES:
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
- source_db = _find_primary_db_path()
377
- if source_db is None:
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
- report = _validate_db_backup(source_db, Path(backup_dir) / source_db.name)
381
- if not report["ok"]:
382
- details = ", ".join(
383
- f"{item['table']} {item['source']}->{item['backup']}"
384
- for item in report["regressions"]
385
- ) or "; ".join(report["errors"])
386
- _log(f"DB backup validation failed: {details}")
387
- return backup_dir, report
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=PROTECTED_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=PROTECTED_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, PROTECTED_TABLES)
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, PROTECTED_TABLES)
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, PROTECTED_TABLES)
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
- for candidate in [DATA_DIR / db_backup.name, NEXO_HOME / db_backup.name, SRC_DIR / db_backup.name]:
2094
- if candidate.is_file():
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.setdefault("NEXO_MCP_PLUGIN_MODE", getattr(args, "plugin_mode", None) or "none")
204
- env.setdefault("NEXO_MCP_RUN_STARTUP_PREFLIGHT", "0")
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 = ["nexo_startup", "nexo_heartbeat", "nexo_task_open", "nexo_guard_check"]
260
- missing = [name for name in required if name not in tool_names]
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 stored with the query audit row")
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
- # Everything an updater/recovery path must preserve. Keep CRITICAL_TABLES as a
89
- # public legacy name for older callers, but new guards should use
90
- # PROTECTED_TABLES so local indexing cannot regress unnoticed.
91
- PROTECTED_TABLES: tuple[str, ...] = CRITICAL_TABLES + LOCAL_CONTEXT_TABLES
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, ...] = PROTECTED_TABLES) -> dict[str, int | None]:
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
- counts = db_row_counts(p, tables)
257
- return _all_tables_empty_or_missing(counts)
258
- counts = db_row_counts(p, tables)
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 recoverable_wipe or recoverable_regression:
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