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.
This commit is contained in:
@@ -0,0 +1,148 @@
|
||||
"""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"]
|
||||
Reference in New Issue
Block a user