nexo-brain 7.20.13 → 7.20.15
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 +5 -1
- package/package.json +1 -1
- package/src/auto_update.py +31 -7
- 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 +112 -18
- 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,11 @@
|
|
|
18
18
|
|
|
19
19
|
[Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
|
|
20
20
|
|
|
21
|
-
Version `7.20.
|
|
21
|
+
Version `7.20.15` is the current packaged-runtime line. Patch release over v7.20.14 — Brain update/recovery paths now fail closed when the DB guard is missing or stale, and backup validation rejects any replacement that loses Local Memory tables.
|
|
22
|
+
|
|
23
|
+
Previously in `7.20.14`: 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.
|
|
24
|
+
|
|
25
|
+
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
26
|
|
|
23
27
|
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
28
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.20.
|
|
3
|
+
"version": "7.20.15",
|
|
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
|
@@ -82,6 +82,23 @@ TEMPLATE_FILE = _RESOLVED_REPO_DIR / "templates" / "CLAUDE.md.template"
|
|
|
82
82
|
CHECK_COOLDOWN_SECONDS = 3600 # 1 hour
|
|
83
83
|
GIT_TIMEOUT_SECONDS = 4 # stay well under the 5s total budget
|
|
84
84
|
CRITICAL_BACKUP_TABLES = ("learnings", "session_diary", "guard_checks", "protocol_debt")
|
|
85
|
+
LOCAL_CONTEXT_BACKUP_TABLES = (
|
|
86
|
+
"local_index_roots",
|
|
87
|
+
"local_index_exclusions",
|
|
88
|
+
"local_index_jobs",
|
|
89
|
+
"local_index_checkpoints",
|
|
90
|
+
"local_index_state",
|
|
91
|
+
"local_index_errors",
|
|
92
|
+
"local_assets",
|
|
93
|
+
"local_asset_versions",
|
|
94
|
+
"local_chunks",
|
|
95
|
+
"local_entities",
|
|
96
|
+
"local_relations",
|
|
97
|
+
"local_embeddings",
|
|
98
|
+
"local_context_queries",
|
|
99
|
+
"local_index_dirs",
|
|
100
|
+
)
|
|
101
|
+
PROTECTED_BACKUP_TABLES = CRITICAL_BACKUP_TABLES + LOCAL_CONTEXT_BACKUP_TABLES
|
|
85
102
|
CLASSIFIER_INSTALL_TIMEOUT_SECONDS = 1800
|
|
86
103
|
CLASSIFIER_INSTALL_JOIN_SECONDS = 1500
|
|
87
104
|
CLASSIFIER_INSTALL_LOG = paths.logs_dir() / "classifier-install.log"
|
|
@@ -321,7 +338,7 @@ def _validate_db_backup(source_db: Path, backup_db: Path) -> dict:
|
|
|
321
338
|
report["errors"].append(f"backup db missing: {backup_db}")
|
|
322
339
|
return report
|
|
323
340
|
|
|
324
|
-
for table in
|
|
341
|
+
for table in PROTECTED_BACKUP_TABLES:
|
|
325
342
|
source_count = _critical_table_count(source_db, table)
|
|
326
343
|
backup_count = _critical_table_count(backup_db, table)
|
|
327
344
|
report["source_counts"][table] = source_count
|
|
@@ -1336,7 +1353,7 @@ def _reload_launch_agents_after_bump() -> dict:
|
|
|
1336
1353
|
return result
|
|
1337
1354
|
|
|
1338
1355
|
|
|
1339
|
-
AUTO_UPDATE_BACKUP_KEEP =
|
|
1356
|
+
AUTO_UPDATE_BACKUP_KEEP = 5
|
|
1340
1357
|
"""Maximum number of auto-update backups to keep per prefix.
|
|
1341
1358
|
|
|
1342
1359
|
Both `pre-autoupdate-*/` (DB snapshots) and `runtime-tree-*/` (code mirrors)
|
|
@@ -1409,8 +1426,10 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1409
1426
|
CRITICAL_TABLES,
|
|
1410
1427
|
HOURLY_BACKUP_MAX_AGE,
|
|
1411
1428
|
MIN_REFERENCE_ROWS,
|
|
1429
|
+
PROTECTED_TABLES,
|
|
1412
1430
|
db_looks_wiped,
|
|
1413
1431
|
db_row_counts,
|
|
1432
|
+
find_best_hourly_backup,
|
|
1414
1433
|
find_latest_hourly_backup,
|
|
1415
1434
|
quiesce_nexo_db_writers,
|
|
1416
1435
|
resume_nexo_launchagents,
|
|
@@ -1424,11 +1443,16 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1424
1443
|
primary = DATA_DIR / "nexo.db"
|
|
1425
1444
|
if not primary.is_file():
|
|
1426
1445
|
return None
|
|
1427
|
-
if not db_looks_wiped(primary,
|
|
1446
|
+
if not db_looks_wiped(primary, PROTECTED_TABLES):
|
|
1428
1447
|
return None
|
|
1429
|
-
reference =
|
|
1448
|
+
reference = find_best_hourly_backup(
|
|
1449
|
+
paths.backups_dir(),
|
|
1450
|
+
max_age_seconds=HOURLY_BACKUP_MAX_AGE,
|
|
1451
|
+
tables=PROTECTED_TABLES,
|
|
1452
|
+
) or find_latest_hourly_backup(
|
|
1430
1453
|
paths.backups_dir(),
|
|
1431
1454
|
max_age_seconds=HOURLY_BACKUP_MAX_AGE,
|
|
1455
|
+
tables=PROTECTED_TABLES,
|
|
1432
1456
|
)
|
|
1433
1457
|
if reference is None:
|
|
1434
1458
|
_log("self-heal: nexo.db looks wiped but no usable hourly backup found — skipping.")
|
|
@@ -1437,7 +1461,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1437
1461
|
"reason": "no_usable_hourly_backup",
|
|
1438
1462
|
"primary_db": str(primary),
|
|
1439
1463
|
}
|
|
1440
|
-
ref_counts = db_row_counts(reference,
|
|
1464
|
+
ref_counts = db_row_counts(reference, PROTECTED_TABLES)
|
|
1441
1465
|
ref_total = sum(v for v in ref_counts.values() if isinstance(v, int))
|
|
1442
1466
|
if ref_total < MIN_REFERENCE_ROWS:
|
|
1443
1467
|
_log(f"self-heal: reference backup {reference.name} has {ref_total} rows, below floor {MIN_REFERENCE_ROWS}")
|
|
@@ -1526,7 +1550,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1526
1550
|
"quiesce": quiesce_report,
|
|
1527
1551
|
"resume": resume_report,
|
|
1528
1552
|
}
|
|
1529
|
-
valid, valid_err = validate_backup_matches_source(reference, primary,
|
|
1553
|
+
valid, valid_err = validate_backup_matches_source(reference, primary, PROTECTED_TABLES)
|
|
1530
1554
|
if not valid:
|
|
1531
1555
|
_log(f"self-heal: post-restore validation failed: {valid_err}")
|
|
1532
1556
|
resume_report = _resume_quiesced()
|
|
@@ -1540,7 +1564,7 @@ def _self_heal_if_wiped() -> dict | None:
|
|
|
1540
1564
|
"resume": resume_report,
|
|
1541
1565
|
}
|
|
1542
1566
|
|
|
1543
|
-
final_counts = db_row_counts(primary,
|
|
1567
|
+
final_counts = db_row_counts(primary, PROTECTED_TABLES)
|
|
1544
1568
|
final_total = sum(v for v in final_counts.values() if isinstance(v, int))
|
|
1545
1569
|
_log(f"self-heal: restored {final_total} critical rows from {reference.name}.")
|
|
1546
1570
|
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
|
@@ -32,27 +32,31 @@ except ModuleNotFoundError as exc:
|
|
|
32
32
|
def is_duplicate_artifact_name(_path) -> bool:
|
|
33
33
|
return False
|
|
34
34
|
|
|
35
|
-
# db_guard
|
|
36
|
-
#
|
|
37
|
-
#
|
|
38
|
-
# the fixed module on its own.
|
|
35
|
+
# db_guard must be available before any DB-touching update runs. Desktop syncs
|
|
36
|
+
# this file plus db_guard.py as a pair; if a stale runtime imports update.py
|
|
37
|
+
# without a current guard, we abort instead of risking a local-memory wipe.
|
|
39
38
|
try:
|
|
40
39
|
from db_guard import (
|
|
41
40
|
CRITICAL_TABLES,
|
|
42
41
|
HOURLY_BACKUP_MAX_AGE,
|
|
43
42
|
MIN_REFERENCE_ROWS,
|
|
43
|
+
PROTECTED_TABLES,
|
|
44
44
|
WIPE_THRESHOLD_PCT,
|
|
45
45
|
db_looks_wiped,
|
|
46
46
|
db_row_counts,
|
|
47
47
|
diff_row_counts,
|
|
48
|
+
find_best_hourly_backup,
|
|
48
49
|
find_latest_hourly_backup,
|
|
49
50
|
safe_sqlite_backup,
|
|
50
51
|
validate_backup_matches_source,
|
|
51
52
|
)
|
|
52
53
|
_DB_GUARD_AVAILABLE = True
|
|
53
|
-
|
|
54
|
+
_DB_GUARD_IMPORT_ERROR = ""
|
|
55
|
+
except Exception as exc: # pragma: no cover - exercised only during stale installs
|
|
54
56
|
_DB_GUARD_AVAILABLE = False
|
|
57
|
+
_DB_GUARD_IMPORT_ERROR = str(exc)
|
|
55
58
|
CRITICAL_TABLES = ()
|
|
59
|
+
PROTECTED_TABLES = ()
|
|
56
60
|
HOURLY_BACKUP_MAX_AGE = 48 * 3600
|
|
57
61
|
MIN_REFERENCE_ROWS = 50
|
|
58
62
|
WIPE_THRESHOLD_PCT = 80
|
|
@@ -66,6 +70,9 @@ except Exception: # pragma: no cover - exercised only during mid-upgrade instal
|
|
|
66
70
|
def diff_row_counts(*_args, **_kwargs): # type: ignore[misc]
|
|
67
71
|
return None
|
|
68
72
|
|
|
73
|
+
def find_best_hourly_backup(*_args, **_kwargs): # type: ignore[misc]
|
|
74
|
+
return None
|
|
75
|
+
|
|
69
76
|
def find_latest_hourly_backup(*_args, **_kwargs): # type: ignore[misc]
|
|
70
77
|
return None
|
|
71
78
|
|
|
@@ -84,6 +91,13 @@ except Exception: # pragma: no cover - exercised only during mid-upgrade instal
|
|
|
84
91
|
def validate_backup_matches_source(*_args, **_kwargs): # type: ignore[misc]
|
|
85
92
|
return True, None
|
|
86
93
|
|
|
94
|
+
REQUIRED_LOCAL_MEMORY_TABLES: tuple[str, ...] = (
|
|
95
|
+
"local_assets",
|
|
96
|
+
"local_chunks",
|
|
97
|
+
"local_embeddings",
|
|
98
|
+
"local_index_dirs",
|
|
99
|
+
)
|
|
100
|
+
|
|
87
101
|
# Code root is the parent of plugins/:
|
|
88
102
|
# - source checkout: <repo>/src
|
|
89
103
|
# - packaged runtime: <NEXO_HOME>
|
|
@@ -121,6 +135,7 @@ def _is_packaged_install() -> bool:
|
|
|
121
135
|
NEXO_HOME = export_resolved_nexo_home()
|
|
122
136
|
DATA_DIR = paths.data_dir()
|
|
123
137
|
BACKUP_BASE = paths.backups_dir()
|
|
138
|
+
TECHNICAL_BACKUP_KEEP = 5
|
|
124
139
|
|
|
125
140
|
# In packaged installs, update.py lives at <NEXO_HOME>/plugins/update.py.
|
|
126
141
|
_PACKAGED_INSTALL = _is_packaged_install()
|
|
@@ -129,6 +144,38 @@ SRC_DIR = CODE_ROOT
|
|
|
129
144
|
PACKAGE_JSON = REPO_DIR / "package.json"
|
|
130
145
|
|
|
131
146
|
|
|
147
|
+
def _rotate_backup_family(prefix: str, keep: int = TECHNICAL_BACKUP_KEEP) -> int:
|
|
148
|
+
"""Rotate technical rollback directories for one backup prefix.
|
|
149
|
+
|
|
150
|
+
Cleanup is best-effort: update/rollback safety must never depend on
|
|
151
|
+
deleting old snapshots successfully.
|
|
152
|
+
"""
|
|
153
|
+
if keep <= 0:
|
|
154
|
+
return 0
|
|
155
|
+
base = BACKUP_BASE
|
|
156
|
+
if not base.is_dir():
|
|
157
|
+
return 0
|
|
158
|
+
try:
|
|
159
|
+
candidates = [p for p in base.iterdir() if p.is_dir() and p.name.startswith(prefix)]
|
|
160
|
+
except Exception:
|
|
161
|
+
return 0
|
|
162
|
+
if len(candidates) <= keep:
|
|
163
|
+
return 0
|
|
164
|
+
try:
|
|
165
|
+
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
|
166
|
+
except Exception:
|
|
167
|
+
return 0
|
|
168
|
+
|
|
169
|
+
removed = 0
|
|
170
|
+
for old in candidates[keep:]:
|
|
171
|
+
try:
|
|
172
|
+
shutil.rmtree(old)
|
|
173
|
+
removed += 1
|
|
174
|
+
except Exception:
|
|
175
|
+
continue
|
|
176
|
+
return removed
|
|
177
|
+
|
|
178
|
+
|
|
132
179
|
def _venv_python_path(runtime_root: Path | None = None) -> Path:
|
|
133
180
|
root = runtime_root or _nexo_home()
|
|
134
181
|
if sys.platform == "win32":
|
|
@@ -344,6 +391,30 @@ def _check_dirty() -> str | None:
|
|
|
344
391
|
return None
|
|
345
392
|
|
|
346
393
|
|
|
394
|
+
def _db_guard_integrity_error() -> str | None:
|
|
395
|
+
"""Return why DB protection is unsafe, or None when current guard is usable."""
|
|
396
|
+
if os.environ.get("NEXO_SKIP_WIPE_GUARD") == "1":
|
|
397
|
+
return None
|
|
398
|
+
primary_db = DATA_DIR / "nexo.db"
|
|
399
|
+
if not primary_db.is_file():
|
|
400
|
+
return None
|
|
401
|
+
if not _DB_GUARD_AVAILABLE:
|
|
402
|
+
return (
|
|
403
|
+
"DB protection module is not available; refusing to update while local memory may exist.\n"
|
|
404
|
+
f" import error: {_DB_GUARD_IMPORT_ERROR or 'unknown'}\n"
|
|
405
|
+
"Restart Desktop so it can sync the bundled Brain guard, then retry the update."
|
|
406
|
+
)
|
|
407
|
+
protected = set(PROTECTED_TABLES)
|
|
408
|
+
missing = [table for table in REQUIRED_LOCAL_MEMORY_TABLES if table not in protected]
|
|
409
|
+
if missing:
|
|
410
|
+
return (
|
|
411
|
+
"DB protection module is stale; refusing to update because local memory tables are not protected.\n"
|
|
412
|
+
f" missing tables: {', '.join(missing)}\n"
|
|
413
|
+
"Restart Desktop so it can sync the bundled Brain guard, then retry the update."
|
|
414
|
+
)
|
|
415
|
+
return None
|
|
416
|
+
|
|
417
|
+
|
|
347
418
|
def _preflight_wipe_check() -> str | None:
|
|
348
419
|
"""Abort the update early if the primary DB already looks wiped.
|
|
349
420
|
|
|
@@ -360,25 +431,40 @@ def _preflight_wipe_check() -> str | None:
|
|
|
360
431
|
"""
|
|
361
432
|
if os.environ.get("NEXO_SKIP_WIPE_GUARD") == "1":
|
|
362
433
|
return None
|
|
434
|
+
guard_err = _db_guard_integrity_error()
|
|
435
|
+
if guard_err:
|
|
436
|
+
return guard_err
|
|
363
437
|
primary_db = DATA_DIR / "nexo.db"
|
|
364
438
|
if not primary_db.is_file():
|
|
365
439
|
return None # Nothing to protect; fresh install path.
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
440
|
+
reference = find_best_hourly_backup(
|
|
441
|
+
BACKUP_BASE,
|
|
442
|
+
tables=PROTECTED_TABLES,
|
|
443
|
+
) or find_latest_hourly_backup(BACKUP_BASE, tables=PROTECTED_TABLES)
|
|
369
444
|
if reference is None:
|
|
370
445
|
return None # No reference to compare against; cannot distinguish wipe from fresh install.
|
|
371
|
-
reference_counts = db_row_counts(reference)
|
|
446
|
+
reference_counts = db_row_counts(reference, PROTECTED_TABLES)
|
|
372
447
|
reference_total = sum(v for v in reference_counts.values() if isinstance(v, int))
|
|
373
448
|
if reference_total < MIN_REFERENCE_ROWS:
|
|
374
449
|
return None # Reference itself is near-empty; likely fresh install.
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
450
|
+
if db_looks_wiped(primary_db, PROTECTED_TABLES):
|
|
451
|
+
return (
|
|
452
|
+
"Primary DB appears wiped while a recent hourly backup still has real data.\n"
|
|
453
|
+
f" nexo.db: {primary_db} (empty)\n"
|
|
454
|
+
f" hourly backup: {reference} ({reference_total} protected rows)\n"
|
|
455
|
+
"Run `nexo recover` to restore from backup, then retry the update.\n"
|
|
456
|
+
"Set NEXO_SKIP_WIPE_GUARD=1 to override (only recommended during a deliberate reinstall)."
|
|
457
|
+
)
|
|
458
|
+
report = diff_row_counts(primary_db, reference, PROTECTED_TABLES)
|
|
459
|
+
if report.is_wipe():
|
|
460
|
+
return (
|
|
461
|
+
"Primary DB has lost protected data compared with the latest usable backup.\n"
|
|
462
|
+
f" nexo.db: {primary_db}\n"
|
|
463
|
+
f" backup: {reference} ({reference_total} protected rows)\n"
|
|
464
|
+
+ "\n".join(report.summary_lines())
|
|
465
|
+
+ "\nRun `nexo doctor --tier boot --plane database_real --fix` before updating."
|
|
466
|
+
)
|
|
467
|
+
return None
|
|
382
468
|
|
|
383
469
|
|
|
384
470
|
def _row_count_regression(pre: dict[str, int | None], post: dict[str, int | None]) -> str | None:
|
|
@@ -391,7 +477,7 @@ def _row_count_regression(pre: dict[str, int | None], post: dict[str, int | None
|
|
|
391
477
|
regressions: list[str] = []
|
|
392
478
|
pre_total = sum(v for v in pre.values() if isinstance(v, int))
|
|
393
479
|
post_total = sum(v for v in post.values() if isinstance(v, int))
|
|
394
|
-
for table in
|
|
480
|
+
for table in PROTECTED_TABLES:
|
|
395
481
|
pre_v = pre.get(table)
|
|
396
482
|
post_v = post.get(table)
|
|
397
483
|
if pre_v is None or pre_v < 10:
|
|
@@ -439,12 +525,16 @@ def _backup_databases() -> tuple[str, str | None]:
|
|
|
439
525
|
# Only validate row counts for the primary DB — the other sidecar DBs
|
|
440
526
|
# (cognitive.db, cron-runs.db) do not share CRITICAL_TABLES.
|
|
441
527
|
if db_file.name == "nexo.db":
|
|
442
|
-
valid, valid_err = validate_backup_matches_source(db_file, dest,
|
|
528
|
+
valid, valid_err = validate_backup_matches_source(db_file, dest, PROTECTED_TABLES)
|
|
443
529
|
if not valid:
|
|
444
530
|
return str(backup_dir), (
|
|
445
531
|
f"Backup of {db_file.name} did not preserve critical tables: {valid_err}"
|
|
446
532
|
)
|
|
447
533
|
|
|
534
|
+
try:
|
|
535
|
+
_rotate_backup_family("pre-update-")
|
|
536
|
+
except Exception:
|
|
537
|
+
pass
|
|
448
538
|
return str(backup_dir), None
|
|
449
539
|
|
|
450
540
|
|
|
@@ -846,6 +936,10 @@ def _backup_code_tree() -> tuple[str | None, str | None]:
|
|
|
846
936
|
shutil.copy2(vf, backup_dir / "version.json")
|
|
847
937
|
except Exception as e:
|
|
848
938
|
return None, f"Code tree backup failed: {e}"
|
|
939
|
+
try:
|
|
940
|
+
_rotate_backup_family("code-tree-")
|
|
941
|
+
except Exception:
|
|
942
|
+
pass
|
|
849
943
|
return str(backup_dir), None
|
|
850
944
|
|
|
851
945
|
|
|
@@ -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
|
|