NEXO 7.9.0 — semantic stack foundation lands, two product bugs close

Published 2026-04-23. Minor release over v7.8.2.

v7.9.0 opens the ONEPASS LLM Coverage plan: the semantic stack finally has a central router, a second-layer reasoner, a JSON CLI so external MCP clients can consume Brain as the single semantic authority, and a runtime kill switch. Alongside the scaffolding, two product bugs observed live on 2026-04-23 are fixed in the same train: the bin/nexo-brain.js upgrade flow was silently dropping templates/ root on client upgrades, and the nexo_startup inject prompt did not tell the model what to do when the MCP host defers tool schemas (Claude Code with many MCPs installed). Every line of code in this release passed a 4-auditor pre-release review before merging; the issues the review found were fixed in the same train.

Semantic stack — router, reasoner, CLI

src/semantic_router.py is now the central authority for every model-backed semantic decision in Brain. Call sites name a decision_kind, the router looks up the policy, and dispatches through the layer chain fast_local → semantic_reasoner → remote_fallback. 18 kinds are registered in this first release: 13 textual (session_end_intent, autonomy_mandate, guard_verbal_ack, r14_correction, r16_declared_done, r17_promise_debt, r34_identity_coherence, followup_operator_attention, drive_signal_type, drive_area, reply_event_type, query_intent, sentiment_intent) and 5 code-aware (r20_constant_change, t4_r15, t4_r23e, t4_r23f, t4_r23h). Each has an explicit policy entry: family (textual / code_aware), fast_local_threshold, reasoner mode (multipass_local or cached_llm), reasoner threshold, and whether remote fallback is allowed. No caller can silently drift away from the policy; a drift-check test (test_policy_kinds_are_documented) fails if a kind registered in code is missing from the model-notes doc.

src/semantic_reasoner.py exposes two modes. Mode A (multipass_local) reuses the already-pinned LocalZeroShotClassifier (MoritzLaurer/mDeBERTa-v3-base-xnli-multilingual-nli-2mil7 at the revision pinned in docs/classifier-model-notes.md) but runs three prompt-perturbed passes, majority-votes across them, and only accepts a decision if at least two of three agree AND the averaged confidence is above the stricter 0.75 floor. No new model download; the strengthening comes from aggregation, not from a bigger model. Mode B (cached_llm) wraps call_model_raw with a disk cache at ~/.nexo/runtime/operations/semantic-reasoner-cache.json. Keys are sha256(json({decision_kind, normalized_question, normalized_context, labels})), 24h TTL, LRU-bound at 2000 entries. Writes are atomic: a pid+uuid temp file + fsync(fileno) + os.replace so concurrent Brain and Desktop CLI writers cannot stomp each other mid-write. Corrupt cache entries (verdict missing or non-string) are dropped on read instead of returned as ok=true, so a partial write can never hand a caller a "successful" result with a None verdict.

Why not ship a dedicated stronger local LLM (Llama 3.1 8B / Qwen 2.5 7B / etc.) in this release? Two honest reasons, documented in docs/semantic-reasoner-model-notes.md. First, install-time risk: provisioning a 5-10 GB model during nexo update requires a new download pipeline, GPU/MPS detection, its own install-state file, and the heavy-bootstrap protections the existing classifier needed — that surface area is a full feature, not a sub-step of this release. Second, pin verifiability: pinning a model SHA without a live connection to HuggingFace at review time risks locking every operator to a revision that turns out to have been silently rewritten upstream. The fast-local pin paid that cost once; adding a second local pin without reproducible verification would recreate the exact footgun the pin policy exists to prevent. A future release can graduate Mode B from cached_llm to a truly local LLM without changing the semantic_router contract. Call sites only see RouterResult; whether the reasoner ran locally or remotely is hidden.

Desktop stays a client, not a second policy tree

scripts/semantic-classify.py is a JSON-in JSON-out CLI wrapper around semantic_router.route. Reads a payload ({decision_kind, question, context?, labels?, allow_remote_fallback?}) from stdin, writes the RouterResult shape to stdout. Malformed input exits 1 with a JSON error body so the parent process never chokes; well-formed input always exits 0 with the full RouterResult (ok, verdict, label, confidence, route_used, degraded, error, meta) regardless of whether the router produced a positive decision. The CLI deliberately does not rehydrate the NEXO MCP stack, touch the database, or invoke nexo_startup; it is a pure function wrapper so Desktop and any other MCP client can call it hot-path without the automation cost.

On the Desktop side, a companion bridge (lib/brain-semantic-router.js in the closed-source NEXO Desktop repository) spawns this CLI over stdio and parses the result. The bridge ships in NEXO Desktop v0.28.0, also coordinated with this release. Desktop does not embed a second JS-only semantic policy tree; Brain stays the single authority.

Runtime kill switch

The ONEPASS plan explicitly demanded an env opt-out dedicated to the reasoner, separate from NEXO_LOCAL_CLASSIFIER (which only gates install-time provisioning of the fast-local model — once the weights are on disk it keeps loading). NEXO_SEMANTIC_REASONER now fills that gap. Accepts 0, off, false, no, disable, or disabled (case-insensitive); when active every reason() call refuses with error="reasoner_disabled_by_env" and the router falls through to remote_fallback on its own (or returns no_route if that is also disallowed). Operators hitting a reasoner regression in production have a real escape hatch that does not require killing the classifier layer too.

Bug fix 1 — upgrade path now syncs templates/

