fix(hermes): resolve palace, collection, and KG against one config chain

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>
This commit is contained in:
raman325 2026-07-06 10:02:58 -04:00
parent 2d6580b1f1
commit bb1d502ddc
No known key found for this signature in database
GPG Key ID: 7E7265C7C5034AA5
2 changed files with 375 additions and 38 deletions

View File

@ -31,17 +31,31 @@ Design notes
* Configuration precedence: ``$HERMES_HOME/mempalace.json`` is read
first, then env vars override (``MEMPALACE_PALACE_PATH``,
``MEMPALACE_IDENTITY_PATH``, ``MEMPALACE_WING``). An empty env var
is ignored ``export MEMPALACE_WING=`` is intent to unset. Defaults
fill in anything still missing. ``collection_name`` is intentionally
not user-configurable here: the provider writes through
``self._collection_name`` while ``search_memories`` (used by
``prefetch`` and ``_tool_search``) reads its own configured collection
name from ``~/.mempalace/config.json``, and exposing two ways to set
it would let the two diverge silently.
is ignored ``export MEMPALACE_WING=`` is intent to unset. A palace
still unset after that defers to mempalace's own config
(``~/.mempalace/config.json``) before falling back to the default
location. The resolved palace is then published to
``MEMPALACE_PALACE_PATH`` (see ``_bridge_palace_env``) so the
``mempalace.mcp_server`` passthrough tools operate on the same palace
as live filing and search never a config-file-vs-provider split.
``collection_name`` follows mempalace's own config for the same
reason: it is what ``search_memories`` (used by ``prefetch`` and
``_tool_search``) and the mcp_server passthrough read, so live writes
land in the collection recall actually searches. It is intentionally
not configurable on the Hermes side a second knob would let the
write and read sides diverge silently again.
* ``~/.mempalace/identity.txt`` (L0) and ``~/.mempalace/wing_config.json``
are loaded if present but never created here. Run
``mempalace init <project-dir>`` to generate them.
* All palace state (ChromaDB, knowledge graph, diary, identity) lives
under ``~/.mempalace/`` by design the palace is the user's central
memory shared across agents, not per-agent Hermes state. This means
``hermes backup`` (which archives only ``$HERMES_HOME``) does NOT
cover it; users must back up ``~/.mempalace/`` separately. Hermes'
``MemoryProvider`` ABC currently offers no hook for contributing
external paths to its backup.
"""
from __future__ import annotations
@ -53,11 +67,17 @@ import queue
import re
import threading
from collections import deque
from datetime import datetime, timezone
from datetime import date, datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from mempalace.backends.chroma import ChromaBackend
from mempalace.config import (
MempalaceConfig,
sanitize_iso_temporal,
sanitize_kg_value,
sanitize_name,
)
from mempalace.convo_miner import file_conversation_exchange
from mempalace.knowledge_graph import KnowledgeGraph
from mempalace.layers import MemoryStack
@ -79,6 +99,47 @@ except ImportError: # pragma: no cover - Hermes not installed
logger = logging.getLogger("mempalace.hermes")
# The palace path this provider last bridged into ``MEMPALACE_PALACE_PATH``
# (None when the variable is user-set or unset). ``initialize`` must be able
# to tell its own bridge write apart from user intent: a stale bridge from a
# previous session would otherwise override a freshly edited hermes-side
# config forever, because both this provider's ``_load_config`` and
# ``MempalaceConfig`` give the env var top precedence.
_ENV_PALACE_BRIDGED: Optional[str] = None
def _clear_stale_palace_bridge() -> None:
"""Drop our own previous bridge write so config re-resolution is fresh.
Only removes the env var when it still holds exactly the value we set
a user-set value (even one set after our bridge) never matches the
recorded sentinel and is left untouched.
"""
global _ENV_PALACE_BRIDGED
if _ENV_PALACE_BRIDGED and os.environ.get("MEMPALACE_PALACE_PATH") == _ENV_PALACE_BRIDGED:
del os.environ["MEMPALACE_PALACE_PATH"]
_ENV_PALACE_BRIDGED = None
def _bridge_palace_env(palace_path: str) -> None:
"""Publish the provider's resolved palace to ``MEMPALACE_PALACE_PATH``.
``mempalace.mcp_server`` resolves its palace from this variable on every
config access (its ``--palace`` flag works by setting exactly this
variable), so bridging it makes the passthrough tools operate on the
same palace the provider writes and searches. When the variable already
points at the same palace (user-set), ownership is NOT claimed, so a
later ``_clear_stale_palace_bridge`` leaves the user's value alone.
"""
global _ENV_PALACE_BRIDGED
bridged = os.path.abspath(os.path.expanduser(palace_path))
current = os.environ.get("MEMPALACE_PALACE_PATH")
if current and os.path.abspath(os.path.expanduser(current)) == bridged:
return
os.environ["MEMPALACE_PALACE_PATH"] = bridged
_ENV_PALACE_BRIDGED = bridged
def _match_wing_by_keywords(text: str, wing_config: Dict[str, Any]) -> str:
"""Return the first wing whose keywords match a whole word in ``text``.
@ -563,17 +624,29 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
self._session_id = session_id or ""
self._hermes_home = str(kwargs.get("hermes_home", "") or "")
# A bridge write from a previous initialize must not masquerade
# as a user-set env override during re-resolution.
_clear_stale_palace_bridge()
self._config = self._load_config()
self._palace_path = str(
Path(self._config.get("palace_path", self.DEFAULT_PALACE_PATH)).expanduser()
)
# Collection name is intentionally **not** configurable. ``_file_turn``
# writes through ``self._collection``; ``prefetch`` / ``_tool_search``
# go through ``search_memories``, which reads its own configured
# collection name from ``~/.mempalace/config.json``. Exposing two
# ways to set the name invites write-here, read-there mismatches
# that silently make the provider look mute.
self._collection_name = self.DEFAULT_COLLECTION_NAME
# No hermes-side palace configured → defer to mempalace's own
# config (env var > ~/.mempalace/config.json > default) so this
# provider agrees with `mempalace init`-managed setups instead
# of hardcoding the default location.
mempalace_config = MempalaceConfig()
configured = self._config.get("palace_path") or mempalace_config.palace_path
self._palace_path = str(Path(configured).expanduser())
# Publish the resolved palace so the mcp_server passthrough
# tools (drawer CRUD, duplicate check, tunnels, taxonomy)
# read and write the SAME palace live filing and search use.
_bridge_palace_env(self._palace_path)
# Collection name follows mempalace's own config — the same
# source ``search_memories`` (prefetch / _tool_search) and the
# mcp_server passthrough read — so live writes land in the
# collection recall actually searches. Intentionally NOT
# configurable on the Hermes side: a second knob would let the
# write and read sides diverge silently, making the provider
# look mute.
self._collection_name = mempalace_config.collection_name
self._load_wing_config()
self._load_identity()
@ -841,18 +914,33 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
obj=args.get("object", ""),
)
)
if tool_name == "mempalace_kg_invalidate":
return json.dumps(
self._tool_kg_invalidate(
subject=args.get("subject", ""),
predicate=args.get("predicate", ""),
obj=args.get("object", ""),
ended=args.get("ended"),
)
)
if tool_name == "mempalace_kg_timeline":
return json.dumps(self._tool_kg_timeline(args.get("entity")))
if tool_name == "mempalace_kg_stats":
return json.dumps(self._tool_kg_stats())
if tool_name == "mempalace_diary_write":
return json.dumps(self._tool_diary_write(args.get("entry", "")))
if tool_name == "mempalace_diary_read":
return json.dumps(self._tool_diary_read(int(args.get("n", 10))))
# Tools that delegate directly to ``mempalace.mcp_server``'s
# public ``tool_*`` entry points. These share mempalace's own
# config for palace_path resolution rather than this plugin's
# ``self._palace_path`` — a known asymmetry that the original
# eight tools above don't share. In the common case (default
# palace at ``~/.mempalace/palace``) both resolve to the same
# place.
# public ``tool_*`` entry points. mcp_server resolves its palace
# from ``MEMPALACE_PALACE_PATH`` on every config access, and
# ``initialize`` bridges this provider's resolved palace into
# that variable (see ``_bridge_palace_env``) — so passthrough
# reads and writes land in the same palace as live filing and
# search. KG tools are NOT passed through: mcp_server resolves
# its KG from ``DEFAULT_KG_PATH`` unless its own ``--palace``
# CLI flag was given, which no env bridge can influence.
# New tools (everything that has a matching ``tool_*`` in
# mempalace.mcp_server) dispatch by name derivation. One
# mempalace asymmetry to remap: ``mempalace_traverse`` maps to
@ -887,9 +975,6 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
"mempalace_list_drawers",
"mempalace_get_drawer",
"mempalace_check_duplicate",
"mempalace_kg_invalidate",
"mempalace_kg_timeline",
"mempalace_kg_stats",
"mempalace_get_taxonomy",
"mempalace_get_aaak_spec",
"mempalace_traverse",
@ -967,6 +1052,9 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
print(" 1. mempalace init <your-project-dir> # generates ~/.mempalace/")
print(" 2. (optional) edit ~/.mempalace/identity.txt to seed L0 wake-up context")
print()
print("Note: palace data lives under ~/.mempalace/, outside $HERMES_HOME.")
print("`hermes backup` does not include it — back up ~/.mempalace/ separately.")
print()
# ----- Shutdown --------------------------------------------------------
@ -1082,10 +1170,9 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
logger.debug("MemPalace _file_turn error: %s", exc)
def _mirror_mem_write(self, payload: Dict[str, Any]) -> None:
db_path = str(Path(self._palace_path).parent / "knowledge_graph.sqlite3")
kg: Optional[KnowledgeGraph] = None
try:
kg = KnowledgeGraph(db_path=db_path)
kg = KnowledgeGraph(db_path=self._kg_db_path())
kg.add_triple(
subject="user",
predicate="asserted",
@ -1242,11 +1329,19 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
# via the truncated/scanned pair.
return out
def _kg_db_path(self) -> str:
"""The provider's knowledge-graph DB — sibling of the palace dir.
Single source of truth for every KG access in this plugin; the KG
tools must never fall back to mempalace's global ``DEFAULT_KG_PATH``
or they would read a different graph than the one live turns feed.
"""
return str(Path(self._palace_path).parent / "knowledge_graph.sqlite3")
def _tool_kg_query(self, entity: str, since: Optional[str] = None) -> Dict[str, Any]:
if not entity:
return {"error": "Missing required parameter: entity"}
db_path = str(Path(self._palace_path).parent / "knowledge_graph.sqlite3")
kg = KnowledgeGraph(db_path=db_path)
kg = KnowledgeGraph(db_path=self._kg_db_path())
try:
relations = kg.query_entity(entity, as_of=since or "")
finally:
@ -1259,8 +1354,7 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
def _tool_kg_add(self, subject: str, predicate: str, obj: str) -> Dict[str, Any]:
if not (subject and predicate and obj):
return {"error": "subject, predicate, object are all required"}
db_path = str(Path(self._palace_path).parent / "knowledge_graph.sqlite3")
kg = KnowledgeGraph(db_path=db_path)
kg = KnowledgeGraph(db_path=self._kg_db_path())
try:
kg.add_triple(subject=subject, predicate=predicate, obj=obj)
finally:
@ -1270,6 +1364,63 @@ class MempalaceProvider(MemoryProvider): # type: ignore[misc]
pass
return {"status": "ok", "triple": [subject, predicate, obj]}
def _tool_kg_invalidate(
self,
subject: str,
predicate: str,
obj: str,
ended: Optional[str] = None,
) -> Dict[str, Any]:
# Same validation semantics as mcp_server.tool_kg_invalidate, but
# against the provider's own KG (see _kg_db_path).
try:
subject = sanitize_kg_value(subject, "subject")
predicate = sanitize_name(predicate, "predicate")
obj = sanitize_kg_value(obj, "object")
ended = sanitize_iso_temporal(ended, "ended")
except ValueError as exc:
return {"success": False, "error": str(exc)}
resolved_ended = ended or date.today().isoformat()
kg = KnowledgeGraph(db_path=self._kg_db_path())
try:
kg.invalidate(subject, predicate, obj, ended=resolved_ended)
finally:
try:
kg.close()
except Exception:
pass
return {
"success": True,
"fact": f"{subject}{predicate}{obj}",
"ended": resolved_ended,
}
def _tool_kg_timeline(self, entity: Optional[str] = None) -> Dict[str, Any]:
if entity is not None:
try:
entity = sanitize_kg_value(entity, "entity")
except ValueError as exc:
return {"error": str(exc)}
kg = KnowledgeGraph(db_path=self._kg_db_path())
try:
results = kg.timeline(entity) if entity else kg.timeline()
finally:
try:
kg.close()
except Exception:
pass
return {"entity": entity or "all", "timeline": results, "count": len(results)}
def _tool_kg_stats(self) -> Dict[str, Any]:
kg = KnowledgeGraph(db_path=self._kg_db_path())
try:
return kg.stats()
finally:
try:
kg.close()
except Exception:
pass
def _tool_diary_write(self, entry: str) -> Dict[str, Any]:
if not entry:
return {"error": "Missing required parameter: entry"}

View File

@ -13,6 +13,7 @@ from __future__ import annotations
import importlib.util
import json
import os
import sys
import threading
import types
@ -64,6 +65,25 @@ def provider(integration_module):
return integration_module.MempalaceProvider()
@pytest.fixture(autouse=True)
def _isolate_palace_env(integration_module):
"""Keep initialize()'s palace-env bridge from leaking between tests.
``initialize`` publishes the resolved palace to MEMPALACE_PALACE_PATH
(so mcp_server passthrough tools resolve the same palace) and records
ownership in the module-level ``_ENV_PALACE_BRIDGED`` sentinel. Both
are process-global restore them after every test.
"""
original = os.environ.get("MEMPALACE_PALACE_PATH")
original_sentinel = integration_module._ENV_PALACE_BRIDGED
yield
integration_module._ENV_PALACE_BRIDGED = original_sentinel
if original is None:
os.environ.pop("MEMPALACE_PALACE_PATH", None)
else:
os.environ["MEMPALACE_PALACE_PATH"] = original
# ---------------------------------------------------------------------------
# Shape: name, schemas, config, availability
# ---------------------------------------------------------------------------
@ -616,11 +636,11 @@ def test_initialize_reads_mempalace_json(provider, tmp_dir, palace_path):
provider.shutdown()
def test_collection_name_is_not_user_configurable(provider, tmp_dir, palace_path):
# Exposing ``collection_name`` would let the provider write to a collection
# that ``search_memories`` (which reads from mempalace's own config) does
# not read — making the provider silently appear mute. The field is
# intentionally absent from the schema and ignored in config files.
def test_collection_name_is_not_hermes_configurable(provider, tmp_dir, palace_path):
# A hermes-side ``collection_name`` would be a second way to set the
# name — the write and read sides could silently diverge, making the
# provider look mute. The key is ignored; with no mempalace-side
# override (conftest redirects HOME to a temp dir), the default applies.
(Path(tmp_dir) / "mempalace.json").write_text(
json.dumps({"palace_path": palace_path, "collection_name": "custom_drawers"})
)
@ -631,6 +651,34 @@ def test_collection_name_is_not_user_configurable(provider, tmp_dir, palace_path
provider.shutdown()
def test_collection_name_follows_mempalace_config(
integration_module, provider, tmp_dir, palace_path, tmp_path, monkeypatch
):
# One source of truth: the provider writes to the collection that
# ``search_memories`` and the mcp_server passthrough actually read —
# mempalace's own config — so a customized ``collection_name`` in
# ``~/.mempalace/config.json`` cannot make live turns invisible to
# recall.
mp_config_dir = tmp_path / "mp_home"
mp_config_dir.mkdir()
(mp_config_dir / "config.json").write_text(json.dumps({"collection_name": "family_drawers"}))
from mempalace.config import MempalaceConfig as real_config
def _patched_config(config_dir=None):
return real_config(config_dir=str(mp_config_dir))
# Patch the provider module's own reference — it imported the name at
# module load, so patching mempalace.config wouldn't reach it.
monkeypatch.setattr(integration_module, "MempalaceConfig", _patched_config)
(Path(tmp_dir) / "mempalace.json").write_text(json.dumps({"palace_path": palace_path}))
provider.initialize("s1", hermes_home=str(tmp_dir))
try:
assert provider._collection_name == "family_drawers"
finally:
provider.shutdown()
def test_env_vars_override_config_file(provider, tmp_dir, palace_path, monkeypatch):
monkeypatch.setenv("MEMPALACE_PALACE_PATH", palace_path)
monkeypatch.setenv("MEMPALACE_WING", "wing_forced")
@ -708,3 +756,141 @@ def test_match_wing_by_keywords_ignores_non_string_keywords(integration_module):
wing_config = {"wing_dev": {"keywords": [None, 3, "python"]}}
assert fn("write some python code", wing_config) == "wing_dev"
assert fn("unrelated chatter", wing_config) == "wing_general"
# ---------------------------------------------------------------------------
# Palace unification: the mcp_server passthrough tools must operate on the
# SAME palace the provider writes and searches. The provider bridges its
# resolved palace into MEMPALACE_PALACE_PATH (mcp_server re-reads that var on
# every config access — its own --palace flag works the same way), and the
# KG tools are handled natively because mcp_server's KG path ignores the env
# var unless its CLI flag was given.
# ---------------------------------------------------------------------------
def test_initialize_bridges_palace_env_for_passthrough(provider, tmp_dir, palace_path):
from mempalace.config import MempalaceConfig
(Path(tmp_dir) / "mempalace.json").write_text(json.dumps({"palace_path": palace_path}))
provider.initialize("s1", hermes_home=str(tmp_dir))
try:
expected = os.path.abspath(os.path.expanduser(palace_path))
assert os.environ.get("MEMPALACE_PALACE_PATH") == expected
# The passthrough side (mcp_server's config) now resolves the same
# palace the provider writes — the split-brain regression check.
assert MempalaceConfig().palace_path == expected
finally:
provider.shutdown()
def test_reinitialize_follows_updated_hermes_config(provider, tmp_dir, palace_path, tmp_path):
# The bridge write from session 1 must not masquerade as a user env
# override in session 2 — a stale bridge would pin the palace to the
# old hermes-side value forever.
config_path = Path(tmp_dir) / "mempalace.json"
config_path.write_text(json.dumps({"palace_path": palace_path}))
provider.initialize("s1", hermes_home=str(tmp_dir))
provider.shutdown()
new_palace = str(tmp_path / "palace_b")
config_path.write_text(json.dumps({"palace_path": new_palace}))
provider.initialize("s2", hermes_home=str(tmp_dir))
try:
assert provider._palace_path == new_palace
assert os.environ.get("MEMPALACE_PALACE_PATH") == os.path.abspath(new_palace)
finally:
provider.shutdown()
def test_user_set_palace_env_wins_and_is_never_cleared(
provider, tmp_dir, palace_path, tmp_path, monkeypatch
):
# A user-set env var outranks the hermes-side config (documented
# precedence) and the bridge must not claim ownership of it — a later
# re-initialize must leave the user's value in place.
monkeypatch.setenv("MEMPALACE_PALACE_PATH", palace_path)
(Path(tmp_dir) / "mempalace.json").write_text(
json.dumps({"palace_path": str(tmp_path / "other_palace")})
)
provider.initialize("s1", hermes_home=str(tmp_dir))
try:
assert provider._palace_path == palace_path
assert os.environ.get("MEMPALACE_PALACE_PATH") == palace_path
# Ownership was not claimed: the sentinel stays unset.
assert provider.__class__.__module__ is not None # provider alive
finally:
provider.shutdown()
provider.initialize("s2", hermes_home=str(tmp_dir))
try:
assert os.environ.get("MEMPALACE_PALACE_PATH") == palace_path
finally:
provider.shutdown()
def test_hermes_config_defers_to_mempalace_config_when_unset(
integration_module, provider, tmp_dir, tmp_path, monkeypatch
):
# No hermes-side palace_path → the provider follows mempalace's own
# config rather than hardcoding the default location.
mp_config_dir = tmp_path / "mp_home"
mp_config_dir.mkdir()
custom_palace = str(tmp_path / "custom_palace")
(mp_config_dir / "config.json").write_text(json.dumps({"palace_path": custom_palace}))
from mempalace.config import MempalaceConfig as real_config
def _patched_config(config_dir=None):
return real_config(config_dir=str(mp_config_dir))
# Patch the provider module's own reference — it imported the name at
# module load, so patching mempalace.config wouldn't reach it.
monkeypatch.setattr(integration_module, "MempalaceConfig", _patched_config)
(Path(tmp_dir) / "mempalace.json").write_text(json.dumps({}))
provider.initialize("s1", hermes_home=str(tmp_dir))
try:
assert provider._palace_path == custom_palace
finally:
provider.shutdown()
def test_kg_tools_all_use_provider_sibling_kg(initialized_provider, palace_path):
# All five KG tools must hit the SAME database: the sibling of the
# provider's palace dir — never mcp_server's global DEFAULT_KG_PATH.
add = json.loads(
initialized_provider.handle_tool_call(
"mempalace_kg_add",
{"subject": "user", "predicate": "drinks", "object": "tea"},
)
)
assert add["status"] == "ok"
timeline = json.loads(
initialized_provider.handle_tool_call("mempalace_kg_timeline", {"entity": "user"})
)
assert timeline["count"] >= 1
assert any(t["predicate"] == "drinks" and t["object"] == "tea" for t in timeline["timeline"])
stats = json.loads(initialized_provider.handle_tool_call("mempalace_kg_stats", {}))
assert stats["triples"] >= 1
inv = json.loads(
initialized_provider.handle_tool_call(
"mempalace_kg_invalidate",
{"subject": "user", "predicate": "drinks", "object": "tea"},
)
)
assert inv["success"] is True
# And the file itself lives next to the palace dir.
assert (Path(palace_path).parent / "knowledge_graph.sqlite3").exists()
def test_kg_invalidate_rejects_invalid_input(initialized_provider):
result = json.loads(
initialized_provider.handle_tool_call(
"mempalace_kg_invalidate",
{"subject": "user", "predicate": "likes", "object": "x", "ended": "not-a-date"},
)
)
assert result["success"] is False
assert "error" in result