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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.13",
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.13` is the current packaged-runtime line. 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.
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.13",
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",
@@ -1336,7 +1336,7 @@ def _reload_launch_agents_after_bump() -> dict:
1336
1336
  return result
1337
1337
 
1338
1338
 
1339
- AUTO_UPDATE_BACKUP_KEEP = 10
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, CRITICAL_TABLES):
1429
+ if not db_looks_wiped(primary, PROTECTED_TABLES):
1428
1430
  return None
1429
- reference = find_latest_hourly_backup(
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, CRITICAL_TABLES)
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, CRITICAL_TABLES)
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, CRITICAL_TABLES)
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, ...] = CRITICAL_TABLES) -> dict[str, int | None]:
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, ...] = CRITICAL_TABLES,
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, ...] = CRITICAL_TABLES,
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, ...] = CRITICAL_TABLES,
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, CRITICAL_TABLES)
85
- reference = find_latest_hourly_backup(
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, CRITICAL_TABLES) if reference else {}
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} critical 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} critical 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 appears wiped or corrupt but a valid backup exists",
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
  ]
@@ -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 = 100,
1683
- live_asset_limit: int = DEFAULT_LIVE_ASSET_LIMIT,
1684
- live_dir_limit: int = DEFAULT_LIVE_DIR_LIMIT,
1685
- live_file_limit: int = DEFAULT_LIVE_FILE_LIMIT,
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=live_asset_limit,
1703
- dir_limit=live_dir_limit,
1704
- file_limit=live_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=limit)
1715
- job_result = process_jobs(limit=process_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,
@@ -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, CRITICAL_TABLES)
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
- if entry["is_usable"]:
172
- return Path(entry["path"]), None
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)
@@ -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
- if not db_looks_wiped(primary_db):
367
- return None # Populated DB — proceed.
368
- reference = find_latest_hourly_backup(BACKUP_BASE)
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
- return (
376
- "Primary DB appears wiped while a recent hourly backup still has real data.\n"
377
- f" nexo.db: {primary_db} (empty)\n"
378
- f" hourly backup: {reference} ({reference_total} critical rows)\n"
379
- "Run `nexo recover` to restore from backup, then retry the update.\n"
380
- "Set NEXO_SKIP_WIPE_GUARD=1 to override (only recommended during a deliberate reinstall)."
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 CRITICAL_TABLES:
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, CRITICAL_TABLES)
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
- SCAN_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_SCAN_LIMIT", "1000") or "1000")
41
- PROCESS_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_PROCESS_LIMIT", "200") or "200")
42
- LIVE_ASSET_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_ASSET_LIMIT", "2000") or "2000")
43
- LIVE_DIR_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_DIR_LIMIT", "300") or "300")
44
- LIVE_FILE_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_FILE_LIMIT", "1000") or "1000")
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 _run_index_cycle() -> dict:
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=SCAN_LIMIT,
121
- process_limit=PROCESS_LIMIT,
122
- live_asset_limit=LIVE_ASSET_LIMIT,
123
- live_dir_limit=LIVE_DIR_LIMIT,
124
- live_file_limit=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=SCAN_LIMIT, process_limit=PROCESS_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 10 most recent
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 10 # override N_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=10, help="N most recent per family to always keep (default: 10)")
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(action: str = "run_once", root: str = "", limit: int = 0, process_limit: int = 100) -> str:
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