nexo-brain 7.20.1 → 7.20.2

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.2",
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.2",
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",
@@ -425,6 +425,48 @@ def _mark_dir_subtree_deleted(conn, dir_path: str, deleted_at: float | None = No
425
425
  return len(rows)
426
426
 
427
427
 
428
+ def _record_index_error(
429
+ conn,
430
+ *,
431
+ asset_id: str = "",
432
+ path: str = "",
433
+ phase: str,
434
+ error_code: str,
435
+ user_message: str,
436
+ technical_detail: str,
437
+ retryable: bool = True,
438
+ ) -> None:
439
+ conn.execute(
440
+ """
441
+ INSERT INTO local_index_errors(asset_id, path, phase, error_code, user_message, technical_detail, retryable, created_at)
442
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
443
+ """,
444
+ (asset_id, path, phase, error_code, user_message, technical_detail, 1 if retryable else 0, now()),
445
+ )
446
+
447
+
448
+ def _record_scan_error(conn, stats: dict | None, path: str, phase: str, exc: Exception) -> None:
449
+ if stats is not None:
450
+ stats["errors"] = int(stats.get("errors", 0) or 0) + 1
451
+ logged = int(stats.get("_errors_logged", 0) or 0)
452
+ if logged >= 20:
453
+ return
454
+ stats["_errors_logged"] = logged + 1
455
+ _record_index_error(
456
+ conn,
457
+ path=path,
458
+ phase=phase,
459
+ error_code=type(exc).__name__,
460
+ user_message="Algunas carpetas o archivos no se pudieron leer",
461
+ technical_detail=str(exc),
462
+ retryable=True,
463
+ )
464
+
465
+
466
+ def _public_stats(stats: dict) -> dict:
467
+ return {key: value for key, value in stats.items() if not str(key).startswith("_")}
468
+
469
+
428
470
  def enqueue_job(conn, asset_id: str, job_type: str, *, priority: int = 50) -> str:
429
471
  job_id = stable_id("job", f"{asset_id}:{job_type}")
430
472
  conn.execute(
@@ -447,6 +489,7 @@ def _iter_files(
447
489
  limit: int | None = None,
448
490
  start_after: str = "",
449
491
  seen_at: float | None = None,
492
+ stats: dict | None = None,
450
493
  ):
451
494
  seen_at = seen_at or now()
452
495
  seen_dirs: set[tuple[int, int]] = set()
@@ -461,7 +504,8 @@ def _iter_files(
461
504
  continue
462
505
  try:
463
506
  st = current.stat()
464
- except Exception:
507
+ except Exception as exc:
508
+ _record_scan_error(conn, stats, str(current), "quick_index", exc)
465
509
  continue
466
510
  key = (getattr(st, "st_dev", 0), getattr(st, "st_ino", 0))
467
511
  if key in seen_dirs:
@@ -470,7 +514,8 @@ def _iter_files(
470
514
  _upsert_dir(conn, root_id, current, seen_at, st)
471
515
  try:
472
516
  entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
473
- except Exception:
517
+ except Exception as exc:
518
+ _record_scan_error(conn, stats, str(current), "quick_index", exc)
474
519
  continue
475
520
  dirs: list[Path] = []
476
521
  for entry in entries:
@@ -577,8 +622,8 @@ def _reconcile_known_assets(conn, exclusions: list[str], *, limit: int) -> dict:
577
622
  continue
578
623
  st = file_path.stat()
579
624
  fingerprint = quick_fingerprint(file_path, st)
580
- except Exception:
581
- stats["errors"] += 1
625
+ except Exception as exc:
626
+ _record_scan_error(conn, stats, path, "live_reconcile", exc)
582
627
  continue
583
628
  if fingerprint != row["quick_fingerprint"]:
584
629
  _, changed, state = _upsert_asset(conn, int(row["root_id"] or 0), file_path, seen_at, int(row["depth"] or 2))
@@ -647,8 +692,8 @@ def _scan_known_directory(
647
692
  if not current.is_dir():
648
693
  continue
649
694
  entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
650
- except Exception:
651
- stats["errors"] += 1
695
+ except Exception as exc:
696
+ _record_scan_error(conn, stats, str(current), "live_reconcile", exc)
652
697
  continue
653
698
  scanned_dirs += 1
654
699
  stats["dirs_scanned"] += 1
@@ -679,8 +724,8 @@ def _scan_known_directory(
679
724
  stats["files_changed"] += 1
680
725
  if state != "ok":
681
726
  stats["errors"] += 1
682
- except Exception:
683
- stats["errors"] += 1
727
+ except Exception as exc:
728
+ _record_scan_error(conn, stats, str(entry), "live_reconcile", exc)
684
729
  deleted_files, deleted_dirs = _prune_missing_children(conn, current, seen_files, seen_dirs, seen_at)
685
730
  stats["files_deleted"] += deleted_files
686
731
  stats["dirs_deleted"] += deleted_dirs
@@ -731,8 +776,8 @@ def _reconcile_known_dirs(conn, exclusions: list[str], *, dir_limit: int, file_l
731
776
  continue
732
777
  st = dir_path.stat()
733
778
  fingerprint = _dir_fingerprint(dir_path, st)
734
- except Exception:
735
- stats["errors"] += 1
779
+ except Exception as exc:
780
+ _record_scan_error(conn, stats, str(dir_path), "live_reconcile", exc)
736
781
  continue
737
782
  if fingerprint != row["quick_fingerprint"]:
738
783
  stats["changed"] += 1
@@ -773,9 +818,18 @@ def reconcile_live_changes(
773
818
  + int(dir_stats.get("dirs_deleted", 0))
774
819
  + int(dir_stats.get("excluded_dirs", 0))
775
820
  )
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}
821
+ error_total = int(asset_stats.get("errors", 0) or 0) + int(dir_stats.get("errors", 0) or 0)
822
+ public_asset_stats = _public_stats(asset_stats)
823
+ public_dir_stats = _public_stats(dir_stats)
824
+ if changed_total or error_total:
825
+ log_event(
826
+ "warn" if error_total else "info",
827
+ "live_reconcile_finished",
828
+ "Local memory live changes reconciled",
829
+ assets=public_asset_stats,
830
+ dirs=public_dir_stats,
831
+ )
832
+ return {"ok": True, "assets": public_asset_stats, "dirs": public_dir_stats}
779
833
 
780
834
 
781
835
  def scan_once(*, limit: int | None = None) -> dict:
@@ -814,6 +868,7 @@ def scan_once(*, limit: int | None = None) -> dict:
814
868
  limit=limit,
815
869
  start_after=str(checkpoint["current_path"] or ""),
816
870
  seen_at=cycle_started_at,
871
+ stats=totals,
817
872
  ):
818
873
  asset_id, changed, state = _upsert_asset(conn, root_id, file_path, cycle_started_at, int(root["depth"] or 2))
819
874
  last_seen_path = norm_path(file_path)
@@ -833,7 +888,7 @@ def scan_once(*, limit: int | None = None) -> dict:
833
888
  path=redact_path(str(root_path)),
834
889
  )
835
890
  if last_seen_path:
836
- _save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=totals)
891
+ _save_checkpoint(conn, root_id, last_seen_path, cycle_started_at=cycle_started_at, totals=_public_stats(totals))
837
892
  else:
838
893
  rows = conn.execute(
839
894
  "SELECT asset_id FROM local_assets WHERE root_id=? AND status='active' AND last_seen_at < ?",
@@ -850,8 +905,9 @@ def scan_once(*, limit: int | None = None) -> dict:
850
905
  (now(), now(), root_id),
851
906
  )
852
907
  conn.commit()
853
- log_event("info", "scan_finished", "Local memory scan finished", **totals)
854
- return {"ok": True, **totals}
908
+ public_totals = _public_stats(totals)
909
+ log_event("warn" if public_totals.get("errors") else "info", "scan_finished", "Local memory scan finished", **public_totals)
910
+ return {"ok": True, **public_totals}
855
911
 
856
912
 
857
913
  def _latest_version_id(conn, asset_id: str) -> str:
@@ -913,11 +969,35 @@ def _replace_entities(conn, asset_id: str, version_id: str, values: list[str]) -
913
969
  )
914
970
 
915
971
 
972
+ def _requeue_due_jobs(conn) -> dict:
973
+ current = now()
974
+ failed = conn.execute(
975
+ """
976
+ UPDATE local_index_jobs
977
+ SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
978
+ WHERE status='failed' AND (next_attempt_at IS NULL OR next_attempt_at <= ?)
979
+ """,
980
+ (current, current),
981
+ ).rowcount
982
+ expired = conn.execute(
983
+ """
984
+ UPDATE local_index_jobs
985
+ SET status='pending', claimed_by='', lease_expires_at=NULL, updated_at=?
986
+ WHERE status='running' AND lease_expires_at IS NOT NULL AND lease_expires_at <= ?
987
+ """,
988
+ (current, current),
989
+ ).rowcount
990
+ if failed or expired:
991
+ log_event("warn", "jobs_requeued", "Local memory recovered stalled jobs", failed=failed, expired=expired)
992
+ return {"failed": int(failed or 0), "expired": int(expired or 0)}
993
+
994
+
916
995
  def process_jobs(*, limit: int = 100) -> dict:
917
996
  conn = _conn()
918
997
  if _is_paused():
919
998
  log_event("info", "jobs_skipped_paused", "Local memory jobs skipped because indexing is paused")
920
999
  return {"ok": True, "paused": True, "processed": 0, "failed": 0}
1000
+ recovered = _requeue_due_jobs(conn)
921
1001
  rows = conn.execute(
922
1002
  """
923
1003
  SELECT j.*, a.path, a.depth, a.status AS asset_status
@@ -976,17 +1056,20 @@ def process_jobs(*, limit: int = 100) -> dict:
976
1056
  """,
977
1057
  (now() + 3600, type(exc).__name__, now(), job_id),
978
1058
  )
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()),
1059
+ _record_index_error(
1060
+ conn,
1061
+ asset_id=asset_id,
1062
+ path=row["path"],
1063
+ phase=job_type,
1064
+ error_code=type(exc).__name__,
1065
+ user_message="Algunos archivos no se pudieron leer",
1066
+ technical_detail=str(exc),
1067
+ retryable=True,
985
1068
  )
986
1069
  conn.commit()
987
1070
  if processed or failed:
988
1071
  log_event("info", "jobs_processed", "Local memory jobs processed", processed=processed, failed=failed)
989
- return {"ok": True, "processed": processed, "failed": failed}
1072
+ return {"ok": True, "processed": processed, "failed": failed, "recovered": recovered}
990
1073
 
991
1074
 
992
1075
  def run_once(
@@ -1000,6 +1083,12 @@ def run_once(
1000
1083
  ) -> dict:
1001
1084
  if root:
1002
1085
  add_root(root)
1086
+ elif (
1087
+ os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1"
1088
+ and os.environ.get("NEXO_SKIP_FS_INDEX", "").strip() != "1"
1089
+ and not list_roots()
1090
+ ):
1091
+ ensure_default_roots()
1003
1092
  live_result = reconcile_live_changes(
1004
1093
  asset_limit=live_asset_limit,
1005
1094
  dir_limit=live_dir_limit,
@@ -1040,7 +1129,7 @@ def _problem_rows(conn) -> list[dict]:
1040
1129
  """
1041
1130
  SELECT created_at, level, event, message, metadata_json
1042
1131
  FROM local_index_logs
1043
- WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback')
1132
+ WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
1044
1133
  AND created_at > ?
1045
1134
  ORDER BY id DESC
1046
1135
  LIMIT 5
@@ -1101,6 +1190,7 @@ def _macos_local_index_service_status() -> dict:
1101
1190
  running = False
1102
1191
  active_process = False
1103
1192
  pid = ""
1193
+ launchctl_status = ""
1104
1194
 
1105
1195
  code, stdout, _ = _command_output(["launchctl", "list"], timeout=2)
1106
1196
  if code == 0:
@@ -1109,6 +1199,7 @@ def _macos_local_index_service_status() -> dict:
1109
1199
  if len(parts) >= 3 and parts[-1] == LOCAL_INDEX_SERVICE_LABEL:
1110
1200
  installed = True
1111
1201
  pid = parts[0]
1202
+ launchctl_status = parts[1]
1112
1203
  running = True
1113
1204
  active_process = pid.isdigit() and int(pid) > 0
1114
1205
  break
@@ -1128,6 +1219,7 @@ def _macos_local_index_service_status() -> dict:
1128
1219
  "manager": "launchagent",
1129
1220
  "label": LOCAL_INDEX_SERVICE_LABEL,
1130
1221
  "pid": pid,
1222
+ "last_exit_code": launchctl_status,
1131
1223
  "config_path": str(plist_path),
1132
1224
  }
1133
1225
 
@@ -1135,11 +1227,22 @@ def _macos_local_index_service_status() -> dict:
1135
1227
  def _windows_local_index_service_status() -> dict:
1136
1228
  command = (
1137
1229
  "$task = Get-ScheduledTask -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue; "
1138
- "if ($task) { Write-Output $task.State }"
1230
+ "$info = if ($task) { Get-ScheduledTaskInfo -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue }; "
1231
+ "if ($task) { "
1232
+ "$lastRun = if ($info -and $info.LastRunTime) { $info.LastRunTime.ToString('o') } else { '' }; "
1233
+ "$nextRun = if ($info -and $info.NextRunTime) { $info.NextRunTime.ToString('o') } else { '' }; "
1234
+ "$lastResult = if ($info) { [string]$info.LastTaskResult } else { '' }; "
1235
+ "Write-Output ($task.State.ToString() + '|' + $lastResult + '|' + $lastRun + '|' + $nextRun) "
1236
+ "}"
1139
1237
  )
1140
1238
  code, stdout, _ = _command_output(["powershell", "-NoProfile", "-Command", command], timeout=4)
1141
- task_state = stdout.strip()
1239
+ raw = stdout.strip()
1240
+ parts = raw.split("|") if "|" in raw else [raw]
1241
+ task_state = parts[0].strip() if parts else ""
1142
1242
  task_state_key = task_state.lower()
1243
+ last_task_result = parts[1].strip() if len(parts) > 1 else ""
1244
+ last_run_time = parts[2].strip() if len(parts) > 2 else ""
1245
+ next_run_time = parts[3].strip() if len(parts) > 3 else ""
1143
1246
  installed = code == 0 and bool(task_state)
1144
1247
  active_process = task_state_key == "running"
1145
1248
  if not active_process:
@@ -1152,6 +1255,9 @@ def _windows_local_index_service_status() -> dict:
1152
1255
  "manager": "scheduled_task",
1153
1256
  "task_name": LOCAL_INDEX_WINDOWS_TASK,
1154
1257
  "task_state": task_state,
1258
+ "last_task_result": last_task_result,
1259
+ "last_run_time": last_run_time,
1260
+ "next_run_time": next_run_time,
1155
1261
  }
1156
1262
 
1157
1263
 
@@ -1194,15 +1300,103 @@ def _local_index_service_status() -> dict:
1194
1300
  return service
1195
1301
 
1196
1302
 
1303
+ def _service_cycle_observation(conn) -> dict:
1304
+ last_success = conn.execute(
1305
+ "SELECT MAX(created_at) AS created_at FROM local_index_logs WHERE event='service_cycle_finished'"
1306
+ ).fetchone()["created_at"] or 0
1307
+ latest = conn.execute(
1308
+ """
1309
+ SELECT created_at, event, level, message, metadata_json
1310
+ FROM local_index_logs
1311
+ WHERE event IN ('service_cycle_finished', 'service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
1312
+ ORDER BY id DESC
1313
+ LIMIT 1
1314
+ """
1315
+ ).fetchone()
1316
+ latest_error = conn.execute(
1317
+ """
1318
+ SELECT created_at, event, level, message, metadata_json
1319
+ FROM local_index_logs
1320
+ WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback', 'service_cycle_skipped_lock')
1321
+ AND created_at > ?
1322
+ ORDER BY id DESC
1323
+ LIMIT 1
1324
+ """,
1325
+ (last_success,),
1326
+ ).fetchone()
1327
+ observation = {
1328
+ "last_success_at": float(last_success or 0),
1329
+ "last_error_at": 0,
1330
+ "last_error_code": "",
1331
+ "last_error_detail": "",
1332
+ "healthy": latest_error is None,
1333
+ }
1334
+ if latest:
1335
+ observation["last_heartbeat_at"] = float(latest["created_at"] or 0)
1336
+ if latest_error:
1337
+ observation["last_error_at"] = float(latest_error["created_at"] or 0)
1338
+ observation["last_error_code"] = latest_error["event"]
1339
+ observation["last_error_detail"] = f"{latest_error['message']} {latest_error['metadata_json']}"
1340
+ return observation
1341
+
1342
+
1343
+ def _service_scheduler_has_error(service: dict) -> bool:
1344
+ if service.get("manager") == "launchagent":
1345
+ code = str(service.get("last_exit_code") or "").strip()
1346
+ return bool(code and code not in {"0", "-"})
1347
+ if service.get("manager") == "scheduled_task":
1348
+ code = str(service.get("last_task_result") or "").strip()
1349
+ return bool(code and code not in {"0"})
1350
+ return False
1351
+
1352
+
1353
+ def _service_problem(service: dict) -> dict | None:
1354
+ if not service.get("installed"):
1355
+ return {
1356
+ "support_code": "local_index_service_not_installed",
1357
+ "user_message": "La memoria local aun no tiene activo el servicio en segundo plano",
1358
+ "recommended_action": "Reabre NEXO Desktop o actualiza a la ultima version para instalarlo automaticamente.",
1359
+ "technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
1360
+ }
1361
+ if not service.get("running"):
1362
+ return {
1363
+ "support_code": "local_index_service_not_running",
1364
+ "user_message": "La memoria local no se esta actualizando en segundo plano",
1365
+ "recommended_action": "NEXO intentara recuperarlo automaticamente. Si se repite, abre soporte y diagnostico.",
1366
+ "technical_detail": f"manager={service.get('manager')} platform={service.get('platform')}",
1367
+ }
1368
+ if _service_scheduler_has_error(service):
1369
+ code = service.get("last_exit_code") or service.get("last_task_result") or ""
1370
+ return {
1371
+ "support_code": "local_index_service_last_run_failed",
1372
+ "user_message": "La ultima comprobacion de memoria local no termino correctamente",
1373
+ "recommended_action": "NEXO lo volvera a intentar automaticamente.",
1374
+ "technical_detail": f"last_result={code}",
1375
+ }
1376
+ if not service.get("healthy", True):
1377
+ return {
1378
+ "support_code": service.get("last_error_code") or "local_index_service_failed",
1379
+ "user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
1380
+ "recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
1381
+ "technical_detail": service.get("last_error_detail") or "",
1382
+ }
1383
+ return None
1384
+
1385
+
1197
1386
  def status() -> dict:
1198
1387
  conn = _conn()
1199
1388
  paused = _is_paused()
1200
1389
  assets = conn.execute(
1201
1390
  "SELECT COUNT(*) AS total, SUM(CASE WHEN status='active' THEN 1 ELSE 0 END) AS active FROM local_assets"
1202
1391
  ).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
1392
+ job_rows = conn.execute("SELECT status, COUNT(*) AS total FROM local_index_jobs GROUP BY status").fetchall()
1393
+ job_counts = {row["status"]: int(row["total"] or 0) for row in job_rows}
1394
+ pending = int(job_counts.get("pending", 0) or 0)
1395
+ running_jobs = int(job_counts.get("running", 0) or 0)
1396
+ failed_jobs = int(job_counts.get("failed", 0) or 0)
1397
+ done = int(job_counts.get("done", 0) or 0)
1398
+ active_jobs = pending + running_jobs + failed_jobs
1399
+ total_jobs = active_jobs + done
1206
1400
  percent = 100 if total_jobs == 0 else int((done / max(total_jobs, 1)) * 100)
1207
1401
  roots = list_roots()
1208
1402
  volumes = []
@@ -1212,23 +1406,42 @@ def status() -> dict:
1212
1406
  for row in by_volume:
1213
1407
  volumes.append({"id": row["volume_id"], "label": row["volume_id"] or "Disk", "files": row["files"], "status": "active"})
1214
1408
  service = _local_index_service_status()
1215
- service["state"] = "paused" if paused else ("idle" if pending == 0 else "indexing")
1409
+ service.update(_service_cycle_observation(conn))
1410
+ problem = _service_problem(service)
1411
+ service["healthy"] = problem is None
1412
+ service["state"] = "paused" if paused else ("attention" if problem else ("idle" if active_jobs == 0 else "indexing"))
1413
+ problems = _problem_rows(conn)
1414
+ if problem:
1415
+ problems.insert(0, {
1416
+ "user_message": problem["user_message"],
1417
+ "recommended_action": problem["recommended_action"],
1418
+ "technical_detail": problem["technical_detail"],
1419
+ "support_code": problem["support_code"],
1420
+ "severity": "warning",
1421
+ "retryable": True,
1422
+ "path": "",
1423
+ "phase": "service",
1424
+ "created_at": now(),
1425
+ })
1216
1426
  return {
1217
1427
  "ok": True,
1218
1428
  "service": service,
1219
1429
  "global": {
1220
- "phase": "paused" if paused else ("idle" if pending == 0 else "light_extraction"),
1430
+ "phase": "paused" if paused else ("service_attention" if problem else ("idle" if active_jobs == 0 else "light_extraction")),
1221
1431
  "percent": percent,
1222
1432
  "files_found": int(assets["total"] or 0),
1223
1433
  "files_processed": int(done or 0),
1224
- "changes_pending": int(pending or 0),
1434
+ "changes_pending": int(active_jobs or 0),
1435
+ "jobs_pending": pending,
1436
+ "jobs_running": running_jobs,
1437
+ "jobs_failed": failed_jobs,
1225
1438
  "elapsed_seconds": 0,
1226
1439
  "eta_seconds": None,
1227
1440
  },
1228
1441
  "volumes": volumes,
1229
1442
  "roots": roots,
1230
1443
  "exclusions": list_exclusions(),
1231
- "problems": _problem_rows(conn),
1444
+ "problems": problems,
1232
1445
  "permissions": [],
1233
1446
  "models": model_status()["models"],
1234
1447
  "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()