Files
pheby-hermes-platform-adapter/plugin/pheby/conversations.py
T
Pheby 53ffdb8aa2 feat: Pheby platform adapter/plugin for Hermes (protocol v1)
Third-party Hermes platform plugin serving the Pheby WebSocket+HTTPS
protocol for the native Android client, behind Caddy:

- Platform adapter (BasePlatformAdapter subclass) registered via the
  documented plugin system (plugins.platforms pattern, kind: platform)
- Multiple conversations mapped to Hermes sessions (authoritative state)
- Streaming chat via GatewayStreamConsumer edit path (message.delta)
- Structured tool events (never fake tool text in chat), incl.
  post_tool_call hook relay with Hermes tool_call_ids
- Native approval + clarification round-trips via tools.approval /
  tools.clarify_gateway primitives
- Attachment delivery: adapter-owned copies, opaque IDs, persistent
  metadata, 7-day retention + safe cleanup, authenticated HTTPS download
- Model + reasoning-effort query/change via Hermes picker data and
  session/global overrides
- Shared-secret auth (constant-time, lockout), size limits, path-
  traversal-proof attachment resolution, minimal health endpoint
- Protocol v1 spec with JSON examples (docs/PROTOCOL.md)
- Install/config/Caddy/security docs + discovered Hermes limitations
- 37 passing tests (auth, conversations, protocol, attachments incl.
  expiry/traversal, tool events, approvals, clarifications, cancel,
  reconnect re-sync, live WS smoke tests) — gateway-free fakes

No Hermes core modifications required.
2026-09-02 19:31:17 +00:00

149 lines
5.8 KiB
Python

"""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:<conversation_id>``. 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:<chat_id>``. 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"]