nexo-brain 7.20.1 → 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.1",
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/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.1` is the current packaged-runtime line. Patch release over v7.20.0the Local Context service now recovers from orphaned locks and mixed-version cycle failures instead of leaving the background index stuck.
21
+ Version `7.20.2` is the current packaged-runtime line. Patch release over v7.20.1 — Local Context now requeues stalled work, reports real macOS/Windows background-service health, records scan errors and preserves Windows drive roots.
22
+
23
+ Previously in `7.20.1`: patch release over v7.20.0 — the Local Context service now recovers from orphaned locks and mixed-version cycle failures instead of leaving the background index stuck.
22
24
 
23
25
  Previously in `7.20.0`: minor release over v7.19.0 — the Local Context index now reconciles known files and folders on every service cycle, so created, modified, deleted and newly excluded local files are reflected automatically between full scans.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.20.1",
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
  )
@@ -425,6 +546,48 @@ def _mark_dir_subtree_deleted(conn, dir_path: str, deleted_at: float | None = No
425
546
  return len(rows)
426
547
 
427
548
 
549
+ def _record_index_error(
550
+ conn,
551
+ *,
552
+ asset_id: str = "",
553
+ path: str = "",
554
+ phase: str,
555
+ error_code: str,
556
+ user_message: str,
557
+ technical_detail: str,
558
+ retryable: bool = True,
559
+ ) -> None:
560
+ conn.execute(
561
+ """
562
+ INSERT INTO local_index_errors(asset_id, path, phase, error_code, user_message, technical_detail, retryable, created_at)
563
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
564
+ """,
565
+ (asset_id, path, phase, error_code, user_message, technical_detail, 1 if retryable else 0, now()),
566
+ )
567
+
568
+
569
+ def _record_scan_error(conn, stats: dict | None, path: str, phase: str, exc: Exception) -> None:
570
+ if stats is not None:
571
+ stats["errors"] = int(stats.get("errors", 0) or 0) + 1
572
+ logged = int(stats.get("_errors_logged", 0) or 0)
573
+ if logged >= 20:
574
+ return
575
+ stats["_errors_logged"] = logged + 1
576
+ _record_index_error(
577
+ conn,
578
+ path=path,
579
+ phase=phase,
580
+ error_code=type(exc).__name__,
581
+ user_message="Algunas carpetas o archivos no se pudieron leer",
582
+ technical_detail=str(exc),
583
+ retryable=True,
584
+ )
585
+
586
+
587
+ def _public_stats(stats: dict) -> dict:
588
+ return {key: value for key, value in stats.items() if not str(key).startswith("_")}
589
+
590
+
428
591
  def enqueue_job(conn, asset_id: str, job_type: str, *, priority: int = 50) -> str:
429
592
  job_id = stable_id("job", f"{asset_id}:{job_type}")
430
593
  conn.execute(
@@ -447,6 +610,7 @@ def _iter_files(
447
610
  limit: int | None = None,
448
611
  start_after: str = "",
449
612
  seen_at: float | None = None,
613
+ stats: dict | None = None,
450
614
  ):
451
615
  seen_at = seen_at or now()
452
616
  seen_dirs: set[tuple[int, int]] = set()
@@ -461,7 +625,8 @@ def _iter_files(
461
625
  continue
462
626
  try:
463
627
  st = current.stat()
464
- except Exception:
628
+ except Exception as exc:
629
+ _record_scan_error(conn, stats, str(current), "quick_index", exc)
465
630
  continue
466
631
  key = (getattr(st, "st_dev", 0), getattr(st, "st_ino", 0))
467
632
  if key in seen_dirs:
@@ -470,7 +635,8 @@ def _iter_files(
470
635
  _upsert_dir(conn, root_id, current, seen_at, st)
471
636
  try:
472
637
  entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
473
- except Exception:
638
+ except Exception as exc:
639
+ _record_scan_error(conn, stats, str(current), "quick_index", exc)
474
640
  continue
475
641
  dirs: list[Path] = []
476
642
  for entry in entries:
@@ -577,8 +743,8 @@ def _reconcile_known_assets(conn, exclusions: list[str], *, limit: int) -> dict:
577
743
  continue
578
744
  st = file_path.stat()
579
745
  fingerprint = quick_fingerprint(file_path, st)
580
- except Exception:
581
- stats["errors"] += 1
746
+ except Exception as exc:
747
+ _record_scan_error(conn, stats, path, "live_reconcile", exc)
582
748
  continue
583
749
  if fingerprint != row["quick_fingerprint"]:
584
750
  _, changed, state = _upsert_asset(conn, int(row["root_id"] or 0), file_path, seen_at, int(row["depth"] or 2))
@@ -647,8 +813,8 @@ def _scan_known_directory(
647
813
  if not current.is_dir():
648
814
  continue
649
815
  entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
650
- except Exception:
651
- stats["errors"] += 1
816
+ except Exception as exc:
817
+ _record_scan_error(conn, stats, str(current), "live_reconcile", exc)
652
818
  continue
653
819
  scanned_dirs += 1
654
820
  stats["dirs_scanned"] += 1
@@ -679,8 +845,8 @@ def _scan_known_directory(
679
845
  stats["files_changed"] += 1
680
846
  if state != "ok":
681
847
  stats["errors"] += 1
682
- except Exception:
683
- stats["errors"] += 1
848
+ except Exception as exc:
849
+ _record_scan_error(conn, stats, str(entry), "live_reconcile", exc)
684
850
  deleted_files, deleted_dirs = _prune_missing_children(conn, current, seen_files, seen_dirs, seen_at)
685
851
  stats["files_deleted"] += deleted_files
686
852
  stats["dirs_deleted"] += deleted_dirs
@@ -731,8 +897,8 @@ def _reconcile_known_dirs(conn, exclusions: list[str], *, dir_limit: int, file_l
731
897
  continue
732
898
  st = dir_path.stat()
733
899
  fingerprint = _dir_fingerprint(dir_path, st)
734
- except Exception:
735
- stats["errors"] += 1
900
+ except Exception as exc:
901
+ _record_scan_error(conn, stats, str(dir_path), "live_reconcile", exc)
736
902
  continue
737
903
  if fingerprint != row["quick_fingerprint"]:
738
904
  stats["changed"] += 1
@@ -773,9 +939,18 @@ def reconcile_live_changes(
773
939
  + int(dir_stats.get("dirs_deleted", 0))
774
940
  + int(dir_stats.get("excluded_dirs", 0))
775
941
  )
776
- if changed_total:
777
- log_event("info", "live_reconcile_finished", "Local memory live changes reconciled", assets=asset_stats, dirs=dir_stats)
778
- return {"ok": True, "assets": asset_stats, "dirs": dir_stats}
942
+ error_total = int(asset_stats.get("errors", 0) or 0) + int(dir_stats.get("errors", 0) or 0)
943
+ public_asset_stats = _public_stats(asset_stats)
944
+ public_dir_stats = _public_stats(dir_stats)
945
+ if changed_total or error_total:
946
+ log_event(
947
+ "warn" if error_total else "info",
948
+ "live_reconcile_finished",
949
+ "Local memory live changes reconciled",
950
+ assets=public_asset_stats,
951
+ dirs=public_dir_stats,
952
+ )
953
+ return {"ok": True, "assets": public_asset_stats, "dirs": public_dir_stats}
779
954
 
780
955
 
781
956
  def scan_once(*, limit: int | None = None) -> dict:
@@ -814,6 +989,7 @@ def scan_once(*, limit: int | None = None) -> dict:
814
989
  limit=limit,
815
990
  start_after=str(checkpoint["current_path"] or ""),
816
991
  seen_at=cycle_started_at,
992
+ stats=totals,
817
993
  ):
818
994
  asset_id, changed, state = _upsert_asset(conn, root_id, file_path, cycle_started_at, int(root["depth"] or 2))
819
995
  last_seen_path = norm_path(file_path)
@@ -833,7 +1009,7 @@ def scan_once(*, limit: int | None = None) -> dict:
833
1009
  path=redact_path(str(root_path)),
834
1010
  )
835
1011
  if last_seen_path:
836
- _save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=totals)
1012
+ _save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=_public_stats(totals))
837
1013
  else:
838
1014
  rows = conn.execute(
839
1015
  "SELECT asset_id FROM local_assets WHERE root_id=? AND status='active' AND last_seen_at < ?",
@@ -850,8 +1026,9 @@ def scan_once(*, limit: int | None = None) -> dict:
850
1026
  (now(), now(), root_id),
851
1027
  )
852
1028
  conn.commit()
853
- log_event("info", "scan_finished", "Local memory scan finished", **totals)
854
- return {"ok": True, **totals}
1029
+ public_totals = _public_stats(totals)
1030
+ log_event("warn" if public_totals.get("errors") else "info", "scan_finished", "Local memory scan finished", **public_totals)
1031
+ return {"ok": True, **public_totals}
855
1032
 
856
1033
 
857
1034
  def _latest_version_id(conn, asset_id: str) -> str:
@@ -913,11 +1090,35 @@ def _replace_entities(conn, asset_id: str, version_id: str, values: list[str]) -
913
1090
  )
914
1091
 
915
1092
 
1093
+ def _requeue_due_jobs(conn) -> dict:
1094
+ current = now()
1095
+ failed = conn.execute(
1096
+ """
1097
+ UPDATE local_index_jobs
1098
+ SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
1099
+ WHERE status='failed' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
1100
+ """,
1101
+ (current, current),
1102
+ ).rowcount
1103
+ expired = conn.execute(
1104
+ """
1105
+ UPDATE local_index_jobs
1106
+ SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
1107
+ WHERE status='running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?
1108
+ """,
1109
+ (current, current),
1110
+ ).rowcount
1111
+ if failed or expired:
1112
+ log_event("warn", "jobs_requeued", "Local memory recovered stalled jobs", failed=failed, expired=expired)
1113
+ return {"failed": int(failed or 0), "expired": int(expired or 0)}
1114
+
1115
+
916
1116
  def process_jobs(*, limit: int = 100) -> dict:
917
1117
  conn = _conn()
918
1118
  if _is_paused():
919
1119
  log_event("info", "jobs_skipped_paused", "Local memory jobs skipped because indexing is paused")
920
1120
  return {"ok": True, "paused": True, "processed": 0, "failed": 0}
1121
+ recovered = _requeue_due_jobs(conn)
921
1122
  rows = conn.execute(
922
1123
  """
923
1124
  SELECT j.*, a.path, a.depth, a.status AS asset_status
@@ -976,17 +1177,20 @@ def process_jobs(*, limit: int = 100) -> dict:
976
1177
  """,
977
1178
  (now() + 3600, type(exc).__name__, now(), job_id),
978
1179
  )
979
- conn.execute(
980
- """
981
- INSERT INTO local_index_errors(asset_id, path, phase, error_code, user_message, technical_detail, retryable, created_at)
982
- VALUES (?, ?, ?, ?, ?, ?, 1, ?)
983
- """,
984
- (asset_id, row["path"], job_type, type(exc).__name__, "Algunos archivos no se pudieron leer", str(exc), now()),
1180
+ _record_index_error(
1181
+ conn,
1182
+ asset_id=asset_id,
1183
+ path=row["path"],
1184
+ phase=job_type,
1185
+ error_code=type(exc).__name__,
1186
+ user_message="Algunos archivos no se pudieron leer",
1187
+ technical_detail=str(exc),
1188
+ retryable=True,
985
1189
  )
986
1190
  conn.commit()
987
1191
  if processed or failed:
988
1192
  log_event("info", "jobs_processed", "Local memory jobs processed", processed=processed, failed=failed)
989
- return {"ok": True, "processed": processed, "failed": failed}
1193
+ return {"ok": True, "processed": processed, "failed": failed, "recovered": recovered}
990
1194
 
991
1195
 
992
1196
  def run_once(
@@ -1000,6 +1204,12 @@ def run_once(
1000
1204
  ) -> dict:
1001
1205
  if root:
1002
1206
  add_root(root)
1207
+ elif (
1208
+ os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1"
1209
+ and os.environ.get("NEXO_SKIP_FS_INDEX", "").strip() != "1"
1210
+ and not list_roots()
1211
+ ):
1212
+ ensure_default_roots()
1003
1213
  live_result = reconcile_live_changes(
1004
1214
  asset_limit=live_asset_limit,
1005
1215
  dir_limit=live_dir_limit,
@@ -1013,9 +1223,19 @@ def run_once(
1013
1223
  def _problem_rows(conn) -> list[dict]:
1014
1224
  rows = conn.execute(
1015
1225
  """
1016
- SELECT path, phase, error_code, user_message, technical_detail, retryable, created_at
1017
- FROM local_index_errors
1018
- 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
1019
1239
  LIMIT 20
1020
1240
  """
1021
1241
  ).fetchall()
@@ -1040,7 +1260,7 @@ def _problem_rows(conn) -> list[dict]:
1040
1260
  """
1041
1261
  SELECT created_at, level, event, message, metadata_json
1042
1262
  FROM local_index_logs
1043
- WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback')
1263
+ WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
1044
1264
  AND created_at > ?
1045
1265
  ORDER BY id DESC
1046
1266
  LIMIT 5
@@ -1101,6 +1321,7 @@ def _macos_local_index_service_status() -> dict:
1101
1321
  running = False
1102
1322
  active_process = False
1103
1323
  pid = ""
1324
+ launchctl_status = ""
1104
1325
 
1105
1326
  code, stdout, _ = _command_output(["launchctl", "list"], timeout=2)
1106
1327
  if code == 0:
@@ -1109,6 +1330,7 @@ def _macos_local_index_service_status() -> dict:
1109
1330
  if len(parts) >= 3 and parts[-1] == LOCAL_INDEX_SERVICE_LABEL:
1110
1331
  installed = True
1111
1332
  pid = parts[0]
1333
+ launchctl_status = parts[1]
1112
1334
  running = True
1113
1335
  active_process = pid.isdigit() and int(pid) > 0
1114
1336
  break
@@ -1128,6 +1350,7 @@ def _macos_local_index_service_status() -> dict:
1128
1350
  "manager": "launchagent",
1129
1351
  "label": LOCAL_INDEX_SERVICE_LABEL,
1130
1352
  "pid": pid,
1353
+ "last_exit_code": launchctl_status,
1131
1354
  "config_path": str(plist_path),
1132
1355
  }
1133
1356
 
@@ -1135,11 +1358,22 @@ def _macos_local_index_service_status() -> dict:
1135
1358
  def _windows_local_index_service_status() -> dict:
1136
1359
  command = (
1137
1360
  "$task = Get-ScheduledTask -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue; "
1138
- "if ($task) { Write-Output $task.State }"
1361
+ "$info = if ($task) { Get-ScheduledTaskInfo -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue }; "
1362
+ "if ($task) { "
1363
+ "$lastRun = if ($info -and $info.LastRunTime) { $info.LastRunTime.ToString('o') } else { '' }; "
1364
+ "$nextRun = if ($info -and $info.NextRunTime) { $info.NextRunTime.ToString('o') } else { '' }; "
1365
+ "$lastResult = if ($info) { [string]$info.LastTaskResult } else { '' }; "
1366
+ "Write-Output ($task.State.ToString() + '|' + $lastResult + '|' + $lastRun + '|' + $nextRun) "
1367
+ "}"
1139
1368
  )
1140
1369
  code, stdout, _ = _command_output(["powershell", "-NoProfile", "-Command", command], timeout=4)
1141
- task_state = stdout.strip()
1370
+ raw = stdout.strip()
1371
+ parts = raw.split("|") if "|" in raw else [raw]
1372
+ task_state = parts[0].strip() if parts else ""
1142
1373
  task_state_key = task_state.lower()
1374
+ last_task_result = parts[1].strip() if len(parts) > 1 else ""
1375
+ last_run_time = parts[2].strip() if len(parts) > 2 else ""
1376
+ next_run_time = parts[3].strip() if len(parts) > 3 else ""
1143
1377
  installed = code == 0 and bool(task_state)
1144
1378
  active_process = task_state_key == "running"
1145
1379
  if not active_process:
@@ -1152,6 +1386,9 @@ def _windows_local_index_service_status() -> dict:
1152
1386
  "manager": "scheduled_task",
1153
1387
  "task_name": LOCAL_INDEX_WINDOWS_TASK,
1154
1388
  "task_state": task_state,
1389
+ "last_task_result": last_task_result,
1390
+ "last_run_time": last_run_time,
1391
+ "next_run_time": next_run_time,
1155
1392
  }
1156
1393
 
1157
1394
 
@@ -1194,41 +1431,171 @@ def _local_index_service_status() -> dict:
1194
1431
  return service
1195
1432
 
1196
1433
 
1434
+ def _service_cycle_observation(conn) -> dict:
1435
+ last_success = conn.execute(
1436
+ "SELECT MAX(created_at) AS created_at FROM local_index_logs WHERE event='service_cycle_finished'"
1437
+ ).fetchone()["created_at"] or 0
1438
+ latest = conn.execute(
1439
+ """
1440
+ SELECT created_at, event, level, message, metadata_json
1441
+ FROM local_index_logs
1442
+ WHERE event IN ('service_cycle_finished', 'service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
1443
+ ORDER BY id DESC
1444
+ LIMIT 1
1445
+ """
1446
+ ).fetchone()
1447
+ latest_error = conn.execute(
1448
+ """
1449
+ SELECT created_at, event, level, message, metadata_json
1450
+ FROM local_index_logs
1451
+ WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
1452
+ AND created_at > ?
1453
+ ORDER BY id DESC
1454
+ LIMIT 1
1455
+ """,
1456
+ (last_success,),
1457
+ ).fetchone()
1458
+ observation = {
1459
+ "last_success_at": float(last_success or 0),
1460
+ "last_error_at": 0,
1461
+ "last_error_code": "",
1462
+ "last_error_detail": "",
1463
+ "healthy": latest_error is None,
1464
+ }
1465
+ if latest:
1466
+ observation["last_heartbeat_at"] = float(latest["created_at"] or 0)
1467
+ if latest_error:
1468
+ observation["last_error_at"] = float(latest_error["created_at"] or 0)
1469
+ observation["last_error_code"] = latest_error["event"]
1470
+ observation["last_error_detail"] = f"{latest_error['message']} {latest_error['metadata_json']}"
1471
+ return observation
1472
+
1473
+
1474
+ def _service_scheduler_has_error(service: dict) -> bool:
1475
+ if service.get("manager") == "launchagent":
1476
+ code = str(service.get("last_exit_code") or "").strip()
1477
+ return bool(code and code not in {"0", "-"})
1478
+ if service.get("manager") == "scheduled_task":
1479
+ code = str(service.get("last_task_result") or "").strip()
1480
+ return bool(code and code not in {"0"})
1481
+ return False
1482
+
1483
+
1484
+ def _service_problem(service: dict) -> dict | None:
1485
+ if not service.get("installed"):
1486
+ return {
1487
+ "support_code": "local_index_service_not_installed",
1488
+ "user_message": "La memoria local aun no tiene activo el servicio en segundo plano",
1489
+ "recommended_action": "Reabre NEXO Desktop o actualiza a la ultima version para instalarlo automaticamente.",
1490
+ "technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
1491
+ }
1492
+ if not service.get("running"):
1493
+ return {
1494
+ "support_code": "local_index_service_not_running",
1495
+ "user_message": "La memoria local no se esta actualizando en segundo plano",
1496
+ "recommended_action": "NEXO intentara recuperarlo automaticamente. Si se repite, abre soporte y diagnostico.",
1497
+ "technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
1498
+ }
1499
+ if _service_scheduler_has_error(service):
1500
+ code = service.get("last_exit_code") or service.get("last_task_result") or ""
1501
+ return {
1502
+ "support_code": "local_index_service_last_run_failed",
1503
+ "user_message": "La ultima comprobacion de memoria local no termino correctamente",
1504
+ "recommended_action": "NEXO lo volvera a intentar automaticamente.",
1505
+ "technical_detail": f"last_result={code}",
1506
+ }
1507
+ if not service.get("healthy", True):
1508
+ return {
1509
+ "support_code": service.get("last_error_code") or "local_index_service_failed",
1510
+ "user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
1511
+ "recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
1512
+ "technical_detail": service.get("last_error_detail") or "",
1513
+ }
1514
+ return None
1515
+
1516
+
1197
1517
  def status() -> dict:
1198
1518
  conn = _conn()
1199
1519
  paused = _is_paused()
1200
1520
  assets = conn.execute(
1201
- "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
+ """
1202
1527
  ).fetchone()
1203
- pending = conn.execute("SELECT COUNT(*) AS total FROM local_index_jobs WHERE status='pending'").fetchone()["total"]
1204
- done = conn.execute("SELECT COUNT(*) AS total FROM local_index_jobs WHERE status='done'").fetchone()["total"]
1205
- total_jobs = pending + done
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()
1539
+ job_counts = {row["status"]: int(row["total"] or 0) for row in job_rows}
1540
+ pending = int(job_counts.get("pending", 0) or 0)
1541
+ running_jobs = int(job_counts.get("running", 0) or 0)
1542
+ failed_jobs = int(job_counts.get("failed", 0) or 0)
1543
+ done = int(job_counts.get("done", 0) or 0)
1544
+ active_jobs = pending + running_jobs + failed_jobs
1545
+ total_jobs = active_jobs + done
1206
1546
  percent = 100 if total_jobs == 0 else int((done / max(total_jobs, 1)) * 100)
1207
1547
  roots = list_roots()
1208
1548
  volumes = []
1209
1549
  by_volume = conn.execute(
1210
- "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
+ """
1211
1559
  ).fetchall()
1212
1560
  for row in by_volume:
1213
1561
  volumes.append({"id": row["volume_id"], "label": row["volume_id"] or "Disk", "files": row["files"], "status": "active"})
1214
1562
  service = _local_index_service_status()
1215
- service["state"] = "paused" if paused else ("idle" if pending == 0 else "indexing")
1563
+ service.update(_service_cycle_observation(conn))
1564
+ problem = _service_problem(service)
1565
+ service["healthy"] = problem is None
1566
+ service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 else "indexing"))
1567
+ problems = _problem_rows(conn)
1568
+ if problem:
1569
+ problems.insert(0, {
1570
+ "user_message": problem["user_message"],
1571
+ "recommended_action": problem["recommended_action"],
1572
+ "technical_detail": problem["technical_detail"],
1573
+ "support_code": problem["support_code"],
1574
+ "severity": "warning",
1575
+ "retryable": True,
1576
+ "path": "",
1577
+ "phase": "service",
1578
+ "created_at": now(),
1579
+ })
1216
1580
  return {
1217
1581
  "ok": True,
1218
1582
  "service": service,
1219
1583
  "global": {
1220
- "phase": "paused" if paused else ("idle" if pending == 0 else "light_extraction"),
1584
+ "phase": "paused" if paused else ("service_attention" if problem else ("idle" if active_jobs == 0 else "light_extraction")),
1221
1585
  "percent": percent,
1222
1586
  "files_found": int(assets["total"] or 0),
1223
1587
  "files_processed": int(done or 0),
1224
- "changes_pending": int(pending or 0),
1588
+ "changes_pending": int(active_jobs or 0),
1589
+ "jobs_pending": pending,
1590
+ "jobs_running": running_jobs,
1591
+ "jobs_failed": failed_jobs,
1225
1592
  "elapsed_seconds": 0,
1226
1593
  "eta_seconds": None,
1227
1594
  },
1228
1595
  "volumes": volumes,
1229
1596
  "roots": roots,
1230
1597
  "exclusions": list_exclusions(),
1231
- "problems": _problem_rows(conn),
1598
+ "problems": problems,
1232
1599
  "permissions": [],
1233
1600
  "models": model_status()["models"],
1234
1601
  "support_log_available": True,
@@ -15,7 +15,15 @@ def now() -> float:
15
15
 
16
16
 
17
17
  def norm_path(path: str | os.PathLike[str]) -> str:
18
- return str(Path(path).expanduser()).rstrip(os.sep)
18
+ text = str(Path(path).expanduser())
19
+ if re.match(r"^[A-Za-z]:[\\/]*$", text):
20
+ return f"{text[0].upper()}:\\"
21
+ if text in {"/", "\\"}:
22
+ return text
23
+ stripped = text.rstrip("/\\")
24
+ if re.match(r"^[A-Za-z]:$", stripped):
25
+ return f"{stripped[0].upper()}:\\"
26
+ return stripped or text
19
27
 
20
28
 
21
29
  def stable_id(prefix: str, value: str) -> str:
@@ -51,6 +51,13 @@ def log(message: str) -> None:
51
51
  handle.write(line + "\n")
52
52
 
53
53
 
54
+ def _log_event_best_effort(level: str, event: str, message: str, **metadata) -> None:
55
+ try:
56
+ log_event(level, event, message, **metadata)
57
+ except Exception as exc:
58
+ log(f"ERROR: failed to record local-index event {event}: {type(exc).__name__}: {exc}")
59
+
60
+
54
61
  def _read_lock() -> dict:
55
62
  try:
56
63
  return json.loads(LOCK_FILE.read_text(encoding="utf-8"))
@@ -121,7 +128,7 @@ def _run_index_cycle() -> dict:
121
128
  live_kwargs = ("live_asset_limit", "live_dir_limit", "live_file_limit")
122
129
  if not any(name in message for name in live_kwargs):
123
130
  raise
124
- log_event(
131
+ _log_event_best_effort(
125
132
  "warn",
126
133
  "service_cycle_compat_fallback",
127
134
  "Local memory service used compatibility fallback",
@@ -134,17 +141,18 @@ def _run_index_cycle() -> dict:
134
141
  def main() -> int:
135
142
  if not acquire_lock():
136
143
  log("Skipped: previous local-index cycle is still running.")
144
+ _log_event_best_effort("warn", "service_cycle_skipped_lock", "Local memory service skipped because a previous cycle is still running")
137
145
  return 0
138
146
  try:
139
147
  if os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1":
140
148
  api.ensure_default_roots()
141
149
  result = _run_index_cycle()
142
- log_event("info", "service_cycle_finished", "Local memory service cycle finished", result=result)
150
+ _log_event_best_effort("info", "service_cycle_finished", "Local memory service cycle finished", result=result)
143
151
  log(json.dumps(result, ensure_ascii=False, sort_keys=True))
144
152
  return 0 if result.get("ok") else 2
145
153
  except Exception as exc:
146
- log_event("error", "service_cycle_failed", "Local memory service cycle failed", error=type(exc).__name__)
147
154
  log(f"ERROR: {type(exc).__name__}: {exc}")
155
+ _log_event_best_effort("error", "service_cycle_failed", "Local memory service cycle failed", error=type(exc).__name__)
148
156
  return 2
149
157
  finally:
150
158
  release_lock()