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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.10.0",
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.10.0` is the current packaged-runtime line. 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.
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.10.0",
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",
@@ -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 activate_versioned_runtime_snapshot, write_restart_required_marker
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
- restart_marker_summary = write_restart_required_marker(
1236
- from_version=old_version,
1237
- to_version=new_version,
1238
- )
1239
- except Exception as e:
1240
- errors.append(f"restart marker: {e}")
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
- lines.append("MCP server restart needed to load new code.")
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
- restart_marker_summary = write_restart_required_marker(
1531
- from_version=old_version,
1532
- to_version=new_version,
1533
- )
1534
- steps_done.append("restart-marker")
1535
- except Exception as e:
1536
- raise RuntimeError(f"Restart marker write failed: {e}")
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
- lines.append("MCP server restart needed to load new code.")
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 = 1
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(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
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
- if effective_installed and effective_process and effective_installed != effective_process:
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(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
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 installed and process and installed != process:
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
- if not installed or not process or installed != process:
393
- return state
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
  )