53ffdb8aa2
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.
149 lines
5.8 KiB
Python
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"]
|