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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +3 -1
- package/package.json +1 -1
- package/src/auto_update.py +5 -0
- package/src/local_context/api.py +144 -9
- package/src/local_context/privacy.py +23 -0
- package/src/runtime_versioning.py +31 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
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.
|
|
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.
|
|
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",
|
package/src/auto_update.py
CHANGED
|
@@ -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"
|
package/src/local_context/api.py
CHANGED
|
@@ -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
|
-
|
|
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
|