nexo-brain 7.10.0 → 7.11.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 -14
- package/package.json +1 -1
- package/src/plugins/update.py +94 -16
- package/src/runtime_versioning.py +267 -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.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.11.0` is the current packaged-runtime line. Minor release — introduces a runtime fingerprint that gates `mcp-restart-required.json`. `nexo update` now only forces connected MCP clients (Claude Code, Codex, Claude Desktop) to restart when at least one `.py` file the running server actually imports has changed. README-only, blog-only, changelog-only releases skip the restart entirely. Conservative fallback (#186): if the fingerprint can't be computed, the gate behaves like the legacy version-string check and writes the marker. Explicit opt-in escape hatch via `"force_restart": true` in `version.json`. Marker schema bumped to v2 with optional `from_fingerprint` / `to_fingerprint`. Full write-up in [`docs/runtime-fingerprint.md`](docs/runtime-fingerprint.md).
|
|
22
|
+
|
|
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.
|
|
22
24
|
|
|
23
25
|
Previously in `7.9.34`: two fixes — the email monitor's header parser was dropping any email whose RFC822 headers came back as `email.header.Header` instances (Q-encoded utf-8 / quoted-printable). Every `msg.get(...)` now goes through `_decode_header`, and the failure log is lifted from DEBUG to WARNING. The PreToolUse Guardian gate hardens hard blocks with stderr + exit 2 enforcement so terminal Claude cannot ignore the deny channel mid-tool-loop.
|
|
24
26
|
|
|
@@ -1093,19 +1095,6 @@ Use a personal plugin only when you need a new MCP tool in the runtime surface.
|
|
|
1093
1095
|
- **Auto-update is resilient.** NEXO checks for updates on startup. If an update fails, it continues with the current version and notifies you. Local migrations (database schema, configuration) always run. Network updates (git pull) can be disabled by setting `auto_update: false` in `NEXO_HOME/config/schedule.json`.
|
|
1094
1096
|
- **Secret redaction.** API keys and tokens are stripped before they ever reach memory storage.
|
|
1095
1097
|
|
|
1096
|
-
## Custom LLM endpoint (advanced)
|
|
1097
|
-
|
|
1098
|
-
NEXO Brain reads two optional override files at `~/.nexo/config/`:
|
|
1099
|
-
|
|
1100
|
-
- `llm_endpoint.json` — set a custom Anthropic-compatible base URL.
|
|
1101
|
-
- `auth_provider.json` — delegate bearer token resolution to a local command (analogous to git's `credential.helper`).
|
|
1102
|
-
|
|
1103
|
-
This lets third-party orchestrators — for example an Anthropic-compatible proxy that adds rate limiting, cost accounting, multi-provider failover, or per-team auth — route Brain's LLM calls without modifying its source.
|
|
1104
|
-
|
|
1105
|
-
**If neither file exists, Brain operates exactly as before:** direct call to `https://api.anthropic.com` using `ANTHROPIC_API_KEY` from environment or filesystem. The override path is opt-in.
|
|
1106
|
-
|
|
1107
|
-
When override mode is active, Brain attaches an opaque `Idempotency-Key` to every request so the proxy can dedup transparent retries (24h window) without double-billing. The same redirection applies to every CLI child Brain spawns (deep-sleep, evolution, followup-runner, morning-agent, email-monitor, `nexo chat`): `agent_runner.py` injects `ANTHROPIC_BASE_URL` and `ANTHROPIC_API_KEY` into the spawned environment when override mode is on, so headless crons hit the proxy too — LaunchAgent crons do not inherit env from a UI process. See `docs/api/override-files.md` for the full schema, fallback rules, and an end-to-end example.
|
|
1108
|
-
|
|
1109
1098
|
## The Psychology Behind NEXO Brain
|
|
1110
1099
|
|
|
1111
1100
|
NEXO Brain isn't just engineering — it's applied cognitive psychology:
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "nexo-brain",
|
|
3
|
-
"version": "7.
|
|
3
|
+
"version": "7.11.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/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",
|
|
@@ -170,6 +188,132 @@ def installed_runtime_version() -> str:
|
|
|
170
188
|
return ""
|
|
171
189
|
|
|
172
190
|
|
|
191
|
+
def installed_force_restart_flag() -> bool:
|
|
192
|
+
"""Read explicit `force_restart` opt-in from version.json/package.json.
|
|
193
|
+
|
|
194
|
+
A release that touches behavior in subtle ways (config schema, runtime
|
|
195
|
+
contract) but happens not to change any tracked MCP source byte can still
|
|
196
|
+
force a restart by setting `force_restart: true` in version.json. Default
|
|
197
|
+
is False — fingerprint is the source of truth.
|
|
198
|
+
"""
|
|
199
|
+
for candidate in [active_runtime_root(), paths.home()]:
|
|
200
|
+
for vfile in _candidate_version_files(candidate):
|
|
201
|
+
try:
|
|
202
|
+
if vfile.is_file():
|
|
203
|
+
payload = json.loads(vfile.read_text(encoding="utf-8"))
|
|
204
|
+
if isinstance(payload, dict) and bool(payload.get("force_restart")):
|
|
205
|
+
return True
|
|
206
|
+
except Exception:
|
|
207
|
+
continue
|
|
208
|
+
return False
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def _iter_runtime_source_files(src_dir: Path) -> list[Path]:
|
|
212
|
+
"""Return MCP-loaded `.py` files under `src_dir`, sorted by relative path."""
|
|
213
|
+
out: list[Path] = []
|
|
214
|
+
if not src_dir or not src_dir.is_dir():
|
|
215
|
+
return out
|
|
216
|
+
for path in src_dir.rglob("*.py"):
|
|
217
|
+
try:
|
|
218
|
+
rel = path.relative_to(src_dir)
|
|
219
|
+
except ValueError:
|
|
220
|
+
continue
|
|
221
|
+
if any(seg in _FINGERPRINT_EXCLUDE_DIRS for seg in rel.parts):
|
|
222
|
+
continue
|
|
223
|
+
out.append(path)
|
|
224
|
+
out.sort(key=lambda p: p.relative_to(src_dir).as_posix())
|
|
225
|
+
return out
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def compute_mcp_runtime_fingerprint(src_dir: Path | None = None) -> str:
|
|
229
|
+
"""Hash of every Python source file the running MCP can import.
|
|
230
|
+
|
|
231
|
+
Returns a sha256 hex digest, or "" when the source tree cannot be located
|
|
232
|
+
or read (caller treats empty as "fingerprint unavailable" and falls back
|
|
233
|
+
to the version-string mismatch check).
|
|
234
|
+
|
|
235
|
+
Includes:
|
|
236
|
+
* every `.py` under the runtime root
|
|
237
|
+
Excludes:
|
|
238
|
+
* subtrees in `_FINGERPRINT_EXCLUDE_DIRS` (scripts/, tests/, migrations/,
|
|
239
|
+
crons/, __pycache__/, node_modules/, .git/)
|
|
240
|
+
* non-`.py` assets (docs, blogs, READMEs, JSON/YAML configs, templates,
|
|
241
|
+
CHANGELOG, marketing files) — these never affect what the live MCP
|
|
242
|
+
process executes
|
|
243
|
+
"""
|
|
244
|
+
if src_dir is None:
|
|
245
|
+
candidates: list[Path] = []
|
|
246
|
+
try:
|
|
247
|
+
here = Path(__file__).resolve().parent
|
|
248
|
+
candidates.append(here)
|
|
249
|
+
except Exception:
|
|
250
|
+
pass
|
|
251
|
+
try:
|
|
252
|
+
root = active_runtime_root()
|
|
253
|
+
if root and root not in candidates:
|
|
254
|
+
candidates.append(root)
|
|
255
|
+
except Exception:
|
|
256
|
+
pass
|
|
257
|
+
try:
|
|
258
|
+
home = paths.home()
|
|
259
|
+
if home and home not in candidates:
|
|
260
|
+
candidates.append(home)
|
|
261
|
+
except Exception:
|
|
262
|
+
pass
|
|
263
|
+
for cand in candidates:
|
|
264
|
+
if (cand / "server.py").is_file() or (cand / "cli.py").is_file():
|
|
265
|
+
src_dir = cand
|
|
266
|
+
break
|
|
267
|
+
if src_dir is None:
|
|
268
|
+
return ""
|
|
269
|
+
|
|
270
|
+
files = _iter_runtime_source_files(src_dir)
|
|
271
|
+
if not files:
|
|
272
|
+
return ""
|
|
273
|
+
h = hashlib.sha256()
|
|
274
|
+
for path in files:
|
|
275
|
+
try:
|
|
276
|
+
rel = path.relative_to(src_dir).as_posix()
|
|
277
|
+
except ValueError:
|
|
278
|
+
continue
|
|
279
|
+
h.update(rel.encode("utf-8"))
|
|
280
|
+
h.update(b"\x00")
|
|
281
|
+
try:
|
|
282
|
+
h.update(path.read_bytes())
|
|
283
|
+
except Exception:
|
|
284
|
+
return ""
|
|
285
|
+
h.update(b"\n")
|
|
286
|
+
return h.hexdigest()
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def installed_runtime_fingerprint() -> str:
|
|
290
|
+
"""Fingerprint of whatever runtime source tree is on disk right now."""
|
|
291
|
+
candidates: list[Path] = []
|
|
292
|
+
try:
|
|
293
|
+
root = active_runtime_root()
|
|
294
|
+
if root:
|
|
295
|
+
candidates.append(root)
|
|
296
|
+
except Exception:
|
|
297
|
+
pass
|
|
298
|
+
try:
|
|
299
|
+
home = paths.home()
|
|
300
|
+
if home and home not in candidates:
|
|
301
|
+
candidates.append(home)
|
|
302
|
+
except Exception:
|
|
303
|
+
pass
|
|
304
|
+
try:
|
|
305
|
+
here = Path(__file__).resolve().parent
|
|
306
|
+
if here not in candidates:
|
|
307
|
+
candidates.append(here)
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
for cand in candidates:
|
|
311
|
+
fp = compute_mcp_runtime_fingerprint(cand)
|
|
312
|
+
if fp:
|
|
313
|
+
return fp
|
|
314
|
+
return ""
|
|
315
|
+
|
|
316
|
+
|
|
173
317
|
def read_restart_required_marker() -> dict:
|
|
174
318
|
path = restart_required_marker_path()
|
|
175
319
|
if not path.exists():
|
|
@@ -198,6 +342,8 @@ def write_restart_required_marker(
|
|
|
198
342
|
to_version: str,
|
|
199
343
|
reason: str = "brain_update",
|
|
200
344
|
client: str = "",
|
|
345
|
+
from_fingerprint: str = "",
|
|
346
|
+
to_fingerprint: str = "",
|
|
201
347
|
) -> dict:
|
|
202
348
|
path = restart_required_marker_path()
|
|
203
349
|
payload = {
|
|
@@ -205,6 +351,8 @@ def write_restart_required_marker(
|
|
|
205
351
|
"required": True,
|
|
206
352
|
"from_version": str(from_version or "").strip(),
|
|
207
353
|
"to_version": str(to_version or "").strip(),
|
|
354
|
+
"from_fingerprint": str(from_fingerprint or "").strip(),
|
|
355
|
+
"to_fingerprint": str(to_fingerprint or "").strip(),
|
|
208
356
|
"reason": str(reason or "brain_update"),
|
|
209
357
|
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime()),
|
|
210
358
|
"clients": _restart_clients_for_marker(client=client),
|
|
@@ -264,7 +412,14 @@ def activate_versioned_runtime_snapshot(*, source_root: Path | None = None, vers
|
|
|
264
412
|
}
|
|
265
413
|
|
|
266
414
|
|
|
267
|
-
def clear_restart_required_marker(
|
|
415
|
+
def clear_restart_required_marker(
|
|
416
|
+
*,
|
|
417
|
+
client: str = "",
|
|
418
|
+
installed_version: str = "",
|
|
419
|
+
process_version: str = "",
|
|
420
|
+
installed_fingerprint: str = "",
|
|
421
|
+
process_fingerprint: str = "",
|
|
422
|
+
) -> dict:
|
|
268
423
|
client = _normalize_restart_client(client)
|
|
269
424
|
path = restart_required_marker_path()
|
|
270
425
|
marker = read_restart_required_marker()
|
|
@@ -285,10 +440,31 @@ def clear_restart_required_marker(*, client: str = "", installed_version: str =
|
|
|
285
440
|
pending_clients = {k: v for k, v in clients.items() if v != "ok"}
|
|
286
441
|
effective_installed = str(installed_version or payload.get("to_version") or "").strip()
|
|
287
442
|
effective_process = str(process_version or "").strip()
|
|
443
|
+
effective_installed_fp = str(
|
|
444
|
+
installed_fingerprint or payload.get("to_fingerprint") or ""
|
|
445
|
+
).strip()
|
|
446
|
+
effective_process_fp = str(
|
|
447
|
+
process_fingerprint or PROCESS_FINGERPRINT or ""
|
|
448
|
+
).strip()
|
|
288
449
|
if pending_clients:
|
|
289
450
|
_write_json_atomic(path, payload)
|
|
290
451
|
return {"ok": True, "cleared": False, "path": str(path), "pending_clients": pending_clients}
|
|
291
|
-
|
|
452
|
+
# Prefer fingerprint match when both sides have it; fall back to version
|
|
453
|
+
# comparison only when one side is missing or unknown.
|
|
454
|
+
if (
|
|
455
|
+
effective_installed_fp
|
|
456
|
+
and effective_process_fp
|
|
457
|
+
and effective_process_fp != "unknown"
|
|
458
|
+
):
|
|
459
|
+
if effective_installed_fp != effective_process_fp:
|
|
460
|
+
_write_json_atomic(path, payload)
|
|
461
|
+
return {
|
|
462
|
+
"ok": True,
|
|
463
|
+
"cleared": False,
|
|
464
|
+
"path": str(path),
|
|
465
|
+
"pending_reason": "process_fingerprint_mismatch",
|
|
466
|
+
}
|
|
467
|
+
elif effective_installed and effective_process and effective_installed != effective_process:
|
|
292
468
|
_write_json_atomic(path, payload)
|
|
293
469
|
return {
|
|
294
470
|
"ok": True,
|
|
@@ -303,15 +479,25 @@ def clear_restart_required_marker(*, client: str = "", installed_version: str =
|
|
|
303
479
|
return {"ok": True, "cleared": True, "path": str(path)}
|
|
304
480
|
|
|
305
481
|
|
|
306
|
-
def resolve_restart_required(
|
|
482
|
+
def resolve_restart_required(
|
|
483
|
+
*,
|
|
484
|
+
client: str = "",
|
|
485
|
+
installed_version: str = "",
|
|
486
|
+
process_version: str = "",
|
|
487
|
+
installed_fingerprint: str = "",
|
|
488
|
+
process_fingerprint: str = "",
|
|
489
|
+
) -> dict:
|
|
307
490
|
client = _normalize_restart_client(client)
|
|
308
491
|
marker = read_restart_required_marker()
|
|
309
492
|
installed = str(installed_version or installed_runtime_version() or "").strip()
|
|
310
493
|
process = str(process_version or PROCESS_VERSION or installed).strip()
|
|
494
|
+
installed_fp = str(installed_fingerprint or installed_runtime_fingerprint() or "").strip()
|
|
495
|
+
process_fp = str(process_fingerprint or PROCESS_FINGERPRINT or "").strip()
|
|
311
496
|
restart_required = False
|
|
312
497
|
reason = ""
|
|
313
498
|
client_action = ""
|
|
314
499
|
marker_clients = dict(marker.get("clients") or {})
|
|
500
|
+
fingerprint_usable = bool(installed_fp) and bool(process_fp) and process_fp != "unknown"
|
|
315
501
|
|
|
316
502
|
if marker.get("required"):
|
|
317
503
|
restart_required = True
|
|
@@ -320,7 +506,16 @@ def resolve_restart_required(*, client: str = "", installed_version: str = "", p
|
|
|
320
506
|
if marker.get("corrupt"):
|
|
321
507
|
restart_required = True
|
|
322
508
|
reason = "marker_corrupt"
|
|
323
|
-
elif
|
|
509
|
+
elif fingerprint_usable and installed_fp != process_fp:
|
|
510
|
+
# Primary signal: the bytes the running process loaded differ from the
|
|
511
|
+
# bytes currently on disk. Doc-only / blog-only releases produce no
|
|
512
|
+
# fingerprint change and therefore never reach this branch.
|
|
513
|
+
restart_required = True
|
|
514
|
+
reason = reason or "fingerprint_mismatch"
|
|
515
|
+
elif not fingerprint_usable and installed and process and installed != process:
|
|
516
|
+
# Fallback: when fingerprint can't be computed (missing source tree,
|
|
517
|
+
# unreadable files, fresh install), fall back to the legacy version
|
|
518
|
+
# mismatch check so we never leave a stale process running unnoticed.
|
|
324
519
|
restart_required = True
|
|
325
520
|
reason = reason or "version_mismatch"
|
|
326
521
|
elif client and client_action == "ok":
|
|
@@ -334,6 +529,8 @@ def resolve_restart_required(*, client: str = "", installed_version: str = "", p
|
|
|
334
529
|
"marker": marker,
|
|
335
530
|
"installed_version": installed,
|
|
336
531
|
"process_version": process,
|
|
532
|
+
"installed_fingerprint": installed_fp,
|
|
533
|
+
"process_fingerprint": process_fp,
|
|
337
534
|
}
|
|
338
535
|
|
|
339
536
|
|
|
@@ -341,12 +538,22 @@ def build_mcp_status(*, client: str = "") -> dict:
|
|
|
341
538
|
client = _normalize_restart_client(client)
|
|
342
539
|
state = resolve_restart_required(client=client)
|
|
343
540
|
marker = state["marker"]
|
|
541
|
+
installed_fp = state.get("installed_fingerprint", "")
|
|
542
|
+
process_fp = state.get("process_fingerprint", "")
|
|
344
543
|
return {
|
|
345
544
|
"ok": True,
|
|
346
545
|
"schema_version": MCP_STATUS_SCHEMA_VERSION,
|
|
347
546
|
"client": str(client or "").strip(),
|
|
348
547
|
"installed_version": state["installed_version"],
|
|
349
548
|
"process_version": state["process_version"],
|
|
549
|
+
"installed_fingerprint": installed_fp,
|
|
550
|
+
"process_fingerprint": process_fp,
|
|
551
|
+
"fingerprint_match": (
|
|
552
|
+
bool(installed_fp)
|
|
553
|
+
and bool(process_fp)
|
|
554
|
+
and process_fp != "unknown"
|
|
555
|
+
and installed_fp == process_fp
|
|
556
|
+
),
|
|
350
557
|
"active_runtime_root": str(active_runtime_root()),
|
|
351
558
|
"active_runtime_version": read_version_for_path(active_runtime_root()),
|
|
352
559
|
"restart_required": bool(state["restart_required"]),
|
|
@@ -377,6 +584,46 @@ def prime_process_version() -> str:
|
|
|
377
584
|
return PROCESS_VERSION
|
|
378
585
|
|
|
379
586
|
|
|
587
|
+
def prime_process_fingerprint() -> str:
|
|
588
|
+
"""Cache the fingerprint of the source tree this process was loaded from.
|
|
589
|
+
|
|
590
|
+
Idempotent. Called once at MCP server startup. After that, the cached
|
|
591
|
+
value reflects what the live process has actually imported, regardless of
|
|
592
|
+
what is later written to disk by `nexo update`.
|
|
593
|
+
|
|
594
|
+
Returns the cached digest (sha256 hex) or the literal string `"unknown"`
|
|
595
|
+
when the source tree cannot be located/read at startup time.
|
|
596
|
+
"""
|
|
597
|
+
global PROCESS_FINGERPRINT
|
|
598
|
+
if PROCESS_FINGERPRINT:
|
|
599
|
+
return PROCESS_FINGERPRINT
|
|
600
|
+
candidates: list[Path] = []
|
|
601
|
+
try:
|
|
602
|
+
here = Path(__file__).resolve().parent
|
|
603
|
+
candidates.append(here)
|
|
604
|
+
except Exception:
|
|
605
|
+
pass
|
|
606
|
+
try:
|
|
607
|
+
root = active_runtime_root()
|
|
608
|
+
if root and root not in candidates:
|
|
609
|
+
candidates.append(root)
|
|
610
|
+
except Exception:
|
|
611
|
+
pass
|
|
612
|
+
try:
|
|
613
|
+
home = paths.home()
|
|
614
|
+
if home and home not in candidates:
|
|
615
|
+
candidates.append(home)
|
|
616
|
+
except Exception:
|
|
617
|
+
pass
|
|
618
|
+
for cand in candidates:
|
|
619
|
+
fp = compute_mcp_runtime_fingerprint(cand)
|
|
620
|
+
if fp:
|
|
621
|
+
PROCESS_FINGERPRINT = fp
|
|
622
|
+
return PROCESS_FINGERPRINT
|
|
623
|
+
PROCESS_FINGERPRINT = "unknown"
|
|
624
|
+
return PROCESS_FINGERPRINT
|
|
625
|
+
|
|
626
|
+
|
|
380
627
|
@dataclass
|
|
381
628
|
class RestartRequiredMiddleware(Middleware):
|
|
382
629
|
client: str = ""
|
|
@@ -389,18 +636,31 @@ class RestartRequiredMiddleware(Middleware):
|
|
|
389
636
|
return state
|
|
390
637
|
installed = str(state.get("installed_version") or "").strip()
|
|
391
638
|
process = str(state.get("process_version") or "").strip()
|
|
392
|
-
|
|
393
|
-
|
|
639
|
+
installed_fp = str(state.get("installed_fingerprint") or "").strip()
|
|
640
|
+
process_fp = str(state.get("process_fingerprint") or "").strip()
|
|
641
|
+
fingerprint_usable = (
|
|
642
|
+
bool(installed_fp) and bool(process_fp) and process_fp != "unknown"
|
|
643
|
+
)
|
|
644
|
+
if fingerprint_usable:
|
|
645
|
+
if installed_fp != process_fp:
|
|
646
|
+
return state
|
|
647
|
+
else:
|
|
648
|
+
if not installed or not process or installed != process:
|
|
649
|
+
return state
|
|
394
650
|
|
|
395
651
|
clear_restart_required_marker(
|
|
396
652
|
client=self.client,
|
|
397
653
|
installed_version=installed,
|
|
398
654
|
process_version=process,
|
|
655
|
+
installed_fingerprint=installed_fp,
|
|
656
|
+
process_fingerprint=process_fp,
|
|
399
657
|
)
|
|
400
658
|
return resolve_restart_required(
|
|
401
659
|
client=self.client,
|
|
402
660
|
installed_version=installed,
|
|
403
661
|
process_version=process,
|
|
662
|
+
installed_fingerprint=installed_fp,
|
|
663
|
+
process_fingerprint=process_fp,
|
|
404
664
|
)
|
|
405
665
|
|
|
406
666
|
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
|
)
|