nexo-brain 7.20.2 → 7.20.3

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.2",
3
+ "version": "7.20.3",
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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.2",
3
+ "version": "7.20.3",
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",
@@ -3833,6 +3833,66 @@ def check_automation_caller_coverage(days: int = 7) -> DoctorCheck:
3833
3833
  )
3834
3834
 
3835
3835
 
3836
+ def check_local_index_hygiene(fix: bool = False) -> DoctorCheck:
3837
+ try:
3838
+ from local_context import api as local_context_api
3839
+
3840
+ result = local_context_api.local_index_hygiene(fix=fix)
3841
+ residue = result.get("residue") or {}
3842
+ cleanup = result.get("cleanup") or {}
3843
+ suspect_roots = [str(path) for path in result.get("removed_roots") or []]
3844
+ residue_total = sum(int(residue.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))
3845
+ cleanup_total = sum(int(cleanup.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))
3846
+ evidence = [
3847
+ "suspect_installer_roots=" + str(len(suspect_roots)),
3848
+ "residue=" + json.dumps(residue, sort_keys=True),
3849
+ "cleanup=" + json.dumps(cleanup, sort_keys=True),
3850
+ ]
3851
+ evidence.extend(f"root={path}" for path in suspect_roots[:5])
3852
+ if residue_total == 0 and not suspect_roots:
3853
+ return DoctorCheck(
3854
+ id="runtime.local_index_hygiene",
3855
+ tier="runtime",
3856
+ status="healthy",
3857
+ severity="info",
3858
+ summary="Local memory index hygiene is clean",
3859
+ evidence=evidence,
3860
+ repair_plan=[],
3861
+ )
3862
+ if fix:
3863
+ return DoctorCheck(
3864
+ id="runtime.local_index_hygiene",
3865
+ tier="runtime",
3866
+ status="healthy",
3867
+ severity="info",
3868
+ summary="Local memory index hygiene repaired",
3869
+ evidence=evidence,
3870
+ repair_plan=[],
3871
+ fixed=cleanup_total > 0 or bool(suspect_roots),
3872
+ )
3873
+ return DoctorCheck(
3874
+ id="runtime.local_index_hygiene",
3875
+ tier="runtime",
3876
+ status="degraded",
3877
+ severity="warn",
3878
+ summary="Local memory index has stale removed-root residue",
3879
+ evidence=evidence,
3880
+ repair_plan=["Run `nexo doctor --tier runtime --fix` to purge stale local memory roots and installer-volume residue"],
3881
+ escalation_prompt="Local memory status may show stale pending or failed jobs from removed roots.",
3882
+ )
3883
+ except Exception as exc:
3884
+ return DoctorCheck(
3885
+ id="runtime.local_index_hygiene",
3886
+ tier="runtime",
3887
+ status="degraded",
3888
+ severity="warn",
3889
+ summary="Local memory index hygiene could not be checked",
3890
+ evidence=[str(exc)],
3891
+ repair_plan=["Inspect local_context.api.local_index_hygiene and runtime DB tables"],
3892
+ escalation_prompt="Support cannot verify local memory index residue.",
3893
+ )
3894
+
3895
+
3836
3896
  def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
3837
3897
  """Run all runtime-tier checks. Read-only by default."""
