diff --git a/plugins/memory/honcho/__init__.py b/plugins/memory/honcho/__init__.py index 6ca32c1dcbb5..c11c14517b72 100644 --- a/plugins/memory/honcho/__init__.py +++ b/plugins/memory/honcho/__init__.py @@ -15,11 +15,13 @@ from __future__ import annotations +import hashlib import json import logging import re import threading import time +from pathlib import Path from typing import Any, Dict, List, Optional from agent.memory_provider import MemoryProvider @@ -268,6 +270,75 @@ def post_setup(self, hermes_home: str, config: dict) -> None: from plugins.memory.honcho.cli import cmd_setup cmd_setup(types.SimpleNamespace()) + @staticmethod + def _memory_backfill_state_path(hermes_home: Path) -> Path: + return hermes_home / "state" / "honcho_memory_backfill.json" + + @staticmethod + def _memory_backfill_fingerprint(memory_dir: Path) -> str: + hasher = hashlib.sha256() + found = False + for name in ("USER.md", "MEMORY.md", "SOUL.md"): + path = memory_dir / name + if not path.exists(): + continue + content = path.read_text(encoding="utf-8").strip() + if not content: + continue + found = True + hasher.update(name.encode("utf-8")) + hasher.update(b"\0") + hasher.update(content.encode("utf-8")) + hasher.update(b"\0") + return hasher.hexdigest() if found else "" + + def _load_memory_backfill_state(self, hermes_home: Path) -> dict[str, str]: + path = self._memory_backfill_state_path(hermes_home) + if not path.exists(): + return {} + try: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception: + return {} + return data if isinstance(data, dict) else {} + + def _save_memory_backfill_state(self, hermes_home: Path, state: dict[str, str]) -> None: + path = self._memory_backfill_state_path(hermes_home) + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(state, indent=2, sort_keys=True), encoding="utf-8") + + def _maybe_backfill_existing_session_memory(self, cfg, session) -> None: + if not self._manager or not self._session_key: + return + if cfg.session_strategy == "per-session": + return + if not getattr(session, "messages", None): + return + + from hermes_constants import get_hermes_home + + hermes_home = Path(get_hermes_home()) + memory_dir = hermes_home / "memories" + fingerprint = self._memory_backfill_fingerprint(memory_dir) + if not fingerprint: + return + + state = self._load_memory_backfill_state(hermes_home) + if state.get(self._session_key) == fingerprint: + logger.debug( + "Honcho existing-session memory backfill already completed for %s", + self._session_key, + ) + return + + if self._manager.migrate_memory_files(self._session_key, str(memory_dir)): + state[self._session_key] = fingerprint + self._save_memory_backfill_state(hermes_home, state) + logger.debug( + "Honcho existing-session memory backfill completed for %s", + self._session_key, + ) + def initialize(self, session_id: str, **kwargs) -> None: """Initialize Honcho session manager. @@ -392,6 +463,8 @@ def _do_session_init(self, cfg, session_id: str, **kwargs) -> None: "Honcho memory file migration skipped: per-session strategy creates a fresh session per run (%s)", self._session_key, ) + else: + self._maybe_backfill_existing_session_memory(cfg, session) except Exception as e: logger.debug("Honcho memory file migration skipped: %s", e) @@ -1153,6 +1226,13 @@ def handle_tool_call(self, tool_name: str, args: dict, **kwargs) -> str: return tool_error("Failed to update peer card.") return json.dumps({"result": f"Peer card updated ({len(result)} facts).", "card": result}) card = self._manager.get_peer_card(self._session_key, peer=peer) + if not card: + session_ctx = self._manager.get_session_context(self._session_key, peer=peer) + raw_card = session_ctx.get("card", "") if session_ctx else "" + if isinstance(raw_card, str): + card = [line.strip() for line in raw_card.splitlines() if line.strip()] + elif isinstance(raw_card, list): + card = [str(line).strip() for line in raw_card if str(line).strip()] if not card: return json.dumps({"result": "No profile facts available yet."}) return json.dumps({"result": card}) diff --git a/plugins/memory/honcho/session.py b/plugins/memory/honcho/session.py index 79625b5cd580..9bb2a0541278 100644 --- a/plugins/memory/honcho/session.py +++ b/plugins/memory/honcho/session.py @@ -988,10 +988,19 @@ def get_peer_card(self, session_key: str, peer: str = "user") -> list[str]: try: observer_peer_id, target_peer_id = self._resolve_observer_target(session, peer) - return self._fetch_peer_card(observer_peer_id, target=target_peer_id) + card = self._fetch_peer_card(observer_peer_id, target=target_peer_id) + if card: + return card except Exception as e: logger.debug("Failed to fetch peer card from Honcho: %s", e) - return [] + + ctx = self.get_session_context(session_key, peer=peer) + raw_card = ctx.get("card", "") if ctx else "" + if isinstance(raw_card, str): + return [line.strip() for line in raw_card.splitlines() if line.strip()] + if isinstance(raw_card, list): + return [str(line).strip() for line in raw_card if str(line).strip()] + return [] def search_context( self, diff --git a/tests/honcho_plugin/test_session.py b/tests/honcho_plugin/test_session.py index 254261183120..88dcd7602fa3 100644 --- a/tests/honcho_plugin/test_session.py +++ b/tests/honcho_plugin/test_session.py @@ -212,6 +212,28 @@ def test_get_peer_card_uses_direct_peer_lookup(self): assert mgr.get_peer_card(session.key) == ["Name: Robert"] assistant_peer.get_card.assert_called_once_with(target=session.user_peer_id) + def test_get_peer_card_falls_back_to_session_context_card(self): + mgr, session = self._make_cached_manager() + assistant_peer = MagicMock() + assistant_peer.get_card.return_value = None + mgr._get_or_create_peer = MagicMock(return_value=assistant_peer) + + honcho_session = MagicMock() + honcho_session.context.return_value = SimpleNamespace( + summary=None, + peer_representation="", + peer_card=["Name: Robert", "Prefers dark mode"], + messages=[], + ) + mgr._sessions_cache[session.honcho_session_id] = honcho_session + + assert mgr.get_peer_card(session.key) == ["Name: Robert", "Prefers dark mode"] + honcho_session.context.assert_called_once_with( + summary=True, + peer_target=session.user_peer_id, + peer_perspective=session.user_peer_id, + ) + def test_search_context_uses_assistant_perspective_with_target(self): mgr, session = self._make_cached_manager() assistant_peer = MagicMock() @@ -430,6 +452,26 @@ def test_honcho_profile_can_target_explicit_peer_id(self): assert "Role: Assistant" in result provider._manager.get_peer_card.assert_called_once_with("telegram:123", peer="hermes") + def test_honcho_profile_falls_back_to_session_context_card(self): + provider = HonchoMemoryProvider() + provider._session_initialized = True + provider._session_key = "telegram:123" + provider._manager = MagicMock() + provider._manager.get_peer_card.return_value = [] + provider._manager.get_session_context.return_value = { + "card": "Name: Robert\nPrefers dark mode", + } + + result = provider.handle_tool_call( + "honcho_profile", + {"peer": "user"}, + ) + + assert "Name: Robert" in result + assert "Prefers dark mode" in result + provider._manager.get_peer_card.assert_called_once_with("telegram:123", peer="user") + provider._manager.get_session_context.assert_called_once_with("telegram:123", peer="user") + def test_honcho_search_can_target_explicit_peer_id(self): provider = HonchoMemoryProvider() provider._session_initialized = True @@ -671,6 +713,47 @@ def test_migrate_runs_for_per_directory(self): mock_manager.migrate_memory_files.assert_called_once() +class TestExistingSessionBackfill: + def test_existing_session_with_empty_profile_backfills_memory_files_once(self): + from pathlib import Path + from tempfile import TemporaryDirectory + from unittest.mock import patch, MagicMock + + from plugins.memory.honcho.client import HonchoClientConfig + + cfg = HonchoClientConfig( + api_key="test-key", + enabled=True, + recall_mode="hybrid", + session_strategy="per-directory", + ) + provider = HonchoMemoryProvider() + + mock_manager = MagicMock() + mock_session = MagicMock() + mock_session.messages = [{"role": "user", "content": "hi"}] + mock_manager.get_or_create.return_value = mock_session + mock_manager.get_peer_card.return_value = [] + + with TemporaryDirectory() as tmpdir: + hermes_home = Path(tmpdir) + memories = hermes_home / "memories" + memories.mkdir() + (memories / "USER.md").write_text("Call Stephen 'Boss.'", encoding="utf-8") + + with patch("plugins.memory.honcho.client.HonchoClientConfig.from_global_config", return_value=cfg), \ + patch("plugins.memory.honcho.client.get_honcho_client", return_value=MagicMock()), \ + patch("plugins.memory.honcho.session.HonchoSessionManager", return_value=mock_manager), \ + patch("hermes_constants.get_hermes_home", return_value=hermes_home): + provider.initialize(session_id="test-session-001") + + mock_manager.migrate_memory_files.assert_called_once() + + provider2 = HonchoMemoryProvider() + provider2.initialize(session_id="test-session-001") + mock_manager.migrate_memory_files.assert_called_once() + + class TestChunkMessage: def test_short_message_single_chunk(self): result = HonchoMemoryProvider._chunk_message("hello world", 100)