Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
80 changes: 80 additions & 0 deletions plugins/memory/honcho/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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})
Expand Down
13 changes: 11 additions & 2 deletions plugins/memory/honcho/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
83 changes: 83 additions & 0 deletions tests/honcho_plugin/test_session.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
Expand Down
Loading