3838
3898
  return [
@@ -3854,6 +3914,7 @@ def run_runtime_checks(fix: bool = False) -> list[DoctorCheck]:
3854
3914
  safe_check(check_automation_telemetry),
3855
3915
  safe_check(check_automation_caller_coverage),
3856
3916
  safe_check(check_state_watchers),
3917
+ safe_check(check_local_index_hygiene, fix=fix),
3857
3918
  safe_check(check_release_artifact_sync),
3858
3919
  safe_check(check_release_trace_hygiene),
3859
3920
  safe_check(check_launchagent_inventory),
@@ -16,6 +16,7 @@ from .api import (
16
16
  get_neighbors,
17
17
  list_exclusions,
18
18
  list_roots,
19
+ local_index_hygiene,
19
20
  model_status,
20
21
  pause,
21
22
  purge_asset,
@@ -39,6 +40,7 @@ __all__ = [
39
40
  "get_neighbors",
40
41
  "list_exclusions",
41
42
  "list_roots",
43
+ "local_index_hygiene",
42
44
  "model_status",
43
45
  "pause",
44
46
  "purge_asset",
@@ -64,9 +64,10 @@ def remove_root(path: str) -> dict:
64
64
  conn = _conn()
65
65
  root_path = norm_path(path)
66
66
  conn.execute("UPDATE local_index_roots SET status='removed', updated_at=? WHERE root_path=?", (now(), root_path))
67
+ cleanup = _purge_removed_root_payloads(conn, root_paths=[root_path])
67
68
  conn.commit()
68
- log_event("info", "root_removed", "Root removed", path=redact_path(root_path))
69
- return {"ok": True, "root_path": root_path}
69
+ log_event("info", "root_removed", "Root removed", path=redact_path(root_path), cleanup=cleanup)
70
+ return {"ok": True, "root_path": root_path, "cleanup": cleanup}
70
71
 
71
72
 
72
73
  def list_roots() -> list[dict]:
@@ -108,6 +109,8 @@ def _mounted_volume_roots() -> list[str]:
108
109
  try:
109
110
  if candidate.name.startswith(".") or not candidate.is_dir():
110
111
  continue
112
+ if _should_skip_mounted_root(candidate):
113
+ continue
111
114
  resolved = candidate.resolve()
112
115
  if resolved == root_resolved:
113
116
  continue
@@ -137,6 +140,124 @@ def ensure_default_roots() -> dict:
137
140
  return {"ok": True, "created": len(created), "roots": list_roots()}
138
141
 
139
142
 
143
+ def _should_skip_mounted_root(candidate: Path) -> bool:
144
+ name = candidate.name.strip().lower()
145
+ if name in {"nexo desktop", "nexo desktop beta"} or name.startswith("nexo desktop "):
146
+ return True
147
+ try:
148
+ app_bundles = [child.name.lower() for child in candidate.iterdir() if child.suffix.lower() == ".app"]
149
+ except Exception:
150
+ app_bundles = []
151
+ if any(name.startswith("nexo desktop") for name in app_bundles):
152
+ installer_markers = (
153
+ candidate / ".background",
154
+ candidate / "Applications",
155
+ candidate / ".DS_Store",
156
+ )
157
+ if any(marker.exists() for marker in installer_markers):
158
+ return True
159
+ return False
160
+
161
+
162
+ def _removed_root_filters(conn, *, root_paths: list[str] | None = None) -> tuple[list[int], list[str]]:
163
+ if root_paths:
164
+ placeholders = ",".join("?" for _ in root_paths)
165
+ rows = conn.execute(
166
+ f"SELECT id, root_path FROM local_index_roots WHERE root_path IN ({placeholders}) AND status='removed'",
167
+ tuple(root_paths),
168
+ ).fetchall()
169
+ else:
170
+ rows = conn.execute("SELECT id, root_path FROM local_index_roots WHERE status='removed'").fetchall()
171
+ return [int(row["id"]) for row in rows], [str(row["root_path"]) for row in rows]
172
+
173
+
174
+ def _removed_root_payload_counts(conn, *, root_paths: list[str] | None = None) -> dict:
175
+ root_ids, removed_paths = _removed_root_filters(conn, root_paths=root_paths)
176
+ if not root_ids and not removed_paths:
177
+ return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
178
+ asset_filter, params = _removed_root_asset_filter(root_ids, removed_paths)
179
+ if not asset_filter:
180
+ return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
181
+ asset_subquery = f"SELECT asset_id FROM local_assets WHERE {asset_filter}"
182
+ assets = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_assets WHERE {asset_filter}", tuple(params)).fetchone()["total"] or 0)
183
+ jobs = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_jobs WHERE asset_id IN ({asset_subquery})", tuple(params)).fetchone()["total"] or 0)
184
+ errors = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_errors WHERE asset_id IN ({asset_subquery})", tuple(params)).fetchone()["total"] or 0)
185
+ for path in removed_paths:
186
+ errors += int(conn.execute("SELECT COUNT(*) AS total FROM local_index_errors WHERE asset_id='' AND (path = ? OR path LIKE ?)", (path, f"{path}/%")).fetchone()["total"] or 0)
187
+ dirs = 0
188
+ checkpoints = 0
189
+ if root_ids:
190
+ root_placeholders = ",".join("?" for _ in root_ids)
191
+ dirs = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_dirs WHERE root_id IN ({root_placeholders})", tuple(root_ids)).fetchone()["total"] or 0)
192
+ checkpoints = int(conn.execute(f"SELECT COUNT(*) AS total FROM local_index_checkpoints WHERE root_id IN ({root_placeholders})", tuple(root_ids)).fetchone()["total"] or 0)
193
+ return {"assets": assets, "jobs": jobs, "errors": errors, "dirs": dirs, "checkpoints": checkpoints}
194
+
195
+
196
+ def _removed_root_asset_filter(root_ids: list[int], removed_paths: list[str]) -> tuple[str, list[Any]]:
197
+ filters: list[str] = []
198
+ params: list[Any] = []
199
+ if root_ids:
200
+ root_placeholders = ",".join("?" for _ in root_ids)
201
+ filters.append(f"root_id IN ({root_placeholders})")
202
+ params.extend(root_ids)
203
+ for path in removed_paths:
204
+ filters.append("(path = ? OR path LIKE ?)")
205
+ params.extend([path, f"{path}/%"])
206
+ return " OR ".join(filters), params
207
+
208
+
209
+ def _purge_removed_root_payloads(conn, *, root_paths: list[str] | None = None) -> dict:
210
+ root_ids, removed_paths = _removed_root_filters(conn, root_paths=root_paths)
211
+ if not root_ids and not removed_paths:
212
+ return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
213
+
214
+ asset_filter, params = _removed_root_asset_filter(root_ids, removed_paths)
215
+ if not asset_filter:
216
+ return {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
217
+ asset_subquery = f"SELECT asset_id FROM local_assets WHERE {asset_filter}"
218
+ counts = _removed_root_payload_counts(conn, root_paths=root_paths)
219
+
220
+ for table in ("local_embeddings", "local_chunks", "local_entities", "local_asset_versions"):
221
+ conn.execute(f"DELETE FROM {table} WHERE asset_id IN ({asset_subquery})", tuple(params))
222
+ conn.execute(f"DELETE FROM local_relations WHERE source_asset_id IN ({asset_subquery})", tuple(params))
223
+ conn.execute(f"DELETE FROM local_relations WHERE target_ref IN ({asset_subquery})", tuple(params))
224
+ conn.execute(f"DELETE FROM local_index_jobs WHERE asset_id IN ({asset_subquery})", tuple(params))
225
+ conn.execute(f"DELETE FROM local_index_errors WHERE asset_id IN ({asset_subquery})", tuple(params))
226
+
227
+ for path in removed_paths:
228
+ conn.execute("DELETE FROM local_index_errors WHERE path = ? OR path LIKE ?", (path, f"{path}/%"))
229
+
230
+ if root_ids:
231
+ root_placeholders = ",".join("?" for _ in root_ids)
232
+ conn.execute(f"DELETE FROM local_index_dirs WHERE root_id IN ({root_placeholders})", tuple(root_ids))
233
+ conn.execute(f"DELETE FROM local_index_checkpoints WHERE root_id IN ({root_placeholders})", tuple(root_ids))
234
+ conn.execute(f"DELETE FROM local_assets WHERE {asset_filter}", tuple(params))
235
+ return counts
236
+
237
+
238
+ def local_index_hygiene(*, fix: bool = False) -> dict:
239
+ conn = _conn()
240
+ removed_paths: list[str] = []
241
+ for row in conn.execute("SELECT id, root_path FROM local_index_roots").fetchall():
242
+ path = str(row["root_path"] or "")
243
+ if _should_skip_mounted_root(Path(path)):
244
+ removed_paths.append(path)
245
+ if fix:
246
+ conn.execute("UPDATE local_index_roots SET status='removed', updated_at=? WHERE id=?", (now(), row["id"]))
247
+ before = _removed_root_payload_counts(conn)
248
+ cleanup = {"assets": 0, "jobs": 0, "errors": 0, "dirs": 0, "checkpoints": 0}
249
+ if fix:
250
+ cleanup = _purge_removed_root_payloads(conn)
251
+ conn.commit()
252
+ if fix and (removed_paths or any(int(cleanup.get(key, 0) or 0) for key in ("assets", "jobs", "errors", "dirs", "checkpoints"))):
253
+ log_event("info", "index_hygiene_repaired", "Local memory index hygiene repaired", roots=[redact_path(path) for path in removed_paths], cleanup=cleanup)
254
+ return {"ok": True, "fix": fix, "removed_roots": removed_paths, "residue": before, "cleanup": cleanup}
255
+
256
+
257
+ def repair_index_hygiene() -> dict:
258
+ return local_index_hygiene(fix=True)
259
+
260
+
140
261
  def add_exclusion(path: str, *, reason: str = "user") -> dict:
141
262
  conn = _conn()
142
263
  excluded_path = norm_path(path)
@@ -398,7 +519,7 @@ def _mark_asset_deleted(conn, asset_id: str, deleted_at: float | None = None) ->
398
519
  """
399
520
  UPDATE local_index_jobs
400
521
  SET status='done', last_error_code='asset_deleted', updated_at=?
401
- WHERE asset_id=? AND status IN ('pending', 'running')
522
+ WHERE asset_id=? AND status IN ('pending', 'running', 'failed')
402
523
  """,
403
524
  (deleted_at, asset_id),
404
525
  )
@@ -1102,9 +1223,19 @@ def run_once(
1102
1223
  def _problem_rows(conn) -> list[dict]:
1103
1224
  rows = conn.execute(
1104
1225
  """
1105
- SELECT path, phase, error_code, user_message, technical_detail, retryable, created_at
1106
- FROM local_index_errors
1107
- ORDER BY id DESC
1226
+ SELECT e.path, e.phase, e.error_code, e.user_message, e.technical_detail, e.retryable, e.created_at
1227
+ FROM local_index_errors e
1228
+ LEFT JOIN local_assets a ON a.asset_id=e.asset_id
1229
+ LEFT JOIN local_index_roots r ON r.id=a.root_id
1230
+ WHERE COALESCE(r.status, 'active') != 'removed'
1231
+ AND NOT EXISTS (
1232
+ SELECT 1
1233
+ FROM local_index_roots rr
1234
+ WHERE rr.status='removed'
1235
+ AND e.path != ''
1236
+ AND (e.path = rr.root_path OR e.path LIKE rr.root_path || '/%')
1237
+ )
1238
+ ORDER BY e.id DESC
1108
1239
  LIMIT 20
1109
1240
  """
1110
1241
  ).fetchall()
@@ -1387,9 +1518,24 @@ def status() -> dict:
1387
1518
  conn = _conn()
1388
1519
  paused = _is_paused()
1389
1520
  assets = conn.execute(
1390
- "SELECT COUNT(*) AS total, SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active FROM local_assets"
1521
+ """
1522
+ SELECT COUNT(*) AS total, SUM(CASE WHEN a.status='active' THEN 1 ELSE 0 END) AS active
1523
+ FROM local_assets a
1524
+ LEFT JOIN local_index_roots r ON r.id=a.root_id
1525
+ WHERE COALESCE(r.status, 'active') != 'removed'
1526
+ """
1391
1527
  ).fetchone()
1392
- job_rows = conn.execute("SELECT status, COUNT(*) AS total FROM local_index_jobs GROUP BY status").fetchall()
1528
+ job_rows = conn.execute(
1529
+ """
1530
+ SELECT j.status, COUNT(*) AS total
1531
+ FROM local_index_jobs j
1532
+ JOIN local_assets a ON a.asset_id=j.asset_id
1533
+ LEFT JOIN local_index_roots r ON r.id=a.root_id
1534
+ WHERE a.status='active'
1535
+ AND COALESCE(r.status, 'active') != 'removed'
1536
+ GROUP BY j.status
1537
+ """
1538
+ ).fetchall()
1393
1539
  job_counts = {row["status"]: int(row["total"] or 0) for row in job_rows}
1394
1540
  pending = int(job_counts.get("pending", 0) or 0)
1395
1541
  running_jobs = int(job_counts.get("running", 0) or 0)
@@ -1401,7 +1547,15 @@ def status() -> dict:
1401
1547
  roots = list_roots()
1402
1548
  volumes = []
1403
1549
  by_volume = conn.execute(
1404
- "SELECT volume_id, COUNT(*) AS files FROM local_assets WHERE status='active' GROUP BY volume_id ORDER BY volume_id"
1550
+ """
1551
+ SELECT a.volume_id, COUNT(*) AS files
1552
+ FROM local_assets a
1553
+ LEFT JOIN local_index_roots r ON r.id=a.root_id
1554
+ WHERE a.status='active'
1555
+ AND COALESCE(r.status, 'active') != 'removed'
1556
+ GROUP BY a.volume_id
1557
+ ORDER BY a.volume_id
1558
+ """
1405
1559
  ).fetchall()
1406
1560
  for row in by_volume:
1407
1561
  volumes.append({"id": row["volume_id"], "label": row["volume_id"] or "Disk", "files": row["files"], "status": "active"})