nexo-brain 7.18.1 → 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.1",
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,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.18.1` is the current packaged-runtime line. 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.
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.
22
24
 
23
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.
24
26
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.18.1",
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",
@@ -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()
@@ -337,6 +343,8 @@ def _iter_files(root: Path, exclusions: list[str], *, limit: int | None = None,
337
343
  current = stack.pop()
338
344
  if _is_excluded(str(current), exclusions):
339
345
  continue
346
+ if current != root and should_skip_tree(str(current)):
347
+ continue
340
348
  try:
341
349
  st = current.stat()
342
350
  except Exception:
@@ -356,6 +364,8 @@ def _iter_files(root: Path, exclusions: list[str], *, limit: int | None = None,
356
364
  if entry.is_symlink():
357
365
  continue
358
366
  if entry.is_dir():
367
+ if should_skip_tree(str(entry)):
368
+ continue
359
369
  dirs.append(entry)
360
370
  continue
361
371
  if entry.is_file():
@@ -653,6 +663,136 @@ def _problem_rows(conn) -> list[dict]:
653
663
  ]
654
664
 
655
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
+
656
796
  def status() -> dict:
657
797
  conn = _conn()
658
798
  paused = _is_paused()
@@ -670,16 +810,11 @@ def status() -> dict:
670
810
  ).fetchall()
671
811
  for row in by_volume:
672
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")
673
815
  return {
674
816
  "ok": True,
675
- "service": {
676
- "installed": False,
677
- "running": False,
678
- "state": "paused" if paused else ("idle" if pending == 0 else "indexing"),
679
- "platform": system_label(),
680
- "started_at": "",
681
- "last_heartbeat_at": "",
682
- },
817
+ "service": service,
683
818
  "global": {
684
819
  "phase": "paused" if paused else ("idle" if pending == 0 else "light_extraction"),
685
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