From 8a384628a5b7628995e4c209fdd33e983f5a0f6e Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Fri, 3 Apr 2026 13:10:11 -0700 Subject: [PATCH] fix(memory): profile-scoped memory isolation and clone support (#4845) Three fixes for memory+profile isolation bugs: 1. memory_tool.py: Replace module-level MEMORY_DIR constant with get_memory_dir() function that calls get_hermes_home() dynamically. The old constant was cached at import time and could go stale if HERMES_HOME changed after import. Internal MemoryStore methods now call get_memory_dir() directly. MEMORY_DIR kept as backward-compat alias. 2. profiles.py: profile create --clone now copies MEMORY.md and USER.md from the source profile. These curated memory files are part of the agent's identity (same as SOUL.md) and should carry over on clone. 3. holographic plugin: initialize() now expands $HERMES_HOME and ${HERMES_HOME} in the db_path config value, so users can write 'db_path: $HERMES_HOME/memory_store.db' and it resolves to the active profile directory, not the default home. Tests updated to mock get_memory_dir() alongside the legacy MEMORY_DIR. --- gateway/run.py | 5 ++-- hermes_cli/profiles.py | 16 +++++++++++ plugins/memory/holographic/__init__.py | 11 ++++++-- .../gateway/test_flush_memory_stale_guard.py | 10 +++---- tests/tools/test_memory_tool.py | 3 ++ tools/memory_tool.py | 28 +++++++++++++------ 6 files changed, 56 insertions(+), 17 deletions(-) diff --git a/gateway/run.py b/gateway/run.py index 82f5e8036..58c52f4b4 100644 --- a/gateway/run.py +++ b/gateway/run.py @@ -667,12 +667,13 @@ class GatewayRunner: # what's already saved and avoid overwriting newer entries. _current_memory = "" try: - from tools.memory_tool import MEMORY_DIR + from tools.memory_tool import get_memory_dir + _mem_dir = get_memory_dir() for fname, label in [ ("MEMORY.md", "MEMORY (your personal notes)"), ("USER.md", "USER PROFILE (who the user is)"), ]: - fpath = MEMORY_DIR / fname + fpath = _mem_dir / fname if fpath.exists(): content = fpath.read_text(encoding="utf-8").strip() if content: diff --git a/hermes_cli/profiles.py b/hermes_cli/profiles.py index e4ffcc30b..bb3f6b994 100644 --- a/hermes_cli/profiles.py +++ b/hermes_cli/profiles.py @@ -51,6 +51,14 @@ _CLONE_CONFIG_FILES = [ "SOUL.md", ] +# Subdirectory files copied during --clone (path relative to profile root). +# Memory files are part of the agent's curated identity — just as important +# as SOUL.md for continuity when cloning a profile. +_CLONE_SUBDIR_FILES = [ + "memories/MEMORY.md", + "memories/USER.md", +] + # Runtime files stripped after --clone-all (shouldn't carry over) _CLONE_ALL_STRIP = [ "gateway.pid", @@ -428,6 +436,14 @@ def create_profile( if src.exists(): shutil.copy2(src, profile_dir / filename) + # Clone memory and other subdirectory files + for relpath in _CLONE_SUBDIR_FILES: + src = source_dir / relpath + if src.exists(): + dst = profile_dir / relpath + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(src, dst) + return profile_dir diff --git a/plugins/memory/holographic/__init__.py b/plugins/memory/holographic/__init__.py index 1b047a644..4ee797fcd 100644 --- a/plugins/memory/holographic/__init__.py +++ b/plugins/memory/holographic/__init__.py @@ -8,7 +8,7 @@ Original plugin by dusterbloom (PR #2351), adapted to the MemoryProvider ABC. Config in $HERMES_HOME/config.yaml (profile-scoped): plugins: hermes-memory-store: - db_path: $HERMES_HOME/memory_store.db + db_path: $HERMES_HOME/memory_store.db # omit to use the default auto_extract: false default_trust: 0.5 min_trust_threshold: 0.3 @@ -156,8 +156,15 @@ class HolographicMemoryProvider(MemoryProvider): def initialize(self, session_id: str, **kwargs) -> None: from hermes_constants import get_hermes_home - _default_db = str(get_hermes_home() / "memory_store.db") + _hermes_home = str(get_hermes_home()) + _default_db = _hermes_home + "/memory_store.db" db_path = self._config.get("db_path", _default_db) + # Expand $HERMES_HOME in user-supplied paths so config values like + # "$HERMES_HOME/memory_store.db" or "~/.hermes/memory_store.db" both + # resolve to the active profile's directory. + if isinstance(db_path, str): + db_path = db_path.replace("$HERMES_HOME", _hermes_home) + db_path = db_path.replace("${HERMES_HOME}", _hermes_home) default_trust = float(self._config.get("default_trust", 0.5)) hrr_dim = int(self._config.get("hrr_dim", 1024)) hrr_weight = float(self._config.get("hrr_weight", 0.3)) diff --git a/tests/gateway/test_flush_memory_stale_guard.py b/tests/gateway/test_flush_memory_stale_guard.py index 9f1722fc2..6a43817ce 100644 --- a/tests/gateway/test_flush_memory_stale_guard.py +++ b/tests/gateway/test_flush_memory_stale_guard.py @@ -95,7 +95,7 @@ class TestMemoryInjection: with ( patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}), patch("gateway.run._resolve_gateway_model", return_value="test-model"), - patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=memory_dir)}), + patch.dict("sys.modules", {"tools.memory_tool": MagicMock(get_memory_dir=lambda: memory_dir)}), ): runner._flush_memories_for_session("session_123") @@ -119,7 +119,7 @@ class TestMemoryInjection: with ( patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}), patch("gateway.run._resolve_gateway_model", return_value="test-model"), - patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=empty_dir)}), + patch.dict("sys.modules", {"tools.memory_tool": MagicMock(get_memory_dir=lambda: empty_dir)}), ): runner._flush_memories_for_session("session_456") @@ -140,7 +140,7 @@ class TestMemoryInjection: with ( patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}), patch("gateway.run._resolve_gateway_model", return_value="test-model"), - patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=memory_dir)}), + patch.dict("sys.modules", {"tools.memory_tool": MagicMock(get_memory_dir=lambda: memory_dir)}), ): runner._flush_memories_for_session("session_789") @@ -171,7 +171,7 @@ class TestFlushAgentSilenced: with ( patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}), patch("gateway.run._resolve_gateway_model", return_value="test-model"), - patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=tmp_path)}), + patch.dict("sys.modules", {"tools.memory_tool": MagicMock(get_memory_dir=lambda: tmp_path)}), ): runner._flush_memories_for_session("session_silent") @@ -213,7 +213,7 @@ class TestFlushPromptStructure: with ( patch("gateway.run._resolve_runtime_agent_kwargs", return_value={"api_key": "k"}), patch("gateway.run._resolve_gateway_model", return_value="test-model"), - patch.dict("sys.modules", {"tools.memory_tool": MagicMock(MEMORY_DIR=Path("/nonexistent"))}), + patch.dict("sys.modules", {"tools.memory_tool": MagicMock(get_memory_dir=lambda: Path("/nonexistent"))}), ): runner._flush_memories_for_session("session_struct") diff --git a/tests/tools/test_memory_tool.py b/tests/tools/test_memory_tool.py index 48cb6a83c..52147dd2c 100644 --- a/tests/tools/test_memory_tool.py +++ b/tests/tools/test_memory_tool.py @@ -93,6 +93,7 @@ class TestScanMemoryContent: def store(tmp_path, monkeypatch): """Create a MemoryStore with temp storage.""" monkeypatch.setattr("tools.memory_tool.MEMORY_DIR", tmp_path) + monkeypatch.setattr("tools.memory_tool.get_memory_dir", lambda: tmp_path) s = MemoryStore(memory_char_limit=500, user_char_limit=300) s.load_from_disk() return s @@ -186,6 +187,7 @@ class TestMemoryStoreRemove: class TestMemoryStorePersistence: def test_save_and_load_roundtrip(self, tmp_path, monkeypatch): monkeypatch.setattr("tools.memory_tool.MEMORY_DIR", tmp_path) + monkeypatch.setattr("tools.memory_tool.get_memory_dir", lambda: tmp_path) store1 = MemoryStore() store1.load_from_disk() @@ -199,6 +201,7 @@ class TestMemoryStorePersistence: def test_deduplication_on_load(self, tmp_path, monkeypatch): monkeypatch.setattr("tools.memory_tool.MEMORY_DIR", tmp_path) + monkeypatch.setattr("tools.memory_tool.get_memory_dir", lambda: tmp_path) # Write file with duplicates mem_file = tmp_path / "MEMORY.md" mem_file.write_text("duplicate entry\n§\nduplicate entry\n§\nunique entry") diff --git a/tools/memory_tool.py b/tools/memory_tool.py index 2d687e94d..91924f66b 100644 --- a/tools/memory_tool.py +++ b/tools/memory_tool.py @@ -36,8 +36,18 @@ from typing import Dict, Any, List, Optional logger = logging.getLogger(__name__) -# Where memory files live -MEMORY_DIR = get_hermes_home() / "memories" +# Where memory files live — resolved dynamically so profile overrides +# (HERMES_HOME env var changes) are always respected. The old module-level +# constant was cached at import time and could go stale if a profile switch +# happened after the first import. +def get_memory_dir() -> Path: + """Return the profile-scoped memories directory.""" + return get_hermes_home() / "memories" + +# Backward-compatible alias — gateway/run.py imports this at runtime inside +# a function body, so it gets the correct snapshot for that process. New code +# should prefer get_memory_dir(). +MEMORY_DIR = get_memory_dir() ENTRY_DELIMITER = "\n§\n" @@ -108,10 +118,11 @@ class MemoryStore: def load_from_disk(self): """Load entries from MEMORY.md and USER.md, capture system prompt snapshot.""" - MEMORY_DIR.mkdir(parents=True, exist_ok=True) + mem_dir = get_memory_dir() + mem_dir.mkdir(parents=True, exist_ok=True) - self.memory_entries = self._read_file(MEMORY_DIR / "MEMORY.md") - self.user_entries = self._read_file(MEMORY_DIR / "USER.md") + self.memory_entries = self._read_file(mem_dir / "MEMORY.md") + self.user_entries = self._read_file(mem_dir / "USER.md") # Deduplicate entries (preserves order, keeps first occurrence) self.memory_entries = list(dict.fromkeys(self.memory_entries)) @@ -143,9 +154,10 @@ class MemoryStore: @staticmethod def _path_for(target: str) -> Path: + mem_dir = get_memory_dir() if target == "user": - return MEMORY_DIR / "USER.md" - return MEMORY_DIR / "MEMORY.md" + return mem_dir / "USER.md" + return mem_dir / "MEMORY.md" def _reload_target(self, target: str): """Re-read entries from disk into in-memory state. @@ -158,7 +170,7 @@ class MemoryStore: def save_to_disk(self, target: str): """Persist entries to the appropriate file. Called after every mutation.""" - MEMORY_DIR.mkdir(parents=True, exist_ok=True) + get_memory_dir().mkdir(parents=True, exist_ok=True) self._write_file(self._path_for(target), self._entries_for(target)) def _entries_for(self, target: str) -> List[str]: