nexo-brain 7.19.0 → 7.20.1

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.19.0",
3
+ "version": "7.20.1",
4
4
  "description": "Local cognitive runtime for Claude Code \u2014 persistent memory, overnight learning, doctor diagnostics, personal scripts, recovery-aware jobs, startup preflight, and optional dashboard/power helper.",
5
5
  "author": {
6
6
  "name": "NEXO Brain",
package/README.md CHANGED
@@ -18,7 +18,11 @@
18
18
 
19
19
  [Watch the overview video](https://nexo-brain.com/watch/) · [Watch on YouTube](https://www.youtube.com/watch?v=i2lkGhKyVqI) · [Open the infographic](https://nexo-brain.com/assets/nexo-brain-infographic-v5.png)
20
20
 
21
- Version `7.19.0` is the current packaged-runtime line. Minor release over v7.18.1bundle-managed installations (NEXO Desktop `brain-bundle/`) can now pin Brain to the host application release cycle via `NEXO_BRAIN_AUTO_UPDATE=false`, and the server auto-exits with code 75 on fingerprint mismatch so MCP clients respawn the server with the new code instead of leaving stale `server.py` processes alive.
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.
22
+
23
+ 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
+
25
+ Previously in `7.19.0`: minor release over v7.18.1 - bundle-managed installations (NEXO Desktop `brain-bundle/`) can now pin Brain to the host application release cycle via `NEXO_BRAIN_AUTO_UPDATE=false`, and the server auto-exits with code 75 on fingerprint mismatch so MCP clients respawn the server with the new code instead of leaving stale `server.py` processes alive.
22
26
 
23
27
  Previously in `7.18.1`: patch release over v7.18.0 - packaged Brain runtimes now include the `local_context` package, so Desktop Local Memory and `nexo local-context` do not get stuck behind `ModuleNotFoundError` or zero-file status; the local-index service also keeps detecting newly mounted volumes automatically.
24
28
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.19.0",
3
+ "version": "7.20.1",
4
4
  "mcpName": "io.github.wazionapps/nexo",
5
5
  "description": "NEXO Brain — Shared brain for AI agents. Persistent memory, semantic RAG, natural forgetting, metacognitive guard, trust scoring, 150+ MCP tools. Works with Claude Code, Codex, Claude Desktop & any MCP client. 100% local, free.",
6
6
  "homepage": "https://nexo-brain.com",
package/src/cli.py CHANGED
@@ -20,7 +20,7 @@ Entry points:
20
20
  nexo scripts run NAME_OR_PATH [-- args...]
21
21
  nexo scripts doctor [NAME_OR_PATH] [--json]
22
22
  nexo scripts call TOOL --input JSON [--json-output]
23
- nexo local-context status|run-once|pause|resume|roots|exclusions|query|diagnostics|models [--json]
23
+ nexo local-context status|run-once|reconcile|pause|resume|roots|exclusions|query|diagnostics|models [--json]
24
24
  nexo automations reactivate NAME [--test-run] [--json]
25
25
  nexo skills list [--level ...] [--source-kind ...] [--json]
26
26
  nexo skills get ID [--json]
@@ -1291,6 +1291,18 @@ def _local_context_run_once(args) -> int:
1291
1291
  )
1292
1292
 
1293
1293
 
1294
+ def _local_context_reconcile(args) -> int:
1295
+ import local_context
1296
+ return _local_context_emit(
1297
+ local_context.reconcile_live_changes(
1298
+ asset_limit=int(getattr(args, "asset_limit", 2000) or 0),
1299
+ dir_limit=int(getattr(args, "dir_limit", 300) or 0),
1300
+ file_limit=int(getattr(args, "file_limit", 1000) or 0),
1301
+ ),
1302
+ args,
1303
+ )
1304
+
1305
+
1294
1306
  def _local_context_control(args) -> int:
1295
1307
  import local_context
1296
1308
  command = str(getattr(args, "local_context_command", "") or "")
@@ -3154,6 +3166,12 @@ def main():
3154
3166
  local_context_run_p.add_argument("--process-limit", type=int, default=100, help="Maximum extraction/graph jobs to process")
3155
3167
  local_context_run_p.add_argument("--json", action="store_true", help="JSON output")
3156
3168
 
3169
+ local_context_reconcile_p = local_context_sub.add_parser("reconcile", help="Reconcile changed, deleted and new local files")
3170
+ local_context_reconcile_p.add_argument("--asset-limit", type=int, default=2000, help="Maximum known files to verify")
3171
+ local_context_reconcile_p.add_argument("--dir-limit", type=int, default=300, help="Maximum known folders to verify")
3172
+ local_context_reconcile_p.add_argument("--file-limit", type=int, default=1000, help="Maximum files to scan inside changed folders")
3173
+ local_context_reconcile_p.add_argument("--json", action="store_true", help="JSON output")
3174
+
3157
3175
  local_context_pause_p = local_context_sub.add_parser("pause", help="Pause local memory indexing")
3158
3176
  local_context_pause_p.add_argument("--json", action="store_true", help="JSON output")
3159
3177
 
@@ -3734,6 +3752,8 @@ def main():
3734
3752
  return _local_context_status(args)
3735
3753
  if args.local_context_command == "run-once":
3736
3754
  return _local_context_run_once(args)
3755
+ if args.local_context_command == "reconcile":
3756
+ return _local_context_reconcile(args)
3737
3757
  if args.local_context_command in {"pause", "resume", "clear-index"}:
3738
3758
  return _local_context_control(args)
3739
3759
  if args.local_context_command == "roots":
package/src/db/_schema.py CHANGED
@@ -1972,6 +1972,36 @@ def _m63_local_context_layer(conn):
1972
1972
  )
