nexo-brain 7.20.13 → 7.20.14
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 +13 -6
- package/src/db_guard.py +162 -5
- package/src/doctor/providers/boot.py +54 -8
- package/src/local_context/__init__.py +4 -0
- package/src/local_context/api.py +123 -11
- package/src/plugins/recover.py +18 -9
- package/src/plugins/update.py +72 -13
- package/src/scripts/backfill_task_owner.py +34 -0
- package/src/scripts/nexo-local-index.py +45 -12
- package/src/scripts/prune_runtime_backups.py +3 -3
- package/src/server.py +13 -4
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.14",
|
|
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.14` is the current packaged-runtime line. Patch release over v7.20.13 — Brain protects Local Memory during update/recovery paths, rotates runtime backup families to the latest 5 entries, keeps first-indexing status stable, and exposes bounded indexing speed profiles for Desktop.
|
|
22
|
+
|
|
23
|
+
Previously in `7.20.13`: patch release over v7.20.12 — Brain recovery now pauses all known DB writers before restoring `nexo.db`, and Doctor can repair the zero-byte/locked database state that made Desktop Local Memory show zero files.
|
|
22
24
|
|
|
23
25
|
Previously in `7.20.12`: patch release over v7.20.11 — Local Context now keeps the first index pass separate from live change tracking, persists the current indexing start time, caps compact context payloads for agents, and installs the Windows host scheduler needed to keep WSL indexing alive after reboots.
|
|
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.14",
|
|
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
|
@@ -1336,7 +1336,7 @@ def _reload_launch_agents_after_bump() -> dict:
|
|
|
1336
1336
|
return result
|
|
1337
1337
|
|
|
1338
1338
|
|
|
1339
|
-
AUTO_UPDATE_BACKUP_KEEP =
|
|
1339
|
+
AUTO_UPDATE_BACKUP_KEEP = 5
|
|
1340
1340
|
"""Maximum number of auto-update backups to keep per prefix.
|
|
1341
1341
|
|
|
1342
1342
|
Both `pre-autoupdate-*/` (DB snapshots) and `runtime-tree-*/` (code mirrors)
|
|
@@ -1409,8 +1409,10 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1409
1409
|
CRITICAL_TABLES,
|
|
1410
1410
|
HOURLY_BACKUP_MAX_AGE,
|
|
1411
1411
|
MIN_REFERENCE_ROWS,
|
|
1412
|
+
PROTECTED_TABLES,
|
|
1412
1413
|
db_looks_wiped,
|
|
1413
1414
|
db_row_counts,
|
|
1415
|
+
find_best_hourly_backup,
|
|
1414
1416
|
find_latest_hourly_backup,
|
|
1415
1417
|
quiesce_nexo_db_writers,
|
|
1416
1418
|
resume_nexo_launchagents,
|
|
@@ -1424,11 +1426,16 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1424
1426
|
primary = DATA_DIR / "nexo.db"
|
|
1425
1427
|
if not primary.is_file():
|
|
1426
1428
|
return None
|
|
1427
|
-
if not db_looks_wiped(primary,
|
|
1429
|
+
if not db_looks_wiped(primary, PROTECTED_TABLES):
|
|
1428
1430
|
return None
|
|
1429
|
-
reference =
|
|
1431
|
+
reference = find_best_hourly_backup(
|
|
1430
1432
|
paths.backups_dir(),
|
|
1431
1433
|
max_age_seconds=HOURLY_BACKUP_MAX_AGE,
|
|
1434
|
+
tables=PROTECTED_TABLES,
|
|
1435
|
+
) or find_latest_hourly_backup(
|
|
1436
|
+
paths.backups_dir(),
|
|
1437
|
+
max_age_seconds=HOURLY_BACKUP_MAX_AGE,
|
|
1438
|
+
tables=PROTECTED_TABLES,
|
|
1432
1439
|
)
|
|
1433
1440
|
if reference is None:
|
|
1434
1441
|
_log("self-heal: nexo.db looks wiped but no usable hourly backup found — skipping.")
|
|
@@ -1437,7 +1444,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1437
1444
|
"reason": "no_usable_hourly_backup",
|
|
1438
1445
|
"primary_db": str(primary),
|
|
1439
1446
|
}
|
|
1440
|
-
ref_counts = db_row_counts(reference,
|
|
1447
|
+
ref_counts = db_row_counts(reference, PROTECTED_TABLES)
|
|
1441
1448
|
ref_total = sum(v for v in ref_counts.values() if isinstance(v, int))
|
|
1442
1449
|
if ref_total < MIN_REFERENCE_ROWS:
|
|
1443
1450
|
_log(f"self-heal: reference backup {reference.name} has {ref_total} rows, below floor {MIN_REFERENCE_ROWS}")
|
|
@@ -1526,7 +1533,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1526
1533
|
"quiesce": quiesce_report,
|
|
1527
1534
|
"resume": resume_report,
|
|
1528
1535
|
}
|
|
1529
|
-
valid, valid_err = validate_backup_matches_source(reference, primary,
|
|
1536
|
+
valid, valid_err = validate_backup_matches_source(reference, primary, PROTECTED_TABLES)
|
|
1530
1537
|
if not valid:
|
|
1531
1538
|
_log(f"self-heal: post-restore validation failed: {valid_err}")
|
|
1532
1539
|
resume_report = _resume_quiesced()
|
|
@@ -1540,7 +1547,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1540
1547
|
"resume": resume_report,
|
|
1541
1548
|
}
|
|
1542
1549
|
|
|
1543
|
-
final_counts = db_row_counts(primary,
|
|
1550
|
+
final_counts = db_row_counts(primary, PROTECTED_TABLES)
|
|
1544
1551
|
final_total = sum(v for v in final_counts.values() if isinstance(v, int))
|
|
1545
1552
|
_log(f"self-heal: restored {final_total} critical rows from {reference.name}.")
|
|
1546
1553
|
resume_report = _resume_quiesced()
|
package/src/db_guard.py
CHANGED
|
@@ -17,15 +17,19 @@ Public surface (stable for use by plugins/update.py, plugins/recover.py,
|
|
|
17
17
|
auto_update.py):
|
|
18
18
|
|
|
19
19
|
CRITICAL_TABLES
|
|
20
|
+
LOCAL_CONTEXT_TABLES
|
|
21
|
+
PROTECTED_TABLES
|
|
20
22
|
WIPE_THRESHOLD_PCT
|
|
21
23
|
MIN_REFERENCE_ROWS
|
|
22
24
|
|
|
23
25
|
db_row_counts(path, tables) -> dict[str, int | None]
|
|
24
26
|
db_looks_wiped(path, tables, min_reference_rows) -> bool
|
|
25
27
|
find_latest_hourly_backup(backups_dir, max_age_seconds) -> Path | None
|
|
28
|
+
find_best_hourly_backup(backups_dir, max_age_seconds) -> Path | None
|
|
26
29
|
diff_row_counts(current, reference, tables) -> WipeReport
|
|
27
30
|
safe_sqlite_backup(source, dest) -> tuple[bool, str | None]
|
|
28
31
|
validate_backup_matches_source(source, dest, tables) -> tuple[bool, str | None]
|
|
32
|
+
restore_tables_from_backup(source, target, tables) -> dict
|
|
29
33
|
kill_nexo_mcp_servers(dry_run) -> dict
|
|
30
34
|
quiesce_nexo_db_writers(dry_run) -> dict
|
|
31
35
|
resume_nexo_launchagents(labels, dry_run) -> dict
|
|
@@ -61,6 +65,31 @@ CRITICAL_TABLES: tuple[str, ...] = (
|
|
|
61
65
|
"decisions",
|
|
62
66
|
)
|
|
63
67
|
|
|
68
|
+
# The local memory index is not a disposable cache. A full-disk first pass can
|
|
69
|
+
# take days, and losing these tables silently makes NEXO look "healthy" while
|
|
70
|
+
# the user's local memory has actually been reset.
|
|
71
|
+
LOCAL_CONTEXT_TABLES: tuple[str, ...] = (
|
|
72
|
+
"local_index_roots",
|
|
73
|
+
"local_index_exclusions",
|
|
74
|
+
"local_index_jobs",
|
|
75
|
+
"local_index_checkpoints",
|
|
76
|
+
"local_index_state",
|
|
77
|
+
"local_index_errors",
|
|
78
|
+
"local_assets",
|
|
79
|
+
"local_asset_versions",
|
|
80
|
+
"local_chunks",
|
|
81
|
+
"local_entities",
|
|
82
|
+
"local_relations",
|
|
83
|
+
"local_embeddings",
|
|
84
|
+
"local_context_queries",
|
|
85
|
+
"local_index_dirs",
|
|
86
|
+
)
|
|
87
|
+
|
|
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
|
|
92
|
+
|
|
64
93
|
# A reference backup must contain at least this many rows (summed across
|
|
65
94
|
# CRITICAL_TABLES) before we will treat it as "proof the user has real data".
|
|
66
95
|
# Otherwise we cannot distinguish a fresh install from a wipe.
|
|
@@ -180,7 +209,7 @@ def _table_count(conn: sqlite3.Connection, table: str) -> int | None:
|
|
|
180
209
|
return int(result[0]) if result is not None else 0
|
|
181
210
|
|
|
182
211
|
|
|
183
|
-
def db_row_counts(path: str | Path, tables: tuple[str, ...] =
|
|
212
|
+
def db_row_counts(path: str | Path, tables: tuple[str, ...] = PROTECTED_TABLES) -> dict[str, int | None]:
|
|
184
213
|
"""Return {table: count} for a SQLite DB. Missing DB / missing tables map to None."""
|
|
185
214
|
p = Path(path)
|
|
186
215
|
counts: dict[str, int | None] = {t: None for t in tables}
|
|
@@ -206,7 +235,7 @@ def db_row_counts(path: str | Path, tables: tuple[str, ...] = CRITICAL_TABLES) -
|
|
|
206
235
|
|
|
207
236
|
def db_looks_wiped(
|
|
208
237
|
path: str | Path,
|
|
209
|
-
tables: tuple[str, ...] =
|
|
238
|
+
tables: tuple[str, ...] = PROTECTED_TABLES,
|
|
210
239
|
min_reference_rows: int = MIN_REFERENCE_ROWS,
|
|
211
240
|
) -> bool:
|
|
212
241
|
"""Heuristic: the file exists AND either all critical tables exist with 0 rows,
|
|
@@ -247,6 +276,7 @@ def find_latest_hourly_backup(
|
|
|
247
276
|
max_age_seconds: int = HOURLY_BACKUP_MAX_AGE,
|
|
248
277
|
glob: str = HOURLY_BACKUP_GLOB,
|
|
249
278
|
min_critical_rows: int = 1,
|
|
279
|
+
tables: tuple[str, ...] = PROTECTED_TABLES,
|
|
250
280
|
) -> Path | None:
|
|
251
281
|
"""Return the newest hourly backup that contains at least ``min_critical_rows``
|
|
252
282
|
across CRITICAL_TABLES and is not older than ``max_age_seconds``.
|
|
@@ -281,19 +311,58 @@ def find_latest_hourly_backup(
|
|
|
281
311
|
# the row-count floor. A production NEXO_HOME can accumulate 40+ hourly
|
|
282
312
|
# backups, so opening every file would add seconds to the CLI startup.
|
|
283
313
|
for _, candidate in stat_candidates:
|
|
284
|
-
counts = db_row_counts(candidate)
|
|
314
|
+
counts = db_row_counts(candidate, tables)
|
|
285
315
|
total = sum(v for v in counts.values() if isinstance(v, int))
|
|
286
316
|
if total >= min_critical_rows:
|
|
287
317
|
return candidate
|
|
288
318
|
return None
|
|
289
319
|
|
|
290
320
|
|
|
321
|
+
def find_best_hourly_backup(
|
|
322
|
+
backups_dir: str | Path,
|
|
323
|
+
max_age_seconds: int = HOURLY_BACKUP_MAX_AGE,
|
|
324
|
+
glob: str = HOURLY_BACKUP_GLOB,
|
|
325
|
+
min_critical_rows: int = 1,
|
|
326
|
+
tables: tuple[str, ...] = PROTECTED_TABLES,
|
|
327
|
+
) -> Path | None:
|
|
328
|
+
"""Return the usable hourly backup with the most protected rows.
|
|
329
|
+
|
|
330
|
+
Newest is not always safest: if an update/reset starts reindexing from
|
|
331
|
+
scratch, the next hourly backups are recent but degraded. For local memory
|
|
332
|
+
recovery we prefer the richest backup and use mtime only as a tie-breaker.
|
|
333
|
+
"""
|
|
334
|
+
base = Path(backups_dir)
|
|
335
|
+
if not base.is_dir():
|
|
336
|
+
return None
|
|
337
|
+
now = time.time()
|
|
338
|
+
best: tuple[int, float, Path] | None = None
|
|
339
|
+
for entry in base.glob(glob):
|
|
340
|
+
if not entry.is_file():
|
|
341
|
+
continue
|
|
342
|
+
try:
|
|
343
|
+
stat = entry.stat()
|
|
344
|
+
except OSError:
|
|
345
|
+
continue
|
|
346
|
+
if now - stat.st_mtime > max_age_seconds:
|
|
347
|
+
continue
|
|
348
|
+
if stat.st_size <= EMPTY_DB_SIZE_BYTES:
|
|
349
|
+
continue
|
|
350
|
+
counts = db_row_counts(entry, tables)
|
|
351
|
+
total = sum(v for v in counts.values() if isinstance(v, int))
|
|
352
|
+
if total < min_critical_rows:
|
|
353
|
+
continue
|
|
354
|
+
candidate = (int(total), float(stat.st_mtime), entry)
|
|
355
|
+
if best is None or candidate[:2] > best[:2]:
|
|
356
|
+
best = candidate
|
|
357
|
+
return best[2] if best else None
|
|
358
|
+
|
|
359
|
+
|
|
291
360
|
# ── Diff & wipe detection ───────────────────────────────────────────────
|
|
292
361
|
|
|
293
362
|
def diff_row_counts(
|
|
294
363
|
current: str | Path,
|
|
295
364
|
reference: str | Path,
|
|
296
|
-
tables: tuple[str, ...] =
|
|
365
|
+
tables: tuple[str, ...] = PROTECTED_TABLES,
|
|
297
366
|
) -> WipeReport:
|
|
298
367
|
"""Compare row counts between two SQLite DBs and return a WipeReport."""
|
|
299
368
|
source_counts = db_row_counts(current, tables)
|
|
@@ -363,7 +432,7 @@ def safe_sqlite_backup(source: str | Path, dest: str | Path) -> tuple[bool, str
|
|
|
363
432
|
def validate_backup_matches_source(
|
|
364
433
|
source: str | Path,
|
|
365
434
|
dest: str | Path,
|
|
366
|
-
tables: tuple[str, ...] =
|
|
435
|
+
tables: tuple[str, ...] = PROTECTED_TABLES,
|
|
367
436
|
) -> tuple[bool, str | None]:
|
|
368
437
|
"""After a backup, verify that every critical table in the copy has at
|
|
369
438
|
least as many rows as the source — i.e. we did not lose data in transit.
|
|
@@ -393,6 +462,94 @@ def validate_backup_matches_source(
|
|
|
393
462
|
return True, None
|
|
394
463
|
|
|
395
464
|
|
|
465
|
+
def _quote_identifier(identifier: str) -> str:
|
|
466
|
+
if identifier not in PROTECTED_TABLES:
|
|
467
|
+
raise ValueError(f"refusing unsafe table identifier: {identifier!r}")
|
|
468
|
+
return '"' + identifier.replace('"', '""') + '"'
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def restore_tables_from_backup(
|
|
472
|
+
source: str | Path,
|
|
473
|
+
target: str | Path,
|
|
474
|
+
tables: tuple[str, ...] = LOCAL_CONTEXT_TABLES,
|
|
475
|
+
) -> dict:
|
|
476
|
+
"""Replace selected tables in ``target`` with the copy from ``source``.
|
|
477
|
+
|
|
478
|
+
This is intentionally table-scoped. It lets Doctor/repair recover days of
|
|
479
|
+
local indexing from a backup without rolling back newer conversations,
|
|
480
|
+
credentials, followups, or other Brain state created after that backup.
|
|
481
|
+
"""
|
|
482
|
+
src = Path(source)
|
|
483
|
+
dst = Path(target)
|
|
484
|
+
result: dict = {
|
|
485
|
+
"ok": False,
|
|
486
|
+
"source": str(src),
|
|
487
|
+
"target": str(dst),
|
|
488
|
+
"tables": {},
|
|
489
|
+
"errors": [],
|
|
490
|
+
}
|
|
491
|
+
if not src.is_file():
|
|
492
|
+
result["errors"].append(f"source missing: {src}")
|
|
493
|
+
return result
|
|
494
|
+
if not dst.is_file():
|
|
495
|
+
result["errors"].append(f"target missing: {dst}")
|
|
496
|
+
return result
|
|
497
|
+
|
|
498
|
+
conn = None
|
|
499
|
+
try:
|
|
500
|
+
conn = sqlite3.connect(str(dst), timeout=30)
|
|
501
|
+
conn.execute("PRAGMA foreign_keys=OFF")
|
|
502
|
+
conn.execute("ATTACH DATABASE ? AS backup_db", (str(src),))
|
|
503
|
+
for table in tables:
|
|
504
|
+
quoted = _quote_identifier(table)
|
|
505
|
+
src_exists = conn.execute(
|
|
506
|
+
"SELECT sql FROM backup_db.sqlite_master WHERE type='table' AND name=?",
|
|
507
|
+
(table,),
|
|
508
|
+
).fetchone()
|
|
509
|
+
if src_exists is None:
|
|
510
|
+
result["tables"][table] = {"status": "missing_in_source"}
|
|
511
|
+
continue
|
|
512
|
+
dst_exists = conn.execute(
|
|
513
|
+
"SELECT name FROM main.sqlite_master WHERE type='table' AND name=?",
|
|
514
|
+
(table,),
|
|
515
|
+
).fetchone()
|
|
516
|
+
if dst_exists is None:
|
|
517
|
+
create_sql = str(src_exists[0] or "").strip()
|
|
518
|
+
if not create_sql:
|
|
519
|
+
result["tables"][table] = {"status": "schema_missing_in_source"}
|
|
520
|
+
continue
|
|
521
|
+
conn.execute(create_sql)
|
|
522
|
+
before = _table_count(conn, table) or 0
|
|
523
|
+
conn.execute(f"DELETE FROM main.{quoted}")
|
|
524
|
+
conn.execute(f"INSERT INTO main.{quoted} SELECT * FROM backup_db.{quoted}")
|
|
525
|
+
after = _table_count(conn, table) or 0
|
|
526
|
+
result["tables"][table] = {
|
|
527
|
+
"status": "restored",
|
|
528
|
+
"before": int(before),
|
|
529
|
+
"after": int(after),
|
|
530
|
+
}
|
|
531
|
+
conn.commit()
|
|
532
|
+
result["ok"] = not result["errors"]
|
|
533
|
+
except Exception as exc:
|
|
534
|
+
result["errors"].append(f"{type(exc).__name__}: {exc}")
|
|
535
|
+
try:
|
|
536
|
+
if conn is not None:
|
|
537
|
+
conn.rollback()
|
|
538
|
+
except Exception:
|
|
539
|
+
pass
|
|
540
|
+
finally:
|
|
541
|
+
if conn is not None:
|
|
542
|
+
try:
|
|
543
|
+
conn.execute("DETACH DATABASE backup_db")
|
|
544
|
+
except Exception:
|
|
545
|
+
pass
|
|
546
|
+
try:
|
|
547
|
+
conn.close()
|
|
548
|
+
except Exception:
|
|
549
|
+
pass
|
|
550
|
+
return result
|
|
551
|
+
|
|
552
|
+
|
|
396
553
|
# ── MCP server discovery / kill ─────────────────────────────────────────
|
|
397
554
|
|
|
398
555
|
def kill_nexo_mcp_servers(dry_run: bool = False) -> dict:
|
|
@@ -45,9 +45,13 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
45
45
|
CRITICAL_TABLES,
|
|
46
46
|
EMPTY_DB_SIZE_BYTES,
|
|
47
47
|
MIN_REFERENCE_ROWS,
|
|
48
|
+
PROTECTED_TABLES,
|
|
48
49
|
db_looks_wiped,
|
|
49
50
|
db_row_counts,
|
|
51
|
+
diff_row_counts,
|
|
52
|
+
find_best_hourly_backup,
|
|
50
53
|
find_latest_hourly_backup,
|
|
54
|
+
restore_tables_from_backup,
|
|
51
55
|
)
|
|
52
56
|
|
|
53
57
|
db_path = paths.db_path()
|
|
@@ -81,13 +85,25 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
81
85
|
except Exception as exc:
|
|
82
86
|
quick_error = f"{type(exc).__name__}: {exc}"
|
|
83
87
|
|
|
84
|
-
looks_wiped = db_looks_wiped(db_path,
|
|
85
|
-
reference =
|
|
88
|
+
looks_wiped = db_looks_wiped(db_path, PROTECTED_TABLES)
|
|
89
|
+
reference = find_best_hourly_backup(
|
|
86
90
|
paths.backups_dir(),
|
|
87
91
|
min_critical_rows=MIN_REFERENCE_ROWS,
|
|
92
|
+
tables=PROTECTED_TABLES,
|
|
93
|
+
) or find_latest_hourly_backup(
|
|
94
|
+
paths.backups_dir(),
|
|
95
|
+
min_critical_rows=MIN_REFERENCE_ROWS,
|
|
96
|
+
tables=PROTECTED_TABLES,
|
|
88
97
|
)
|
|
89
|
-
reference_counts = db_row_counts(reference,
|
|
98
|
+
reference_counts = db_row_counts(reference, PROTECTED_TABLES) if reference else {}
|
|
90
99
|
reference_rows = sum(v for v in reference_counts.values() if isinstance(v, int))
|
|
100
|
+
protected_regression = diff_row_counts(db_path, reference, PROTECTED_TABLES) if reference else None
|
|
101
|
+
recoverable_regression = bool(
|
|
102
|
+
reference
|
|
103
|
+
and protected_regression
|
|
104
|
+
and protected_regression.is_wipe()
|
|
105
|
+
and not looks_wiped
|
|
106
|
+
)
|
|
91
107
|
lower_error = quick_error.lower()
|
|
92
108
|
corrupt_error = any(token in lower_error for token in (
|
|
93
109
|
"database disk image is malformed",
|
|
@@ -105,7 +121,7 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
105
121
|
)
|
|
106
122
|
)
|
|
107
123
|
|
|
108
|
-
if quick_ok and not recoverable_wipe:
|
|
124
|
+
if quick_ok and not recoverable_wipe and not recoverable_regression:
|
|
109
125
|
if looks_wiped and not reference:
|
|
110
126
|
return DoctorCheck(
|
|
111
127
|
id="boot.db_integrity",
|
|
@@ -131,7 +147,37 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
131
147
|
f"looks_wiped: {looks_wiped}",
|
|
132
148
|
]
|
|
133
149
|
if reference:
|
|
134
|
-
evidence.append(f"Reference backup: {reference} ({reference_rows}
|
|
150
|
+
evidence.append(f"Reference backup: {reference} ({reference_rows} protected rows)")
|
|
151
|
+
if protected_regression:
|
|
152
|
+
evidence.extend(protected_regression.summary_lines())
|
|
153
|
+
|
|
154
|
+
if fix and recoverable_regression:
|
|
155
|
+
report = restore_tables_from_backup(reference, db_path)
|
|
156
|
+
if report.get("ok"):
|
|
157
|
+
restored = {
|
|
158
|
+
table: payload
|
|
159
|
+
for table, payload in (report.get("tables") or {}).items()
|
|
160
|
+
if isinstance(payload, dict) and payload.get("status") == "restored"
|
|
161
|
+
}
|
|
162
|
+
restored_rows = sum(int(payload.get("after") or 0) for payload in restored.values())
|
|
163
|
+
return DoctorCheck(
|
|
164
|
+
id="boot.db_integrity",
|
|
165
|
+
tier="boot",
|
|
166
|
+
status="healthy",
|
|
167
|
+
severity="info",
|
|
168
|
+
summary=f"Local memory restored from backup ({restored_rows} protected rows)",
|
|
169
|
+
evidence=evidence + [f"Restored local-memory tables: {len(restored)}"],
|
|
170
|
+
fixed=True,
|
|
171
|
+
)
|
|
172
|
+
return DoctorCheck(
|
|
173
|
+
id="boot.db_integrity",
|
|
174
|
+
tier="boot",
|
|
175
|
+
status="critical",
|
|
176
|
+
severity="error",
|
|
177
|
+
summary="Local memory repair failed",
|
|
178
|
+
evidence=evidence + [f"Restore errors: {report.get('errors') or []}"],
|
|
179
|
+
repair_plan=["Close NEXO Desktop and run nexo doctor --tier boot --plane database_real --fix"],
|
|
180
|
+
)
|
|
135
181
|
|
|
136
182
|
if fix and recoverable_wipe:
|
|
137
183
|
from plugins.recover import recover
|
|
@@ -145,7 +191,7 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
145
191
|
tier="boot",
|
|
146
192
|
status="healthy",
|
|
147
193
|
severity="info",
|
|
148
|
-
summary=f"Database restored from backup ({restored_rows}
|
|
194
|
+
summary=f"Database restored from backup ({restored_rows} protected rows)",
|
|
149
195
|
evidence=evidence + [f"Pre-recover snapshot: {report.get('pre_recover_dir', '')}"],
|
|
150
196
|
fixed=True,
|
|
151
197
|
)
|
|
@@ -159,13 +205,13 @@ def check_db_integrity(fix: bool = False) -> DoctorCheck:
|
|
|
159
205
|
repair_plan=["Run nexo recover --force --yes, then restart Desktop"],
|
|
160
206
|
)
|
|
161
207
|
|
|
162
|
-
if recoverable_wipe:
|
|
208
|
+
if recoverable_wipe or recoverable_regression:
|
|
163
209
|
return DoctorCheck(
|
|
164
210
|
id="boot.db_integrity",
|
|
165
211
|
tier="boot",
|
|
166
212
|
status="critical",
|
|
167
213
|
severity="error",
|
|
168
|
-
summary="Database
|
|
214
|
+
summary="Database or local memory appears degraded but a valid backup exists",
|
|
169
215
|
evidence=evidence,
|
|
170
216
|
repair_plan=["Run nexo doctor --tier boot --plane database_real --fix"],
|
|
171
217
|
escalation_prompt="NEXO database needs automatic recovery from backup.",
|
|
@@ -20,12 +20,14 @@ from .api import (
|
|
|
20
20
|
local_index_hygiene,
|
|
21
21
|
model_status,
|
|
22
22
|
pause,
|
|
23
|
+
performance_config,
|
|
23
24
|
purge_asset,
|
|
24
25
|
reconcile_live_changes,
|
|
25
26
|
remove_exclusion,
|
|
26
27
|
remove_root,
|
|
27
28
|
resume,
|
|
28
29
|
run_once,
|
|
30
|
+
set_performance_profile,
|
|
29
31
|
status,
|
|
30
32
|
warmup_models,
|
|
31
33
|
)
|
|
@@ -45,12 +47,14 @@ __all__ = [
|
|
|
45
47
|
"local_index_hygiene",
|
|
46
48
|
"model_status",
|
|
47
49
|
"pause",
|
|
50
|
+
"performance_config",
|
|
48
51
|
"purge_asset",
|
|
49
52
|
"reconcile_live_changes",
|
|
50
53
|
"remove_exclusion",
|
|
51
54
|
"remove_root",
|
|
52
55
|
"resume",
|
|
53
56
|
"run_once",
|
|
57
|
+
"set_performance_profile",
|
|
54
58
|
"status",
|
|
55
59
|
"warmup_models",
|
|
56
60
|
]
|
package/src/local_context/api.py
CHANGED
|
@@ -36,7 +36,55 @@ DEFAULT_ROUTER_MAX_CHARS = int(os.environ.get("NEXO_LOCAL_CONTEXT_ROUTER_MAX_CHA
|
|
|
36
36
|
DEFAULT_MAX_JOB_ATTEMPTS = int(os.environ.get("NEXO_LOCAL_INDEX_MAX_JOB_ATTEMPTS", "3") or "3")
|
|
37
37
|
INITIAL_INDEX_COMPLETE_KEY = "initial_index_complete"
|
|
38
38
|
INITIAL_INDEX_STARTED_AT_KEY = "initial_index_started_at"
|
|
39
|
+
PERFORMANCE_PROFILE_KEY = "performance_profile"
|
|
40
|
+
DEFAULT_PERFORMANCE_PROFILE = os.environ.get("NEXO_LOCAL_INDEX_PERFORMANCE_PROFILE", "medium").strip().lower() or "medium"
|
|
39
41
|
VALID_CONTEXT_MODES = {"compact", "full"}
|
|
42
|
+
PERFORMANCE_PROFILES: dict[str, dict[str, Any]] = {
|
|
43
|
+
"low": {
|
|
44
|
+
"profile": "low",
|
|
45
|
+
"label": "Bajo",
|
|
46
|
+
"scan_limit": 250,
|
|
47
|
+
"process_limit": 50,
|
|
48
|
+
"live_asset_limit": 500,
|
|
49
|
+
"live_dir_limit": 100,
|
|
50
|
+
"live_file_limit": 250,
|
|
51
|
+
"cycles_per_run": 1,
|
|
52
|
+
"warning": False,
|
|
53
|
+
},
|
|
54
|
+
"medium": {
|
|
55
|
+
"profile": "medium",
|
|
56
|
+
"label": "Medio",
|
|
57
|
+
"scan_limit": 1000,
|
|
58
|
+
"process_limit": 200,
|
|
59
|
+
"live_asset_limit": DEFAULT_LIVE_ASSET_LIMIT,
|
|
60
|
+
"live_dir_limit": DEFAULT_LIVE_DIR_LIMIT,
|
|
61
|
+
"live_file_limit": DEFAULT_LIVE_FILE_LIMIT,
|
|
62
|
+
"cycles_per_run": 1,
|
|
63
|
+
"warning": False,
|
|
64
|
+
},
|
|
65
|
+
"high": {
|
|
66
|
+
"profile": "high",
|
|
67
|
+
"label": "Alto",
|
|
68
|
+
"scan_limit": 3000,
|
|
69
|
+
"process_limit": 600,
|
|
70
|
+
"live_asset_limit": 5000,
|
|
71
|
+
"live_dir_limit": 800,
|
|
72
|
+
"live_file_limit": 2500,
|
|
73
|
+
"cycles_per_run": 2,
|
|
74
|
+
"warning": True,
|
|
75
|
+
},
|
|
76
|
+
"extreme": {
|
|
77
|
+
"profile": "extreme",
|
|
78
|
+
"label": "Extremo",
|
|
79
|
+
"scan_limit": 8000,
|
|
80
|
+
"process_limit": 1500,
|
|
81
|
+
"live_asset_limit": 10000,
|
|
82
|
+
"live_dir_limit": 2000,
|
|
83
|
+
"live_file_limit": 6000,
|
|
84
|
+
"cycles_per_run": 3,
|
|
85
|
+
"warning": True,
|
|
86
|
+
},
|
|
87
|
+
}
|
|
40
88
|
|
|
41
89
|
|
|
42
90
|
def ensure_ready() -> None:
|
|
@@ -545,6 +593,53 @@ def _get_state(key: str, default: str = "") -> str:
|
|
|
545
593
|
return _get_state_conn(conn, key, default)
|
|
546
594
|
|
|
547
595
|
|
|
596
|
+
def _normalize_performance_profile(profile: str | None) -> str:
|
|
597
|
+
value = str(profile or "").strip().lower()
|
|
598
|
+
aliases = {
|
|
599
|
+
"slow": "low",
|
|
600
|
+
"bajo": "low",
|
|
601
|
+
"normal": "medium",
|
|
602
|
+
"balanced": "medium",
|
|
603
|
+
"medio": "medium",
|
|
604
|
+
"fast": "high",
|
|
605
|
+
"alto": "high",
|
|
606
|
+
"max": "extreme",
|
|
607
|
+
"maximum": "extreme",
|
|
608
|
+
"extremo": "extreme",
|
|
609
|
+
}
|
|
610
|
+
value = aliases.get(value, value)
|
|
611
|
+
return value if value in PERFORMANCE_PROFILES else "medium"
|
|
612
|
+
|
|
613
|
+
|
|
614
|
+
def performance_config(profile: str | None = None, *, conn=None) -> dict:
|
|
615
|
+
active_profile = profile
|
|
616
|
+
if active_profile is None:
|
|
617
|
+
if conn is None:
|
|
618
|
+
active_profile = _get_state(PERFORMANCE_PROFILE_KEY, DEFAULT_PERFORMANCE_PROFILE)
|
|
619
|
+
else:
|
|
620
|
+
active_profile = _get_state_conn(conn, PERFORMANCE_PROFILE_KEY, DEFAULT_PERFORMANCE_PROFILE)
|
|
621
|
+
normalized = _normalize_performance_profile(active_profile)
|
|
622
|
+
config = dict(PERFORMANCE_PROFILES[normalized])
|
|
623
|
+
config["available_profiles"] = [dict(PERFORMANCE_PROFILES[key]) for key in ("low", "medium", "high", "extreme")]
|
|
624
|
+
config["interval_seconds"] = 60
|
|
625
|
+
return config
|
|
626
|
+
|
|
627
|
+
|
|
628
|
+
def set_performance_profile(profile: str) -> dict:
|
|
629
|
+
normalized = _normalize_performance_profile(profile)
|
|
630
|
+
_set_state(PERFORMANCE_PROFILE_KEY, normalized)
|
|
631
|
+
config = performance_config(normalized)
|
|
632
|
+
log_event(
|
|
633
|
+
"info",
|
|
634
|
+
"performance_profile_updated",
|
|
635
|
+
"Local memory performance profile updated",
|
|
636
|
+
profile=normalized,
|
|
637
|
+
scan_limit=config["scan_limit"],
|
|
638
|
+
process_limit=config["process_limit"],
|
|
639
|
+
)
|
|
640
|
+
return {"ok": True, "profile": normalized, "performance": config}
|
|
641
|
+
|
|
642
|
+
|
|
548
643
|
def _root_initial_scan_key(root_id: int) -> str:
|
|
549
644
|
return f"root:{int(root_id)}:initial_scan_complete"
|
|
550
645
|
|
|
@@ -1679,10 +1774,10 @@ def run_once(
|
|
|
1679
1774
|
*,
|
|
1680
1775
|
root: str | None = None,
|
|
1681
1776
|
limit: int | None = None,
|
|
1682
|
-
process_limit: int =
|
|
1683
|
-
live_asset_limit: int =
|
|
1684
|
-
live_dir_limit: int =
|
|
1685
|
-
live_file_limit: int =
|
|
1777
|
+
process_limit: int | None = None,
|
|
1778
|
+
live_asset_limit: int | None = None,
|
|
1779
|
+
live_dir_limit: int | None = None,
|
|
1780
|
+
live_file_limit: int | None = None,
|
|
1686
1781
|
) -> dict:
|
|
1687
1782
|
if _get_state("privacy_hygiene_v2", "0") != "1":
|
|
1688
1783
|
local_index_privacy_hygiene(fix=True)
|
|
@@ -1694,14 +1789,20 @@ def run_once(
|
|
|
1694
1789
|
ensure_default_roots()
|
|
1695
1790
|
if root:
|
|
1696
1791
|
add_root(root)
|
|
1792
|
+
config = performance_config()
|
|
1793
|
+
effective_scan_limit = int(limit if limit is not None else config["scan_limit"])
|
|
1794
|
+
effective_process_limit = int(process_limit if process_limit is not None else config["process_limit"])
|
|
1795
|
+
effective_live_asset_limit = int(live_asset_limit if live_asset_limit is not None else config["live_asset_limit"])
|
|
1796
|
+
effective_live_dir_limit = int(live_dir_limit if live_dir_limit is not None else config["live_dir_limit"])
|
|
1797
|
+
effective_live_file_limit = int(live_file_limit if live_file_limit is not None else config["live_file_limit"])
|
|
1697
1798
|
conn = _conn()
|
|
1698
1799
|
initial_before = _initial_scan_status(conn, list_roots())
|
|
1699
1800
|
initial_index_before = _refresh_initial_index_complete(conn, initial_before)
|
|
1700
1801
|
if initial_index_before:
|
|
1701
1802
|
live_result = reconcile_live_changes(
|
|
1702
|
-
asset_limit=
|
|
1703
|
-
dir_limit=
|
|
1704
|
-
file_limit=
|
|
1803
|
+
asset_limit=effective_live_asset_limit,
|
|
1804
|
+
dir_limit=effective_live_dir_limit,
|
|
1805
|
+
file_limit=effective_live_file_limit,
|
|
1705
1806
|
)
|
|
1706
1807
|
else:
|
|
1707
1808
|
live_result = {
|
|
@@ -1711,8 +1812,8 @@ def run_once(
|
|
|
1711
1812
|
"assets": {},
|
|
1712
1813
|
"dirs": {},
|
|
1713
1814
|
}
|
|
1714
|
-
scan_result = scan_once(limit=
|
|
1715
|
-
job_result = process_jobs(limit=
|
|
1815
|
+
scan_result = scan_once(limit=effective_scan_limit)
|
|
1816
|
+
job_result = process_jobs(limit=effective_process_limit)
|
|
1716
1817
|
conn_after = _conn()
|
|
1717
1818
|
initial_after = _initial_scan_status(conn_after, list_roots())
|
|
1718
1819
|
active_after = _active_job_count(conn_after)
|
|
@@ -1724,6 +1825,14 @@ def run_once(
|
|
|
1724
1825
|
"live": live_result,
|
|
1725
1826
|
"scan": scan_result,
|
|
1726
1827
|
"jobs": job_result,
|
|
1828
|
+
"performance": {
|
|
1829
|
+
"profile": config["profile"],
|
|
1830
|
+
"scan_limit": effective_scan_limit,
|
|
1831
|
+
"process_limit": effective_process_limit,
|
|
1832
|
+
"live_asset_limit": effective_live_asset_limit,
|
|
1833
|
+
"live_dir_limit": effective_live_dir_limit,
|
|
1834
|
+
"live_file_limit": effective_live_file_limit,
|
|
1835
|
+
},
|
|
1727
1836
|
}
|
|
1728
1837
|
|
|
1729
1838
|
|
|
@@ -2083,6 +2192,7 @@ def status() -> dict:
|
|
|
2083
2192
|
problem = _service_problem(service)
|
|
2084
2193
|
service["healthy"] = problem is None
|
|
2085
2194
|
service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 and initial_index_complete else "indexing"))
|
|
2195
|
+
performance = performance_config(conn=conn)
|
|
2086
2196
|
problems = _problem_rows(conn)
|
|
2087
2197
|
if problem:
|
|
2088
2198
|
problems.insert(0, {
|
|
@@ -2098,10 +2208,10 @@ def status() -> dict:
|
|
|
2098
2208
|
})
|
|
2099
2209
|
if paused:
|
|
2100
2210
|
phase = "paused"
|
|
2101
|
-
elif problem:
|
|
2102
|
-
phase = "service_attention"
|
|
2103
2211
|
elif not initial_index_complete:
|
|
2104
2212
|
phase = "initial_indexing"
|
|
2213
|
+
elif problem:
|
|
2214
|
+
phase = "service_attention"
|
|
2105
2215
|
elif active_jobs == 0:
|
|
2106
2216
|
phase = "idle"
|
|
2107
2217
|
else:
|
|
@@ -2125,7 +2235,9 @@ def status() -> dict:
|
|
|
2125
2235
|
"initial_discovery_complete": bool(initial_scan["complete"]),
|
|
2126
2236
|
"initial_index_complete": bool(initial_index_complete),
|
|
2127
2237
|
"index_mode": "watching_changes" if initial_index_complete else "initial_indexing",
|
|
2238
|
+
"performance_profile": performance["profile"],
|
|
2128
2239
|
},
|
|
2240
|
+
"performance": performance,
|
|
2129
2241
|
"initial_scan": initial_scan,
|
|
2130
2242
|
"initial_index_complete": bool(initial_index_complete),
|
|
2131
2243
|
"volumes": volumes,
|
package/src/plugins/recover.py
CHANGED
|
@@ -36,6 +36,7 @@ from db_guard import (
|
|
|
36
36
|
EMPTY_DB_SIZE_BYTES,
|
|
37
37
|
HOURLY_BACKUP_GLOB,
|
|
38
38
|
MIN_REFERENCE_ROWS,
|
|
39
|
+
PROTECTED_TABLES,
|
|
39
40
|
WIPE_THRESHOLD_PCT,
|
|
40
41
|
db_looks_wiped,
|
|
41
42
|
db_row_counts,
|
|
@@ -138,7 +139,7 @@ def _describe_backup(path: Path, kind: str) -> dict:
|
|
|
138
139
|
counts: dict[str, int | None] = {}
|
|
139
140
|
critical_rows = 0
|
|
140
141
|
if size > EMPTY_DB_SIZE_BYTES:
|
|
141
|
-
counts = db_row_counts(path,
|
|
142
|
+
counts = db_row_counts(path, PROTECTED_TABLES)
|
|
142
143
|
critical_rows = sum(v for v in counts.values() if isinstance(v, int))
|
|
143
144
|
return {
|
|
144
145
|
"path": str(path),
|
|
@@ -147,6 +148,7 @@ def _describe_backup(path: Path, kind: str) -> dict:
|
|
|
147
148
|
"mtime": mtime,
|
|
148
149
|
"size_bytes": size,
|
|
149
150
|
"critical_rows": critical_rows,
|
|
151
|
+
"protected_rows": critical_rows,
|
|
150
152
|
"row_counts": {k: v for k, v in counts.items() if v is not None},
|
|
151
153
|
"is_usable": size > EMPTY_DB_SIZE_BYTES and critical_rows >= MIN_REFERENCE_ROWS,
|
|
152
154
|
}
|
|
@@ -167,9 +169,16 @@ def _pick_source(entries: list[dict], explicit: str | None) -> tuple[Path | None
|
|
|
167
169
|
|
|
168
170
|
if not entries:
|
|
169
171
|
return None, "no backups found under NEXO_HOME/backups/"
|
|
170
|
-
for entry in entries
|
|
171
|
-
|
|
172
|
-
|
|
172
|
+
usable = [entry for entry in entries if entry["is_usable"]]
|
|
173
|
+
if usable:
|
|
174
|
+
best = max(
|
|
175
|
+
usable,
|
|
176
|
+
key=lambda entry: (
|
|
177
|
+
int(entry.get("protected_rows") or entry.get("critical_rows") or 0),
|
|
178
|
+
float(entry.get("mtime") or 0),
|
|
179
|
+
),
|
|
180
|
+
)
|
|
181
|
+
return Path(best["path"]), None
|
|
173
182
|
return None, "no usable backup with critical rows found"
|
|
174
183
|
|
|
175
184
|
|
|
@@ -206,8 +215,8 @@ def recover(
|
|
|
206
215
|
"steps": [],
|
|
207
216
|
"warnings": [],
|
|
208
217
|
"errors": [],
|
|
209
|
-
"current_looks_wiped": db_looks_wiped(target_path),
|
|
210
|
-
"current_row_counts": {k: v for k, v in db_row_counts(target_path).items() if v is not None},
|
|
218
|
+
"current_looks_wiped": db_looks_wiped(target_path, PROTECTED_TABLES),
|
|
219
|
+
"current_row_counts": {k: v for k, v in db_row_counts(target_path, PROTECTED_TABLES).items() if v is not None},
|
|
211
220
|
}
|
|
212
221
|
|
|
213
222
|
# Step 1: pick source
|
|
@@ -231,7 +240,7 @@ def recover(
|
|
|
231
240
|
result["source"] = str(chosen)
|
|
232
241
|
result["steps"].append(f"chose source: {chosen}")
|
|
233
242
|
|
|
234
|
-
source_counts = db_row_counts(chosen)
|
|
243
|
+
source_counts = db_row_counts(chosen, PROTECTED_TABLES)
|
|
235
244
|
result["source_row_counts"] = {k: v for k, v in source_counts.items() if v is not None}
|
|
236
245
|
source_total = sum(v for v in source_counts.values() if isinstance(v, int))
|
|
237
246
|
if source_total < MIN_REFERENCE_ROWS:
|
|
@@ -308,7 +317,7 @@ def recover(
|
|
|
308
317
|
return result
|
|
309
318
|
result["steps"].append(f"restored {chosen.name} -> {target_path}")
|
|
310
319
|
|
|
311
|
-
valid, valid_err = validate_backup_matches_source(chosen, target_path)
|
|
320
|
+
valid, valid_err = validate_backup_matches_source(chosen, target_path, PROTECTED_TABLES)
|
|
312
321
|
if not valid:
|
|
313
322
|
result["errors"].append(f"post-restore validation failed: {valid_err}")
|
|
314
323
|
if stopped_launchagents:
|
|
@@ -316,7 +325,7 @@ def recover(
|
|
|
316
325
|
return result
|
|
317
326
|
result["steps"].append("validated post-restore row counts")
|
|
318
327
|
|
|
319
|
-
final_counts = db_row_counts(target_path)
|
|
328
|
+
final_counts = db_row_counts(target_path, PROTECTED_TABLES)
|
|
320
329
|
result["final_row_counts"] = {k: v for k, v in final_counts.items() if v is not None}
|
|
321
330
|
if stopped_launchagents:
|
|
322
331
|
result["resume"] = resume_nexo_launchagents(stopped_launchagents)
|
package/src/plugins/update.py
CHANGED
|
@@ -41,10 +41,12 @@ try:
|
|
|
41
41
|
CRITICAL_TABLES,
|
|
42
42
|
HOURLY_BACKUP_MAX_AGE,
|
|
43
43
|
MIN_REFERENCE_ROWS,
|
|
44
|
+
PROTECTED_TABLES,
|
|
44
45
|
WIPE_THRESHOLD_PCT,
|
|
45
46
|
db_looks_wiped,
|
|
46
47
|
db_row_counts,
|
|
47
48
|
diff_row_counts,
|
|
49
|
+
find_best_hourly_backup,
|
|
48
50
|
find_latest_hourly_backup,
|
|
49
51
|
safe_sqlite_backup,
|
|
50
52
|
validate_backup_matches_source,
|
|
@@ -53,6 +55,7 @@ try:
|
|
|
53
55
|
except Exception: # pragma: no cover - exercised only during mid-upgrade installs
|
|
54
56
|
_DB_GUARD_AVAILABLE = False
|
|
55
57
|
CRITICAL_TABLES = ()
|
|
58
|
+
PROTECTED_TABLES = ()
|
|
56
59
|
HOURLY_BACKUP_MAX_AGE = 48 * 3600
|
|
57
60
|
MIN_REFERENCE_ROWS = 50
|
|
58
61
|
WIPE_THRESHOLD_PCT = 80
|
|
@@ -66,6 +69,9 @@ except Exception: # pragma: no cover - exercised only during mid-upgrade instal
|
|
|
66
69
|
def diff_row_counts(*_args, **_kwargs): # type: ignore[misc]
|
|
67
70
|
return None
|
|
68
71
|
|
|
72
|
+
def find_best_hourly_backup(*_args, **_kwargs): # type: ignore[misc]
|
|
73
|
+
return None
|
|
74
|
+
|
|
69
75
|
def find_latest_hourly_backup(*_args, **_kwargs): # type: ignore[misc]
|
|
70
76
|
return None
|
|
71
77
|
|
|
@@ -121,6 +127,7 @@ def _is_packaged_install() -> bool:
|
|
|
121
127
|
NEXO_HOME = export_resolved_nexo_home()
|
|
122
128
|
DATA_DIR = paths.data_dir()
|
|
123
129
|
BACKUP_BASE = paths.backups_dir()
|
|
130
|
+
TECHNICAL_BACKUP_KEEP = 5
|
|
124
131
|
|
|
125
132
|
# In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
|
|
126
133
|
_PACKAGED_INSTALL = _is_packaged_install()
|
|
@@ -129,6 +136,38 @@ SRC_DIR = CODE_ROOT
|
|
|
129
136
|
PACKAGE_JSON = REPO_DIR / "package.json"
|
|
130
137
|
|
|
131
138
|
|
|
139
|
+
def _rotate_backup_family(prefix: str, keep: int = TECHNICAL_BACKUP_KEEP) -> int:
|
|
140
|
+
"""Rotate technical rollback directories for one backup prefix.
|
|
141
|
+
|
|
142
|
+
Cleanup is best-effort: update/rollback safety must never depend on
|
|
143
|
+
deleting old snapshots successfully.
|
|
144
|
+
"""
|
|
145
|
+
if keep <= 0:
|
|
146
|
+
return 0
|
|
147
|
+
base = BACKUP_BASE
|
|
148
|
+
if not base.is_dir():
|
|
149
|
+
return 0
|
|
150
|
+
try:
|
|
151
|
+
candidates = [p for p in base.iterdir() if p.is_dir() and p.name.startswith(prefix)]
|
|
152
|
+
except Exception:
|
|
153
|
+
return 0
|
|
154
|
+
if len(candidates) <= keep:
|
|
155
|
+
return 0
|
|
156
|
+
try:
|
|
157
|
+
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
158
|
+
except Exception:
|
|
159
|
+
return 0
|
|
160
|
+
|
|
161
|
+
removed = 0
|
|
162
|
+
for old in candidates[keep:]:
|
|
163
|
+
try:
|
|
164
|
+
shutil.rmtree(old)
|
|
165
|
+
removed += 1
|
|
166
|
+
except Exception:
|
|
167
|
+
continue
|
|
168
|
+
return removed
|
|
169
|
+
|
|
170
|
+
|
|
132
171
|
def _venv_python_path(runtime_root: Path | None = None) -> Path:
|
|
133
172
|
root = runtime_root or _nexo_home()
|
|
134
173
|
if sys.platform == "win32":
|
|
@@ -363,22 +402,34 @@ def _preflight_wipe_check() -> str | None:
|
|
|
363
402
|
primary_db = DATA_DIR / "nexo.db"
|
|
364
403
|
if not primary_db.is_file():
|
|
365
404
|
return None # Nothing to protect; fresh install path.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
405
|
+
reference = find_best_hourly_backup(
|
|
406
|
+
BACKUP_BASE,
|
|
407
|
+
tables=PROTECTED_TABLES,
|
|
408
|
+
) or find_latest_hourly_backup(BACKUP_BASE, tables=PROTECTED_TABLES)
|
|
369
409
|
if reference is None:
|
|
370
410
|
return None # No reference to compare against; cannot distinguish wipe from fresh install.
|
|
371
|
-
reference_counts = db_row_counts(reference)
|
|
411
|
+
reference_counts = db_row_counts(reference, PROTECTED_TABLES)
|
|
372
412
|
reference_total = sum(v for v in reference_counts.values() if isinstance(v, int))
|
|
373
413
|
if reference_total < MIN_REFERENCE_ROWS:
|
|
374
414
|
return None # Reference itself is near-empty; likely fresh install.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
415
|
+
if db_looks_wiped(primary_db, PROTECTED_TABLES):
|
|
416
|
+
return (
|
|
417
|
+
"Primary DB appears wiped while a recent hourly backup still has real data.\n"
|
|
418
|
+
f" nexo.db: {primary_db} (empty)\n"
|
|
419
|
+
f" hourly backup: {reference} ({reference_total} protected rows)\n"
|
|
420
|
+
"Run `nexo recover` to restore from backup, then retry the update.\n"
|
|
421
|
+
"Set NEXO_SKIP_WIPE_GUARD=1 to override (only recommended during a deliberate reinstall)."
|
|
422
|
+
)
|
|
423
|
+
report = diff_row_counts(primary_db, reference, PROTECTED_TABLES)
|
|
424
|
+
if report.is_wipe():
|
|
425
|
+
return (
|
|
426
|
+
"Primary DB has lost protected data compared with the latest usable backup.\n"
|
|
427
|
+
f" nexo.db: {primary_db}\n"
|
|
428
|
+
f" backup: {reference} ({reference_total} protected rows)\n"
|
|
429
|
+
+ "\n".join(report.summary_lines())
|
|
430
|
+
+ "\nRun `nexo doctor --tier boot --plane database_real --fix` before updating."
|
|
431
|
+
)
|
|
432
|
+
return None
|
|
382
433
|
|
|
383
434
|
|
|
384
435
|
def _row_count_regression(pre: dict[str, int | None], post: dict[str, int | None]) -> str | None:
|
|
@@ -391,7 +442,7 @@ def _row_count_regression(pre: dict[str, int | None], post: dict[str, int | None
|
|
|
391
442
|
regressions: list[str] = []
|
|
392
443
|
pre_total = sum(v for v in pre.values() if isinstance(v, int))
|
|
393
444
|
post_total = sum(v for v in post.values() if isinstance(v, int))
|
|
394
|
-
for table in
|
|
445
|
+
for table in PROTECTED_TABLES:
|
|
395
446
|
pre_v = pre.get(table)
|
|
396
447
|
post_v = post.get(table)
|
|
397
448
|
if pre_v is None or pre_v < 10:
|
|
@@ -439,12 +490,16 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
439
490
|
# Only validate row counts for the primary DB — the other sidecar DBs
|
|
440
491
|
# (cognitive.db, cron-runs.db) do not share CRITICAL_TABLES.
|
|
441
492
|
if db_file.name == "nexo.db":
|
|
442
|
-
valid, valid_err = validate_backup_matches_source(db_file, dest,
|
|
493
|
+
valid, valid_err = validate_backup_matches_source(db_file, dest, PROTECTED_TABLES)
|
|
443
494
|
if not valid:
|
|
444
495
|
return str(backup_dir), (
|
|
445
496
|
f"Backup of {db_file.name} did not preserve critical tables: {valid_err}"
|
|
446
497
|
)
|
|
447
498
|
|
|
499
|
+
try:
|
|
500
|
+
_rotate_backup_family("pre-update-")
|
|
501
|
+
except Exception:
|
|
502
|
+
pass
|
|
448
503
|
return str(backup_dir), None
|
|
449
504
|
|
|
450
505
|
|
|
@@ -846,6 +901,10 @@ def _backup_code_tree() -> tuple[str | None, str | None]:
|
|
|
846
901
|
shutil.copy2(vf, backup_dir / "version.json")
|
|
847
902
|
except Exception as e:
|
|
848
903
|
return None, f"Code tree backup failed: {e}"
|
|
904
|
+
try:
|
|
905
|
+
_rotate_backup_family("code-tree-")
|
|
906
|
+
except Exception:
|
|
907
|
+
pass
|
|
849
908
|
return str(backup_dir), None
|
|
850
909
|
|
|
851
910
|
|
|
@@ -43,6 +43,7 @@ from pathlib import Path
|
|
|
43
43
|
|
|
44
44
|
DEFAULT_DB_PATH = Path.home() / ".nexo" / "runtime" / "data" / "nexo.db"
|
|
45
45
|
DEFAULT_CALIBRATION = Path.home() / ".nexo" / "brain" / "calibration.json"
|
|
46
|
+
BACKFILL_OWNER_BACKUP_KEEP = 5
|
|
46
47
|
|
|
47
48
|
_WAITING_PHRASES = (
|
|
48
49
|
r"esperando (?:respuesta|a\s+\w+|confirmaci[oó]n)",
|
|
@@ -256,9 +257,42 @@ def _backup_db(db_path: Path) -> Path:
|
|
|
256
257
|
backup = db_path.parent.parent / "backups" / f"pre-backfill-owner-{ts}" / db_path.name
|
|
257
258
|
backup.parent.mkdir(parents=True, exist_ok=True)
|
|
258
259
|
shutil.copy2(db_path, backup)
|
|
260
|
+
_rotate_backup_family(backup.parent.parent)
|
|
259
261
|
return backup
|
|
260
262
|
|
|
261
263
|
|
|
264
|
+
def _rotate_backup_family(
|
|
265
|
+
backups_dir: Path,
|
|
266
|
+
prefix: str = "pre-backfill-owner-",
|
|
267
|
+
keep: int = BACKFILL_OWNER_BACKUP_KEEP,
|
|
268
|
+
) -> int:
|
|
269
|
+
"""Keep the newest technical backfill backups and prune older ones."""
|
|
270
|
+
if keep <= 0:
|
|
271
|
+
return 0
|
|
272
|
+
backups_dir = Path(backups_dir)
|
|
273
|
+
if not backups_dir.is_dir():
|
|
274
|
+
return 0
|
|
275
|
+
try:
|
|
276
|
+
candidates = [p for p in backups_dir.iterdir() if p.is_dir() and p.name.startswith(prefix)]
|
|
277
|
+
except Exception:
|
|
278
|
+
return 0
|
|
279
|
+
if len(candidates) <= keep:
|
|
280
|
+
return 0
|
|
281
|
+
try:
|
|
282
|
+
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
283
|
+
except Exception:
|
|
284
|
+
return 0
|
|
285
|
+
|
|
286
|
+
removed = 0
|
|
287
|
+
for old in candidates[keep:]:
|
|
288
|
+
try:
|
|
289
|
+
shutil.rmtree(old)
|
|
290
|
+
removed += 1
|
|
291
|
+
except Exception:
|
|
292
|
+
continue
|
|
293
|
+
return removed
|
|
294
|
+
|
|
295
|
+
|
|
262
296
|
def run(
|
|
263
297
|
db_path: Path,
|
|
264
298
|
calibration_path: Path,
|
|
@@ -37,11 +37,18 @@ LOG_DIR.mkdir(parents=True, exist_ok=True)
|
|
|
37
37
|
LOG_FILE = LOG_DIR / "local-index.log"
|
|
38
38
|
LOCK_FILE = LOG_DIR / "local-index.lock"
|
|
39
39
|
LOCK_STALE_SECONDS = int(os.environ.get("NEXO_LOCAL_INDEX_LOCK_STALE_SECONDS", "1800") or "1800")
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _optional_env_int(name: str) -> int | None:
|
|
43
|
+
value = os.environ.get(name, "").strip()
|
|
44
|
+
if not value:
|
|
45
|
+
return None
|
|
46
|
+
try:
|
|
47
|
+
parsed = int(value)
|
|
48
|
+
except ValueError:
|
|
49
|
+
log(f"Ignoring invalid integer env {name}={value!r}.")
|
|
50
|
+
return None
|
|
51
|
+
return parsed if parsed > 0 else None
|
|
45
52
|
|
|
46
53
|
|
|
47
54
|
def log(message: str) -> None:
|
|
@@ -114,14 +121,19 @@ def release_lock() -> None:
|
|
|
114
121
|
pass
|
|
115
122
|
|
|
116
123
|
|
|
117
|
-
def
|
|
124
|
+
def _run_single_index_cycle(config: dict) -> dict:
|
|
125
|
+
scan_limit = _optional_env_int("NEXO_LOCAL_INDEX_SCAN_LIMIT") or int(config["scan_limit"])
|
|
126
|
+
process_limit = _optional_env_int("NEXO_LOCAL_INDEX_PROCESS_LIMIT") or int(config["process_limit"])
|
|
127
|
+
live_asset_limit = _optional_env_int("NEXO_LOCAL_INDEX_LIVE_ASSET_LIMIT") or int(config["live_asset_limit"])
|
|
128
|
+
live_dir_limit = _optional_env_int("NEXO_LOCAL_INDEX_LIVE_DIR_LIMIT") or int(config["live_dir_limit"])
|
|
129
|
+
live_file_limit = _optional_env_int("NEXO_LOCAL_INDEX_LIVE_FILE_LIMIT") or int(config["live_file_limit"])
|
|
118
130
|
try:
|
|
119
131
|
return api.run_once(
|
|
120
|
-
limit=
|
|
121
|
-
process_limit=
|
|
122
|
-
live_asset_limit=
|
|
123
|
-
live_dir_limit=
|
|
124
|
-
live_file_limit=
|
|
132
|
+
limit=scan_limit,
|
|
133
|
+
process_limit=process_limit,
|
|
134
|
+
live_asset_limit=live_asset_limit,
|
|
135
|
+
live_dir_limit=live_dir_limit,
|
|
136
|
+
live_file_limit=live_file_limit,
|
|
125
137
|
)
|
|
126
138
|
except TypeError as exc:
|
|
127
139
|
message = str(exc)
|
|
@@ -135,7 +147,28 @@ def _run_index_cycle() -> dict:
|
|
|
135
147
|
error=message,
|
|
136
148
|
)
|
|
137
149
|
log(f"Compatibility fallback: api.run_once does not accept live reconcile limits ({message}).")
|
|
138
|
-
return api.run_once(limit=
|
|
150
|
+
return api.run_once(limit=scan_limit, process_limit=process_limit)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def _run_index_cycle() -> dict:
|
|
154
|
+
config = api.performance_config()
|
|
155
|
+
cycles = _optional_env_int("NEXO_LOCAL_INDEX_CYCLES_PER_RUN") or int(config.get("cycles_per_run") or 1)
|
|
156
|
+
results = []
|
|
157
|
+
ok = True
|
|
158
|
+
for _index in range(max(1, cycles)):
|
|
159
|
+
result = _run_single_index_cycle(config)
|
|
160
|
+
results.append(result)
|
|
161
|
+
ok = ok and bool(result.get("ok"))
|
|
162
|
+
paused = bool(result.get("paused") or result.get("scan", {}).get("paused") or result.get("jobs", {}).get("paused"))
|
|
163
|
+
if paused or not result.get("ok"):
|
|
164
|
+
break
|
|
165
|
+
return {
|
|
166
|
+
"ok": ok,
|
|
167
|
+
"profile": config["profile"],
|
|
168
|
+
"cycles": len(results),
|
|
169
|
+
"result": results[-1] if results else {},
|
|
170
|
+
"results": results,
|
|
171
|
+
}
|
|
139
172
|
|
|
140
173
|
|
|
141
174
|
def main() -> int:
|
|
@@ -33,7 +33,7 @@ Class taxonomy (prefix-based) and retention policy:
|
|
|
33
33
|
retired-personal-scripts-*, retired-personal-skills-*,
|
|
34
34
|
runtime-core-sync-*, pre-freshinstall-*
|
|
35
35
|
Retention (per prefix family): keep last N_RECENT + 1 per month for
|
|
36
|
-
MONTHLY_WINDOW_DAYS. Older than that and outside the
|
|
36
|
+
MONTHLY_WINDOW_DAYS. Older than that and outside the recent window
|
|
37
37
|
are eligible for deletion.
|
|
38
38
|
|
|
39
39
|
HOURLY_DB (sqlite dumps, managed by nexo-backup.sh):
|
|
@@ -51,7 +51,7 @@ Usage:
|
|
|
51
51
|
prune_runtime_backups.py # dry-run summary
|
|
52
52
|
prune_runtime_backups.py --apply # actually delete
|
|
53
53
|
prune_runtime_backups.py --json # machine-readable report
|
|
54
|
-
prune_runtime_backups.py --recent
|
|
54
|
+
prune_runtime_backups.py --recent 5 # override N_RECENT
|
|
55
55
|
prune_runtime_backups.py --window-days 90
|
|
56
56
|
prune_runtime_backups.py --only pre-backfill-owner # restrict family
|
|
57
57
|
|
|
@@ -361,7 +361,7 @@ def main() -> int:
|
|
|
361
361
|
ap.add_argument("--root", help="override runtime/backups path")
|
|
362
362
|
ap.add_argument("--apply", action="store_true", help="actually delete (default is dry-run)")
|
|
363
363
|
ap.add_argument("--json", action="store_true", help="machine-readable report")
|
|
364
|
-
ap.add_argument("--recent", type=int, default=
|
|
364
|
+
ap.add_argument("--recent", type=int, default=5, help="N most recent per family to always keep (default: 5)")
|
|
365
365
|
ap.add_argument("--window-days", type=int, default=90, help="month-spaced retention window (default: 90)")
|
|
366
366
|
ap.add_argument("--only", help="restrict to one technical family (e.g. 'pre-backfill-owner')")
|
|
367
367
|
args = ap.parse_args()
|
package/src/server.py
CHANGED
|
@@ -629,14 +629,21 @@ def nexo_local_index_status() -> str:
|
|
|
629
629
|
|
|
630
630
|
|
|
631
631
|
@mcp.tool
|
|
632
|
-
def nexo_local_index_control(
|
|
632
|
+
def nexo_local_index_control(
|
|
633
|
+
action: str = "run_once",
|
|
634
|
+
root: str = "",
|
|
635
|
+
limit: int = 0,
|
|
636
|
+
process_limit: int = 0,
|
|
637
|
+
performance_profile: str = "",
|
|
638
|
+
) -> str:
|
|
633
639
|
"""Control the local memory index.
|
|
634
640
|
|
|
635
641
|
Args:
|
|
636
|
-
action: one of run_once, pause, resume, clear_index.
|
|
642
|
+
action: one of run_once, pause, resume, clear_index, set_performance.
|
|
637
643
|
root: optional folder to add and scan when action=run_once.
|
|
638
644
|
limit: optional per-root scan limit for cooperative background cycles.
|
|
639
645
|
process_limit: max pending jobs to process in this cycle.
|
|
646
|
+
performance_profile: low, medium, high or extreme when action=set_performance.
|
|
640
647
|
"""
|
|
641
648
|
normalized = str(action or "run_once").strip().lower()
|
|
642
649
|
if normalized == "pause":
|
|
@@ -645,10 +652,12 @@ def nexo_local_index_control(action: str = "run_once", root: str = "", limit: in
|
|
|
645
652
|
result = local_context_api.resume()
|
|
646
653
|
elif normalized == "clear_index":
|
|
647
654
|
result = local_context_api.clear_index()
|
|
655
|
+
elif normalized == "set_performance":
|
|
656
|
+
result = local_context_api.set_performance_profile(performance_profile)
|
|
648
657
|
elif normalized == "run_once":
|
|
649
|
-
result = local_context_api.run_once(root=root or None, limit=limit or None, process_limit=process_limit)
|
|
658
|
+
result = local_context_api.run_once(root=root or None, limit=limit or None, process_limit=process_limit or None)
|
|
650
659
|
else:
|
|
651
|
-
result = {"ok": False, "error": "unknown_action", "allowed": ["run_once", "pause", "resume", "clear_index"]}
|
|
660
|
+
result = {"ok": False, "error": "unknown_action", "allowed": ["run_once", "pause", "resume", "clear_index", "set_performance"]}
|
|
652
661
|
return json.dumps(result, ensure_ascii=False)
|
|
653
662
|
|
|
654
663
|
|