Skip to content

feat(integrations): Hermes memory provider core - #1915

Merged
igorls merged 8 commits into
MemPalace:developfrom
raman325:feat/hermes-provider-core
Aug 11, 2026
Merged

feat(integrations): Hermes memory provider core#1915
igorls merged 8 commits into
MemPalace:developfrom
raman325:feat/hermes-provider-core

Conversation

@raman325

@raman325 raman325 commented Jul 2, 2026

Copy link
Copy Markdown
Contributor

Per @igorls I am splitting #1684 into two separate PRs and this is the first — the provider core and tests, as recommended. I attempted to resolve all of your blockers but may have missed e.g. some helpers that are available for use — I gave Fable a good run at it though!

What this adds

A MemPalace memory provider for Hermes (NousResearch/hermes-agent #6323): a Python class implementing Hermes' MemoryProvider ABC, exposing the full 27-tool MCP surface, with lifecycle hooks (sync_turn, on_session_end, on_pre_compress, session-switch bookkeeping), a cron/flush context guard so system-generated turns don't poison the palace, a bounded background worker so filing never blocks the conversation, and an AAAK wake-up cache.

The mempalace hermes install command, session backfill, and docs land in a stacked follow-up PR so this one stays reviewable.

Beyond the provider itself, this touches two core modules:

  • convo_miner.py: new file_conversation_exchange() — a canonical write path for filing one verbatim exchange as a drawer, carrying the same metadata the convo miner writes (hall, entities, authored_at, ingest_mode, extract_mode, normalize_version, id_recipe). This exists so live integration writes and their backfills route through one implementation instead of two hand-rolled copies.
  • ids.py: new make_exchange_drawer_id() alongside the existing recipes.

How the review blockers were addressed

  • Conflicts with develop — rebuilt on the current develop tip; no conflicts.
  • Paths bypassing backend/write helpers — all ChromaDB access goes through ChromaBackend.get_or_create_collection() (embedding function stays centralized, no dimension drift; writes inherit mine_palace_lock via ChromaCollection). Live turn filing routes through the new file_conversation_exchange() instead of a hand-rolled col.upsert. Tool calls delegate to mempalace.mcp_server.tool_* entry points rather than reimplementing them.
  • Live/historical ingest parityfile_conversation_exchange() is the single write path both will share; the stacked backfill PR routes through it and has a regression test pinning wing-routing parity.
  • Large-palace status/list operations — metadata scans in status/list_wings/list_rooms are capped (STATUS_SCAN_LIMIT = 5000) with structured truncation fields (truncated, scan coverage) so the model knows when a breakdown is partial.
  • Packaging from a wheel — the integration lives at mempalace/integrations/hermes/ so it ships automatically via packages = ["mempalace"]; nothing depends on top-level files being adjacent to the installed package.

Why this lives at mempalace/integrations/hermes/ and not top-level integrations/hermes/

Top-level integrations/openclaw/ exists and the natural read is "put it next to that one." The asymmetry comes from how each integration is consumed at runtime:

  • openclaw is a Claude Code skill — a single SKILL.md that Claude Code reads from the source repo. It doesn't need to be importable Python and it doesn't need to ship in the wheel.
  • The Hermes integration is a Python class implementing the MemoryProvider ABC — Hermes' plugin loader imports the module and calls methods on it. mempalace hermes install has to find the source files at runtime after a wheel install in order to copy them into ~/.hermes/plugins/mempalace/ (the install command itself lands in the follow-up PR).

Tests

42 new tests in tests/test_hermes_integration.py covering the tool surface, lifecycle/worker behavior, wing routing, canonical drawer metadata, and the ChromaBackend round-trip that regression-checks the dimension-mismatch bug from prior in-tree Hermes attempts.

Backfill + install + docs PR to follow, stacked on this branch.

Behavior note: wing/room validation falls back instead of erroring

file_conversation_exchange() validates wing/room with the same sanitize_name rules the MCP write tools apply (blocks /, .., null bytes, over-length names). Unlike the MCP tools — which return an error to their caller — this path falls back on an invalid name: wing → wing_general, room → conversations, with a warning logged. Rationale: this function files live turns, and dropping a turn over a config typo would violate the verbatim / 100%-recall promise. Misrouted-but-recallable beats gone. (Raised in review; documented in the integration README in the follow-up PR.)

Copilot AI review requested due to automatic review settings July 2, 2026 18:46

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request implements the MemPalace memory provider integration for Hermes, adding the file_conversation_exchange function, custom drawer ID generation, the MempalaceProvider class with background worker processing and tool dispatching, and a comprehensive test suite. The review feedback focuses on enhancing robustness and idiomatic Python usage, specifically suggesting to handle potential None elements in metadata scans, add type checks for parsed JSON configurations and keyword lists, utilize the context manager protocol for KnowledgeGraph instances, and wrap file system operations in try...except OSError blocks to prevent unexpected crashes.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment thread mempalace/integrations/hermes/__init__.py Outdated
Comment thread mempalace/integrations/hermes/__init__.py Outdated
Comment thread mempalace/integrations/hermes/__init__.py Outdated
Comment thread mempalace/integrations/hermes/__init__.py Outdated
Comment thread mempalace/integrations/hermes/__init__.py
Comment thread mempalace/integrations/hermes/__init__.py
Comment thread mempalace/integrations/hermes/__init__.py Outdated
Comment thread mempalace/integrations/hermes/__init__.py Outdated
Comment thread mempalace/integrations/hermes/__init__.py
Comment thread mempalace/integrations/hermes/__init__.py

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR introduces the core of a Hermes MemoryProvider implementation backed by MemPalace, enabling Hermes to access MemPalace’s MCP tool surface and persist conversation turns without blocking the agent loop. It also adds a canonical conversation-exchange filing path and a new ID recipe to ensure live integration writes match the existing convo-miner metadata conventions.

Changes:

  • Added MempalaceProvider for Hermes with lifecycle hooks, bounded background worker, and 27-tool MCP surface exposure.
  • Added file_conversation_exchange() as the canonical write path for one verbatim exchange (shared by live integrations/backfills) plus a new make_exchange_drawer_id() recipe.
  • Added a comprehensive test suite covering provider/tool behavior, lifecycle/worker behavior, wing routing, and canonical metadata expectations.

Reviewed changes

Copilot reviewed 4 out of 5 changed files in this pull request and generated 5 comments.

File Description
tests/test_hermes_integration.py New end-to-end and unit coverage for Hermes provider surface, lifecycle, and persistence behavior.
mempalace/integrations/hermes/init.py New Hermes provider implementation and tool schema/dispatch logic.
mempalace/ids.py Adds make_exchange_drawer_id() to avoid upsert collisions for exchange drawers.
mempalace/convo_miner.py Adds file_conversation_exchange() canonical exchange filing helper used by integrations.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread tests/test_hermes_integration.py Outdated
Comment thread mempalace/integrations/hermes/__init__.py Outdated
Comment thread mempalace/convo_miner.py
Comment thread mempalace/convo_miner.py
Comment thread mempalace/integrations/hermes/__init__.py Outdated
raman325 added a commit to raman325/mempalace that referenced this pull request Jul 2, 2026
- _scan_metadatas: fetch cap+1 and compare len > cap, so a collection
  holding exactly STATUS_SCAN_LIMIT rows is no longer reported as
  truncated (the view is complete). Callers still get at most cap rows.
- status/list_wings/list_rooms: tolerate None metadata entries from
  legacy palaces / raw writers instead of failing the tool call.
- _match_wing_by_keywords: skip non-string keywords so a hand-edited
  wing_config.json can't break live turn filing.
- file_conversation_exchange: extra_metadata can no longer overwrite
  canonical keys (matches the documented append-only contract), and
  wing/room are validated with sanitize_name — invalid names fall back
  to wing_general / conversations rather than dropping the turn, per
  the verbatim-first mandate.
- Fix two stale docstrings left from the pre-split layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@igorls

igorls commented Jul 6, 2026

Copy link
Copy Markdown
Member

Thanks for splitting this out. Overall this is a strong direction: keeping the Hermes provider inside the package makes sense for wheel installs, routing live writes through a shared file_conversation_exchange() path is the right abstraction, and I appreciate the added coverage around lifecycle behavior, ChromaBackend usage, cron/flush guards, and canonical metadata.

I don’t think this is quite ready to merge yet, but it feels close. The main blockers I’d like fixed first are:

  1. Duplicate filing across normal session lifecycle

    Hermes calls sync_turn() after completed turns, and then on_session_end() receives the full transcript at the session boundary. This provider currently files both paths, so the same user/assistant exchange can be written twice. Since make_exchange_drawer_id() includes filed_at, those duplicates usually become distinct drawers rather than idempotent rewrites.

    That would pollute recall over time, especially for long-running Hermes users. I think we need either:

    • on_session_end() to file only turns that were not already handled by sync_turn(), or
    • sync_turn() to be the live path and on_session_end() to avoid re-mining already-synced turns.
  2. Passthrough tools can target a different palace than the provider

    The provider initializes live writes/search/status against self._palace_path, but many exposed tools delegate directly to mempalace.mcp_server.tool_*, which uses MemPalace’s global config. That means a Hermes profile configured with a custom palace_path can search one palace while mempalace_add_drawer, drawer CRUD, duplicate checks, tunnels, etc. read/write another.

    That is a pretty sharp footgun and could easily look like lost memories. I think the passthrough path needs to be made palace-scoped, or those tools should stay unexposed until they can honor the provider’s configured palace.

A couple of smaller follow-ups I noticed:

  • on_memory_write() only mirrors target == "user", but Hermes’ memory tool defaults to target="memory" when omitted, so ordinary memory writes may not get mirrored.
  • The provider stores state outside HERMES_HOME by default (~/.mempalace/...) but does not implement backup_paths(), so hermes backup would not include the actual MemPalace state.

So: I’m positive on the shape of this PR, but I’d request changes on the two lifecycle/config-path issues before merge.

@GoXLd

GoXLd commented Jul 6, 2026

Copy link
Copy Markdown

Split off a fix for the two blockers @igorls raised above (duplicate filing between sync_turn()/on_session_end(), and passthrough tools bypassing the provider's configured palace_path), plus the two smaller follow-ups (on_memory_write default target, backup_paths()). Sent as raman325#2, stacked on this branch.

@raman325

raman325 commented Jul 6, 2026

Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @igorls — all four points were real, and all four are now addressed. Point by point:

1. Duplicate filing across the session lifecycle — fixed in e6bb4b8; the capture side split to a follow-up to keep this PR scoped.

We took your second option and went a step further: sync_turn is now the sole filing path. on_session_end files nothing (it keeps only the wake-up-cache refresh) and on_pre_compress returns "" so it never promises persistence it didn't perform. _mine_session and the worker's re-filing branches are gone; tests pin the no-duplicate behavior.

The "file only what sync_turn missed" safety net is deliberately not in this PR. Doing it correctly turned out to be subtle: exact-text matching between sync_turn drawers and the raw transcript can never work — Hermes hands sync_turn the cleaned user message and think-stripped final response, while the raw message list carries injected skill content and tool traffic. The follow-up correlates turns via a content fingerprint of the raw user message stored in drawer metadata, scans the session's existing drawers (plus /branch lineage) at session end, and files only uncovered turns, with loss-safe fallbacks (no session id or failed scan → file rather than skip; the failure direction is always a duplicate, never a lost turn). That's designed and partially built, and will come as its own reviewable PR. In the meantime nothing duplicates and nothing durable is lost: every completed turn is filed exactly once by sync_turn.

2. Passthrough tools targeting a different palace — fixed in 73b3b31.

Three parts:

  • initialize() now publishes the provider's resolved palace to MEMPALACE_PALACE_PATH — mcp_server's own override mechanism (its --palace flag sets exactly this variable, and its config re-reads it on every access). All drawer CRUD / duplicate-check / tunnel / taxonomy passthroughs now resolve the provider's palace. Ownership of the env write is tracked, so a stale bridge from a previous session never outranks freshly edited config, and a genuinely user-set env var is never claimed or cleared.
  • The KG passthroughs (kg_invalidate, kg_timeline, kg_stats) could not be fixed by the bridge — mcp_server resolves its KG from DEFAULT_KG_PATH unless its own CLI flag was given — so they're now native handlers hitting the same <palace>/../knowledge_graph.sqlite3 as kg_query/kg_add, with the same input validation as their mcp_server counterparts.
  • While closing this we found the same split one level down: collection_name. The provider wrote the hardcoded default collection while search_memories (and the passthrough) honor ~/.mempalace/config.json — a customized collection name would have made live turns invisible to recall. The provider now reads collection_name (and the unset-palace default) from MempalaceConfig, so the embedded provider follows the same single resolution chain the standalone MCP server does. Still intentionally not configurable on the Hermes side — a second knob would let the write and read sides diverge again.

3. on_memory_write target filter — fixed in 9edcec2.

Confirmed against the Hermes source: memory_tool defaults to target="memory", so the target == "user" filter was dropping the majority of writes. Both targets now mirror into the knowledge graph under distinct subjects — user → asserted → <fact> for user facts, hermes → noted → <fact> for the agent's own notes — so kg_query("user") never surfaces environment quirks. (replace/remove actions still don't mirror; mapping them onto kg_invalidate requires content matching we'd rather design deliberately in the follow-up than guess at here.)

4. backup_paths() — no such hook exists to implement; documented the boundary instead.

We searched the Hermes tree: MemoryProvider has no backup_paths() (or equivalent), and hermes backup walks only $HERMES_HOME — there's currently no way for a provider to contribute external paths. Substantively, we'd also argue the palace shouldn't ride along in a Hermes backup: ~/.mempalace/ is the user's central memory shared across agents (OpenClaw, Claude Code, etc.), not per-agent Hermes state. The module docstring now documents this boundary and post_setup explicitly tells users hermes backup does not cover ~/.mempalace/. If a backup_paths()-style hook lands upstream, we'll implement it.

All 58 provider tests pass (3,273 across the suite), lint clean, CI green.

🤖 Generated with Claude Code

@GoXLd

GoXLd commented Jul 6, 2026

Copy link
Copy Markdown

Thanks for the detailed writeup, @raman325 - the direction you landed on is cleaner than what I had in raman325#2: making sync_turn the sole filing path (rather than de-duping across two paths) sidesteps the whole fragile correlation problem, and the env-bridge + collection_name unification for point 2 closes a gap I hadn't even caught in #2.

Happy to help with the sync_turn/on_session_end correlation follow-up once it's ready for review —-the content-fingerprint approach you outlined sounds like the right way to avoid the exact-text-match trap.

raman325 and others added 6 commits July 16, 2026 00:08
Live agent integrations and their backfills need to file one
conversation exchange at a time, but hand-rolling the upsert leaves
drawers without hall / entities / filed_at / extract_mode metadata —
silently invisible to hallway traversal, entity search, and the
since/before date filters.

file_conversation_exchange() builds the same metadata the convo miner
writes, and make_exchange_drawer_id() moves the ID construction into
ids.py per its single-source-of-truth contract (full-content hash, no
prefix collisions; filed_at keeps repeated exchanges distinct).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The Hermes provider from feat/hermes-integration, split out per review
on MemPalace#1684 — provider + tests only; backfill, the hermes install CLI,
and docs follow in a stacked PR.

Changes vs the original branch:
- _file_turn routes through convo_miner.file_conversation_exchange()
  instead of a hand-rolled col.upsert, so live turns carry canonical
  drawer metadata and the ids.py ID recipe.
- The backfill/live wing-routing parity test moves to the backfill PR.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
- _scan_metadatas: fetch cap+1 and compare len > cap, so a collection
  holding exactly STATUS_SCAN_LIMIT rows is no longer reported as
  truncated (the view is complete). Callers still get at most cap rows.
- status/list_wings/list_rooms: tolerate None metadata entries from
  legacy palaces / raw writers instead of failing the tool call.
- _match_wing_by_keywords: skip non-string keywords so a hand-edited
  wing_config.json can't break live turn filing.
- file_conversation_exchange: extra_metadata can no longer overwrite
  canonical keys (matches the documented append-only contract), and
  wing/room are validated with sanitize_name — invalid names fall back
  to wing_general / conversations rather than dropping the turn, per
  the verbatim-first mandate.
- Fix two stale docstrings left from the pre-split layout.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
on_session_end and on_pre_compress blind-re-filed the raw message list,
duplicating every turn sync_turn had already stored — filed_at is hashed
into the drawer id, so upserts cannot collapse the copies. Drop the
re-filing (and the pre-compress hint that over-promised persistence);
on_session_end keeps only the wake-up cache refresh.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The provider filed and searched self._palace_path while the mcp_server
passthrough tools resolved mempalace's global config — a custom Hermes
palace_path searched one palace while drawer CRUD, duplicate checks,
and tunnels wrote another. Publish the resolved palace to
MEMPALACE_PALACE_PATH (mcp_server's own --palace mechanism) with
ownership tracking so a stale bridge never outranks edited config and
a user-set env var is never touched. The KG tools become native
handlers: mcp_server resolves its KG from DEFAULT_KG_PATH unless its
own CLI flag was given, which no env bridge can influence.
collection_name and the unset-palace default now defer to
MempalaceConfig — the same single chain the MCP server itself uses.
Also document that hermes backup does not cover ~/.mempalace (no ABC
hook exists for contributing external paths).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…raph

Hermes' memory tool defaults to target="memory" (the agent's own
notes); filtering on_memory_write to target == "user" silently dropped
the majority of writes. Mirror both targets under distinct subjects —
user→asserted for user facts, hermes→noted for agent notes — so
kg_query("user") never surfaces environment quirks.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@raman325
raman325 force-pushed the feat/hermes-provider-core branch from 9edcec2 to fc3beb7 Compare July 16, 2026 04:11
@vavush

vavush commented Jul 30, 2026

Copy link
Copy Markdown

Hey @igorls — just checking in on this one. All four review blockers were addressed back on July 6 (duplicate filing, passthrough tools bypassing palace_path, sync_turn as sole filing path, KG mirroring). The PR is mergeable and has been sitting for two weeks. Is there anything else needed before it can land? Happy to rebase or adjust if something changed on develop in the meantime.

@alistairwalsh

Copy link
Copy Markdown

Thanks for pushing this stack forward. I maintain a separately developed native Hermes/MemPalace provider that is running in production on Hermes 0.20.0 and MemPalace 3.6.0 against authenticated Qdrant, with more than 25,000 drawers.

I reviewed #1915, #1941, and #1942. I do not intend to open a competing Hermes provider; this series is clearly the right upstream integration point.

Our independently tested line may be useful for focused follow-ups in two areas:

  1. Backend-neutral / Qdrant operation

    • profile-scoped Qdrant namespaces;
    • an explicit backend marker and fail-closed startup rather than silent Chroma fallback;
    • authenticated health, collection-identity, and semantic-query checks;
    • production verification that multiple Hermes execution contexts do not cross-write profiles.
  2. Crash and update durability

    • durable turn spooling before asynchronous filing;
    • replay/recovery of uncommitted spool entries;
    • atomic, fsynced transcript archival;
    • execution-context guards for cron/flush/system turns;
    • a compatibility gate that checks the Hermes MemoryProvider contract, MemPalace dependency line, resolved backend, namespace identity, non-shrinking drawer count, semantic retrieval, and a fresh-process Hermes tool call.

The current upstream stack's use of ChromaBackend is internally consistent and well tested. If Qdrant/backend-neutral operation is wanted, I can adapt the relevant pieces to this architecture rather than importing our deployment-specific provider wholesale. I would keep profile names, local paths, watchdog language, and operational history out of the patch, and preserve the existing authorship and design.

@raman325 @igorls, which sequencing would you prefer?

I can also start with a compact backend contract test so storage abstraction expectations are agreed before implementation.

* develop: (45 commits)
  ci: retry transient Chroma reader initialization failure
  fix(encoding): recover undefined CP1252 continuation bytes
  fix(encoding): make repair conservative and reversible
  fix(windows): add legacy encoding repair tool
  feat(searcher): wire i18n stop words into BM25 tokenizer (MemPalace#973)
  test: expect dry_run=False on rebuild-index alias call
  fix(repair): honor --dry-run for repair --mode from-sqlite
  fix(hooks): ingest only the active transcript
  fix(embedding): remap unsupported EmbeddingGemma token IDs
  fix(convos): honor mined state during dry runs
  fix: harden release polish for bot findings and search errors
  chore(release): 3.7.0
  fix: reopen immutable readers and clear identity on promote
  fix: address backend ownership edge cases
  fix: serialize SQLite writes before palace lease
  fix: take mine-lock before archive in repair --mode from-sqlite
  fix(chroma): adopt chromadb's own HNSW write defaults
  fix: retry transient MCP ownership failures
  fix: address MCP ownership review feedback
  fix: address single-writer review feedback
  ...

# Conflicts:
#	mempalace/convo_miner.py
#	tests/test_convo_miner.py
@raman325

raman325 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

I'd prefer to land my stack and to queue up any additional changes for after as it would reduce merge conflict churn, but I will leave it to your and Igor's discretion!

@igorls igorls left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wave 2 for 3.7.0: LGTM after review. Merging into develop for the release train.

@igorls
igorls merged commit 7eaa3bc into MemPalace:develop Aug 11, 2026
8 checks passed
@raman325
raman325 deleted the feat/hermes-provider-core branch August 11, 2026 11:04
pull Bot pushed a commit to FaZios/mempalace that referenced this pull request Aug 11, 2026
MemoryStack/Layer1 opened a second PersistentClient on the same palace
while the Hermes provider already held one for live filing. Concurrent
access corrupted local Chroma SQLite (disk I/O / Failed to get segments)
and failed CI on develop after MemPalace#1915.

Wake-up L1 now scans the long-lived collection under the collection lock,
and filing holds that lock for the full upsert. Also rewrite the RFC 001
section-4.4 docstring to avoid the internal §N jargon guard.
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.

6 participants