nexo-brain 7.18.0 → 7.19.0

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.18.0",
3
+ "version": "7.19.0",
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.18.0` is the current packaged-runtime line. Minor release over v7.17.8 - Brain adds the Local Context Layer: a local-only background memory index with checkpoints, extraction, graph links, embeddings, MCP/CLI controls, and pre-action evidence for NEXO agents.
21
+ Version `7.19.0` is the current packaged-runtime line. 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
+
23
+ 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
+
25
+ Previously in `7.18.0`: minor release over v7.17.8 - Brain adds the Local Context Layer: a local-only background memory index with checkpoints, extraction, graph links, embeddings, MCP/CLI controls, and pre-action evidence for NEXO agents.
22
26
 
23
27
  Previously in `7.17.8`: patch release over v7.17.7 - standalone `nexo chat` now surfaces macOS Full Disk Access guidance, and Brain clears stale permission state after a live access probe succeeds.
24
28
 
package/bin/nexo-brain.js CHANGED
@@ -1123,7 +1123,7 @@ function getCoreRuntimeFlatFiles(srcDir = path.join(__dirname, "..", "src")) {
1123
1123
  }
1124
1124
 
1125
1125
  function getCoreRuntimePackages() {
1126
- return ["db", "cognitive", "doctor"];
1126
+ return ["db", "cognitive", "doctor", "local_context"];
1127
1127
  }
1128
1128
 
1129
1129
  // Brain contracts — files the NEXO Brain publishes to consumers like
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.18.0",
3
+ "version": "7.19.0",
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",
@@ -4350,7 +4350,7 @@ def _restore_runtime_tree(backup_dir: str, dest: Path = NEXO_HOME) -> None:
4350
4350
  def _copy_runtime_from_source(src_dir: Path, repo_dir: Path, dest: Path = NEXO_HOME, progress_fn=None) -> dict:
4351
4351
  import shutil
4352
4352
 
4353
- packages = ["db", "cognitive", "doctor", "dashboard", "rules", "crons", "hooks", "presets"]
4353
+ packages = ["db", "cognitive", "doctor", "local_context", "dashboard", "rules", "crons", "hooks", "presets"]
4354
4354
  flat_files = _runtime_flat_files(src_dir)
4355
4355
  copied_packages = 0
4356
4356
  copied_files = 0
@@ -5372,6 +5372,11 @@ def startup_preflight(*, entrypoint: str, interactive: bool = False) -> dict:
5372
5372
  return result
5373
5373
 
5374
5374
  try:
5375
+ env_auto_update = os.environ.get("NEXO_BRAIN_AUTO_UPDATE", "").strip().lower()
5376
+ if env_auto_update in ("0", "false", "off", "no"):
5377
+ result["skipped_reason"] = "auto_update disabled via NEXO_BRAIN_AUTO_UPDATE env var"
5378
+ _write_update_summary(result)
5379
+ return result
5375
5380
  last_check = _read_last_check()
5376
5381
  now = time.time()
5377
5382
  schedule_file = _runtime_config_dir(NEXO_HOME) / "schedule.json"
@@ -5,6 +5,7 @@ import os
5
5
  import shutil
6
6
  import stat
7
7
  import hashlib
8
+ import subprocess
8
9
  import sys
9
10
  from pathlib import Path
10
11
  from typing import Any
@@ -15,9 +16,14 @@ from db._schema import run_migrations
15
16
  from . import embeddings
16
17
  from .extractors import chunk_text, entities, extract_text, summarize
17
18
  from .logging import log_event, tail
18
- from .privacy import classify_path, should_extract
19
+ from .privacy import classify_path, should_extract, should_skip_tree
19
20
  from .util import content_hash, json_dumps, json_loads, norm_path, now, quick_fingerprint, redact_path, stable_id, system_label, tokenize
20
21
 
22
+ LOCAL_INDEX_SERVICE_LABEL = "com.nexo.local-index"
23
+ LOCAL_INDEX_SCRIPT_NAME = "nexo-local-index.py"
24
+ LOCAL_INDEX_WINDOWS_TASK = "NEXO Local Memory"
25
+ LOCAL_INDEX_LINUX_UNIT = "nexo-local-index.service"
26
+
21
27
 
22
28
  def ensure_ready() -> None:
23
29
  init_db()
@@ -66,20 +72,62 @@ def list_roots() -> list[dict]:
66
72
  return [dict(row) for row in rows]
67
73
 
68
74
 
75
+ def _dedupe_roots(roots: list[str]) -> list[str]:
76
+ seen: set[str] = set()
77
+ result: list[str] = []
78
+ for root in roots:
79
+ normalized = norm_path(root)
80
+ if not normalized or normalized in seen:
81
+ continue
82
+ seen.add(normalized)
83
+ result.append(normalized)
84
+ return result
85
+
86
+
87
+ def _mounted_volume_roots() -> list[str]:
88
+ candidates: list[Path] = []
89
+ if sys.platform == "darwin":
90
+ candidates.extend((Path("/Volumes")).iterdir() if Path("/Volumes").is_dir() else [])
91
+ elif sys.platform.startswith("win"):
92
+ candidates.extend(Path(f"{letter}:\\") for letter in "ABCDEFGHIJKLMNOPQRSTUVWXYZ")
93
+ else:
94
+ user = os.environ.get("USER") or os.environ.get("USERNAME") or ""
95
+ mount_bases = [Path("/mnt")]
96
+ if user:
97
+ mount_bases.extend([Path("/media") / user, Path("/run/media") / user])
98
+ for base in mount_bases:
99
+ if base.is_dir():
100
+ candidates.extend(base.iterdir())
101
+
102
+ roots: list[str] = []
103
+ root_resolved = Path("/").resolve()
104
+ for candidate in candidates:
105
+ try:
106
+ if candidate.name.startswith(".") or not candidate.is_dir():
107
+ continue
108
+ resolved = candidate.resolve()
109
+ if resolved == root_resolved:
110
+ continue
111
+ roots.append(str(candidate))
112
+ except Exception:
113
+ continue
114
+ return roots
115
+
116
+
69
117
  def default_roots() -> list[str]:
70
118
  home = Path.home()
71
119
  configured = os.environ.get("NEXO_LOCAL_INDEX_DEFAULT_ROOTS", "").strip()
72
120
  if configured:
73
- return [norm_path(item) for item in configured.split(os.pathsep) if item.strip()]
74
- return [norm_path(home)]
121
+ return _dedupe_roots([item for item in configured.split(os.pathsep) if item.strip()])
122
+ return _dedupe_roots([str(home), *_mounted_volume_roots()])
75
123
 
76
124
 
77
125
  def ensure_default_roots() -> dict:
78
- existing = list_roots()
79
- if existing:
80
- return {"ok": True, "created": 0, "roots": existing}
126
+ existing_paths = {row["root_path"] for row in list_roots()}
81
127
  created = []
82
128
  for root in default_roots():
129
+ if root in existing_paths:
130
+ continue
83
131
  candidate = Path(root).expanduser()
84
132
  if candidate.exists() and candidate.is_dir():
85
133
  created.append(add_root(str(candidate), mode="normal", depth=2))
@@ -295,6 +343,8 @@ def _iter_files(root: Path, exclusions: list[str], *, limit: int | None = None,
295
343
  current = stack.pop()
296
344
  if _is_excluded(str(current), exclusions):
297
345
  continue
346
+ if current != root and should_skip_tree(str(current)):
347
+ continue
298
348
  try:
299
349
  st = current.stat()
300
350
  except Exception:
@@ -314,6 +364,8 @@ def _iter_files(root: Path, exclusions: list[str], *, limit: int | None = None,
314
364
  if entry.is_symlink():
315
365
  continue
316
366
  if entry.is_dir():
367
+ if should_skip_tree(str(entry)):
368
+ continue
317
369
  dirs.append(entry)
318
370
  continue
319
371
  if entry.is_file():
@@ -611,6 +663,136 @@ def _problem_rows(conn) -> list[dict]:
611
663
  ]
612
664
 
613
665
 
666
+ def _command_output(args: list[str], *, timeout: int = 2) -> tuple[int, str, str]:
667
+ try:
668
+ result = subprocess.run(args, capture_output=True, text=True, timeout=timeout)
669
+ except FileNotFoundError as exc:
670
+ return 127, "", str(exc)
671
+ except subprocess.TimeoutExpired as exc:
672
+ return 124, exc.stdout or "", exc.stderr or "timeout"
673
+ except Exception as exc:
674
+ return 1, "", str(exc)
675
+ return result.returncode, result.stdout or "", result.stderr or ""
676
+
677
+
678
+ def _process_running(pattern: str) -> bool:
679
+ if system_label() == "windows":
680
+ command = (
681
+ "$pattern = '" + pattern.replace("'", "''") + "'; "
682
+ "$match = Get-CimInstance Win32_Process | "
683
+ "Where-Object { $_.CommandLine -like \"*$pattern*\" } | "
684
+ "Select-Object -First 1 -ExpandProperty ProcessId; "
685
+ "if ($match) { Write-Output $match }"
686
+ )
687
+ code, stdout, _ = _command_output(["powershell", "-NoProfile", "-Command", command], timeout=4)
688
+ return code == 0 and bool(stdout.strip())
689
+
690
+ code, stdout, _ = _command_output(["pgrep", "-f", pattern], timeout=2)
691
+ if code == 0 and stdout.strip():
692
+ return True
693
+ code, stdout, _ = _command_output(["ps", "aux"], timeout=2)
694
+ return code == 0 and pattern in stdout
695
+
696
+
697
+ def _macos_local_index_service_status() -> dict:
698
+ plist_path = Path.home() / "Library" / "LaunchAgents" / f"{LOCAL_INDEX_SERVICE_LABEL}.plist"
699
+ installed = plist_path.is_file()
700
+ running = False
701
+ active_process = False
702
+ pid = ""
703
+
704
+ code, stdout, _ = _command_output(["launchctl", "list"], timeout=2)
705
+ if code == 0:
706
+ for line in stdout.splitlines():
707
+ parts = line.split()
708
+ if len(parts) >= 3 and parts[-1] == LOCAL_INDEX_SERVICE_LABEL:
709
+ installed = True
710
+ pid = parts[0]
711
+ running = True
712
+ active_process = pid.isdigit() and int(pid) > 0
713
+ break
714
+
715
+ if not installed:
716
+ code, _, _ = _command_output(["launchctl", "print", f"gui/{os.getuid()}/{LOCAL_INDEX_SERVICE_LABEL}"], timeout=2)
717
+ installed = code == 0
718
+
719
+ if not active_process:
720
+ active_process = _process_running(LOCAL_INDEX_SCRIPT_NAME)
721
+ running = running or active_process
722
+
723
+ return {
724
+ "installed": installed,
725
+ "running": running,
726
+ "active_process": active_process,
727
+ "manager": "launchagent",
728
+ "label": LOCAL_INDEX_SERVICE_LABEL,
729
+ "pid": pid,
730
+ "config_path": str(plist_path),
731
+ }
732
+
733
+
734
+ def _windows_local_index_service_status() -> dict:
735
+ command = (
736
+ "$task = Get-ScheduledTask -TaskName 'NEXO Local Memory' -ErrorAction SilentlyContinue; "
737
+ "if ($task) { Write-Output $task.State }"
738
+ )
739
+ code, stdout, _ = _command_output(["powershell", "-NoProfile", "-Command", command], timeout=4)
740
+ task_state = stdout.strip()
741
+ task_state_key = task_state.lower()
742
+ installed = code == 0 and bool(task_state)
743
+ active_process = task_state_key == "running"
744
+ if not active_process:
745
+ active_process = _process_running(LOCAL_INDEX_SCRIPT_NAME)
746
+ running = task_state_key in {"ready", "running"} or active_process
747
+ return {
748
+ "installed": installed,
749
+ "running": running,
750
+ "active_process": active_process,
751
+ "manager": "scheduled_task",
752
+ "task_name": LOCAL_INDEX_WINDOWS_TASK,
753
+ "task_state": task_state,
754
+ }
755
+
756
+
757
+ def _linux_local_index_service_status() -> dict:
758
+ unit_dir = Path.home() / ".config" / "systemd" / "user"
759
+ unit_path = unit_dir / LOCAL_INDEX_LINUX_UNIT
760
+ timer_path = unit_dir / "nexo-local-index.timer"
761
+ installed = unit_path.is_file() or timer_path.is_file()
762
+
763
+ code, stdout, _ = _command_output(["systemctl", "--user", "is-active", LOCAL_INDEX_LINUX_UNIT], timeout=2)
764
+ unit_state = stdout.strip()
765
+ running = code == 0 and unit_state == "active"
766
+ active_process = _process_running(LOCAL_INDEX_SCRIPT_NAME)
767
+ running = running or active_process
768
+
769
+ return {
770
+ "installed": installed,
771
+ "running": running,
772
+ "active_process": active_process,
773
+ "manager": "systemd_user",
774
+ "unit": LOCAL_INDEX_LINUX_UNIT,
775
+ "unit_state": unit_state,
776
+ "config_path": str(unit_path),
777
+ }
778
+
779
+
780
+ def _local_index_service_status() -> dict:
781
+ platform_value = system_label()
782
+ if platform_value == "macos":
783
+ service = _macos_local_index_service_status()
784
+ elif platform_value == "windows":
785
+ service = _windows_local_index_service_status()
786
+ else:
787
+ service = _linux_local_index_service_status()
788
+ service.setdefault("installed", False)
789
+ service.setdefault("running", False)
790
+ service["platform"] = platform_value
791
+ service["started_at"] = ""
792
+ service["last_heartbeat_at"] = ""
793
+ return service
794
+
795
+
614
796
  def status() -> dict:
615
797
  conn = _conn()
616
798
  paused = _is_paused()
@@ -628,16 +810,11 @@ def status() -> dict:
628
810
  ).fetchall()
629
811
  for row in by_volume:
630
812
  volumes.append({"id": row["volume_id"], "label": row["volume_id"] or "Disk", "files": row["files"], "status": "active"})
813
+ service = _local_index_service_status()
814
+ service["state"] = "paused" if paused else ("idle" if pending == 0 else "indexing")
631
815
  return {
632
816
  "ok": True,
633
- "service": {
634
- "installed": False,
635
- "running": False,
636
- "state": "paused" if paused else ("idle" if pending == 0 else "indexing"),
637
- "platform": system_label(),
638
- "started_at": "",
639
- "last_heartbeat_at": "",
640
- },
817
+ "service": service,
641
818
  "global": {
642
819
  "phase": "paused" if paused else ("idle" if pending == 0 else "light_extraction"),
643
820
  "percent": percent,
@@ -36,10 +36,24 @@ NOISY_PARTS = {
36
36
  "dist",
37
37
  "build",
38
38
  ".git",
39
+ ".venv",
40
+ "venv",
41
+ "env",
39
42
  ".cache",
40
43
  "cache",
41
44
  "coverage",
42
45
  "__pycache__",
46
+ ".tox",
47
+ ".mypy_cache",
48
+ ".pytest_cache",
49
+ ".ruff_cache",
50
+ ".next",
51
+ ".nuxt",
52
+ ".turbo",
53
+ ".parcel-cache",
54
+ ".bun",
55
+ ".gradle",
56
+ "target",
43
57
  }
44
58
 
45
59
  SYSTEM_PARTS = {
@@ -71,6 +85,15 @@ def classify_path(path: str) -> tuple[int, str, str]:
71
85
  return 2, "normal", "default"
72
86
 
73
87
 
88
+ def should_skip_tree(path: str) -> bool:
89
+ p = Path(path)
90
+ lowered = str(p).replace("\\", "/").lower()
91
+ parts = {part.lower() for part in p.parts}
92
+ if any(item in lowered for item in SYSTEM_PARTS):
93
+ return True
94
+ return bool(parts & NOISY_PARTS)
95
+
96
+
74
97
  def should_extract(path: str, depth: int) -> bool:
75
98
  if depth < 2:
76
99
  return False
@@ -1,11 +1,13 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import contextlib
4
5
  import hashlib
5
6
  import json
6
7
  import os
7
8
  import re
8
9
  import shutil
10
+ import sys
9
11
  import time
10
12
  from dataclasses import dataclass
11
13
  from pathlib import Path
@@ -814,6 +816,31 @@ def prime_process_fingerprint() -> str:
814
816
  return PROCESS_FINGERPRINT
815
817
 
816
818
 
819
+ _DRIFT_AUTOEXIT_SCHEDULED = False
820
+ _DRIFT_EXIT_CODE = 75
821
+ _DRIFT_EXIT_DELAY_SECONDS = 0.5
822
+
823
+
824
+ def _request_drift_exit() -> None:
825
+ try:
826
+ os._exit(_DRIFT_EXIT_CODE)
827
+ except Exception:
828
+ os._exit(1)
829
+
830
+
831
+ def _schedule_drift_autoexit() -> None:
832
+ global _DRIFT_AUTOEXIT_SCHEDULED
833
+ if _DRIFT_AUTOEXIT_SCHEDULED:
834
+ return
835
+ _DRIFT_AUTOEXIT_SCHEDULED = True
836
+ try:
837
+ loop = asyncio.get_running_loop()
838
+ except RuntimeError:
839
+ _request_drift_exit()
840
+ return
841
+ loop.call_later(_DRIFT_EXIT_DELAY_SECONDS, _request_drift_exit)
842
+
843
+
817
844
  @dataclass
818
845
  class RestartRequiredMiddleware(Middleware):
819
846
  client: str = ""
@@ -893,4 +920,7 @@ class RestartRequiredMiddleware(Middleware):
893
920
  "reason": state["reason"],
894
921
  "client_action": state["client_action"],
895
922
  }
896
- return await self._tool_result_for_restart_required(context, payload)
923
+ result = await self._tool_result_for_restart_required(context, payload)
924
+ if state.get("reason") == "fingerprint_mismatch":
925
+ _schedule_drift_autoexit()
926
+ return result