nexo-brain 7.10.1 → 7.11.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/plugins/update.py +94 -16
- package/src/runtime_versioning.py +382 -7
- package/src/server.py +2 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.11.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,7 @@
|
|
|
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.11.1` is the current packaged-runtime line. Patch release — caches the runtime fingerprint by `(file_count, size_total, max_mtime)` signature so MCP startup and the per-tool-call `resolve_restart_required` skip the 263-file rehash when nothing on disk changed. ~11× speedup warm path (~40ms → ~3.7ms locally), ~10-20s/day saved across Claude Code / Codex / headless / deep-sleep / cron startups. Cache miss is always safe (falls through to full hash and self-repairs). Default `use_cache=False` keeps `plugins/update.py` on the ground-truth path around `git pull` / `npm update`. Builds on the v7.11.0 runtime fingerprint that gates `mcp-restart-required.json`. Full write-up in [`docs/runtime-fingerprint.md`](docs/runtime-fingerprint.md).
|
|
22
22
|
|
|
23
23
|
Previously in `7.10.0`: minor release — **removes the LLM proxy override path that 7.9.28 → 7.9.34 introduced**. Background: 7.9.28 added two opt-in files at `~/.nexo/config/llm_endpoint.json` and `~/.nexo/config/auth_provider.json` that let a third-party orchestrator (NEXO Desktop) redirect every Anthropic SDK call from Brain to a custom proxy and resolve the bearer via a local helper, with concrete model names translated to wire aliases (`nexo-max`, `nexo-high`, `nexo-medium`, `nexo-low`, `nexo-mini`) and an `Idempotency-Key` per request. NEXO Desktop's commercial model has changed: Desktop is now a wrapper over the user's own Claude Code subscription (Max / Pro), with a separate Desktop licence. Brain calls go directly to `api.anthropic.com` using the user's existing OAuth (the one stored under `~/.claude/` and consumed by Claude Code spawns) or a plain `ANTHROPIC_API_KEY`. There is no NEXO bearer, no NEXO proxy, no NEXO credit accounting in this codebase. Every proxy symbol is gone from `call_model_raw.py` and `agent_runner.py`; the proxy-specific tests and `docs/api/override-files.md` are removed; any pre-existing override files on disk are simply ignored from this release forward.
|
|
24
24
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.11.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/plugins/update.py
CHANGED
|
@@ -12,7 +12,12 @@ import time
|
|
|
12
12
|
from pathlib import Path
|
|
13
13
|
|
|
14
14
|
from runtime_home import export_resolved_nexo_home
|
|
15
|
-
from runtime_versioning import
|
|
15
|
+
from runtime_versioning import (
|
|
16
|
+
activate_versioned_runtime_snapshot,
|
|
17
|
+
compute_mcp_runtime_fingerprint,
|
|
18
|
+
installed_force_restart_flag,
|
|
19
|
+
write_restart_required_marker,
|
|
20
|
+
)
|
|
16
21
|
|
|
17
22
|
try:
|
|
18
23
|
from tree_hygiene import is_duplicate_artifact_name
|
|
@@ -1066,6 +1071,11 @@ def _reload_launch_agents_after_bump() -> dict:
|
|
|
1066
1071
|
def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> str:
|
|
1067
1072
|
"""Update a packaged (npm) install — no git repo available."""
|
|
1068
1073
|
old_version = _read_version()
|
|
1074
|
+
# Capture pre-update fingerprint of the installed runtime so we can decide
|
|
1075
|
+
# whether the npm payload actually changed any MCP-loaded source byte.
|
|
1076
|
+
# Empty string = "fingerprint unavailable", which the marker logic below
|
|
1077
|
+
# treats as "assume changed" (safe fallback).
|
|
1078
|
+
old_fingerprint = compute_mcp_runtime_fingerprint(_runtime_code_root())
|
|
1069
1079
|
|
|
1070
1080
|
# 0. Pre-flight wipe guard (v5.5.5+)
|
|
1071
1081
|
_emit_progress(progress_fn, "Checking DB integrity before update...")
|
|
@@ -1222,6 +1232,9 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1222
1232
|
|
|
1223
1233
|
versioned_runtime_summary = None
|
|
1224
1234
|
restart_marker_summary = None
|
|
1235
|
+
mcp_code_changed = False
|
|
1236
|
+
force_restart = False
|
|
1237
|
+
new_fingerprint = ""
|
|
1225
1238
|
if old_version != new_version:
|
|
1226
1239
|
try:
|
|
1227
1240
|
_emit_progress(progress_fn, "Activating versioned runtime snapshot...")
|
|
@@ -1231,13 +1244,35 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1231
1244
|
)
|
|
1232
1245
|
except Exception as e:
|
|
1233
1246
|
errors.append(f"versioned runtime activation: {e}")
|
|
1247
|
+
# Decide whether the new release actually altered any MCP-loaded
|
|
1248
|
+
# source file. Doc-only / blog-only / changelog-only releases keep
|
|
1249
|
+
# the same fingerprint and skip the restart marker entirely.
|
|
1234
1250
|
try:
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1251
|
+
new_fingerprint = compute_mcp_runtime_fingerprint(_runtime_code_root())
|
|
1252
|
+
except Exception:
|
|
1253
|
+
new_fingerprint = ""
|
|
1254
|
+
try:
|
|
1255
|
+
force_restart = installed_force_restart_flag()
|
|
1256
|
+
except Exception:
|
|
1257
|
+
force_restart = False
|
|
1258
|
+
# Conservative default: if either fingerprint is missing, behave as
|
|
1259
|
+
# before and force a restart (#186 — never leave a stale process
|
|
1260
|
+
# silently running new bytes).
|
|
1261
|
+
if not old_fingerprint or not new_fingerprint:
|
|
1262
|
+
mcp_code_changed = True
|
|
1263
|
+
else:
|
|
1264
|
+
mcp_code_changed = old_fingerprint != new_fingerprint
|
|
1265
|
+
if mcp_code_changed or force_restart:
|
|
1266
|
+
try:
|
|
1267
|
+
restart_marker_summary = write_restart_required_marker(
|
|
1268
|
+
from_version=old_version,
|
|
1269
|
+
to_version=new_version,
|
|
1270
|
+
reason="brain_update_force" if (force_restart and not mcp_code_changed) else "brain_update",
|
|
1271
|
+
from_fingerprint=old_fingerprint,
|
|
1272
|
+
to_fingerprint=new_fingerprint,
|
|
1273
|
+
)
|
|
1274
|
+
except Exception as e:
|
|
1275
|
+
errors.append(f"restart marker: {e}")
|
|
1241
1276
|
|
|
1242
1277
|
if errors:
|
|
1243
1278
|
# 5. Full rollback: restore code tree + DBs + pip deps + rollback npm package
|
|
@@ -1305,7 +1340,15 @@ def _handle_packaged_update(progress_fn=None, *, include_clis: bool = True) -> s
|
|
|
1305
1340
|
if restart_marker_summary:
|
|
1306
1341
|
lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
|
|
1307
1342
|
lines.append("")
|
|
1308
|
-
|
|
1343
|
+
if old_version != new_version and not mcp_code_changed and not force_restart:
|
|
1344
|
+
# Doc-only / blog-only / changelog-only release. The bytes the running
|
|
1345
|
+
# MCP imports are byte-identical to what's now on disk, so existing
|
|
1346
|
+
# MCP clients keep working without a forced restart.
|
|
1347
|
+
lines.append(
|
|
1348
|
+
"MCP source unchanged (no `.py` byte changed) — no restart needed."
|
|
1349
|
+
)
|
|
1350
|
+
else:
|
|
1351
|
+
lines.append("MCP server restart needed to load new code.")
|
|
1309
1352
|
return "\n".join(lines)
|
|
1310
1353
|
|
|
1311
1354
|
|
|
@@ -1361,6 +1404,9 @@ def handle_update(
|
|
|
1361
1404
|
# Record current state
|
|
1362
1405
|
old_version = _read_version()
|
|
1363
1406
|
old_req_hash = _requirements_hash()
|
|
1407
|
+
# Capture pre-pull fingerprint so we can detect doc-only releases that
|
|
1408
|
+
# bump version.json but never touch a single MCP-loaded `.py` byte.
|
|
1409
|
+
old_fingerprint = compute_mcp_runtime_fingerprint(SRC_DIR)
|
|
1364
1410
|
rc, old_commit, _ = _git("rev-parse", "HEAD")
|
|
1365
1411
|
if rc != 0:
|
|
1366
1412
|
return "ABORTED: Not a git repository or git not available."
|
|
@@ -1516,6 +1562,9 @@ def handle_update(
|
|
|
1516
1562
|
|
|
1517
1563
|
versioned_runtime_summary = None
|
|
1518
1564
|
restart_marker_summary = None
|
|
1565
|
+
mcp_code_changed = False
|
|
1566
|
+
force_restart = False
|
|
1567
|
+
new_fingerprint = ""
|
|
1519
1568
|
if version_changed:
|
|
1520
1569
|
try:
|
|
1521
1570
|
_emit_progress(progress_fn, "Activating versioned runtime snapshot...")
|
|
@@ -1526,14 +1575,35 @@ def handle_update(
|
|
|
1526
1575
|
steps_done.append("versioned-runtime")
|
|
1527
1576
|
except Exception as e:
|
|
1528
1577
|
raise RuntimeError(f"Versioned runtime activation failed: {e}")
|
|
1578
|
+
# Decide whether the new release actually altered any MCP-loaded
|
|
1579
|
+
# source file. Doc-only / blog-only / changelog-only releases keep
|
|
1580
|
+
# the same fingerprint and skip the restart marker entirely.
|
|
1529
1581
|
try:
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
except Exception
|
|
1536
|
-
|
|
1582
|
+
new_fingerprint = compute_mcp_runtime_fingerprint(SRC_DIR)
|
|
1583
|
+
except Exception:
|
|
1584
|
+
new_fingerprint = ""
|
|
1585
|
+
try:
|
|
1586
|
+
force_restart = installed_force_restart_flag()
|
|
1587
|
+
except Exception:
|
|
1588
|
+
force_restart = False
|
|
1589
|
+
# Conservative default: if either fingerprint is missing, treat as
|
|
1590
|
+
# changed and force restart (#186).
|
|
1591
|
+
if not old_fingerprint or not new_fingerprint:
|
|
1592
|
+
mcp_code_changed = True
|
|
1593
|
+
else:
|
|
1594
|
+
mcp_code_changed = old_fingerprint != new_fingerprint
|
|
1595
|
+
if mcp_code_changed or force_restart:
|
|
1596
|
+
try:
|
|
1597
|
+
restart_marker_summary = write_restart_required_marker(
|
|
1598
|
+
from_version=old_version,
|
|
1599
|
+
to_version=new_version,
|
|
1600
|
+
reason="brain_update_force" if (force_restart and not mcp_code_changed) else "brain_update",
|
|
1601
|
+
from_fingerprint=old_fingerprint,
|
|
1602
|
+
to_fingerprint=new_fingerprint,
|
|
1603
|
+
)
|
|
1604
|
+
steps_done.append("restart-marker")
|
|
1605
|
+
except Exception as e:
|
|
1606
|
+
raise RuntimeError(f"Restart marker write failed: {e}")
|
|
1537
1607
|
|
|
1538
1608
|
# Build result
|
|
1539
1609
|
dep_summary_lines = _format_dep_results(dep_results)
|
|
@@ -1570,7 +1640,15 @@ def handle_update(
|
|
|
1570
1640
|
if restart_marker_summary:
|
|
1571
1641
|
lines.append(f" Restart marker: {restart_marker_summary.get('path')}")
|
|
1572
1642
|
lines.append("")
|
|
1573
|
-
|
|
1643
|
+
if version_changed and not mcp_code_changed and not force_restart:
|
|
1644
|
+
# Doc-only / blog-only / changelog-only release. The bytes the
|
|
1645
|
+
# running MCP imports are byte-identical to what's now on disk, so
|
|
1646
|
+
# existing MCP clients keep working without a forced restart.
|
|
1647
|
+
lines.append(
|
|
1648
|
+
"MCP source unchanged (no `.py` byte changed) — no restart needed."
|
|
1649
|
+
)
|
|
1650
|
+
else:
|
|
1651
|
+
lines.append("MCP server restart needed to load new code.")
|
|
1574
1652
|
return "\n".join(lines)
|
|
1575
1653
|
|
|
1576
1654
|
except Exception as e:
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
from __future__ import annotations
|
|
2
2
|
|
|
3
|
+
import hashlib
|
|
3
4
|
import json
|
|
4
5
|
import os
|
|
5
6
|
import shutil
|
|
@@ -14,8 +15,25 @@ import paths
|
|
|
14
15
|
|
|
15
16
|
|
|
16
17
|
CONTINUITY_API_LEVEL = 1
|
|
17
|
-
MCP_STATUS_SCHEMA_VERSION =
|
|
18
|
+
MCP_STATUS_SCHEMA_VERSION = 2
|
|
18
19
|
PROCESS_VERSION = ""
|
|
20
|
+
PROCESS_FINGERPRINT = ""
|
|
21
|
+
|
|
22
|
+
# Subtrees under the runtime source root that are NOT loaded by the running
|
|
23
|
+
# MCP server process (subprocess scripts, test fixtures, migrations executed
|
|
24
|
+
# out-of-process, cron entry points spawned separately). Any change limited
|
|
25
|
+
# to these directories should NOT force a restart of running MCP clients.
|
|
26
|
+
# Anything else under the runtime root (server.py, cli.py, plugins/*, helpers)
|
|
27
|
+
# is included in the fingerprint by default.
|
|
28
|
+
_FINGERPRINT_EXCLUDE_DIRS = frozenset({
|
|
29
|
+
"scripts",
|
|
30
|
+
"tests",
|
|
31
|
+
"migrations",
|
|
32
|
+
"crons",
|
|
33
|
+
"__pycache__",
|
|
34
|
+
"node_modules",
|
|
35
|
+
".git",
|
|
36
|
+
})
|
|
19
37
|
RESTART_CLIENT_ACTIONS = {
|
|
20
38
|
"claude_desktop": "restart_client_required",
|
|
21
39
|
"claude_code": "restart_session_required",
|
|
@@ -142,6 +160,98 @@ def restart_required_marker_path() -> Path:
|
|
|
142
160
|
return paths.operations_dir() / "mcp-restart-required.json"
|
|
143
161
|
|
|
144
162
|
|
|
163
|
+
def fingerprint_cache_path() -> Path:
|
|
164
|
+
"""Where the runtime fingerprint cache lives.
|
|
165
|
+
|
|
166
|
+
The cache lets `prime_process_fingerprint()` and `installed_runtime_fingerprint()`
|
|
167
|
+
skip hashing 200+ source files on every MCP startup / tool call when the
|
|
168
|
+
runtime tree on disk hasn't changed (same file count, same total size, same
|
|
169
|
+
max mtime). Invalidates automatically when any source byte changes.
|
|
170
|
+
"""
|
|
171
|
+
return paths.operations_dir() / "fingerprint-cache.json"
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def _runtime_tree_signature(src_dir: Path) -> tuple[int, int, float] | None:
|
|
175
|
+
"""Cheap stat-only walk over the fingerprint-tracked tree.
|
|
176
|
+
|
|
177
|
+
Returns ``(file_count, size_total, max_mtime)`` or ``None`` when the source
|
|
178
|
+
tree cannot be traversed. This is the cache key — if it matches, the bytes
|
|
179
|
+
haven't changed in any way the fingerprint would care about.
|
|
180
|
+
"""
|
|
181
|
+
try:
|
|
182
|
+
files = _iter_runtime_source_files(src_dir)
|
|
183
|
+
except Exception:
|
|
184
|
+
return None
|
|
185
|
+
if not files:
|
|
186
|
+
return None
|
|
187
|
+
count = 0
|
|
188
|
+
size_total = 0
|
|
189
|
+
max_mtime = 0.0
|
|
190
|
+
for path in files:
|
|
191
|
+
try:
|
|
192
|
+
st = path.stat()
|
|
193
|
+
except Exception:
|
|
194
|
+
return None
|
|
195
|
+
count += 1
|
|
196
|
+
size_total += int(st.st_size)
|
|
197
|
+
if st.st_mtime > max_mtime:
|
|
198
|
+
max_mtime = float(st.st_mtime)
|
|
199
|
+
return (count, size_total, max_mtime)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def _read_fingerprint_cache(src_dir: Path) -> str:
|
|
203
|
+
"""Return cached fingerprint when the on-disk signature still matches.
|
|
204
|
+
|
|
205
|
+
Empty string means cache miss (corrupt, missing, or signature drifted).
|
|
206
|
+
Cache miss is always safe — caller falls through to a full hash.
|
|
207
|
+
"""
|
|
208
|
+
cache_path = fingerprint_cache_path()
|
|
209
|
+
if not cache_path.is_file():
|
|
210
|
+
return ""
|
|
211
|
+
try:
|
|
212
|
+
payload = json.loads(cache_path.read_text(encoding="utf-8"))
|
|
213
|
+
except Exception:
|
|
214
|
+
return ""
|
|
215
|
+
if not isinstance(payload, dict):
|
|
216
|
+
return ""
|
|
217
|
+
if str(payload.get("src_dir") or "") != str(src_dir):
|
|
218
|
+
return ""
|
|
219
|
+
sig = _runtime_tree_signature(src_dir)
|
|
220
|
+
if sig is None:
|
|
221
|
+
return ""
|
|
222
|
+
try:
|
|
223
|
+
cached_count = int(payload.get("file_count"))
|
|
224
|
+
cached_size = int(payload.get("size_total"))
|
|
225
|
+
cached_mtime = float(payload.get("max_mtime"))
|
|
226
|
+
except (TypeError, ValueError):
|
|
227
|
+
return ""
|
|
228
|
+
if cached_count != sig[0] or cached_size != sig[1] or cached_mtime != sig[2]:
|
|
229
|
+
return ""
|
|
230
|
+
fingerprint = str(payload.get("fingerprint") or "").strip()
|
|
231
|
+
return fingerprint
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
def _write_fingerprint_cache(src_dir: Path, fingerprint: str) -> None:
|
|
235
|
+
"""Persist the fingerprint+signature pair. Best-effort; failures don't propagate."""
|
|
236
|
+
if not fingerprint:
|
|
237
|
+
return
|
|
238
|
+
sig = _runtime_tree_signature(src_dir)
|
|
239
|
+
if sig is None:
|
|
240
|
+
return
|
|
241
|
+
payload = {
|
|
242
|
+
"fingerprint": fingerprint,
|
|
243
|
+
"src_dir": str(src_dir),
|
|
244
|
+
"file_count": sig[0],
|
|
245
|
+
"size_total": sig[1],
|
|
246
|
+
"max_mtime": sig[2],
|
|
247
|
+
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
248
|
+
}
|
|
249
|
+
try:
|
|
250
|
+
_write_json_atomic(fingerprint_cache_path(), payload)
|
|
251
|
+
except Exception:
|
|
252
|
+
pass
|
|
253
|
+
|
|
254
|
+
|
|
145
255
|
def _candidate_version_files(base: Path) -> list[Path]:
|
|
146
256
|
return [
|
|
147
257
|
base / "version.json",
|
|
@@ -170,6 +280,155 @@ def installed_runtime_version() -> str:
|
|
|
170
280
|
return ""
|
|
171
281
|
|
|
172
282
|
|
|
283
|
+
def installed_force_restart_flag() -> bool:
|
|
284
|
+
"""Read explicit `force_restart` opt-in from version.json/package.json.
|
|
285
|
+
|
|
286
|
+
A release that touches behavior in subtle ways (config schema, runtime
|
|
287
|
+
contract) but happens not to change any tracked MCP source byte can still
|
|
288
|
+
force a restart by setting `force_restart: true` in version.json. Default
|
|
289
|
+
is False — fingerprint is the source of truth.
|
|
290
|
+
"""
|
|
291
|
+
for candidate in [active_runtime_root(), paths.home()]:
|
|
292
|
+
for vfile in _candidate_version_files(candidate):
|
|
293
|
+
try:
|
|
294
|
+
if vfile.is_file():
|
|
295
|
+
payload = json.loads(vfile.read_text(encoding="utf-8"))
|
|
296
|
+
if isinstance(payload, dict) and bool(payload.get("force_restart")):
|
|
297
|
+
return True
|
|
298
|
+
except Exception:
|
|
299
|
+
continue
|
|
300
|
+
return False
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _iter_runtime_source_files(src_dir: Path) -> list[Path]:
|
|
304
|
+
"""Return MCP-loaded `.py` files under `src_dir`, sorted by relative path."""
|
|
305
|
+
out: list[Path] = []
|
|
306
|
+
if not src_dir or not src_dir.is_dir():
|
|
307
|
+
return out
|
|
308
|
+
for path in src_dir.rglob("*.py"):
|
|
309
|
+
try:
|
|
310
|
+
rel = path.relative_to(src_dir)
|
|
311
|
+
except ValueError:
|
|
312
|
+
continue
|
|
313
|
+
if any(seg in _FINGERPRINT_EXCLUDE_DIRS for seg in rel.parts):
|
|
314
|
+
continue
|
|
315
|
+
out.append(path)
|
|
316
|
+
out.sort(key=lambda p: p.relative_to(src_dir).as_posix())
|
|
317
|
+
return out
|
|
318
|
+
|
|
319
|
+
|
|
320
|
+
def compute_mcp_runtime_fingerprint(
|
|
321
|
+
src_dir: Path | None = None, *, use_cache: bool = False
|
|
322
|
+
) -> str:
|
|
323
|
+
"""Hash of every Python source file the running MCP can import.
|
|
324
|
+
|
|
325
|
+
Returns a sha256 hex digest, or "" when the source tree cannot be located
|
|
326
|
+
or read (caller treats empty as "fingerprint unavailable" and falls back
|
|
327
|
+
to the version-string mismatch check).
|
|
328
|
+
|
|
329
|
+
Includes:
|
|
330
|
+
* every `.py` under the runtime root
|
|
331
|
+
Excludes:
|
|
332
|
+
* subtrees in `_FINGERPRINT_EXCLUDE_DIRS` (scripts/, tests/, migrations/,
|
|
333
|
+
crons/, __pycache__/, node_modules/, .git/)
|
|
334
|
+
* non-`.py` assets (docs, blogs, READMEs, JSON/YAML configs, templates,
|
|
335
|
+
CHANGELOG, marketing files) — these never affect what the live MCP
|
|
336
|
+
process executes
|
|
337
|
+
|
|
338
|
+
When ``use_cache=True`` (hot paths: server startup, every tool call) the
|
|
339
|
+
function consults ``fingerprint-cache.json``: if the on-disk tree
|
|
340
|
+
signature (file count + total size + max mtime) still matches the cached
|
|
341
|
+
one, the cached digest is returned without re-reading any byte. Cache miss
|
|
342
|
+
falls through to the normal full-hash path and writes a fresh entry. The
|
|
343
|
+
update flow keeps ``use_cache=False`` (default) so it always sees ground
|
|
344
|
+
truth around the pull/npm step.
|
|
345
|
+
"""
|
|
346
|
+
if src_dir is None:
|
|
347
|
+
candidates: list[Path] = []
|
|
348
|
+
try:
|
|
349
|
+
here = Path(__file__).resolve().parent
|
|
350
|
+
candidates.append(here)
|
|
351
|
+
except Exception:
|
|
352
|
+
pass
|
|
353
|
+
try:
|
|
354
|
+
root = active_runtime_root()
|
|
355
|
+
if root and root not in candidates:
|
|
356
|
+
candidates.append(root)
|
|
357
|
+
except Exception:
|
|
358
|
+
pass
|
|
359
|
+
try:
|
|
360
|
+
home = paths.home()
|
|
361
|
+
if home and home not in candidates:
|
|
362
|
+
candidates.append(home)
|
|
363
|
+
except Exception:
|
|
364
|
+
pass
|
|
365
|
+
for cand in candidates:
|
|
366
|
+
if (cand / "server.py").is_file() or (cand / "cli.py").is_file():
|
|
367
|
+
src_dir = cand
|
|
368
|
+
break
|
|
369
|
+
if src_dir is None:
|
|
370
|
+
return ""
|
|
371
|
+
|
|
372
|
+
if use_cache:
|
|
373
|
+
cached = _read_fingerprint_cache(src_dir)
|
|
374
|
+
if cached:
|
|
375
|
+
return cached
|
|
376
|
+
|
|
377
|
+
files = _iter_runtime_source_files(src_dir)
|
|
378
|
+
if not files:
|
|
379
|
+
return ""
|
|
380
|
+
h = hashlib.sha256()
|
|
381
|
+
for path in files:
|
|
382
|
+
try:
|
|
383
|
+
rel = path.relative_to(src_dir).as_posix()
|
|
384
|
+
except ValueError:
|
|
385
|
+
continue
|
|
386
|
+
h.update(rel.encode("utf-8"))
|
|
387
|
+
h.update(b"\x00")
|
|
388
|
+
try:
|
|
389
|
+
h.update(path.read_bytes())
|
|
390
|
+
except Exception:
|
|
391
|
+
return ""
|
|
392
|
+
h.update(b"\n")
|
|
393
|
+
digest = h.hexdigest()
|
|
394
|
+
if use_cache and digest:
|
|
395
|
+
_write_fingerprint_cache(src_dir, digest)
|
|
396
|
+
return digest
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def installed_runtime_fingerprint() -> str:
|
|
400
|
+
"""Fingerprint of whatever runtime source tree is on disk right now.
|
|
401
|
+
|
|
402
|
+
Hot path — runs on every MCP tool call via ``resolve_restart_required``.
|
|
403
|
+
Uses the disk-signature cache so a repeated call without any source
|
|
404
|
+
change is a few stat() syscalls instead of 200+ file reads.
|
|
405
|
+
"""
|
|
406
|
+
candidates: list[Path] = []
|
|
407
|
+
try:
|
|
408
|
+
root = active_runtime_root()
|
|
409
|
+
if root:
|
|
410
|
+
candidates.append(root)
|
|
411
|
+
except Exception:
|
|
412
|
+
pass
|
|
413
|
+
try:
|
|
414
|
+
home = paths.home()
|
|
415
|
+
if home and home not in candidates:
|
|
416
|
+
candidates.append(home)
|
|
417
|
+
except Exception:
|
|
418
|
+
pass
|
|
419
|
+
try:
|
|
420
|
+
here = Path(__file__).resolve().parent
|
|
421
|
+
if here not in candidates:
|
|
422
|
+
candidates.append(here)
|
|
423
|
+
except Exception:
|
|
424
|
+
pass
|
|
425
|
+
for cand in candidates:
|
|
426
|
+
fp = compute_mcp_runtime_fingerprint(cand, use_cache=True)
|
|
427
|
+
if fp:
|
|
428
|
+
return fp
|
|
429
|
+
return ""
|
|
430
|
+
|
|
431
|
+
|
|
173
432
|
def read_restart_required_marker() -> dict:
|
|
174
433
|
path = restart_required_marker_path()
|
|
175
434
|
if not path.exists():
|
|
@@ -198,6 +457,8 @@ def write_restart_required_marker(
|
|
|
198
457
|
to_version: str,
|
|
199
458
|
reason: str = "brain_update",
|
|
200
459
|
client: str = "",
|
|
460
|
+
from_fingerprint: str = "",
|
|
461
|
+
to_fingerprint: str = "",
|
|
201
462
|
) -> dict:
|
|
202
463
|
path = restart_required_marker_path()
|
|
203
464
|
payload = {
|
|
@@ -205,6 +466,8 @@ def write_restart_required_marker(
|
|
|
205
466
|
"required": True,
|
|
206
467
|
"from_version": str(from_version or "").strip(),
|
|
207
468
|
"to_version": str(to_version or "").strip(),
|
|
469
|
+
"from_fingerprint": str(from_fingerprint or "").strip(),
|
|
470
|
+
"to_fingerprint": str(to_fingerprint or "").strip(),
|
|
208
471
|
"reason": str(reason or "brain_update"),
|
|
209
472
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
210
473
|
"clients": _restart_clients_for_marker(client=client),
|
|
@@ -264,7 +527,14 @@ def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, vers
|
|
|
264
527
|
}
|
|
265
528
|
|
|
266
529
|
|
|
267
|
-
def clear_restart_required_marker(
|
|
530
|
+
def clear_restart_required_marker(
|
|
531
|
+
*,
|
|
532
|
+
client: str = "",
|
|
533
|
+
installed_version: str = "",
|
|
534
|
+
process_version: str = "",
|
|
535
|
+
installed_fingerprint: str = "",
|
|
536
|
+
process_fingerprint: str = "",
|
|
537
|
+
) -> dict:
|
|
268
538
|
client = _normalize_restart_client(client)
|
|
269
539
|
path = restart_required_marker_path()
|
|
270
540
|
marker = read_restart_required_marker()
|
|
@@ -285,10 +555,31 @@ def clear_restart_required_marker(*, client: str = "", installed_version: str =
|
|
|
285
555
|
pending_clients = {k: v for k, v in clients.items() if v != "ok"}
|
|
286
556
|
effective_installed = str(installed_version or payload.get("to_version") or "").strip()
|
|
287
557
|
effective_process = str(process_version or "").strip()
|
|
558
|
+
effective_installed_fp = str(
|
|
559
|
+
installed_fingerprint or payload.get("to_fingerprint") or ""
|
|
560
|
+
).strip()
|
|
561
|
+
effective_process_fp = str(
|
|
562
|
+
process_fingerprint or PROCESS_FINGERPRINT or ""
|
|
563
|
+
).strip()
|
|
288
564
|
if pending_clients:
|
|
289
565
|
_write_json_atomic(path, payload)
|
|
290
566
|
return {"ok": True, "cleared": False, "path": str(path), "pending_clients": pending_clients}
|
|
291
|
-
|
|
567
|
+
# Prefer fingerprint match when both sides have it; fall back to version
|
|
568
|
+
# comparison only when one side is missing or unknown.
|
|
569
|
+
if (
|
|
570
|
+
effective_installed_fp
|
|
571
|
+
and effective_process_fp
|
|
572
|
+
and effective_process_fp != "unknown"
|
|
573
|
+
):
|
|
574
|
+
if effective_installed_fp != effective_process_fp:
|
|
575
|
+
_write_json_atomic(path, payload)
|
|
576
|
+
return {
|
|
577
|
+
"ok": True,
|
|
578
|
+
"cleared": False,
|
|
579
|
+
"path": str(path),
|
|
580
|
+
"pending_reason": "process_fingerprint_mismatch",
|
|
581
|
+
}
|
|
582
|
+
elif effective_installed and effective_process and effective_installed != effective_process:
|
|
292
583
|
_write_json_atomic(path, payload)
|
|
293
584
|
return {
|
|
294
585
|
"ok": True,
|
|
@@ -303,15 +594,25 @@ def clear_restart_required_marker(*, client: str = "", installed_version: str =
|
|
|
303
594
|
return {"ok": True, "cleared": True, "path": str(path)}
|
|
304
595
|
|
|
305
596
|
|
|
306
|
-
def resolve_restart_required(
|
|
597
|
+
def resolve_restart_required(
|
|
598
|
+
*,
|
|
599
|
+
client: str = "",
|
|
600
|
+
installed_version: str = "",
|
|
601
|
+
process_version: str = "",
|
|
602
|
+
installed_fingerprint: str = "",
|
|
603
|
+
process_fingerprint: str = "",
|
|
604
|
+
) -> dict:
|
|
307
605
|
client = _normalize_restart_client(client)
|
|
308
606
|
marker = read_restart_required_marker()
|
|
309
607
|
installed = str(installed_version or installed_runtime_version() or "").strip()
|
|
310
608
|
process = str(process_version or PROCESS_VERSION or installed).strip()
|
|
609
|
+
installed_fp = str(installed_fingerprint or installed_runtime_fingerprint() or "").strip()
|
|
610
|
+
process_fp = str(process_fingerprint or PROCESS_FINGERPRINT or "").strip()
|
|
311
611
|
restart_required = False
|
|
312
612
|
reason = ""
|
|
313
613
|
client_action = ""
|
|
314
614
|
marker_clients = dict(marker.get("clients") or {})
|
|
615
|
+
fingerprint_usable = bool(installed_fp) and bool(process_fp) and process_fp != "unknown"
|
|
315
616
|
|
|
316
617
|
if marker.get("required"):
|
|
317
618
|
restart_required = True
|
|
@@ -320,7 +621,16 @@ def resolve_restart_required(*, client: str = "", installed_version: str = "", p
|
|
|
320
621
|
if marker.get("corrupt"):
|
|
321
622
|
restart_required = True
|
|
322
623
|
reason = "marker_corrupt"
|
|
323
|
-
elif
|
|
624
|
+
elif fingerprint_usable and installed_fp != process_fp:
|
|
625
|
+
# Primary signal: the bytes the running process loaded differ from the
|
|
626
|
+
# bytes currently on disk. Doc-only / blog-only releases produce no
|
|
627
|
+
# fingerprint change and therefore never reach this branch.
|
|
628
|
+
restart_required = True
|
|
629
|
+
reason = reason or "fingerprint_mismatch"
|
|
630
|
+
elif not fingerprint_usable and installed and process and installed != process:
|
|
631
|
+
# Fallback: when fingerprint can't be computed (missing source tree,
|
|
632
|
+
# unreadable files, fresh install), fall back to the legacy version
|
|
633
|
+
# mismatch check so we never leave a stale process running unnoticed.
|
|
324
634
|
restart_required = True
|
|
325
635
|
reason = reason or "version_mismatch"
|
|
326
636
|
elif client and client_action == "ok":
|
|
@@ -334,6 +644,8 @@ def resolve_restart_required(*, client: str = "", installed_version: str = "", p
|
|
|
334
644
|
"marker": marker,
|
|
335
645
|
"installed_version": installed,
|
|
336
646
|
"process_version": process,
|
|
647
|
+
"installed_fingerprint": installed_fp,
|
|
648
|
+
"process_fingerprint": process_fp,
|
|
337
649
|
}
|
|
338
650
|
|
|
339
651
|
|
|
@@ -341,12 +653,22 @@ def build_mcp_status(*, client: str = "") -> dict:
|
|
|
341
653
|
client = _normalize_restart_client(client)
|
|
342
654
|
state = resolve_restart_required(client=client)
|
|
343
655
|
marker = state["marker"]
|
|
656
|
+
installed_fp = state.get("installed_fingerprint", "")
|
|
657
|
+
process_fp = state.get("process_fingerprint", "")
|
|
344
658
|
return {
|
|
345
659
|
"ok": True,
|
|
346
660
|
"schema_version": MCP_STATUS_SCHEMA_VERSION,
|
|
347
661
|
"client": str(client or "").strip(),
|
|
348
662
|
"installed_version": state["installed_version"],
|
|
349
663
|
"process_version": state["process_version"],
|
|
664
|
+
"installed_fingerprint": installed_fp,
|
|
665
|
+
"process_fingerprint": process_fp,
|
|
666
|
+
"fingerprint_match": (
|
|
667
|
+
bool(installed_fp)
|
|
668
|
+
and bool(process_fp)
|
|
669
|
+
and process_fp != "unknown"
|
|
670
|
+
and installed_fp == process_fp
|
|
671
|
+
),
|
|
350
672
|
"active_runtime_root": str(active_runtime_root()),
|
|
351
673
|
"active_runtime_version": read_version_for_path(active_runtime_root()),
|
|
352
674
|
"restart_required": bool(state["restart_required"]),
|
|
@@ -377,6 +699,46 @@ def prime_process_version() -> str:
|
|
|
377
699
|
return PROCESS_VERSION
|
|
378
700
|
|
|
379
701
|
|
|
702
|
+
def prime_process_fingerprint() -> str:
|
|
703
|
+
"""Cache the fingerprint of the source tree this process was loaded from.
|
|
704
|
+
|
|
705
|
+
Idempotent. Called once at MCP server startup. After that, the cached
|
|
706
|
+
value reflects what the live process has actually imported, regardless of
|
|
707
|
+
what is later written to disk by `nexo update`.
|
|
708
|
+
|
|
709
|
+
Returns the cached digest (sha256 hex) or the literal string `"unknown"`
|
|
710
|
+
when the source tree cannot be located/read at startup time.
|
|
711
|
+
"""
|
|
712
|
+
global PROCESS_FINGERPRINT
|
|
713
|
+
if PROCESS_FINGERPRINT:
|
|
714
|
+
return PROCESS_FINGERPRINT
|
|
715
|
+
candidates: list[Path] = []
|
|
716
|
+
try:
|
|
717
|
+
here = Path(__file__).resolve().parent
|
|
718
|
+
candidates.append(here)
|
|
719
|
+
except Exception:
|
|
720
|
+
pass
|
|
721
|
+
try:
|
|
722
|
+
root = active_runtime_root()
|
|
723
|
+
if root and root not in candidates:
|
|
724
|
+
candidates.append(root)
|
|
725
|
+
except Exception:
|
|
726
|
+
pass
|
|
727
|
+
try:
|
|
728
|
+
home = paths.home()
|
|
729
|
+
if home and home not in candidates:
|
|
730
|
+
candidates.append(home)
|
|
731
|
+
except Exception:
|
|
732
|
+
pass
|
|
733
|
+
for cand in candidates:
|
|
734
|
+
fp = compute_mcp_runtime_fingerprint(cand, use_cache=True)
|
|
735
|
+
if fp:
|
|
736
|
+
PROCESS_FINGERPRINT = fp
|
|
737
|
+
return PROCESS_FINGERPRINT
|
|
738
|
+
PROCESS_FINGERPRINT = "unknown"
|
|
739
|
+
return PROCESS_FINGERPRINT
|
|
740
|
+
|
|
741
|
+
|
|
380
742
|
@dataclass
|
|
381
743
|
class RestartRequiredMiddleware(Middleware):
|
|
382
744
|
client: str = ""
|
|
@@ -389,18 +751,31 @@ class RestartRequiredMiddleware(Middleware):
|
|
|
389
751
|
return state
|
|
390
752
|
installed = str(state.get("installed_version") or "").strip()
|
|
391
753
|
process = str(state.get("process_version") or "").strip()
|
|
392
|
-
|
|
393
|
-
|
|
754
|
+
installed_fp = str(state.get("installed_fingerprint") or "").strip()
|
|
755
|
+
process_fp = str(state.get("process_fingerprint") or "").strip()
|
|
756
|
+
fingerprint_usable = (
|
|
757
|
+
bool(installed_fp) and bool(process_fp) and process_fp != "unknown"
|
|
758
|
+
)
|
|
759
|
+
if fingerprint_usable:
|
|
760
|
+
if installed_fp != process_fp:
|
|
761
|
+
return state
|
|
762
|
+
else:
|
|
763
|
+
if not installed or not process or installed != process:
|
|
764
|
+
return state
|
|
394
765
|
|
|
395
766
|
clear_restart_required_marker(
|
|
396
767
|
client=self.client,
|
|
397
768
|
installed_version=installed,
|
|
398
769
|
process_version=process,
|
|
770
|
+
installed_fingerprint=installed_fp,
|
|
771
|
+
process_fingerprint=process_fp,
|
|
399
772
|
)
|
|
400
773
|
return resolve_restart_required(
|
|
401
774
|
client=self.client,
|
|
402
775
|
installed_version=installed,
|
|
403
776
|
process_version=process,
|
|
777
|
+
installed_fingerprint=installed_fp,
|
|
778
|
+
process_fingerprint=process_fp,
|
|
404
779
|
)
|
|
405
780
|
|
|
406
781
|
async def _tool_result_for_restart_required(self, context, payload: dict) -> ToolResult:
|
package/src/server.py
CHANGED
|
@@ -94,6 +94,7 @@ from tools_guardian import handle_guardian_rule_override
|
|
|
94
94
|
from runtime_versioning import (
|
|
95
95
|
RestartRequiredMiddleware,
|
|
96
96
|
build_mcp_status,
|
|
97
|
+
prime_process_fingerprint,
|
|
97
98
|
prime_process_version,
|
|
98
99
|
)
|
|
99
100
|
|
|
@@ -276,6 +277,7 @@ mcp = FastMCP(
|
|
|
276
277
|
),
|
|
277
278
|
)
|
|
278
279
|
prime_process_version()
|
|
280
|
+
prime_process_fingerprint()
|
|
279
281
|
mcp.add_middleware(
|
|
280
282
|
RestartRequiredMiddleware(client=str(os.environ.get("NEXO_MCP_CLIENT", "") or "").strip())
|
|
281
283
|
)
|