On 2026-04-23 Maria's iMac upgrade 7.1.10 → 7.8.1 failed post-update import verification because autonomy_mandate.py required autonomy-mandate-question.md and the template was never copied. Root cause: the upgrade flow in bin/nexo-brain.js (the branch that runs when installedVersion !== currentVersion) copied hooks, plugins, dashboard, rules, crons, and scripts via copyDirRec, but never touched templates/ root. Fresh install (~line 3085) and same-version refresh (~line 2303) both synced templates; only the upgrade path missed it. Any client upgrading from <7.8.1 therefore kept its old templates/core-prompts/ tree, and every new prompt shipped since then was silently absent. v7.9.0 adds an 8-line copyDirRec(migTemplatesSrc, migTemplatesDest) block immediately after log(" Scripts updated."), so the upgrade path now matches fresh install and same-version refresh. The upgrade log line also explicitly says Templates updated (user-edited templates/ files are overwritten). so an operator who did customize a template sees the incident in the upgrade transcript. An inline comment points future readers at personal/ as the safer place for local forks. The diff-before-overwrite behaviour itself is a larger contract decision and is NOT changed in this release.

Bug fix 2 — bootstrap preload for deferred MCP schemas

Observed live in session nexo-1776899231-34499: Claude Code with many MCPs installed surfaces mcp__nexo__* tools as "deferred" — the names are visible in <system-reminder> blocks but the JSONSchemas are NOT loaded into the model's context until the model invokes ToolSearch with select:<tool_name> to force-load them. The NEXO bootstrap prompt assumed tools were immediately callable. The model saw mcp__nexo__* missing from the available tools, interpreted it as "NEXO is unavailable in this environment", and silently skipped the protocol for the entire session — no nexo_startup, no nexo_heartbeat, no nexo_guard_check, no nexo_task_open, no learning capture, no diary. Francisco caught it in one turn; the average external client would not.

v7.9.0 amends the inject_prompt of nexo_startup in tool-enforcement-map.json to explicitly tell the model: if mcp__nexo__* tools appear as deferred in the tool list (names visible but JSONSchemas not loaded), first call ToolSearch with query select:mcp__nexo__nexo_startup,... to load the schemas — deferred is not absent. Then execute nexo_startup. The list covers 13 protocol tools (startup, heartbeat, diary_read, reminders, smart_startup, task_open, task_close, task_acknowledge_guard, guard_check, learning_add, confidence_check, followup_create, protocol_debt_resolve) so the bug cannot reproduce one step deeper either, and it explicitly instructs the model to preload additional nexo_* tools the same way when they surface deferred later in the session. NEXO Desktop v0.28.0 carries the same step 0 in its lib/conversation-bootstrap.js so both layers of the bootstrap (the MCP server inject prompt and the Desktop renderer prompt) are covered. Belt-and-suspenders.

Audit-driven hardening

Four auditors reviewed the release train before this PR opened. They flagged five CRITICAL issues and eleven HIGH. Every CRITICAL and every HIGH is fixed in the same train, before cut. Highlights: semantic_router._run_remote_fallback and semantic_reasoner._reason_cached_llm no longer crash with NameError when a stubbed call_model_raw module exposes only one of the two expected symbols — they now resolve the callable and exception class with getattr and catch plain Exception as a trailing clause so provider-specific errors (APIError, TimeoutError, KeyError) degrade with error="remote_error: <cause>" instead of propagating. _write_cache uses a per-pid+uuid temp filename + fsync + os.replace so concurrent writers cannot stomp each other mid-write. _parse_ttl_env tolerates malformed NEXO_SEMANTIC_REASONER_TTL values (non-integer, negative, zero) with a logger warning + default fallback instead of raising ValueError on first call. raw[:80] slices now tolerate None returns from LLM stubs that fail to produce a body. Nine audit-driven tests cover every one of these fail-closed paths.

Tests, scope discipline, what is not in this release

Tests: +50 across three new files. tests/test_semantic_router.py — 22 tests covering policy table integrity, dispatch logic, fallback chain, allow_remote_fallback gate, code-aware fast-local skip, and the two audit-driven fail-closed contracts. tests/test_semantic_reasoner.py — 20 tests covering Mode A majority-vote, threshold refusal, missing labels, Mode B cache miss/hit/TTL expiry, per-kind scope, LLM unavailable, unrelated-exception propagation, missing call_model_raw callable, NEXO_SEMANTIC_REASONER kill switch, corrupt-cache-entry drop, null-LLM-response safety, TTL defensive parse, concurrent-write safety. tests/test_semantic_classify_cli.py — 8 CLI contract tests.

Not in this release (explicitly deferred to follow-up patch releases under NF-SEMANTIC-ROUTER-SITE-MIGRATION): per-site migration of existing callers into the new router. The files that will migrate eventually — session_end_intent.py, autonomy_mandate.py, guard_verbal_ack.py, r14_correction_learning.py, r16_declared_done.py, r17_promise_debt.py, r34_identity_coherence.py, tools_drive.py, nexo-followup-runner.py, r20_constant_change.py, the T4 gates in enforcement_engine.py, tools_email_guard.py — all continue to use their current classifier paths. Nothing in v7.9.0 changes the behaviour of any existing caller. The scaffolding can be imported today without regression; themed follow-up PRs will migrate the call sites group by group so each migration ships with its own regression test.

Operator notes

No new runtime dependency. No changes to src/auto_update.py. The on-disk reasoner cache under ~/.nexo/runtime/operations/ is advisory: deleting the file forces a cold start but does not break anything. NEXO_SEMANTIC_REASONER=0 is the runtime kill switch if you hit a regression. Companion coordinated release: NEXO Desktop v0.28.0 (closed-source, distributed at nexo-desktop.com).

Full changelog entry →