"""Pheby protocol v1 — message types, error codes, and (de)serialization helpers. The protocol is JSON-over-WebSocket. Every message (both directions) has a ``type`` field. Client → server requests may carry a ``request_id`` (any string) which is echoed on the direct reply so the client can correlate RPC-style calls. Server → client events are broadcast to all authenticated connections and carry the IDs needed to associate them with a conversation, message, run, tool call, approval, clarification, or attachment. This module is intentionally dependency-free (stdlib only) so it can be unit tested without Hermes/aiohttp installed. """ from __future__ import annotations import json import logging import re import uuid from datetime import datetime, timezone from typing import Any, Dict, Optional, Tuple logger = logging.getLogger(__name__) # ── Protocol version ───────────────────────────────────────────────────────── PROTOCOL_VERSION = 1 # Bump when a wire-incompatible change lands. Clients negotiate via the # ``hello`` handshake; the server refuses mismatches with a ``version_mismatch`` # error instead of guessing. # ── Limits ─────────────────────────────────────────────────────────────────── MAX_TEXT_CHARS = 64_000 # client chat message body limit MAX_WS_MESSAGE_BYTES = 2 * 1024 * 1024 # inbound WebSocket frame cap (aiohttp) MAX_HISTORY_MESSAGES = 500 # per conversation.open fetch cap AUTH_TIMEOUT_SECONDS = 10.0 # hello must arrive within this window AUTH_FAILURE_LOCKOUT_SECONDS = 60.0 # repeated auth failures lock the source AUTH_FAILURE_THRESHOLD = 5 # failures before lockout # ── Error codes (machine-readable) ─────────────────────────────────────────── ERR_UNAUTHORIZED = "unauthorized" ERR_AUTH_TIMEOUT = "auth_timeout" ERR_VERSION_MISMATCH = "version_mismatch" ERR_BAD_REQUEST = "bad_request" ERR_INVALID_JSON = "invalid_json" ERR_UNKNOWN_TYPE = "unknown_type" ERR_NOT_FOUND = "not_found" ERR_CONVERSATION_NOT_FOUND = "conversation_not_found" ERR_APPROVAL_NOT_FOUND = "approval_not_found" ERR_CLARIFY_NOT_FOUND = "clarify_not_found" ERR_RUN_ACTIVE = "run_active" ERR_TOO_LARGE = "too_large" ERR_RATE_LIMITED = "rate_limited" ERR_INTERNAL = "internal_error" ERR_NOT_IMPLEMENTED = "not_implemented" # ── Client → server message types ──────────────────────────────────────────── C_HELLO = "hello" C_PING = "ping" C_CONVERSATION_LIST = "conversation.list" C_CONVERSATION_OPEN = "conversation.open" C_CONVERSATION_CREATE = "conversation.create" C_CONVERSATION_RENAME = "conversation.rename" C_CONVERSATION_DELETE = "conversation.delete" C_CHAT_SEND = "chat.send" C_RUN_CANCEL = "run.cancel" C_APPROVAL_RESPOND = "approval.respond" C_CLARIFY_RESPOND = "clarify.respond" C_MODELS_LIST = "models.list" C_MODEL_SET = "model.set" C_MODEL_CURRENT = "models.current" C_REASONING_SET = "reasoning.set" C_REASONING_CURRENT = "reasoning.current" C_YOLO_SET = "yolo.set" C_YOLO_CURRENT = "yolo.current" # ── Server → client message types ──────────────────────────────────────────── S_READY = "ready" S_PONG = "pong" S_ERROR = "error" S_CONVERSATION_SNAPSHOT = "conversation.snapshot" # reply to conversation.list S_CONVERSATION_CREATED = "conversation.created" S_CONVERSATION_RENAMED = "conversation.renamed" S_CONVERSATION_UPDATED = "conversation.updated" # auto-title etc. S_CONVERSATION_DELETED = "conversation.deleted" S_CONVERSATION_HISTORY = "conversation.history" # reply to conversation.open S_RUN_ACCEPTED = "run.accepted" S_RUN_FINISHED = "run.finished" S_MESSAGE_START = "message.start" # streaming draft opened S_MESSAGE_DELTA = "message.delta" # cumulative streamed draft text S_MESSAGE_COMPLETE = "message.complete" # final assistant message S_TOOL_EVENT = "tool.event" # structured tool activity S_APPROVAL_REQUEST = "approval.request" S_APPROVAL_RESOLVED = "approval.resolved" S_CLARIFY_REQUEST = "clarify.request" S_CLARIFY_RESOLVED = "clarify.resolved" S_ATTACHMENT_ADDED = "attachment.added" S_MODELS_SNAPSHOT = "models.snapshot" # reply to models.list S_MODEL_CURRENT_SNAPSHOT = "model.current" # reply to models.current S_MODEL_CHANGED = "model.changed" # after model.set accepted S_REASONING_SNAPSHOT = "reasoning.snapshot" # reply to reasoning.current S_REASONING_CHANGED = "reasoning.changed" # after reasoning.set accepted S_YOLO_SNAPSHOT = "yolo.snapshot" # reply to yolo.current S_YOLO_CHANGED = "yolo.changed" # after yolo.set accepted # Reasoning effort levels supported by Hermes (hermes_constants). REASONING_EFFORTS = ("minimal", "low", "medium", "high", "xhigh", "max", "ultra") _ATTACHMENT_ID_RE = re.compile(r"^[0-9a-f]{32}$") def now_iso() -> str: """UTC timestamp in ISO-8601 format.""" return datetime.now(timezone.utc).isoformat() def new_id() -> str: """Generate an opaque 32-hex identifier (also the attachment ID shape).""" return uuid.uuid4().hex def is_valid_attachment_id(value: str) -> bool: """True when *value* looks like one of our opaque attachment IDs.""" return bool(isinstance(value, str) and _ATTACHMENT_ID_RE.match(value)) def encode_message(payload: Dict[str, Any]) -> str: """Serialize a protocol message to a JSON string (compact, UTF-8).""" return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) def decode_message(raw: Any) -> Tuple[Optional[Dict[str, Any]], Optional[str]]: """Parse one inbound WebSocket text frame. Returns ``(message, None)`` on success or ``(None, error_code)`` when the frame is not a valid JSON object. Never raises. """ try: data = json.loads(raw) except (json.JSONDecodeError, UnicodeDecodeError, TypeError, ValueError): return None, ERR_INVALID_JSON if not isinstance(data, dict): return None, ERR_INVALID_JSON if not isinstance(data.get("type"), str) or not data["type"]: return None, ERR_INVALID_JSON return data, None def error_event(code: str, message: str, request_id: Optional[str] = None, **extra: Any) -> Dict[str, Any]: """Build a server → client error event with a machine-readable code.""" event: Dict[str, Any] = { "type": S_ERROR, "error": {"code": code, "message": str(message)[:500]}, "ts": now_iso(), } if request_id is not None: event["request_id"] = request_id if extra: event.update(extra) return event def safe_str(value: Any, max_len: int = 500) -> str: """Coerce *value* to a bounded string for logs and summaries.""" if value is None: return "" text = value if isinstance(value, str) else json.dumps( value, ensure_ascii=False, default=str) return text[:max_len] __all__ = [ "PROTOCOL_VERSION", "MAX_TEXT_CHARS", "MAX_WS_MESSAGE_BYTES", "MAX_HISTORY_MESSAGES", "AUTH_TIMEOUT_SECONDS", "AUTH_FAILURE_LOCKOUT_SECONDS", "AUTH_FAILURE_THRESHOLD", "ERR_UNAUTHORIZED", "ERR_AUTH_TIMEOUT", "ERR_VERSION_MISMATCH", "ERR_BAD_REQUEST", "ERR_INVALID_JSON", "ERR_UNKNOWN_TYPE", "ERR_NOT_FOUND", "ERR_CONVERSATION_NOT_FOUND", "ERR_APPROVAL_NOT_FOUND", "ERR_CLARIFY_NOT_FOUND", "ERR_RUN_ACTIVE", "ERR_TOO_LARGE", "ERR_RATE_LIMITED", "ERR_INTERNAL", "ERR_NOT_IMPLEMENTED", "C_HELLO", "C_PING", "C_CONVERSATION_LIST", "C_CONVERSATION_OPEN", "C_CONVERSATION_CREATE", "C_CONVERSATION_RENAME", "C_CONVERSATION_DELETE", "C_CHAT_SEND", "C_RUN_CANCEL", "C_APPROVAL_RESPOND", "C_CLARIFY_RESPOND", "C_MODELS_LIST", "C_MODEL_SET", "C_MODEL_CURRENT", "C_REASONING_SET", "C_REASONING_CURRENT", "C_YOLO_SET", "C_YOLO_CURRENT", "S_READY", "S_PONG", "S_ERROR", "S_CONVERSATION_SNAPSHOT", "S_CONVERSATION_CREATED", "S_CONVERSATION_RENAMED", "S_CONVERSATION_UPDATED", "S_CONVERSATION_DELETED", "S_CONVERSATION_HISTORY", "S_RUN_ACCEPTED", "S_RUN_FINISHED", "S_MESSAGE_START", "S_MESSAGE_DELTA", "S_MESSAGE_COMPLETE", "S_TOOL_EVENT", "S_APPROVAL_REQUEST", "S_APPROVAL_RESOLVED", "S_CLARIFY_REQUEST", "S_CLARIFY_RESOLVED", "S_ATTACHMENT_ADDED", "S_MODELS_SNAPSHOT", "S_MODEL_CURRENT_SNAPSHOT", "S_MODEL_CHANGED", "S_REASONING_SNAPSHOT", "S_REASONING_CHANGED", "S_YOLO_SNAPSHOT", "S_YOLO_CHANGED", "REASONING_EFFORTS", "now_iso", "new_id", "is_valid_attachment_id", "encode_message", "decode_message", "error_event", "safe_str", ]