"""Pheby conversation/session routing — maps Pheby conversations to Hermes sessions. Hermes remains the authoritative source of conversation state. Each Pheby conversation is one Hermes session reached through a stable ``SessionSource`` keyed ``pheby:dm:``. Pheby keeps only a thin, rebuildable mapping (conversation ID → display name) in ``pheby_conversations.json`` under HERMES_HOME; everything else (history, titles, tokens) is read from the SessionStore / SessionDB on demand. """ from __future__ import annotations import asyncio import json import logging from pathlib import Path from typing import Any, Dict, List, Optional from hermes_constants import get_hermes_home from . import protocol logger = logging.getLogger(__name__) PLATFORM_NAME = "pheby" _INDEX_FILE = "pheby_conversations.json" _INDEX_MAX_ENTRIES = 500 class ConversationRouter: """Maps stable Pheby conversation IDs to Hermes session sources.""" def __init__(self) -> None: self._lock = asyncio.Lock() self._names: Dict[str, str] = {} self._loaded = False # ── persistence ────────────────────────────────────────────────────── @property def _index_path(self) -> Path: return Path(get_hermes_home()) / _INDEX_FILE def _load(self) -> None: if self._loaded: return self._loaded = True try: if self._index_path.exists(): data = json.loads(self._index_path.read_text(encoding="utf-8")) if isinstance(data, dict): self._names = { str(k): str(v) for k, v in data.items() if isinstance(k, str) and isinstance(v, (str, int)) } except Exception: logger.warning("[pheby] conversation index unreadable", exc_info=True) def _save(self) -> None: try: # Bound the index; oldest-written entries lose (dict order). if len(self._names) > _INDEX_MAX_ENTRIES: keep = list(self._names.items())[-_INDEX_MAX_ENTRIES:] self._names = dict(keep) tmp = self._index_path.with_suffix(".tmp") tmp.write_text(json.dumps(self._names, ensure_ascii=False, indent=1), encoding="utf-8") import os os.replace(tmp, self._index_path) except Exception: logger.error("[pheby] failed to persist conversation index", exc_info=True) # ── ID management ──────────────────────────────────────────────────── async def ensure_conversation(self, conversation_id: str, name: Optional[str] = None) -> str: """Register a conversation ID (client-generated or server-new).""" async with self._lock: self._load() cid = str(conversation_id or protocol.new_id()) if not cid or len(cid) > 128: cid = protocol.new_id() if cid not in self._names: self._names[cid] = (name or "").strip() or "New chat" self._save() elif name: self._names[cid] = name.strip() self._save() return cid async def new_conversation(self, name: Optional[str] = None) -> str: return await self.ensure_conversation(protocol.new_id(), name) async def rename(self, conversation_id: str, name: str) -> bool: async with self._lock: self._load() cid = str(conversation_id) if cid not in self._names: return False self._names[cid] = (name or "").strip() or self._names[cid] self._save() return True async def forget(self, conversation_id: str) -> bool: """Remove the local index entry (session deletion is handled via DB).""" async with self._lock: self._load() existed = str(conversation_id) in self._names self._names.pop(str(conversation_id), None) if existed: self._save() return existed async def get_name(self, conversation_id: str) -> Optional[str]: async with self._lock: self._load() return self._names.get(str(conversation_id)) async def known_ids(self) -> List[str]: async with self._lock: self._load() return list(self._names.keys()) # ── session key ────────────────────────────────────────────────────── @staticmethod def session_key_for(conversation_id: str) -> str: """The Hermes gateway session key for a Pheby conversation. Session keys are built by ``gateway.session.build_session_key`` for DM sources as ``agent:main:pheby:dm:``. Conversation IDs are server/opaque-controlled (32-hex or client GUIDs validated below), so the chat_id component is safe to embed in the key. """ return f"agent:main:{PLATFORM_NAME}:dm:{conversation_id}" @staticmethod def is_valid_conversation_id(value: Any) -> bool: """Accept opaque IDs up to 128 chars from a safe alphabet.""" if not isinstance(value, str) or not value or len(value) > 128: return False allowed = set( "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789-_" ) return all(c in allowed for c in value) __all__ = ["ConversationRouter", "PLATFORM_NAME"]