Skip to content

feat(integrations): MemMesh memory provider for Hermes Agent - #6

Merged
rrader26 merged 19 commits into
mainfrom
feat/hermes-memory-provider
Aug 25, 2026
Merged

feat(integrations): MemMesh memory provider for Hermes Agent#6
rrader26 merged 19 commits into
mainfrom
feat/hermes-memory-provider

Conversation

@rrader26

Copy link
Copy Markdown
Contributor

Makes MemMesh a drop-in memory backend for Hermes Agent. Ships as a pip entry point (hermes_agent.memory_providers) — their bundled plugins/memory/ is closed to new providers, and an entry point still gets the dashboard config panel and hermes memmesh subcommands.

Their built-in memory is two char-capped files (MEMORY.md 2,200 chars, USER.md 1,375) frozen into the system prompt at session start, with no auto-compaction. That constraint is the whole reason the provider socket exists, and local mode makes replacing it free — no key, no account, no network.

pip install memmesh-hermes && hermes config set memory.provider memmesh

9 tests passing, no network or Hermes checkout required.


The two findings that shaped the design

Local mode speaks MCP, not REST

The obvious design is one HTTP client and a configurable base URL — both deployments expose /observe, both expose /search. That's a trap, and it's worth being precise about where it springs.

Our OSS binary serves two different search paths:

Route Implementation What it actually does
POST /search storage.query() MemoryFilter — scope, ids, status, text_match LIKE. Storage order.
POST /mcp -> memory_search memory_storage::search() semantic + lexical + recency hybrid

/search is a filter endpoint that happens to accept text. A plugin pointed at it would work, return plausible-looking rows, and quietly deliver substring matching where the user expected semantic recall. Nothing errors, nothing logs. So local mode talks to memmesh serve-mcp over JSON-RPC, and the two deployments get real adapters rather than one client written to the lowest common denominator of both.

(Worth considering separately: whether REST /search should route through the hybrid searcher too. It's a surprising asymmetry for anyone integrating against the REST surface, and I'd expect us to trip over it again.)

Only cloud mode declares the compaction checkpoint

Hermes offers an opt-in contract: pre_compress_checkpoint_api_version = 2 promises that every successful on_pre_compress() means the transcript is durably committed, and an operator who sets compression.checkpoint_required: true gets fail-closed compaction on the strength of it.

It's declared per instance, not on the class. The OSS binary has no durable transcript archive, so local mode doesn't make the claim. Advertising a guarantee the backend can't honour is worse than not offering it — the operator would have configured compaction to depend on it.

That's also the one path in the plugin that fails closed. Everything else fails soft: recall returns empty, writes drop from a full queue rather than blocking a turn, a dying background writer can't take the session with it — a lost observation costs one memory, a blocked turn costs the conversation. The checkpoint raises instead, so compaction is blocked and the uncompressed transcript survives. Compaction is irreversible; a provider whose job is holding the evidence must not let it be destroyed best-effort.

Checkpoints are keyed by SHA-256 of the transcript, salted with the bank id. After a fail-closed block Hermes re-calls with a transcript that's grown only slightly, so attempts carry overlapping evidence — the content digest is what makes a retry a no-op instead of a duplicate archive.

Hooks

Hook Behaviour
prefetch recall injected pre-turn, gated by Hermes' own is_trivial_prompt
sync_turn turn queued to observe on a background thread
on_pre_compress cloud only — durable archive before compaction, fail-closed
on_delegation subagent (task -> result) recorded as a reasoning trace
on_memory_write mirrors Hermes' own MEMORY.md / USER.md writes into MemMesh
recall_status deterministic "MemMesh — recalled N memories" indicator
backup_paths declares the local DB — hermes backup only walks HERMES_HOME, and in local mode that DB is the memory
tools memmesh_search, memmesh_observe

Smaller deliberate choices

  • The trivial-prompt predicate is imported from Hermes, not reimplemented. Two copies of that regex would drift, and the drift is invisible: recall silently stops happening for some word nobody thought to test. It gates automatic recall only — an explicit memmesh_search for "ok" still runs.
  • on_delegation omits verified rather than sending false. Their API treats false as "checked and found wrong" and keeps it as a counterexample. A subagent returning a result isn't evidence it was correct, and false would mislabel every delegation as a known failure — removing it from procedure induction entirely, which is the opposite of the point.
  • Bank ids collapse empty placeholders, so hermes-{user} with no user is hermes, not hermes-. A dangling separator is a different bank, and the symptom is an agent that appears to have lost its memory rather than an error anyone can see. Tested.
  • The managed daemon binds loopback only and is token-gated even there, because every other process on the machine can reach loopback. Refcounted and left running after the last release so /reset doesn't pay a cold start; atexit reclaims it.

Tests

Scoped to the logic that fails quietly: bank-id scoping, MCP result parsing (junk must degrade to empty recall, never an exception), and digest stability including field-boundary confusion. requests and the Hermes runtime are both stubbed.

Not verified here

