feat(integrations): MemMesh memory provider for Hermes Agent - #6
Merged
Conversation
…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.
…one-home + federation
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.
…e streams (memory + utilization)
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.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Makes MemMesh a drop-in memory backend for Hermes Agent. Ships as a pip entry point (
hermes_agent.memory_providers) — their bundledplugins/memory/is closed to new providers, and an entry point still gets the dashboard config panel andhermes memmeshsubcommands.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.
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:
POST /searchstorage.query()text_matchLIKE. Storage order.POST /mcp->memory_searchmemory_storage::search()/searchis 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 tomemmesh serve-mcpover 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
/searchshould 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 = 2promises that every successfulon_pre_compress()means the transcript is durably committed, and an operator who setscompression.checkpoint_required: truegets 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
prefetchis_trivial_promptsync_turnobserveon a background threadon_pre_compresson_delegationon_memory_writerecall_statusbackup_pathshermes backuponly walks HERMES_HOME, and in local mode that DB is the memorymemmesh_search,memmesh_observeSmaller deliberate choices
memmesh_searchfor "ok" still runs.on_delegationomitsverifiedrather than sendingfalse. Their API treatsfalseas "checked and found wrong" and keeps it as a counterexample. A subagent returning a result isn't evidence it was correct, andfalsewould mislabel every delegation as a known failure — removing it from procedure induction entirely, which is the opposite of the point.hermes-{user}with no user ishermes, nothermes-. 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./resetdoesn't pay a cold start;atexitreclaims 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.
requestsand the Hermes runtime are both stubbed.Not verified here
Not yet run against a live Hermes install — the hooks are written against their
MemoryProviderABC and their published contracts, but end-to-end wiring (daemon spawn under a real agent, the checkpoint undercompression.checkpoint_required) needs a Hermes checkout to exercise. Cloud mode's routes target the hosted API's current shape;skipTrivialQueriesand the trust-scoring work it pairs with are in flight in memory-thinkfleet #393/#394.