1973
1973
 
1974
1974
 
1975
+ def _m64_local_context_live_dirs(conn):
1976
+ """Track known folders so local context can detect new/deleted/changed files quickly."""
1977
+ conn.executescript(
1978
+ """
1979
+ CREATE TABLE IF NOT EXISTS local_index_dirs (
1980
+ dir_id TEXT PRIMARY KEY,
1981
+ root_id INTEGER,
1982
+ path TEXT NOT NULL UNIQUE,
1983
+ display_path TEXT NOT NULL,
1984
+ parent_path TEXT NOT NULL DEFAULT '',
1985
+ quick_fingerprint TEXT NOT NULL DEFAULT '',
1986
+ status TEXT NOT NULL DEFAULT 'active',
1987
+ first_seen_at REAL NOT NULL,
1988
+ last_seen_at REAL NOT NULL,
1989
+ updated_at REAL NOT NULL,
1990
+ deleted_at REAL
1991
+ );
1992
+
1993
+ CREATE INDEX IF NOT EXISTS idx_local_index_dirs_root_status
1994
+ ON local_index_dirs(root_id, status);
1995
+ CREATE INDEX IF NOT EXISTS idx_local_index_dirs_path
1996
+ ON local_index_dirs(path);
1997
+ CREATE INDEX IF NOT EXISTS idx_local_index_dirs_parent
1998
+ ON local_index_dirs(parent_path);
1999
+ CREATE INDEX IF NOT EXISTS idx_local_assets_updated
2000
+ ON local_assets(updated_at);
2001
+ """
2002
+ )
2003
+
2004
+
1975
2005
  MIGRATIONS = [
1976
2006
  (1, "learnings_columns", _m1_learnings_columns),
1977
2007
  (2, "followups_reasoning", _m2_followups_reasoning),
@@ -2036,6 +2066,7 @@ MIGRATIONS = [
2036
2066
  (61, "memory_observations_fts", _m61_memory_observations_fts),
2037
2067
  (62, "memory_observations_fts_trigger_fix", _m62_memory_observations_fts_trigger_fix),
2038
2068
  (63, "local_context_layer", _m63_local_context_layer),
2069
+ (64, "local_context_live_dirs", _m64_local_context_live_dirs),
2039
2070
  ]
2040
2071
 
2041
2072
 
@@ -19,6 +19,7 @@ from .api import (
19
19
  model_status,
20
20
  pause,
21
21
  purge_asset,
22
+ reconcile_live_changes,
22
23
  remove_exclusion,
23
24
  remove_root,
24
25
  resume,
@@ -41,6 +42,7 @@ __all__ = [
41
42
  "model_status",
42
43
  "pause",
43
44
  "purge_asset",
45
+ "reconcile_live_changes",
44
46
  "remove_exclusion",
45
47
  "remove_root",
46
48
  "resume",
@@ -23,6 +23,9 @@ LOCAL_INDEX_SERVICE_LABEL = "com.nexo.local-index"
23
23
  LOCAL_INDEX_SCRIPT_NAME = "nexo-local-index.py"
24
24
  LOCAL_INDEX_WINDOWS_TASK = "NEXO Local Memory"
25
25
  LOCAL_INDEX_LINUX_UNIT = "nexo-local-index.service"
26
+ DEFAULT_LIVE_ASSET_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_ASSET_LIMIT", "2000") or "2000")
27
+ DEFAULT_LIVE_DIR_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_DIR_LIMIT", "300") or "300")
28
+ DEFAULT_LIVE_FILE_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_LIVE_FILE_LIMIT", "1000") or "1000")
26
29
 
27
30
 
28
31
  def ensure_ready() -> None:
@@ -205,6 +208,11 @@ def _is_excluded(path: str, exclusions: list[str]) -> bool:
205
208
  return any(value == item or value.startswith(item + os.sep) for item in exclusions)
206
209
 
207
210
 
211
+ def _path_prefix(path: str) -> str:
212
+ normalized = norm_path(path)
213
+ return normalized + os.sep if normalized else os.sep
214
+
215
+
208
216
  def _file_type(path: Path) -> str:
209
217
  if path.is_dir():
210
218
  return "folder"
@@ -232,6 +240,65 @@ def _permission_state(path: Path) -> str:
232
240
  return "granted"
233
241
 
234
242
 
243
+ def _dir_fingerprint(path: Path, stat_result: os.stat_result | None = None) -> str:
244
+ st = stat_result or path.stat()
245
+ ctime_ns = getattr(st, "st_ctime_ns", int(float(st.st_ctime) * 1_000_000_000))
246
+ return f"{int(st.st_mtime_ns)}:{int(ctime_ns)}"
247
+
248
+
249
+ def _upsert_dir(
250
+ conn,
251
+ root_id: int,
252
+ path: Path,
253
+ seen_at: float,
254
+ stat_result: os.stat_result | None = None,
255
+ ) -> tuple[bool, str]:
256
+ raw_path = str(path)
257
+ normalized = norm_path(raw_path)
258
+ dir_id = stable_id("dir", normalized)
259
+ parent = norm_path(path.parent)
260
+ try:
261
+ fingerprint = _dir_fingerprint(path, stat_result)
262
+ except Exception:
263
+ return False, "error"
264
+ row = conn.execute(
265
+ "SELECT quick_fingerprint, status FROM local_index_dirs WHERE dir_id=?",
266
+ (dir_id,),
267
+ ).fetchone()
268
+ changed = not row or row["quick_fingerprint"] != fingerprint or row["status"] == "deleted"
269
+ conn.execute(
270
+ """
271
+ INSERT INTO local_index_dirs(
272
+ dir_id, root_id, path, display_path, parent_path, quick_fingerprint,
273
+ status, first_seen_at, last_seen_at, updated_at, deleted_at
274
+ )
275
+ VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?, NULL)
276
+ ON CONFLICT(dir_id) DO UPDATE SET
277
+ root_id=excluded.root_id,
278
+ path=excluded.path,
279
+ display_path=excluded.display_path,
280
+ parent_path=excluded.parent_path,
281
+ quick_fingerprint=excluded.quick_fingerprint,
282
+ status='active',
283
+ last_seen_at=excluded.last_seen_at,
284
+ updated_at=excluded.updated_at,
285
+ deleted_at=NULL
286
+ """,
287
+ (
288
+ dir_id,
289
+ root_id,
290
+ normalized,
291
+ raw_path,
292
+ parent,
293
+ fingerprint,
294
+ seen_at,
295
+ seen_at,
296
+ seen_at,
297
+ ),
298
+ )
299
+ return changed, fingerprint
300
+
301
+
235
302
  def _upsert_asset(conn, root_id: int, path: Path, seen_at: float, root_depth: int) -> tuple[str, bool, str]:
236
303
  raw_path = str(path)
237
304
  normalized = norm_path(raw_path)
@@ -321,6 +388,43 @@ def _upsert_asset(conn, root_id: int, path: Path, seen_at: float, root_depth: in
321
388
  return asset_id, changed, "ok"
322
389
 
323
390
 
391
+ def _mark_asset_deleted(conn, asset_id: str, deleted_at: float | None = None) -> None:
392
+ deleted_at = deleted_at or now()
393
+ conn.execute(
394
+ "UPDATE local_assets SET status='deleted', deleted_at=?, updated_at=? WHERE asset_id=? AND status!='deleted'",
395
+ (deleted_at, deleted_at, asset_id),
396
+ )
397
+ conn.execute(
398
+ """
399
+ UPDATE local_index_jobs
400
+ SET status='done', last_error_code='asset_deleted', updated_at=?
401
+ WHERE asset_id=? AND status IN ('pending', 'running')
402
+ """,
403
+ (deleted_at, asset_id),
404
+ )
405
+
406
+
407
+ def _mark_dir_subtree_deleted(conn, dir_path: str, deleted_at: float | None = None) -> int:
408
+ deleted_at = deleted_at or now()
409
+ normalized = norm_path(dir_path)
410
+ prefix = _path_prefix(normalized)
411
+ conn.execute(
412
+ """
413
+ UPDATE local_index_dirs
414
+ SET status='deleted', deleted_at=?, updated_at=?
415
+ WHERE status='active' AND (path=? OR path LIKE ?)
416
+ """,
417
+ (deleted_at, deleted_at, normalized, prefix + "%"),
418
+ )
419
+ rows = conn.execute(
420
+ "SELECT asset_id FROM local_assets WHERE status='active' AND (path=? OR path LIKE ?)",
421
+ (normalized, prefix + "%"),
422
+ ).fetchall()
423
+ for row in rows:
424
+ _mark_asset_deleted(conn, row["asset_id"], deleted_at)
425
+ return len(rows)
426
+
427
+
324
428
  def enqueue_job(conn, asset_id: str, job_type: str, *, priority: int = 50) -> str:
325
429
  job_id = stable_id("job", f"{asset_id}:{job_type}")
326
430
  conn.execute(
@@ -334,7 +438,17 @@ def enqueue_job(conn, asset_id: str, job_type: str, *, priority: int = 50) -> st
334
438
  return job_id
335
439
 
336
440
 
337
- def _iter_files(root: Path, exclusions: list[str], *, limit: int | None = None, start_after: str = ""):
441
+ def _iter_files(
442
+ conn,
443
+ root_id: int,
444
+ root: Path,
445
+ exclusions: list[str],
446
+ *,
447
+ limit: int | None = None,
448
+ start_after: str = "",
449
+ seen_at: float | None = None,
450
+ ):
451
+ seen_at = seen_at or now()
338
452
  seen_dirs: set[tuple[int, int]] = set()
339
453
  count = 0
340
454
  stack = [root]
@@ -353,6 +467,7 @@ def _iter_files(root: Path, exclusions: list[str], *, limit: int | None = None,
353
467
  if key in seen_dirs:
354
468
  continue
355
469
  seen_dirs.add(key)
470
+ _upsert_dir(conn, root_id, current, seen_at, st)
356
471
  try:
357
472
  entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
358
473
  except Exception:
@@ -427,6 +542,242 @@ def _clear_checkpoint(conn, root_id: int) -> None:
427
542
  conn.execute("DELETE FROM local_index_checkpoints WHERE root_id=? AND phase='quick_index'", (root_id,))
428
543
 
429
544
 
545
+ def _reconcile_known_assets(conn, exclusions: list[str], *, limit: int) -> dict:
546
+ stats = {"checked": 0, "modified": 0, "deleted": 0, "excluded": 0, "offline": 0, "errors": 0}
547
+ if limit <= 0:
548
+ return stats
549
+ rows = conn.execute(
550
+ """
551
+ SELECT a.asset_id, a.path, a.root_id, a.quick_fingerprint, a.depth, r.root_path
552
+ FROM local_assets a
553
+ LEFT JOIN local_index_roots r ON r.id = a.root_id
554
+ WHERE a.status='active'
555
+ ORDER BY a.updated_at ASC
556
+ LIMIT ?
557
+ """,
558
+ (int(limit),),
559
+ ).fetchall()
560
+ seen_at = now()
561
+ for row in rows:
562
+ stats["checked"] += 1
563
+ path = str(row["path"])
564
+ root_path = Path(row["root_path"]).expanduser() if row["root_path"] else None
565
+ if _is_excluded(path, exclusions):
566
+ _mark_asset_deleted(conn, row["asset_id"], seen_at)
567
+ stats["excluded"] += 1
568
+ continue
569
+ if root_path is not None and not root_path.exists():
570
+ stats["offline"] += 1
571
+ continue
572
+ file_path = Path(path)
573
+ try:
574
+ if not file_path.exists() or not file_path.is_file():
575
+ _mark_asset_deleted(conn, row["asset_id"], seen_at)
576
+ stats["deleted"] += 1
577
+ continue
578
+ st = file_path.stat()
579
+ fingerprint = quick_fingerprint(file_path, st)
580
+ except Exception:
581
+ stats["errors"] += 1
582
+ continue
583
+ if fingerprint != row["quick_fingerprint"]:
584
+ _, changed, state = _upsert_asset(conn, int(row["root_id"] or 0), file_path, seen_at, int(row["depth"] or 2))
585
+ if changed:
586
+ stats["modified"] += 1
587
+ if state != "ok":
588
+ stats["errors"] += 1
589
+ else:
590
+ conn.execute("UPDATE local_assets SET updated_at=? WHERE asset_id=?", (seen_at, row["asset_id"]))
591
+ return stats
592
+
593
+
594
+ def _prune_missing_children(
595
+ conn,
596
+ directory: Path,
597
+ seen_files: set[str],
598
+ seen_dirs: set[str],
599
+ seen_at: float,
600
+ ) -> tuple[int, int]:
601
+ parent = norm_path(directory)
602
+ deleted_files = 0
603
+ deleted_dirs = 0
604
+ file_rows = conn.execute(
605
+ "SELECT asset_id, path FROM local_assets WHERE parent_path=? AND status='active'",
606
+ (parent,),
607
+ ).fetchall()
608
+ for row in file_rows:
609
+ if row["path"] not in seen_files:
610
+ _mark_asset_deleted(conn, row["asset_id"], seen_at)
611
+ deleted_files += 1
612
+ dir_rows = conn.execute(
613
+ "SELECT path FROM local_index_dirs WHERE parent_path=? AND status='active'",
614
+ (parent,),
615
+ ).fetchall()
616
+ for row in dir_rows:
617
+ if row["path"] not in seen_dirs:
618
+ deleted_files += _mark_dir_subtree_deleted(conn, row["path"], seen_at)
619
+ deleted_dirs += 1
620
+ return deleted_files, deleted_dirs
621
+
622
+
623
+ def _scan_known_directory(
624
+ conn,
625
+ root_id: int,
626
+ directory: Path,
627
+ root_depth: int,
628
+ exclusions: list[str],
629
+ stats: dict,
630
+ *,
631
+ file_limit: int,
632
+ dir_limit: int,
633
+ ) -> None:
634
+ stack = [directory]
635
+ seen_at = now()
636
+ scanned_dirs = 0
637
+ while stack and stats["files_scanned"] < file_limit and scanned_dirs < dir_limit:
638
+ current = stack.pop()
639
+ if _is_excluded(str(current), exclusions):
640
+ _mark_dir_subtree_deleted(conn, str(current), seen_at)
641
+ stats["excluded_dirs"] += 1
642
+ continue
643
+ if current != directory and should_skip_tree(str(current)):
644
+ continue
645
+ try:
646
+ st = current.stat()
647
+ if not current.is_dir():
648
+ continue
649
+ entries = sorted(current.iterdir(), key=lambda item: str(item).lower())
650
+ except Exception:
651
+ stats["errors"] += 1
652
+ continue
653
+ scanned_dirs += 1
654
+ stats["dirs_scanned"] += 1
655
+ _upsert_dir(conn, root_id, current, seen_at, st)
656
+ seen_files: set[str] = set()
657
+ seen_dirs: set[str] = set()
658
+ for entry in entries:
659
+ if _is_excluded(str(entry), exclusions):
660
+ continue
661
+ try:
662
+ if entry.is_symlink():
663
+ continue
664
+ if entry.is_dir():
665
+ if should_skip_tree(str(entry)):
666
+ continue
667
+ changed, _ = _upsert_dir(conn, root_id, entry, seen_at)
668
+ seen_dirs.add(norm_path(entry))
669
+ if changed and scanned_dirs + len(stack) < dir_limit:
670
+ stack.append(entry)
671
+ continue
672
+ if entry.is_file():
673
+ seen_files.add(norm_path(entry))
674
+ if stats["files_scanned"] >= file_limit:
675
+ continue
676
+ _, changed, state = _upsert_asset(conn, root_id, entry, seen_at, root_depth)
677
+ stats["files_scanned"] += 1
678
+ if changed:
679
+ stats["files_changed"] += 1
680
+ if state != "ok":
681
+ stats["errors"] += 1
682
+ except Exception:
683
+ stats["errors"] += 1
684
+ deleted_files, deleted_dirs = _prune_missing_children(conn, current, seen_files, seen_dirs, seen_at)
685
+ stats["files_deleted"] += deleted_files
686
+ stats["dirs_deleted"] += deleted_dirs
687
+
688
+
689
+ def _reconcile_known_dirs(conn, exclusions: list[str], *, dir_limit: int, file_limit: int) -> dict:
690
+ stats = {
691
+ "checked": 0,
692
+ "changed": 0,
693
+ "dirs_scanned": 0,
694
+ "files_scanned": 0,
695
+ "files_changed": 0,
696
+ "files_deleted": 0,
697
+ "dirs_deleted": 0,
698
+ "excluded_dirs": 0,
699
+ "offline": 0,
700
+ "errors": 0,
701
+ }
702
+ if dir_limit <= 0 or file_limit <= 0:
703
+ return stats
704
+ rows = conn.execute(
705
+ """
706
+ SELECT d.dir_id, d.path, d.quick_fingerprint, d.root_id, r.root_path, r.depth
707
+ FROM local_index_dirs d
708
+ LEFT JOIN local_index_roots r ON r.id = d.root_id
709
+ WHERE d.status='active'
710
+ ORDER BY d.updated_at ASC
711
+ LIMIT ?
712
+ """,
713
+ (int(dir_limit),),
714
+ ).fetchall()
715
+ seen_at = now()
716
+ for row in rows:
717
+ stats["checked"] += 1
718
+ dir_path = Path(row["path"])
719
+ root_path = Path(row["root_path"]).expanduser() if row["root_path"] else None
720
+ if _is_excluded(str(dir_path), exclusions):
721
+ stats["files_deleted"] += _mark_dir_subtree_deleted(conn, str(dir_path), seen_at)
722
+ stats["excluded_dirs"] += 1
723
+ continue
724
+ if root_path is not None and not root_path.exists():
725
+ stats["offline"] += 1
726
+ continue
727
+ try:
728
+ if not dir_path.exists() or not dir_path.is_dir():
729
+ stats["files_deleted"] += _mark_dir_subtree_deleted(conn, str(dir_path), seen_at)
730
+ stats["dirs_deleted"] += 1
731
+ continue
732
+ st = dir_path.stat()
733
+ fingerprint = _dir_fingerprint(dir_path, st)
734
+ except Exception:
735
+ stats["errors"] += 1
736
+ continue
737
+ if fingerprint != row["quick_fingerprint"]:
738
+ stats["changed"] += 1
739
+ _scan_known_directory(
740
+ conn,
741
+ int(row["root_id"] or 0),
742
+ dir_path,
743
+ int(row["depth"] or 2),
744
+ exclusions,
745
+ stats,
746
+ file_limit=file_limit,
747
+ dir_limit=dir_limit,
748
+ )
749
+ else:
750
+ conn.execute("UPDATE local_index_dirs SET updated_at=? WHERE dir_id=?", (seen_at, row["dir_id"]))
751
+ return stats
752
+
753
+
754
+ def reconcile_live_changes(
755
+ *,
756
+ asset_limit: int = DEFAULT_LIVE_ASSET_LIMIT,
757
+ dir_limit: int = DEFAULT_LIVE_DIR_LIMIT,
758
+ file_limit: int = DEFAULT_LIVE_FILE_LIMIT,
759
+ ) -> dict:
760
+ conn = _conn()
761
+ if _is_paused():
762
+ return {"ok": True, "paused": True, "assets": {}, "dirs": {}}
763
+ exclusions = [row["path"] for row in list_exclusions()]
764
+ asset_stats = _reconcile_known_assets(conn, exclusions, limit=int(asset_limit or 0))
765
+ dir_stats = _reconcile_known_dirs(conn, exclusions, dir_limit=int(dir_limit or 0), file_limit=int(file_limit or 0))
766
+ conn.commit()
767
+ changed_total = (
768
+ int(asset_stats.get("modified", 0))
769
+ + int(asset_stats.get("deleted", 0))
770
+ + int(asset_stats.get("excluded", 0))
771
+ + int(dir_stats.get("files_changed", 0))
772
+ + int(dir_stats.get("files_deleted", 0))
773
+ + int(dir_stats.get("dirs_deleted", 0))
774
+ + int(dir_stats.get("excluded_dirs", 0))
775
+ )
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}
779
+
780
+
430
781
  def scan_once(*, limit: int | None = None) -> dict:
431
782
  conn = _conn()
432
783
  if _is_paused():
@@ -455,7 +806,15 @@ def scan_once(*, limit: int | None = None) -> dict:
455
806
  cycle_started_at = float(checkpoint["cycle_started_at"])
456
807
  seen_for_root = 0
457
808
  last_seen_path = ""
458
- for file_path in _iter_files(root_path, exclusions, limit=limit, start_after=str(checkpoint["current_path"] or "")):
809
+ for file_path in _iter_files(
810
+ conn,
811
+ root_id,
812
+ root_path,
813
+ exclusions,
814
+ limit=limit,
815
+ start_after=str(checkpoint["current_path"] or ""),
816
+ seen_at=cycle_started_at,
817
+ ):
459
818
  asset_id, changed, state = _upsert_asset(conn, root_id, file_path, cycle_started_at, int(root["depth"] or 2))
460
819
  last_seen_path = norm_path(file_path)
461
820
  totals["seen"] += 1
@@ -630,12 +989,25 @@ def process_jobs(*, limit: int = 100) -> dict:
630
989
  return {"ok": True, "processed": processed, "failed": failed}
631
990
 
632
991
 
633
- def run_once(*, root: str | None = None, limit: int | None = None, process_limit: int = 100) -> dict:
992
+ def run_once(
993
+ *,
994
+ root: str | None = None,
995
+ limit: int | None = None,
996
+ process_limit: int = 100,
997
+ live_asset_limit: int = DEFAULT_LIVE_ASSET_LIMIT,
998
+ live_dir_limit: int = DEFAULT_LIVE_DIR_LIMIT,
999
+ live_file_limit: int = DEFAULT_LIVE_FILE_LIMIT,
1000
+ ) -> dict:
634
1001
  if root:
635
1002
  add_root(root)
1003
+ live_result = reconcile_live_changes(
1004
+ asset_limit=live_asset_limit,
1005
+ dir_limit=live_dir_limit,
1006
+ file_limit=live_file_limit,
1007
+ )
636
1008
  scan_result = scan_once(limit=limit)
637
1009
  job_result = process_jobs(limit=process_limit)
638
- return {"ok": True, "scan": scan_result, "jobs": job_result}
1010
+ return {"ok": True, "live": live_result, "scan": scan_result, "jobs": job_result}
639
1011
 
640
1012
 
641
1013
  def _problem_rows(conn) -> list[dict]:
@@ -647,7 +1019,7 @@ def _problem_rows(conn) -> list[dict]:
647
1019
  LIMIT 20
648
1020
  """
649
1021
  ).fetchall()
650
- return [
1022
+ problems = [
651
1023
  {
652
1024
  "user_message": row["user_message"],
653
1025
  "recommended_action": "NEXO lo volvera a intentar mas tarde" if row["retryable"] else "Revisa permisos o archivo",
@@ -661,6 +1033,35 @@ def _problem_rows(conn) -> list[dict]:
661
1033
  }
662
1034
  for row in rows
663
1035
  ]
1036
+ last_success = conn.execute(
1037
+ "SELECT MAX(created_at) AS created_at FROM local_index_logs WHERE event='service_cycle_finished'"
1038
+ ).fetchone()["created_at"] or 0
1039
+ service_rows = conn.execute(
1040
+ """
1041
+ SELECT created_at, level, event, message, metadata_json
1042
+ FROM local_index_logs
1043
+ WHERE event IN ('service_cycle_failed', 'service_cycle_compat_fallback')
1044
+ AND created_at > ?
1045
+ ORDER BY id DESC
1046
+ LIMIT 5
1047
+ """,
1048
+ (last_success,),
1049
+ ).fetchall()
1050
+ problems.extend(
1051
+ {
1052
+ "user_message": "La memoria local tuvo un problema temporal y NEXO la reintentara automaticamente",
1053
+ "recommended_action": "No tienes que hacer nada. Si se repite, abre soporte y diagnostico para ver el detalle.",
1054
+ "technical_detail": f"{row['event']}: {row['message']} {row['metadata_json']}",
1055
+ "support_code": row["event"],
1056
+ "severity": "warning" if row["level"] == "warn" else "error",
1057
+ "retryable": True,
1058
+ "path": "",
1059
+ "phase": "service",
1060
+ "created_at": row["created_at"],
1061
+ }
1062
+ for row in service_rows
1063
+ )
1064
+ return problems
664
1065
 
665
1066
 
666
1067
  def _command_output(args: list[str], *, timeout: int = 2) -> tuple[int, str, str]:
@@ -955,6 +1356,21 @@ def context_query(query: str, *, intent: str = "answer", limit: int = 12, eviden
955
1356
  (f"%{query.lower()}%", int(limit)),
956
1357
  ).fetchall()
957
1358
  entities_payload = [dict(row) for row in entity_rows]
1359
+ relations_payload: list[dict] = []
1360
+ if seen_assets:
1361
+ asset_ids = list(seen_assets)[: int(limit)]
1362
+ placeholders = ",".join("?" for _ in asset_ids)
1363
+ relation_rows = conn.execute(
1364
+ f"""
1365
+ SELECT relation_id, source_asset_id, target_ref, relation_type, confidence, evidence
1366
+ FROM local_relations
1367
+ WHERE active=1 AND source_asset_id IN ({placeholders})
1368
+ ORDER BY confidence DESC
1369
+ LIMIT ?
1370
+ """,
1371
+ [*asset_ids, int(limit) * 3],
1372
+ ).fetchall()
1373
+ relations_payload = [dict(row) for row in relation_rows]
958
1374
  warnings = []
959
1375
  if evidence_required and not evidence_refs:
960
1376
  warnings.append("No local evidence found for this query.")
@@ -984,7 +1400,7 @@ def context_query(query: str, *, intent: str = "answer", limit: int = 12, eviden
984
1400
  "summary": summary,
985
1401
  "assets": assets,
986
1402
  "entities": entities_payload,
987
- "relations": [],
1403
+ "relations": relations_payload,
988
1404
  "chunks": chunks,
989
1405
  "warnings": warnings,
990
1406
  "evidence_refs": evidence_refs,
@@ -1034,6 +1450,7 @@ def clear_index() -> dict:
1034
1450
  "local_chunks",
1035
1451
  "local_entities",
1036
1452
  "local_relations",
1453
+ "local_index_dirs",
1037
1454
  "local_index_errors",
1038
1455
  "local_index_jobs",
1039
1456
  "local_asset_versions",
@@ -920,7 +920,20 @@ def ensure_full_disk_access_choice(
920
920
  probe = probe_fn()
921
921
  if probe.get("granted") is True:
922
922
  verified = True
923
- message = f"Full Disk Access verified via {probe.get('probe_path')}."
923
+ schedule[FULL_DISK_ACCESS_STATUS_KEY] = FULL_DISK_ACCESS_GRANTED
924
+ schedule[FULL_DISK_ACCESS_REASONS_KEY] = []
925
+ clear_full_disk_access_required_state()
926
+ save_schedule_config(schedule)
927
+ return {
928
+ "status": FULL_DISK_ACCESS_GRANTED,
929
+ "prompted": False,
930
+ "verified": True,
931
+ "settings_opened": False,
932
+ "reasons": [],
933
+ "schedule_file": str(SCHEDULE_FILE),
934
+ "message": "",
935
+ "relevant": False,
936
+ }
924
937
  else:
925
938
  status = FULL_DISK_ACCESS_LATER
926
939
  message = (
@@ -70,6 +70,57 @@ TRANSIENT_ERROR_KINDS = {
70
70
  }
71
71
  REQUIRED_PROTOCOL_SUMMARY_KEYS = ("guard_check", "heartbeat", "change_log")
72
72
 
73
+ # Compact few-shot rendered into the prompt on a `json_schema` retry. Keeps
74
+ # the placeholder structure intact so the model sees the exact contract that
75
+ # `_is_valid_extraction` enforces. Kept as a string to avoid pulling in a
76
+ # template engine for a one-shot block.
77
+ JSON_SCHEMA_FEWSHOT = (
78
+ "RETRY_HINT: the previous attempt produced JSON that did not match the "
79
+ "Deep Sleep extraction contract. The response must be a SINGLE JSON "
80
+ "object with the following minimum shape (extra keys allowed):\n"
81
+ "{\n"
82
+ ' "session_id": "<exact session id, string>",\n'
83
+ ' "findings": [ { "type": "...", "summary": "...", "evidence": "..." } ],\n'
84
+ ' "protocol_summary": {\n'
85
+ ' "guard_check": { "ran": true|false, "notes": "..." },\n'
86
+ ' "heartbeat": { "count": 0, "notes": "..." },\n'
87
+ ' "change_log": { "entries": 0, "notes": "..." }\n'
88
+ " }\n"
89
+ "}\n"
90
+ "Mandatory: session_id is a non-empty string equal to {{SESSION_ID}}; "
91
+ "findings is a list of objects; protocol_summary contains the three "
92
+ "object keys above. Return ONLY the JSON object, no prose, no fences."
93
+ )
94
+
95
+
96
+ def _record_protocol_debt(
97
+ session_id: str,
98
+ *,
99
+ debt_type: str,
100
+ severity: str,
101
+ evidence: str,
102
+ ) -> None:
103
+ """Best-effort registration of an extraction failure as protocol debt.
104
+
105
+ Imported lazily so the extractor still runs in environments where the
106
+ DB layer is unavailable (e.g. partial installs, unit tests). Any error
107
+ inside the debt path is swallowed: we never want a debt-logging issue
108
+ to mask the real extraction failure already being reported.
109
+ """
110
+ try:
111
+ from db._protocol import create_protocol_debt
112
+ except Exception: # pragma: no cover - best effort
113
+ return
114
+ try:
115
+ create_protocol_debt(
116
+ session_id,
117
+ debt_type,
118
+ severity=severity,
119
+ evidence=evidence[:3500],
120
+ )
121
+ except Exception as exc: # pragma: no cover - best effort
122
+ print(f" Warning: could not record protocol_debt: {exc}", file=sys.stderr)
123
+
73
124
 
74
125
  def _classify_cli_result(result) -> tuple[str, str]:
75
126
  """Return (kind, short_message) describing a failed automation backend call.
@@ -211,11 +262,18 @@ def analyze_session(
211
262
  date_dir: Path,
212
263
  shared_context_file: Path | None,
213
264
  session_txt_map: dict[str, str] | None = None,
265
+ *,
266
+ prior_error_kind: str = "",
214
267
  ) -> tuple[dict | None, str | None]:
215
268
  """Send a session to the automation backend for extraction analysis.
216
269
 
217
270
  Returns (parsed_result, error_kind). `error_kind` is only set on failure.
218
271
  See `_classify_cli_result` for possible values.
272
+
273
+ ``prior_error_kind`` is consumed by the retry path: when the previous
274
+ attempt failed validation with ``json_schema`` we append a few-shot of
275
+ the contract so the model sees the exact shape it must produce instead
276
+ of repeating the same structurally wrong payload.
219
277
  """
220
278
  session_file = find_session_file(session_id, date_dir, session_txt_map=session_txt_map)
221
279
  if not session_file:
@@ -236,6 +294,15 @@ def analyze_session(
236
294
  prompt = prompt_template.replace("{{CONTEXT_FILE}}", str(session_file))
237
295
  prompt = prompt.replace("{{SESSION_ID}}", session_id)
238
296
  prompt += shared_ctx_instruction
297
+ if prior_error_kind == "json_schema":
298
+ prompt += "\n\n" + JSON_SCHEMA_FEWSHOT.replace("{{SESSION_ID}}", session_id)
299
+
300
+ # Bootstrap the subagent with the day's deep-sleep dir as cwd so its
301
+ # default Read allowlist already covers the session transcript, the
302
+ # shared context, and the day's working files. Without this, the CLI
303
+ # subprocess inherits the parent's cwd (often "/") and fails with
304
+ # `cannot_comply` the first time it tries to Read the session file.
305
+ subagent_cwd = str(date_dir) if date_dir and Path(date_dir).exists() else None
239
306
 
240
307
  try:
241
308
  json_system_prompt = render_core_prompt(
@@ -246,6 +313,7 @@ def analyze_session(
246
313
  result = run_automation_prompt(
247
314
  prompt,
248
315
  caller="deep-sleep/extract",
316
+ cwd=subagent_cwd,
249
317
  timeout=CLAUDE_TIMEOUT,
250
318
  output_format="text",
251
319
  append_system_prompt=json_system_prompt,
@@ -276,6 +344,7 @@ def analyze_session(
276
344
  convert_result = run_automation_prompt(
277
345
  convert_prompt,
278
346
  caller="deep-sleep/extract",
347
+ cwd=subagent_cwd,
279
348
  timeout=120,
280
349
  output_format="text",
281
350
  append_system_prompt=json_system_prompt,
@@ -471,6 +540,7 @@ def main():
471
540
  date_dir,
472
541
  shared_context_file,
473
542
  session_txt_map=session_txt_map,
543
+ prior_error_kind=last_error_kind,
474
544
  )
475
545
  if result:
476
546
  break
@@ -522,6 +592,21 @@ def main():
522
592
  }
523
593
  all_extractions.append(failed_entry)
524
594
  _save_checkpoint(checkpoint_file, failed_entry)
595
+ # Surface deterministic extractor failures as protocol debt so
596
+ # the aggregate self-audit cannot silently absorb the pattern.
597
+ # Severity escalates once the session is poisoned because by
598
+ # then it stops being a per-run hiccup and becomes a recurring
599
+ # runtime issue worth a louder signal.
600
+ _record_protocol_debt(
601
+ session_id,
602
+ debt_type=f"deep-sleep.extract.{last_error_kind}",
603
+ severity="error" if state == "poisoned" else "warn",
604
+ evidence=(
605
+ f"date={target_date} state={state} attempts={new_count}/"
606
+ f"{MAX_POISON_ATTEMPTS} kind={last_error_kind} "
607
+ f"checkpoint={checkpoint_file}"
608
+ ),
609
+ )
525
610
  if state == "poisoned":
526
611
  poisoned += 1
527
612
 
@@ -39,6 +39,9 @@ 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
40
  SCAN_LIMIT = int(os.environ.get("NEXO_LOCAL_INDEX_SCAN_LIMIT", "1000") or "1000")
41
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")
42
45
 
43
46
 
44
47
  def log(message: str) -> None:
@@ -48,6 +51,27 @@ def log(message: str) -> None:
48
51
  handle.write(line + "\n")
49
52
 
50
53
 
54
+ def _read_lock() -> dict:
55
+ try:
56
+ return json.loads(LOCK_FILE.read_text(encoding="utf-8"))
57
+ except Exception:
58
+ return {}
59
+
60
+
61
+ def _pid_running(pid: int) -> bool:
62
+ if pid <= 0:
63
+ return False
64
+ try:
65
+ os.kill(pid, 0)
66
+ except ProcessLookupError:
67
+ return False
68
+ except PermissionError:
69
+ return True
70
+ except OSError:
71
+ return False
72
+ return True
73
+
74
+
51
75
  def acquire_lock() -> bool:
52
76
  try:
53
77
  fd = os.open(str(LOCK_FILE), os.O_CREAT | os.O_EXCL | os.O_WRONLY)
@@ -56,9 +80,16 @@ def acquire_lock() -> bool:
56
80
  return True
57
81
  except FileExistsError:
58
82
  try:
59
- age = time.time() - LOCK_FILE.stat().st_mtime
83
+ lock = _read_lock()
84
+ pid = int(lock.get("pid") or 0)
85
+ age = time.time() - float(lock.get("created_at") or LOCK_FILE.stat().st_mtime)
86
+ if pid and not _pid_running(pid):
87
+ LOCK_FILE.unlink(missing_ok=True)
88
+ log(f"Removed stale local-index lock for dead pid {pid}.")
89
+ return acquire_lock()
60
90
  if age > LOCK_STALE_SECONDS:
61
91
  LOCK_FILE.unlink(missing_ok=True)
92
+ log(f"Removed stale local-index lock older than {int(age)} seconds.")
62
93
  return acquire_lock()
63
94
  except Exception:
64
95
  pass
@@ -67,11 +98,39 @@ def acquire_lock() -> bool:
67
98
 
68
99
  def release_lock() -> None:
69
100
  try:
101
+ lock = _read_lock()
102
+ pid = int(lock.get("pid") or 0)
103
+ if pid and pid != os.getpid():
104
+ return
70
105
  LOCK_FILE.unlink(missing_ok=True)
71
106
  except Exception:
72
107
  pass
73
108
 
74
109
 
110
+ def _run_index_cycle() -> dict:
111
+ try:
112
+ return api.run_once(
113
+ limit=SCAN_LIMIT,
114
+ process_limit=PROCESS_LIMIT,
115
+ live_asset_limit=LIVE_ASSET_LIMIT,
116
+ live_dir_limit=LIVE_DIR_LIMIT,
117
+ live_file_limit=LIVE_FILE_LIMIT,
118
+ )
119
+ except TypeError as exc:
120
+ message = str(exc)
121
+ live_kwargs = ("live_asset_limit", "live_dir_limit", "live_file_limit")
122
+ if not any(name in message for name in live_kwargs):
123
+ raise
124
+ log_event(
125
+ "warn",
126
+ "service_cycle_compat_fallback",
127
+ "Local memory service used compatibility fallback",
128
+ error=message,
129
+ )
130
+ log(f"Compatibility fallback: api.run_once does not accept live reconcile limits ({message}).")
131
+ return api.run_once(limit=SCAN_LIMIT, process_limit=PROCESS_LIMIT)
132
+
133
+
75
134
  def main() -> int:
76
135
  if not acquire_lock():
77
136
  log("Skipped: previous local-index cycle is still running.")
@@ -79,7 +138,7 @@ def main() -> int:
79
138
  try:
80
139
  if os.environ.get("NEXO_LOCAL_INDEX_DISABLE_DEFAULT_ROOTS", "").strip() != "1":
81
140
  api.ensure_default_roots()
82
- result = api.run_once(limit=SCAN_LIMIT, process_limit=PROCESS_LIMIT)
141
+ result = _run_index_cycle()
83
142
  log_event("info", "service_cycle_finished", "Local memory service cycle finished", result=result)
84
143
  log(json.dumps(result, ensure_ascii=False, sort_keys=True))
85
144
  return 0 if result.get("ok") else 2