Not yet run against a live Hermes install — the hooks are written against their MemoryProvider ABC and their published contracts, but end-to-end wiring (daemon spawn under a real agent, the checkpoint under compression.checkpoint_required) needs a Hermes checkout to exercise. Cloud mode's routes target the hosted API's current shape; skipTrivialQueries and the trust-scoring work it pairs with are in flight in memory-thinkfleet #393/#394.

…laude.ai)

- memmesh serve-mcp: authed Streamable-HTTP MCP endpoint (POST /mcp) wrapping
  the same 11 tools as stdio; bearer-token required; stateless
- reusable memory_mcp::handle_message shared by stdio + HTTP transports
- initialize echoes client's negotiated protocolVersion (2025-* clients)
- separate from the loopback console (which stays unauthed/local-only)
The installer promised 'session-start memory injection' but only wrote the
UserPromptSubmit observe hook. Add a SessionStart hook that runs
`search --format claude-context` so relevant memories are injected into
context automatically at session start — completing the capture+recall loop
without relying on the model choosing to call memory_search. Refactored the
append-or-replace logic into a shared upsert_owned_hook helper.
Claude Code's UserPromptSubmit (and similar hooks) pipe a JSON envelope
({"hook_event_name":...,"prompt":"..."}) to the hook command, not the raw
prompt. `observe` read stdin as literal text, so it was observing the wrapper
JSON and extracting nothing — the passive-capture hook silently no-op'd on
every prompt. Detect the hook envelope and observe the inner .prompt instead.
Plain-text stdin is unchanged (bare text won't match the hook markers).
…n recall

Two quality fixes that made memory *look* broken:
- observe now drops harness chatter (task-notification / system-reminder /
  command wrappers) instead of saving it as 'memories' that bury real ones
- session-start injection (claude-context) sorts by importance so facts/rules/
  preferences lead, not recency-ordered conversational filler
Softer forward-looking language (we should…, what if we…, might want to…,
I'm thinking we…, it would be nice to…) was falling through to low-value raw
observations. Add an Ideas rule block (lower priority than facts/decisions/
rules) so conversational ideas are captured as a listable 'idea' type.
Lists type=idea memories (auto-captured from conversation via the new
extraction rules), with manual add (observe 'idea: …') and archive-when-done.
Completes the idea-tracking loop: passive capture + a dedicated surface.
observe now drops any line matching common live-credential shapes (AWS/OpenAI/
Anthropic/GitHub/Slack/Google keys, PEM private keys, JWTs, password=… , DSNs
with embedded creds) before extraction. Secrets belong in the encrypted vault,
never in the plaintext memory store or the context we inject back. Legit
mentions ('build a secrets manager') are unaffected — patterns require real
token shapes or key=value assignments.
…through-vault)

AI can reference credentials but never read them:
- XChaCha20-Poly1305 vault in ~/.memmesh/vault.db (separate from memories);
  master key in OS keychain, 0600-file fallback for headless hosts
- values entered by the human (hidden CLI prompt / stdin), never in chat
- execute-through-vault:  resolves {{memmesh:NAME}} in-process,
  runs the command, and SCRUBS every secret value from the output — so even
  {{memmesh:x}} can't leak it back
- CLI: secret set / list / rm / run; no value is ever printed or logged
- verified: encrypted at rest (0 plaintext in db), scrubbing works
…ads)

Completes the secrets manager:
- MCP tools: memory_secret_list (names/kinds only), memory_secret_request
  (status only; tells the model to have the USER add a missing secret, never
  to paste it in chat), memory_secret_run (execute-through-vault with output
  scrubbing). No read-value tool exists.
- Console Vault tab: add (name/kind/desc/value over loopback), list
  (metadata only), delete. GET/POST/DELETE /vault endpoints.
- Verified end-to-end: encrypted at rest, OS keychain in use (no file
  fallback), values never returned over HTTP or MCP, echo-exfil scrubbed.
…mpt deep link

- Vault tab: Reveal (show) / Copy actions via GET /vault/:name/reveal — human
  retrieval of their own secret over loopback; still no AI/MCP read path.
- secret_request now returns a one-click deep link
  (http://127.0.0.1:7878/?tab=vault&add=NAME) so the AI prompts the user to
  enter a missing credential in the console Vault form (pre-filled), never in chat.
- console honors ?tab=&add=&kind= to open the Vault tab pre-filled.
Adds a Credentials section to the memmesh skill: when a task needs an API key/
password/token, the AI must call memory_secret_request (never ask for it in
chat), relay the pre-filled vault link for the user to enter it, then use it via
memory_secret_run with {{memmesh:NAME}} placeholders (value scrubbed, never
seen). Installed into Claude Code / Codex / Cursor / Windsurf.
When an AI tool sends the user to the vault (secret_request deep link now
carries &purpose=), the Vault tab shows a clean, single-field request card —
name pre-filled + purpose, show/hide value, one Save button, and a success
state — instead of the plain multi-field form (now a collapsed 'add manually'
details). Nicer, focused 'just enter this credential' UX.
…direct)

Advisory skill wasn't enough — a model will still offer "paste your key". Add a
fast, side-effect-free `memmesh hook secret-guard`: reads a tool's PreToolUse
JSON on stdin, scans the command, and BLOCKS (exit 2 + guidance) if it carries a
live credential — redirecting to the vault. Allows {{memmesh:...}} placeholders.
Installer wires it as a Claude Code PreToolUse[Bash] hook. ~10ms/call; no engine
init (dispatched before tracing/license/store setup). Secrets now physically
can't reach a shell command or the transcript.
secret_request now takes a `kind` (api_key/password/basic_auth/oauth2/
connection_string/ssh_key/token/custom) and passes it in the deep link; the
console request panel renders the matching form — single value, username +
password, DSN, SSH-key textarea — with per-field show/hide. Multi-field secrets
are stored as a JSON record and referenced field-wise as {{memmesh:NAME|field}}
(secret_run resolves + scrubs each). Falls back to inferring the kind from the
name when the AI doesn't declare one.
crates/desktop — the Rust engine + the console UI in a single native app: the
memory-server (REST + embedded console UI) runs in-process on a background
tokio runtime, and a system webview (wry/tao — no browser, no Node, no Tauri
CLI) renders it in a native window. Reads ~/.memmesh/memory.db. This is the
desktop form of the OSS console; the enterprise build points the same window at
the full engine.
Hermes Agent ships a MemoryProvider ABC and activates exactly one provider by
name. This is ours, in three modes: local (we manage a daemon), local_external
(point at one you run), and cloud (app.memmesh.ai).

Hermes' built-in memory is two char-capped files — MEMORY.md at 2,200 chars,
USER.md at 1,375 — frozen into the system prompt at session start, with no
auto-compaction. That constraint is the entire reason a provider socket
exists, and local mode makes replacing it free.

LOCAL MODE SPEAKS MCP, NOT REST

The obvious design is one HTTP client and a configurable base URL, since both
deployments expose /observe and both expose /search. That is a trap.

The OSS binary serves two different search paths. `POST /search` goes to
storage.query(): a MemoryFilter — scope, ids, status, and a text_match LIKE —
returned in storage order. The hybrid searcher (semantic + lexical + recency)
is reachable only through the MCP tool surface.

So a plugin pointed at REST /search would work, return plausible-looking rows,
and quietly deliver substring matching where the user expected semantic
recall. Nothing errors. Local mode therefore talks to `memmesh serve-mcp` over
JSON-RPC, and the two deployments get real adapters rather than one client
written to the lowest common denominator.

ONLY CLOUD DECLARES THE COMPACTION CHECKPOINT

pre_compress_checkpoint_api_version = 2 is set per INSTANCE, not on the class.
Version 2 promises that every successful on_pre_compress() means the
transcript is durably committed, and an operator who sets
compression.checkpoint_required is trusting that promise with data they cannot
get back. The OSS binary has no durable transcript archive, so in local mode
the plugin does not make the claim.

That path is also the one place in the file that fails CLOSED. Everything else
fails soft — recall returns empty, writes drop from a full queue rather than
blocking a turn, a dying background writer cannot take the session with it,
because a lost observation costs one memory and a blocked turn costs the
conversation. The checkpoint raises instead, so compaction is blocked and the
uncompressed transcript survives. Compaction is irreversible; a provider whose
job is holding the evidence must not let it be destroyed best-effort.

Checkpoints are keyed by a SHA-256 of the transcript salted with the bank id.
After a fail-closed block Hermes re-calls with a transcript that has grown
only slightly, so attempts carry overlapping evidence — the content digest is
what makes a retry a no-op instead of a duplicate archive.

OTHER DELIBERATE CHOICES

- The trivial-prompt gate uses Hermes' own is_trivial_prompt, imported rather
  than reimplemented. Two copies of that regex would drift and the drift would
  be invisible: recall silently stops happening for some word nobody tested.
  It gates AUTOMATIC recall only — an explicit memmesh_search for "ok" still
  runs.

- on_delegation OMITS `verified` rather than sending false. The API treats
  false as "checked and found wrong"; a subagent returning a result is not
  evidence it was correct, and false would mislabel every delegation as a
  known failure, removing it from procedure induction entirely.

- Bank ids collapse empty placeholders, so `hermes-{user}` with no user is
  `hermes` and not `hermes-`. A dangling separator is a DIFFERENT bank, and
  the symptom is an agent that appears to have lost its memory rather than an
  error anyone can see. Tested.

- The managed daemon binds loopback only and is token-gated even there,
  because every other process on the machine can reach loopback. Refcounted
  and left running after the last release so /reset does not pay a cold start.

- backup_paths declares the local DB: `hermes backup` only walks HERMES_HOME,
  and in local mode that database IS the memory.

Tests cover the logic that fails quietly — bank scoping, MCP result parsing
(junk must degrade to empty recall, never an exception), digest stability.
requests and the Hermes runtime are both stubbed, so no checkout or network is
needed. 9 passing.
@rrader26
rrader26 merged commit bba48f8 into main Aug 25, 2026
1 check failed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants