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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nexo-brain",
3
- "version": "7.10.1",
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.10.1` is the current packaged-runtime line. Patch release — removes a residual "Custom LLM endpoint (advanced)" section in this README that still documented the `llm_endpoint.json` / `auth_provider.json` override path as a current feature, including the `Idempotency-Key` proxy semantics and a pointer to the now-deleted `docs/api/override-files.md`. The 7.10.0 code revert was complete; the README cleanup landed in 7.10.1.
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.10.1",
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",
@@ -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",
@@ -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(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
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
- if effective_installed and effective_process and effective_installed != effective_process:
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(*, client: str = "", installed_version: str = "", process_version: str = "") -> dict:
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 installed and process and installed != process:
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
- if not installed or not process or installed != process:
393
- return state
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
  )