Repository navigation
Releases
v0.22.48 - one gate for every write path
[0.22.48] - 2026-11-11
Added - one write gate for every write path
Until now every path decided for itself what may be written. Hermes' nexus_remember could store an
empty memory, a document could turn into a permanent rule, and the OpenClaw plugin bypassed every
check by posting straight to Qdrant over HTTP. There is now a single gate in the library, and every
write route calls it: Hermes plugin, MCP server, Claude Code auto-capture, and OpenClaw through a new
/write endpoint.
- It fails closed: if the gate cannot be loaded, writes are refused instead of passing unchecked. Reads keep working.
ruleis only accepted from an explicit call or manual input, never from the automatic extraction after a turn; quote markers in the content are rejected as well.- The access level inherits the strictest level of its source: paperless and wiki become
private, a session staystrusted, and the gate never grantspublicon its own. Mail, calendar, health, account, insurance, tax and invoice content is always private. publiccounts only as a whole word - paths and "not-public" no longer qualify.- Instead of a character length, the gate now detects labels: short facts with a number pass, bare labels do not.
- Source is mandatory and is built from the caller when missing; the keyword index is updated after the write, and an index failure never blocks the write.
Added - a rule needs a human confirmation
A rule can only be created with proof that a model cannot produce inside a conversation: a time-window
token from a terminal (rule-confirm, 10 minutes, single use). Without that proof the write is demoted
to fact, not discarded. scripts/regeln-pruefen.py audits the existing stock read-only.
The first run found 2,475 rules in the collection, none of them confirmed by hand, 540 with quote
markers. A re-run on 2026-11-14 over 2,476 rules again found 0 confirmed by hand.
Added - daily oversight of the collection
Numbers instead of impressions: one read pass over the collection, then a report per category, access
level and writer, plus empty and very short contents, points without a source, labels without a
sentence, growth since the last run and a comparison against the keyword index. An alarm only fires on
jumps. Reports land in ~/.hermes/state/nexus-aufsicht/.
First real run: 51,847 points, 632 without content, 5,146 without a source (9.9 percent), 1,890 labels
without a sentence.
Fix - the injected block can now see 632 more points
Those 632 points carry their text in the text field instead of content; the block read only
content and left them invisible. The block now falls back (content -> text), in the keyword index
too.
Fix - the 400-character cut threw away the answer
In the longest points the answer sits at the very end (keyword at 921 of 929, 713 of 929, 1269 of 2000
characters) and the hard cut at 400 characters discarded it, so the correct entry counted as a miss. A
window around the first hit is cut instead, with ellipses at the edges. Question words that slipped
through as word marks (gerade, viele, etwa) were added to the stop word list.
Added - keyword search with rare-word weighting, fused by RRF
A yes/no sort decided before: a hit on "nutzt" weighed as much as one on "Ina". A separate BM25 index
(SQLite FTS5, ~/.hermes/state/nexus-fts.db, 51,806 rows) now drives the keyword side with rare words
weighted higher, and similarity plus keyword list are fused by RRF (k=60). The index builds in the
background on first need, catches up when the count drifts, and if it fails the injection falls back to
plain similarity.
Measured on 20 questions: 16 of 20 in the final block (13 before). The local cross-encoder makes it
measurably worse (14) and stays out. On the 23-question basis the measured score was 19 of 23 before and
after this change; the same measurement reports 21 of 23 at 4,000 characters per injection, the budget
setting used while measuring.
Fix - full-text candidates ordered by similarity, not by id
The full-text search returns hits in id order. For a frequent word that means hundreds of points, and
the useful one was not among the first ten. 64 hits are fetched, scored against the question vector and
the ten closest are taken, with their real similarity value feeding the re-sort instead of a shared
floor. The candidate count went from twelve down to six and now sits at ten
(_KEYWORD_CANDIDATE_LIMIT = 10), each candidate is cut to a 400-character window, and short state facts
marked by the gate need only 12 characters instead of the 40-character floor.
Fix - the extractor kept no sentence
An extracted entity now carries the sentence it came from, so a hit can be judged without reading the
whole point.
Tooling
scripts/prefetch-trichter.py reads content and limit from the plugin instead of a transcript of its
own rules; scripts/prueffragen-23.txt is the permanent measurement basis (23 real questions with a
required keyword, drawn from a random sample of the collection). Two outdated tests now check behaviour
instead of spelling and are independent of the NEXUS_REWRITE emergency brake.
v0.22.47 - a shared room caps writes too
[0.22.47] - 2026-11-08
Fix - a shared room caps writes too, not only reads
The room state added in the previous release guarded the read side only. Recall served public memories in
a room a stranger had posted in, while nexus_remember, sync_turn and the OpenClaw store and
capture paths still wrote with the configured access level — so the owner's private material
went straight into such a room.
- The OpenClaw pre-check now reads with the room level instead of the configured level, so the
gate and the write agree on what is allowed. - Saving is capped to
publicin a room that is provably shared: OpenClawstoreand
Auto-Capture, Hermesnexus_rememberandsync_turn. - Reading stays fail-closed (an unknown room counts as shared); writing caps only on proof
that a human other than the owner wrote there. Bots never count. - Nothing is rejected — a memory that would have been private is stored as
publicinstead.
v0.22.46 - a shared room now follows who wrote
[0.22.46] - 2026-11-07
Fix - in shared rooms only public memories are served
The plugin decided whether a room is shared from the chat type, so a Discord channel
counted as shared even when only the owner and its own agents ever wrote there, and the
owner's private memories stayed suppressed. Worse, nothing looked at who actually wrote:
in a room where a stranger had posted, recall, the tools and the graph lookups still
served whatever the configured access level allowed.
The plugin now observes the authors it sees. A room counts as shared as soon as a human
who is not the owner has written in it; bots never count. Until then the configured level
applies, and an unknown room counts as shared. Recall, nexus_recall, the graph tools and
nexus_forget all read the same room state, and nexus_forget also cleans the capture
retry queue. Auto-Capture stays blocked in every group turn.
The owner is identified per platform (owner_user_ids accepts one id per platform), and
the room state is kept in ~/.hermes/nexus/chat-sharing.json (mode 0600) as author
hashes only. A prefetched memory block is rebuilt when the room status changed since it
was prepared.
Fix - the reconstruction test only ran where a second checkout existed
tests/test_query_rewrite_recon.py put a directory outside the repository on sys.path
(<parent-of-the-clone>/nexus-memory-test/src) and imported nexus_memory through it. The
module was also a script rather than a test module: pytest imported it, executed the checks at
import time and reported zero tests for the file, so a failure only ever surfaced as a
collection error.
The extra path is gone. The checks are now separate test_* functions, so they appear in the
count and fail like any other test. The module imports nexus_memory the way the rest of the
suite does, through the source layout that tests/conftest.py already puts on sys.path.
Fix - the suite could not be collected on Python 3.13
tests/test_query_rewrite_recon.py took os from pathlib (from pathlib import Path, os).
pathlib stopped exposing os as a module attribute in Python 3.13 (hasattr(pathlib, "os") is
False on 3.13, True on 3.11), so pytest tests/ -q stopped during collection with
ImportError: cannot import name 'os' from 'pathlib'. pytest aborts the whole run on a collection
error, so no test module ran at all. pyproject.toml declares requires-python = ">=3.11", so 3.13
is inside the supported range.
The file now imports os directly. Collection succeeds and the suite runs on 3.11 and on 3.13.
v0.22.45: readable dashboard label, Windows subprocess decoding
[0.22.45] - 2026-11-01
Fix - dashboard shows a readable category instead of "uncategorized"
Around 4.3k of ~48.7k points carry no category field, and they are not one leftover
bucket: scanned documents (doc_id/document_type or source=paperless) and free-text
notes from a former collection (type=turn/memory/index). Both were displayed as
"uncategorized", which reads like an unfinished state. The label is now derived for
display only — document, note, other — and the payloads stay untouched.
Fix - subprocess output no longer depends on the console code page
Ten subprocess.run(..., text=True) calls decoded child output with the parent's
preferred encoding and never passed errors=, so a child writing bytes that
encoding cannot represent made CPython's reader thread raise and left
stdout/stderr as None — without raising in the caller. On a Chinese
Windows install (cp936) a plain git log whose commit message is not ASCII
was enough; in UTF-8 mode (PYTHONUTF8=1, the default from Python 3.15) the
same happens to icacls, which writes the OEM code page.
Three error paths then raised AttributeError: 'NoneType' object has no attribute 'strip' instead of reporting the failure they were meant to report
(serve_daemon.ensure_serve_daemon, and both the git pull and pip install
steps of the MCP update tool), one reported the wrong reason
(setup._install_plugin), and six silently lost the child's message.
Each call site now names its child's encoding — UTF-8 for git and bash,
oem for icacls on Windows, PYTHONIOENCODING=utf-8 for child Python/pip
processes — and falls back to errors="replace", so a wrong guess degrades to
a few mangled characters instead of a None that crashes the caller.
v0.22.44 - the guardrail now blocks protected paths on Windows
[0.22.44] - 2026-11-01
Security - the guardrail compared Windows paths in a form that never matched
guardrail_check.normalize_path normalised with os.path.normpath, which on Windows rewrites every
/ to \, while the containment checks in path_matches compare against a literal /. A protected
path therefore never matched — neither itself, nor its children (p + "/"), nor its parents — and the
guardrail allowed deletes and writes it exists to block. The four failing wave29 tests were the
first thing to say so.
The function now forces / as the separator on every platform, so one canonical form is compared
against. POSIX behaviour is unchanged.
Two limits are now written down instead of assumed: plugin installation on Windows degrades to
MCP-only, because every installer entry is a POSIX shell script, and the per-agent MCP config has no
0600 promise there, because it inherits the directory ACL and holds no secrets.
Fix - the automatic backup is protected on Windows too
MemoryStore._do_backup writes a copy of the store next to it and tightens the directory to 0700
and the file to 0600 with os.chmod. On Windows those calls grant and deny nothing, the inherited
ACL of the profile directory stays, and the backup was readable by every local account. The windows
job reported it as backup readable by group/other: 0o666.
The backup path now applies the same ACL restriction that already protects the secrets file, for
the directory and the file, on Windows only. POSIX keeps the mode bits, which do apply there; the
test asserts the ACL content on Windows and the modes on POSIX, so neither platform loses its
guarantee and neither is skipped.
[0.22.43] - 2026-11-01
Fix - the webhook store no longer loses subscriptions on Windows
The store serializes its read-modify-write through a sidecar file lock so two processes cannot
overwrite each other's subscriptions. That lock was fcntl.flock, and the code was honest about
the gap: without fcntl, cross-process serialization degrades to none. On Windows the store
therefore kept only the asyncio lock of its own process, and two agents sharing
~/.nexus-webhooks.json dropped each other's entries.
The windows job showed it as a plain number: two processes wrote five subscriptions each, ten were
written, eight survived.
msvcrt.locking on one byte of the same sidecar file closes the gap, with the wait loop
fuel_chain already uses, because msvcrt has no blocking mode. POSIX keeps flock unchanged. If
neither module exists the store logs a warning once instead of pretending the lock is there.
v0.22.40 - the plugin names the agent
Memory - the plugin names the agent instead of leaving it unidentified
0.22.39 stamped the agent name by reading NEXUS_AGENT_ID. In practice that variable does not reach
the plugin process, so Hermes memories were filed as unidentified even though the plugin is Hermes
and knows it. A field that says nothing is worse than no field, because it looks filled in.
The plugin now passes the name: NEXUS_AGENT_ID when set, hermes otherwise. Measured after the
change with no environment set, _agent_stamp() returns {'agent': 'hermes', 'instance': 'a9bd653dda02246b'}. Two tests pin the behaviour, the default and the environment override.
v0.22.39 - the plugin stamps agent and instance too
Memory - the Hermes plugin stamps agent and instance as well
0.22.36 to 0.22.38 put the two provenance fields into the MCP server. Hermes does not store through
the MCP server, it stores through the plugin, so the fields stayed empty on the path that matters
most. Measured after the restart: a probe memory written through the plugin came back with
agent: None and instance: None.
The plugin now stamps both fields through _agent_stamp() and spreads them into the remember and
the entity payload. The helper is fail-open on purpose: when nexus_memory is not importable in the
host interpreter, which is the normal case by design, the point is stored with None stamps instead
of failing. A memory must never be lost because a label could not be read.
Both write paths now produce the same shape, so a point can be attributed no matter which agent
wrote it.
v0.22.38 - every installation keeps its own id file
Memory - every installation keeps its own id file, even in a shared Nexus home
0.22.37 derived a new id from the installation's home but stored it in instance.json inside
NEXUS_HOME. Four Hermes profiles sharing one Nexus home therefore read the same file, so whichever
profile wrote first decided the id for all of them. Measured before the fix, two profiles with one
shared home both returned 2ebf77795f48a750.
The file name now carries the seed: instance-<8 hex of sha256(seed)>.json. Four profiles sharing
one home write four files and read four ids. Measured after the fix with the same setup: vater
841e9597ff4f2b5a, mutter 2c1c09afd35e304c, kind1 c3319c8826307a52, kind2
61f7294371275bd5, and a second run returns exactly the same four values.
The old instance.json is deliberately neither migrated nor read: it arrived in 0.22.36, there is
no real data behind it, and carrying it over would hand the shared value to every profile, which is
the bug itself. The test for the shared-home case fails without this change and passes with it.
v0.22.37 - installation ids come from the installation's home
Memory - a new installation id comes from the installation's home, not from a random value
0.22.36 gave every store a random installation id on first use. It was stable, but it only told
data directories apart. Four Hermes profiles on one machine that share one Nexus data directory all
read the same instance.json and therefore carried the same id, which is the exact case the field
exists for. Telling the user to set NEXUS_INSTANCE_ID is not an answer, because nobody in that
situation knows the variable exists.
A NEW id is therefore derived from the home of the installation: HERMES_HOME, which Hermes sets
per profile, together with NEXUS_HOME, hashed to 16 hex characters. Four profiles have four homes
and get four ids with no configuration at all. An id that already exists is still only read back,
never recomputed, so moving a folder does not re-file memories that were stored before.
The seed is a location, not a guess: it says where the store lives and claims nothing about the
person or the machine. The rule that ids are never derived from hostname, user name or port stays in
force for everything else, and the docstring says why this case is different. Three tests cover the
behaviour: two homes give two ids, one home keeps its id when the environment changes, and no home
at all still yields a valid stored id.
v0.22.36 - every memory says who stored it
Memory - every entry now says who stored it, and from which installation
A stored point carried source (where the information came from) and nothing about who put it
there. A preference could therefore be read later without anyone knowing whether it came from the
user, from Hermes or from another agent, and two Hermes installations on two machines looked
identical because the agent id is the same for both. This surfaced first as a real mix-up between
two agents on the same store, and it matches the transplant measurement in Yuan (2026): a memory
store carries more of an agent's identity than it looks.
Every stored point now carries two fields. agent comes from resolve_agent_id (explicit value,
then NEXUS_AGENT_ID, then the unidentified placeholder). instance comes from
resolve_instance_id: NEXUS_INSTANCE_ID if set, otherwise instance.json in the Nexus home,
created once on first use with a 16 hex character id. Both are returned wherever source is
returned today, including the hybrid and BM25 hit paths.
The instance id is deliberately never derived from hostname, user name, path or port. A guessed id
files a memory under the wrong installation, which is worse than no id. A corrupt instance.json
is replaced rather than used, and a read or write error yields an in-memory id instead of failing
the store call: saving a memory must not fail because of an id. Points stored before this release
return nothing for both fields; there is no migration.