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]: