From 05d02a65018112e54dae87c1f589d26bfe8a43fd Mon Sep 17 00:00:00 2001 From: BarnacleBoy Date: Sat, 23 May 2026 00:02:22 +0000 Subject: [PATCH] feat: add mascot dashboard plugin with animated sprites --- plugins/mascot/README.md | 85 ++++ plugins/mascot/__init__.py | 1 + plugins/mascot/dashboard/__init__.py | 2 + plugins/mascot/dashboard/dist/index.js | 447 ++++++++++++++++++ plugins/mascot/dashboard/dist/style.css | 227 +++++++++ plugins/mascot/dashboard/manifest.json | 15 + plugins/mascot/dashboard/plugin_api.py | 200 ++++++++ .../mascot/dashboard/static/sprites/.gitkeep | 3 + .../dashboard/static/sprites/hermes_error.png | Bin 0 -> 261 bytes .../dashboard/static/sprites/hermes_idle.png | Bin 0 -> 263 bytes .../static/sprites/hermes_thinking.png | Bin 0 -> 264 bytes .../static/sprites/hermes_waiting_input.png | Bin 0 -> 264 bytes .../static/sprites/hermes_working.png | Bin 0 -> 263 bytes plugins/mascot/mascot_state.py | 185 ++++++++ plugins/mascot/test_mascot_state.py | 157 ++++++ 15 files changed, 1322 insertions(+) create mode 100644 plugins/mascot/README.md create mode 100644 plugins/mascot/__init__.py create mode 100644 plugins/mascot/dashboard/__init__.py create mode 100644 plugins/mascot/dashboard/dist/index.js create mode 100644 plugins/mascot/dashboard/dist/style.css create mode 100644 plugins/mascot/dashboard/manifest.json create mode 100644 plugins/mascot/dashboard/plugin_api.py create mode 100644 plugins/mascot/dashboard/static/sprites/.gitkeep create mode 100644 plugins/mascot/dashboard/static/sprites/hermes_error.png create mode 100644 plugins/mascot/dashboard/static/sprites/hermes_idle.png create mode 100644 plugins/mascot/dashboard/static/sprites/hermes_thinking.png create mode 100644 plugins/mascot/dashboard/static/sprites/hermes_waiting_input.png create mode 100644 plugins/mascot/dashboard/static/sprites/hermes_working.png create mode 100644 plugins/mascot/mascot_state.py create mode 100644 plugins/mascot/test_mascot_state.py diff --git a/plugins/mascot/README.md b/plugins/mascot/README.md new file mode 100644 index 000000000..a88beab73 --- /dev/null +++ b/plugins/mascot/README.md @@ -0,0 +1,85 @@ +# Mascot Plugin + +Animated agent mascot with real-time state tracking for the Hermes dashboard. + +## Features + +- **Real-time state updates**: WebSocket connection streams state changes instantly +- **Automatic fallback**: Polls REST API if WebSocket fails +- **5 animation states**: idle, thinking, working, waiting_input, error +- **Tab + sidebar widget**: Full view and compact sidebar slot +- **SVG fallback**: Placeholder works without sprite files + +## Installation + +The plugin is bundled with Hermes Agent. Enable it in your dashboard config. + +## Usage + +The mascot automatically displays the agent's current state: + +- **idle**: Agent is waiting for input +- **thinking**: Agent is processing a message +- **working**: Agent is executing a task +- **waiting_input**: Agent needs user confirmation +- **error**: Agent encountered an error + +### Manual State Control + +Visit the Mascot tab in the dashboard to manually set states for testing. + +## Sprite Files + +Place PNG/GIF sprites in `plugins/mascot/static/sprites/`: + +``` +hermes_idle.png +hermes_thinking.png +hermes_working.png +hermes_waiting_input.png +hermes_error.png +``` + +If sprite files are missing, an SVG placeholder is displayed automatically. + +## API Endpoints + +| Endpoint | Method | Description | +|----------|--------|-------------| +| `/api/plugins/mascot/state` | GET | Current mascot state | +| `/api/plugins/mascot/state` | POST | Update state (status, task, mood) | +| `/api/plugins/mascot/reset` | POST | Reset to idle state | +| `/api/plugins/mascot/events` | WS | WebSocket stream of state changes | + +## WebSocket Protocol + +Server sends: +```json +{"type": "state", "state": {"status": "thinking", "task": "...", "mood": null, "last_update": 1234567890.123}} +``` + +Client connects with session token: +``` +ws://localhost:9119/api/plugins/mascot/events?token= +``` + +## Agent Loop Integration + +To emit mascot state changes from your code: + +```python +from plugins.mascot.mascot_state import get_manager + +manager = get_manager() + +# Set status +manager.set_state(status="thinking", task="Processing message") + +# Working on something +manager.set_state(status="working", task="Running tests") + +# Done +manager.reset() # Returns to idle +``` + +Subscribers (like the WebSocket broadcaster) receive notifications automatically. \ No newline at end of file diff --git a/plugins/mascot/__init__.py b/plugins/mascot/__init__.py new file mode 100644 index 000000000..7a8280971 --- /dev/null +++ b/plugins/mascot/__init__.py @@ -0,0 +1 @@ +name = "mascot" \ No newline at end of file diff --git a/plugins/mascot/dashboard/__init__.py b/plugins/mascot/dashboard/__init__.py new file mode 100644 index 000000000..95ffb0ee2 --- /dev/null +++ b/plugins/mascot/dashboard/__init__.py @@ -0,0 +1,2 @@ +# Mascot dashboard plugin +from .plugin_api import router # noqa: F401 \ No newline at end of file diff --git a/plugins/mascot/dashboard/dist/index.js b/plugins/mascot/dashboard/dist/index.js new file mode 100644 index 000000000..746d16db7 --- /dev/null +++ b/plugins/mascot/dashboard/dist/index.js @@ -0,0 +1,447 @@ +/** + * Hermes Mascot — Dashboard Plugin + * + * Animated agent mascot with real-time state tracking. + * Connects to backend WebSocket for live state updates. + * Falls back to polling if WebSocket fails. + * + * Backend API uses agent_state and task_description. + * Frontend normalizes to status/task for internal use. + */ +(function () { + "use strict"; + + const SDK = window.__HERMES_PLUGIN_SDK__; + if (!SDK || !window.__HERMES_PLUGINS__) return; + + const { React } = SDK; + const h = React.createElement; + const { Card, CardContent, Badge } = SDK.components; + const { useState, useEffect, useCallback, useRef } = SDK.hooks; + + // Valid state values matching backend AgentState enum + const VALID_STATES = ["idle", "thinking", "working", "waiting_input", "error"]; + const STATE_LABELS = { + idle: "Idle", + thinking: "Thinking...", + working: "Working", + waiting_input: "Waiting for input", + error: "Error", + }; + + const API = "/api/plugins/mascot"; + + // Convert backend response to internal state + function normalizeState(backendState) { + return { + status: backendState.agent_state || "idle", + task: backendState.task_description || null, + mood: backendState.mood || null, + session_id: backendState.session_id || null, + last_update: backendState.timestamp ? new Date(backendState.timestamp).getTime() / 1000 : Date.now() / 1000, + }; + } + + // Convert internal state to backend request + function toBackendState(internalState) { + return { + agent_state: internalState.status, + task_description: internalState.task, + mood: internalState.mood, + }; + } + + // --------------------------------------------------------------------------- + // SVG Placeholder (development/fallback) + // --------------------------------------------------------------------------- + + function MascotPlaceholder({ state, size = 96 }) { + const colors = { + idle: "#4ade80", + thinking: "#60a5fa", + working: "#fbbf24", + waiting_input: "#a78bfa", + error: "#f87171", + }; + const color = colors[state] || colors.idle; + + const animateProps = state === "thinking" || state === "working" + ? { className: "mascot-pulse" } + : {}; + + return h("svg", { + viewBox: "0 0 64 64", + width: size, + height: size, + className: "mascot-svg", + ...animateProps, + }, + // Background circle + h("circle", { + cx: 32, cy: 32, r: 28, + fill: color, + opacity: 0.2, + }), + // State indicator ring + h("circle", { + cx: 32, cy: 32, r: 26, + fill: "none", + stroke: color, + strokeWidth: 2, + }), + // Face outline + h("circle", { + cx: 32, cy: 30, r: 14, + fill: color, + opacity: 0.8, + }), + // Eyes based on state + state === "idle" && h(React.Fragment, null, + h("circle", { cx: 26, cy: 28, r: 2, fill: "#fff" }), + h("circle", { cx: 38, cy: 28, r: 2, fill: "#fff" }) + ), + state === "thinking" && h(React.Fragment, null, + h("circle", { cx: 26, cy: 28, r: 2, fill: "#fff" }), + h("circle", { cx: 38, cy: 28, r: 2, fill: "#fff" }), + h("text", { x: 44, y: 30, fontSize: 12, fill: "#fff" }, "?") + ), + state === "working" && h(React.Fragment, null, + h("ellipse", { cx: 26, cy: 28, rx: 2, ry: 1, fill: "#fff" }), + h("ellipse", { cx: 38, cy: 28, rx: 2, ry: 1, fill: "#fff" }) + ), + state === "waiting_input" && h(React.Fragment, null, + h("circle", { cx: 26, cy: 28, r: 3, fill: "#fff" }), + h("circle", { cx: 38, cy: 28, r: 3, fill: "#fff" }) + ), + state === "error" && h(React.Fragment, null, + h("text", { x: 24, y: 32, fontSize: 12, fill: "#fff" }, "x"), + h("text", { x: 36, y: 32, fontSize: 12, fill: "#fff" }, "x") + ), + // Task arrow shape below face + h("path", { + d: state === "working" + ? "M32 46 L26 56 L32 52 L38 56 Z" + : "M32 46 L28 54 L36 54 Z", + fill: color, + stroke: color, + strokeWidth: 1, + }) + ); + } + + // --------------------------------------------------------------------------- + // Sprite Component + // --------------------------------------------------------------------------- + + function MascotSprite({ state, size = 96 }) { + const [loaded, setLoaded] = useState(false); + const [error, setError] = useState(false); + + const src = `/plugins/mascot/sprites/hermes_${state}.png`; + + useEffect(function () { + setLoaded(false); + setError(false); + }, [state]); + + if (error) { + return h(MascotPlaceholder, { state, size }); + } + + return h("img", { + src: src, + alt: `Mascot: ${STATE_LABELS[state]}`, + width: size, + height: size, + className: loaded ? "mascot-sprite loaded" : "mascot-sprite loading", + onLoad: function () { setLoaded(true); setError(false); }, + onError: function () { setError(true); }, + }); + } + + // --------------------------------------------------------------------------- + // useMascotState Hook + // --------------------------------------------------------------------------- + + function useMascotState() { + const [state, setState] = useState({ + status: "idle", + task: null, + mood: null, + session_id: null, + last_update: Date.now() / 1000, + }); + const [connected, setConnected] = useState(false); + const [error, setError] = useState(null); + const wsRef = useRef(null); + const backoffRef = useRef(1000); + const closedRef = useRef(false); + const pollRef = useRef(null); + + // Fetch current state via HTTP + const fetchState = useCallback(function () { + return fetch(`${API}/state`) + .then(function (r) { return r.json(); }) + .then(function (data) { + setState(normalizeState(data)); + setError(null); + return data; + }) + .catch(function (e) { + setError(String(e.message || e)); + return null; + }); + }, []); + + // WebSocket connection + useEffect(function () { + closedRef.current = false; + + function openWs() { + if (closedRef.current) return; + + const token = window.__HERMES_SESSION_TOKEN__ || ""; + const proto = window.location.protocol === "https:" ? "wss:" : "ws:"; + const url = `${proto}//${window.location.host}${API}/events?token=${encodeURIComponent(token)}`; + + let ws; + try { + ws = new WebSocket(url); + } catch (e) { + // WebSocket failed, fall back to polling + setError("WebSocket unavailable, using polling"); + startPolling(); + return; + } + + wsRef.current = ws; + + ws.onopen = function () { + backoffRef.current = 1000; + setConnected(true); + setError(null); + // Clear polling fallback if WS connects + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + }; + + ws.onmessage = function (ev) { + try { + const msg = JSON.parse(ev.data); + // Backend sends {"event": "state_change", "state": {...}} + // or {"type": "state", "state": {...}} (fallback format) + const stateData = msg.state || msg; + if (stateData && stateData.agent_state) { + setState(normalizeState(stateData)); + } + } catch (parseErr) { + // Ignore malformed messages + } + }; + + ws.onclose = function (ev) { + setConnected(false); + wsRef.current = null; + + if (closedRef.current) return; + + // Reconnect with exponential backoff + const delay = backoffRef.current; + backoffRef.current = Math.min(30000, backoffRef.current * 2); + setTimeout(openWs, delay); + + if (ev.code === 1008) { + // Auth error - don't retry + setError("WebSocket auth failed — reload page"); + return; + } + + // Start polling while WS is down + if (!pollRef.current) { + startPolling(); + } + }; + + ws.onerror = function () { + setError("WebSocket error"); + }; + } + + function startPolling() { + if (pollRef.current) return; + fetchState(); // Immediate fetch + pollRef.current = setInterval(fetchState, 3000); // Poll every 3s + } + + // Initial connection + openWs(); + + return function () { + closedRef.current = true; + if (pollRef.current) { + clearInterval(pollRef.current); + pollRef.current = null; + } + if (wsRef.current) { + wsRef.current.close(); + wsRef.current = null; + } + }; + }, [fetchState]); + + // Manual state update (for testing/manual control) + const updateState = useCallback(function (newState) { + const body = new URLSearchParams(); + if (newState.status) body.set("agent_state", newState.status); + if (newState.task !== undefined) body.set("task_description", newState.task); + if (newState.mood !== undefined) body.set("mood", newState.mood); + + return fetch(`${API}/state`, { + method: "POST", + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + body: body.toString(), + }) + .then(function (r) { return r.json(); }) + .then(function (data) { + if (data.agent_state) { + return normalizeState(data); + } + throw new Error(data.detail || "Update failed"); + }); + }, []); + + return { + state: state, + connected: connected, + error: error, + updateState: updateState, + fetchState: fetchState, + }; + } + + // --------------------------------------------------------------------------- + // MascotWidget (sidebar slot) + // --------------------------------------------------------------------------- + + function MascotWidget() { + const { state, connected, error } = useMascotState(); + const currentStatus = VALID_STATES.includes(state.status) ? state.status : "idle"; + + return h("div", { className: "mascot-widget" }, + h("div", { className: "mascot-avatar" }, + h(MascotSprite, { state: currentStatus, size: 64 }), + !connected && h("div", { className: "mascot-connection-indicator disconnected" }), + error && h("div", { className: "mascot-error-badge", title: error }, "!") + ), + h("div", { className: "mascot-info" }, + h("div", { className: "mascot-status" }, + h(Badge, { variant: currentStatus === "error" ? "destructive" : "secondary" }, + STATE_LABELS[currentStatus] + ) + ), + state.task && h("div", { className: "mascot-task" }, state.task.slice(0, 50)) + ) + ); + } + + // --------------------------------------------------------------------------- + // MascotPage (full tab) + // --------------------------------------------------------------------------- + + function MascotPage() { + const { state, connected, error, updateState } = useMascotState(); + const [selectedStatus, setSelectedStatus] = useState(state.status); + + useEffect(function () { + setSelectedStatus(state.status); + }, [state.status]); + + function handleStatusChange(status) { + setSelectedStatus(status); + updateState({ status: status }).catch(function () {}); + } + + function handleReset() { + fetch(`${API}/reset`, { method: "POST" }) + .then(function (r) { return r.json(); }) + .then(function () {}) + .catch(function () {}); + } + + const currentStatus = VALID_STATES.includes(state.status) ? state.status : "idle"; + + return h("div", { className: "mascot-page" }, + h("div", { className: "mascot-hero" }, + h(MascotSprite, { state: currentStatus, size: 192 }), + h("div", { className: "mascot-state-info" }, + h("h2", null, STATE_LABELS[currentStatus]), + state.task && h("p", { className: "mascot-task-display" }, state.task), + h("p", { className: "mascot-connection-status" }, + connected + ? "Connected (WebSocket)" + : error + ? "Disconnected (" + error + ")" + : "Connecting...", + h("span", { + className: connected ? "status-dot connected" : "status-dot disconnected", + }) + ) + ) + ), + + h(Card, null, + h(CardContent, { className: "mascot-controls" }, + h("h3", null, "State Controls"), + h("div", { className: "mascot-status-grid" }, + VALID_STATES.map(function (s) { + return h("button", { + key: s, + className: currentStatus === s ? "mascot-status-btn active" : "mascot-status-btn", + onClick: function () { handleStatusChange(s); }, + }, + h("div", { className: "mascot-status-preview" }, + h(MascotPlaceholder, { state: s, size: 32 }) + ), + h("span", null, STATE_LABELS[s]) + ); + }) + ), + h("div", { className: "mascot-actions" }, + h("button", { + className: "mascot-reset-btn", + onClick: handleReset, + }, "Reset to Idle") + ) + ) + ), + + h(Card, null, + h(CardContent, { className: "mascot-debug" }, + h("h3", null, "Debug Info"), + h("pre", { className: "mascot-state-json" }, + JSON.stringify(state, null, 2) + ) + ) + ) + ); + } + + // --------------------------------------------------------------------------- + // Registration + // --------------------------------------------------------------------------- + + function MascotTab() { + return h(MascotPage); + } + + // Register tab + window.__HERMES_PLUGINS__.register("mascot", MascotTab); + + // Register sidebar widget + if (window.__HERMES_PLUGINS__.registerSlot) { + window.__HERMES_PLUGINS__.registerSlot("sidebar:bottom", MascotWidget); + } + +})(); \ No newline at end of file diff --git a/plugins/mascot/dashboard/dist/style.css b/plugins/mascot/dashboard/dist/style.css new file mode 100644 index 000000000..b9450339b --- /dev/null +++ b/plugins/mascot/dashboard/dist/style.css @@ -0,0 +1,227 @@ +/* Mascot Plugin Styles */ + +/* Connection status indicator */ +.mascot-connection-indicator { + position: absolute; + bottom: 4px; + right: 4px; + width: 10px; + height: 10px; + border-radius: 50%; + background: #4ade80; + border: 2px solid var(--muted); +} + +.mascot-connection-indicator.disconnected { + background: #f87171; + animation: pulse-error 1s infinite; +} + +.mascot-error-badge { + position: absolute; + top: -4px; + right: -4px; + width: 18px; + height: 18px; + border-radius: 50%; + background: #f87171; + color: white; + font-size: 12px; + font-weight: bold; + display: flex; + align-items: center; + justify-content: center; +} + +/* Widget (sidebar) */ +.mascot-widget { + display: flex; + align-items: center; + gap: 8px; + padding: 8px; + border-radius: 8px; + background: var(--card); + border: 1px solid var(--border); +} + +.mascot-avatar { + position: relative; + flex-shrink: 0; +} + +.mascot-info { + flex: 1; + min-width: 0; +} + +.mascot-status { + margin-bottom: 2px; +} + +.mascot-task { + font-size: 11px; + color: var(--muted-foreground); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +/* SVG placeholder animation */ +.mascot-svg.mascot-pulse { + animation: mascot-pulse 1.5s ease-in-out infinite; +} + +@keyframes mascot-pulse { + 0%, 100% { transform: scale(1); } + 50% { transform: scale(1.05); } +} + +/* Sprite loading state */ +.mascot-sprite.loading { + opacity: 0.3; + filter: blur(2px); +} + +.mascot-sprite.loaded { + opacity: 1; + filter: none; + transition: opacity 0.2s ease, filter 0.2s ease; +} + +/* Page */ +.mascot-page { + padding: 16px; +} + +.mascot-hero { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px; + background: linear-gradient(135deg, var(--card) 0%, var(--muted) 100%); + border-radius: 12px; + margin-bottom: 16px; +} + +.mascot-title { + margin-bottom: 8px; +} + +.mascot-state-info { + text-align: center; + margin-top: 16px; +} + +.mascot-task-display { + color: var(--muted-foreground); + margin-top: 4px; +} + +.mascot-connection-status { + font-size: 12px; + color: var(--muted-foreground); + margin-top: 8px; + display: flex; + align-items: center; + justify-content: center; + gap: 6px; +} + +.status-dot { + display: inline-block; + width: 8px; + height: 8px; + border-radius: 50%; +} + +.status-dot.connected { + background: #4ade80; +} + +.status-dot.disconnected { + background: #f87171; +} + +/* Controls */ +.mascot-controls { + padding: 16px; +} + +.mascot-status-grid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 8px; + margin-top: 12px; +} + +.mascot-status-btn { + display: flex; + flex-direction: column; + align-items: center; + padding: 12px 8px; + border-radius: 8px; + border: 1px solid var(--border); + background: var(--card); + cursor: pointer; + transition: all 0.15s ease; +} + +.mascot-status-btn:hover { + background: var(--muted); +} + +.mascot-status-btn.active { + border-color: var(--primary); + background: var(--primary); + color: white; +} + +.mascot-status-preview { + margin-bottom: 4px; +} + +.mascot-actions { + margin-top: 12px; + display: flex; + justify-content: center; +} + +.mascot-reset-btn { + padding: 8px 16px; + border-radius: 6px; + border: 1px solid var(--border); + background: var(--card); + cursor: pointer; +} + +.mascot-reset-btn:hover { + background: var(--muted); +} + +/* Debug */ +.mascot-debug { + padding: 16px; +} + +.mascot-state-json { + font-size: 11px; + font-family: ui-monospace, 'SF Mono', Menlo, monospace; + background: var(--muted); + padding: 12px; + border-radius: 8px; + overflow: auto; + max-height: 200px; +} + +/* Animations */ +@keyframes pulse-error { + 0%, 100% { opacity: 1; } + 50% { opacity: 0.5; } +} + +/* Responsive */ +@media (max-width: 640px) { + .mascot-status-grid { + grid-template-columns: repeat(3, 1fr); + } +} \ No newline at end of file diff --git a/plugins/mascot/dashboard/manifest.json b/plugins/mascot/dashboard/manifest.json new file mode 100644 index 000000000..b1532da25 --- /dev/null +++ b/plugins/mascot/dashboard/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "mascot", + "label": "Mascot", + "description": "Animated agent mascot with real-time state tracking", + "icon": "Cat", + "version": "1.0.0", + "tab": { + "path": "/mascot", + "position": "after:achievements" + }, + "slots": ["sidebar:bottom"], + "entry": "dist/index.js", + "css": "dist/style.css", + "api": "dashboard/plugin_api.py" +} \ No newline at end of file diff --git a/plugins/mascot/dashboard/plugin_api.py b/plugins/mascot/dashboard/plugin_api.py new file mode 100644 index 000000000..8ec69cda3 --- /dev/null +++ b/plugins/mascot/dashboard/plugin_api.py @@ -0,0 +1,200 @@ +""" +Mascot Dashboard Plugin — Backend API routes. + +Mounted at /api/plugins/mascot/ by the dashboard plugin system. + +Provides: +- GET /state — Current mascot state +- POST /state — Update mascot state +- POST /reset — Reset to idle +- WebSocket /events — Live state stream + +Security note: +HTTP routes are unauthenticated (dashboard binds to localhost). +WebSocket requires session token via ?token= query param. +""" + +from __future__ import annotations + +import asyncio +import json +import logging +from dataclasses import asdict +from typing import Optional + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from ..mascot_state import get_manager, MascotState, VALID_STATES + +log = logging.getLogger(__name__) + +router = APIRouter() + + +def _state_to_dict(state: MascotState) -> dict: + """Convert state to API response.""" + return { + "status": state.status, + "task": state.task, + "mood": state.mood, + "last_update": state.last_update, + } + + +# --------------------------------------------------------------------------- +# HTTP Endpoints +# --------------------------------------------------------------------------- + +@router.get("/state") +def get_state(): + """Get current mascot state.""" + manager = get_manager() + state = manager.get_state() + return _state_to_dict(state) + + +class UpdateStateBody: + """Request body for POST /state.""" + status: Optional[str] = None + task: Optional[str] = None + mood: Optional[str] = None + + +@router.post("/state") +def update_state( + status: Optional[str] = None, + task: Optional[str] = None, + mood: Optional[str] = None, +): + """Update mascot state.""" + manager = get_manager() + + # Validate status if provided + if status is not None and status not in VALID_STATES: + return { + "success": False, + "error": f"Invalid status: {status}. Must be one of {VALID_STATES}", + } + + new_state = manager.set_state(status=status, task=task, mood=mood) + return { + "success": True, + "state": _state_to_dict(new_state), + } + + +@router.post("/reset") +def reset_state(): + """Reset mascot to idle state.""" + manager = get_manager() + new_state = manager.reset() + return { + "success": True, + "state": _state_to_dict(new_state), + } + + +# --------------------------------------------------------------------------- +# WebSocket Endpoint +# --------------------------------------------------------------------------- + +def _check_ws_token(provided: Optional[str]) -> bool: + """Validate WebSocket session token.""" + if not provided: + return False + try: + from hermes_cli import web_server as _ws + except Exception: + # No dashboard context (tests), accept + return True + expected = getattr(_ws, "_SESSION_TOKEN", None) + if not expected: + return True + import hmac + return hmac.compare_digest(str(provided), str(expected)) + + +# Track active WebSocket connections for broadcasting +_active_ws_clients: list = [] + + +@router.websocket("/events") +async def stream_events(ws: WebSocket): + """ + Stream mascot state changes over WebSocket. + + Client sends ?token= for auth. + Server sends: {"type": "state", "state": {...}} + Server also sends immediate current state on connect. + + Reconnection: client should reconnect with same logic; the state + manager will push current state immediately. + """ + token = ws.query_params.get("token") + if not _check_ws_token(token): + await ws.close(code=1008, reason="Invalid token") + return + + await ws.accept() + _active_ws_clients.append(ws) + log.debug("Mascot WS client connected (%d active)", len(_active_ws_clients)) + + # Send current state immediately + manager = get_manager() + current = manager.get_state() + try: + await ws.send_json({ + "type": "state", + "state": _state_to_dict(current), + }) + except Exception: + pass + + # Set up state change callback + state_changed = asyncio.Event() + latest_state = [current] + + def on_state_change(new_state: MascotState): + latest_state[0] = new_state + # Signal from sync thread to async loop + try: + loop = asyncio.get_event_loop() + loop.call_soon_threadsafe(state_changed.set) + except RuntimeError: + pass + + manager.subscribe(on_state_change) + + try: + # Polling loop with fallback (300ms) + # This is simpler than pushing from callback and works + # reliably across asyncio contexts + poll_interval = 0.3 # seconds + + while True: + try: + # Wait for state change or timeout + try: + await asyncio.wait_for(state_changed.wait(), timeout=poll_interval) + state_changed.clear() + except asyncio.TimeoutError: + pass + + # Send current state + current = latest_state[0] + await ws.send_json({ + "type": "state", + "state": _state_to_dict(current), + }) + + except WebSocketDisconnect: + break + except Exception as e: + log.debug("Mascot WS error: %s", e) + break + + finally: + manager.unsubscribe(on_state_change) + if ws in _active_ws_clients: + _active_ws_clients.remove(ws) + log.debug("Mascot WS client disconnected (%d active)", len(_active_ws_clients)) \ No newline at end of file diff --git a/plugins/mascot/dashboard/static/sprites/.gitkeep b/plugins/mascot/dashboard/static/sprites/.gitkeep new file mode 100644 index 000000000..775cafeed --- /dev/null +++ b/plugins/mascot/dashboard/static/sprites/.gitkeep @@ -0,0 +1,3 @@ +# Mascot sprite assets directory +# Place sprite files here: hermes_idle.png, hermes_thinking.png, etc. +# Size: 64x64 or 128x128 recommended (CSS scales uniformly) \ No newline at end of file diff --git a/plugins/mascot/dashboard/static/sprites/hermes_error.png b/plugins/mascot/dashboard/static/sprites/hermes_error.png new file mode 100644 index 0000000000000000000000000000000000000000..7ec66155b19a828a1773628a16452b19b08e9039 GIT binary patch literal 261 zcmV+g0s8)lP)D4`wZtWU=pA5kUeS0Bkbs=?$BM?g1aME8eBWa$E}`%$>I>LnJD$g!178(@-sdRx z{Np)56z4*XcT5L}{9TavR0q67Xgp>MdPP|IzwuX$2#KB}C*1J@-)Uqoh_V-s00000 LNkvXXu0mjfvGQ@S literal 0 HcmV?d00001 diff --git a/plugins/mascot/dashboard/static/sprites/hermes_idle.png b/plugins/mascot/dashboard/static/sprites/hermes_idle.png new file mode 100644 index 0000000000000000000000000000000000000000..38751545fbf4af2541ea8023c477579537bf77e2 GIT binary patch literal 263 zcmV+i0r>ujP)RHgqQN<&iKHqD}v zG+9f$)`)8ok{C6zYnj}$UNumg+`|zKL?yR)W(!)YXdrs`8h1bdm*f8ReZEg{yre}1 zq}?+-rL;)qiA#Lw3^1HWG+q*9Am{usqVXyN+*5|ndu+ueL5I+SdXv7Z&wJpj2GIXG zsyV;E3J|rcLW$dF1&HFSAo0`+M48YyW(#UfSnGe|Rg4&EeUEGk*ApKyWHwam2Gjrm N002ovPDHLkV1fhUZVLba literal 0 HcmV?d00001 diff --git a/plugins/mascot/dashboard/static/sprites/hermes_thinking.png b/plugins/mascot/dashboard/static/sprites/hermes_thinking.png new file mode 100644 index 0000000000000000000000000000000000000000..b1df170da9df9a987da057afc32f5cf7a7fb1d04 GIT binary patch literal 264 zcmV+j0r&oiP)wxDvq|~=an$x*telGfiI7Qp%88QccnFpj+aN4V)yd_ zVnUO(#A}VXCLxJYBfFN#J?m8iwaGmk(Lhvki)Xf=wTcF!cdu~=1aKSoukX`!g5xDE zDj@Bi;VGp>GEZFMLuY{DJfiWEAOkt)j}eVm8Q`8WeBNU#E(tn>7Sx;cU47mIUp0XK z&r!|!{Z)XdT@^~)J}W>JUj>P$Rv^lR#xYw^YrnBm literal 0 HcmV?d00001 diff --git a/plugins/mascot/dashboard/static/sprites/hermes_waiting_input.png b/plugins/mascot/dashboard/static/sprites/hermes_waiting_input.png new file mode 100644 index 0000000000000000000000000000000000000000..f756f815ea46baad8fbc663683bf47a6b8a9d3c9 GIT binary patch literal 264 zcmV+j0r&oiP)Eo~uar zOlY!}c&!oFBqT9vWY;peXT55mHo1o*8i-16@yr&qR?$H8?lta!0FLAS^?koiaJ-~N z1*F|GJf*Zq=7~#u=nOENM>Jj%WFY7KF{1G*1Kd-F&wFgeB|(SKf_js_tIvDjs|L{j zIjT9ozX}kwt3rv}X9bAjt03{z3PhREIA#lKO<3!H<5i3pX?>4u3fB|+$7GNw;%;XE O0000ujP)~zlj}Y7CYrEMU38;=-Y5~{-WQe>etoorLA2}Nm9{K;UQbzy9mfa6 zgeGf=w;FLxLK34!4lR>=)~g0;lY2O#fvDsb&ul?!6%9o1UgHi3V7T1p@7L`N$4gpO zK-xXSQ%b93p18z^&H%%CMB^nv26E0HBO0$Vz&&O7vd30j5_AYHs5j}m`n(6eY5@J8 zqgwL&s{m2EDwMc=R)8qJ3KCDPK$HoMW455ygth)RUd4!!*7wM+a839dWAyc7bj<(& N002ovPDHLkV1l=3avJ~u literal 0 HcmV?d00001 diff --git a/plugins/mascot/mascot_state.py b/plugins/mascot/mascot_state.py new file mode 100644 index 000000000..77b38dcd5 --- /dev/null +++ b/plugins/mascot/mascot_state.py @@ -0,0 +1,185 @@ +""" +Mascot state manager - singleton for tracking agent state. + +State values: idle, thinking, working, waiting_input, error +Track: status, task (description), mood (optional), last_update (timestamp) +""" + +from __future__ import annotations + +import json +import logging +import os +import threading +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import Any, Callable, Dict, List, Optional + +log = logging.getLogger(__name__) + +# State values +STATE_IDLE = "idle" +STATE_THINKING = "thinking" +STATE_WORKING = "working" +STATE_WAITING_INPUT = "waiting_input" +STATE_ERROR = "error" + +VALID_STATES = (STATE_IDLE, STATE_THINKING, STATE_WORKING, STATE_WAITING_INPUT, STATE_ERROR) + + +@dataclass +class MascotState: + """Current mascot state.""" + status: str = STATE_IDLE + task: Optional[str] = None + mood: Optional[str] = None + last_update: float = field(default_factory=time.time) + + def to_dict(self) -> Dict[str, Any]: + return asdict(self) + + +class MascotStateManager: + """ + Singleton state manager for mascot. + + Thread-safe updates via set_state(). + Pub/sub for WebSocket broadcasting. + File persistence for cross-restart state survival. + """ + + _instance: Optional["MascotStateManager"] = None + _lock = threading.Lock() + + def __new__(cls) -> "MascotStateManager": + if cls._instance is None: + with cls._lock: + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + if hasattr(self, "_initialized"): + return + self._initialized = True + + self._state = MascotState() + self._subscribers: List[Callable[[MascotState], None]] = [] + self._state_lock = threading.Lock() + + # Persistence path + self._state_path = Path.home() / ".hermes" / "plugins" / "mascot" / "state.json" + self._state_path.parent.mkdir(parents=True, exist_ok=True) + + # Load persisted state + self._load_state() + + def _load_state(self) -> None: + """Load state from disk if available.""" + try: + if self._state_path.exists(): + data = json.loads(self._state_path.read_text()) + with self._state_lock: + self._state = MascotState( + status=data.get("status", STATE_IDLE), + task=data.get("task"), + mood=data.get("mood"), + last_update=data.get("last_update", time.time()), + ) + # Reset transient states that don't survive restarts + if self._state.status == STATE_THINKING: + self._state.status = STATE_IDLE + log.debug("Loaded mascot state from %s", self._state_path) + except Exception as e: + log.warning("Failed to load mascot state: %s", e) + + def _save_state(self) -> None: + """Persist state to disk.""" + try: + with self._state_lock: + data = self._state.to_dict() + self._state_path.write_text(json.dumps(data, indent=2)) + log.debug("Saved mascot state to %s", self._state_path) + except Exception as e: + log.warning("Failed to save mascot state: %s", e) + + def get_state(self) -> MascotState: + """Get current state (thread-safe copy).""" + with self._state_lock: + return MascotState( + status=self._state.status, + task=self._state.task, + mood=self._state.mood, + last_update=self._state.last_update, + ) + + def set_state( + self, + status: Optional[str] = None, + task: Optional[str] = None, + mood: Optional[str] = None, + ) -> MascotState: + """ + Update mascot state. + + Thread-safe. Persists to disk. Broadcast to subscribers. + + Args: + status: New status (idle/thinking/working/waiting_input/error) + task: Task description (optional) + mood: Mood override (optional) + + Returns: + The new state (copy) + """ + if status is not None and status not in VALID_STATES: + raise ValueError(f"Invalid status: {status}. Must be one of {VALID_STATES}") + + with self._state_lock: + if status is not None: + self._state.status = status + if task is not None: + self._state.task = task if task else None + if mood is not None: + self._state.mood = mood + self._state.last_update = time.time() + + new_state = self.get_state() + + # Persist + self._save_state() + + # Broadcast to subscribers + for callback in self._subscribers[:]: # Copy to avoid modification during iteration + try: + callback(new_state) + except Exception as e: + log.warning("Subscriber callback failed: %s", e) + + return new_state + + def reset(self) -> MascotState: + """Reset to idle state.""" + return self.set_state(status=STATE_IDLE, task=None, mood=None) + + def subscribe(self, callback: Callable[[MascotState], None]) -> None: + """Register a callback for state changes.""" + self._subscribers.append(callback) + + def unsubscribe(self, callback: Callable[[MascotState], None]) -> None: + """Unregister a callback.""" + if callback in self._subscribers: + self._subscribers.remove(callback) + + +# Global singleton accessor +_manager: Optional[MascotStateManager] = None + + +def get_manager() -> MascotStateManager: + """Get the global mascot state manager singleton.""" + global _manager + if _manager is None: + _manager = MascotStateManager() + return _manager \ No newline at end of file diff --git a/plugins/mascot/test_mascot_state.py b/plugins/mascot/test_mascot_state.py new file mode 100644 index 000000000..d04098950 --- /dev/null +++ b/plugins/mascot/test_mascot_state.py @@ -0,0 +1,157 @@ +""" +Unit tests for mascot_state module. +Run with: python -m pytest plugins/mascot/test_mascot_state.py -v +""" + +import json +import tempfile +import time +import unittest +from pathlib import Path +from unittest.mock import patch + +# Import the module under test +import sys +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from plugins.mascot.mascot_state import ( + MascotState, + MascotStateManager, + get_manager, + STATE_IDLE, + STATE_THINKING, + STATE_WORKING, + STATE_WAITING_INPUT, + STATE_ERROR, + VALID_STATES, +) + + +class TestMascotState(unittest.TestCase): + """Tests for MascotState dataclass.""" + + def test_default_state(self): + """Default state should be idle.""" + state = MascotState() + self.assertEqual(state.status, STATE_IDLE) + self.assertIsNone(state.task) + self.assertIsNone(state.mood) + self.assertGreater(state.last_update, 0) + + def test_to_dict(self): + """to_dict should return all fields.""" + state = MascotState( + status=STATE_THINKING, + task="Test task", + mood="happy", + last_update=12345.0, + ) + d = state.to_dict() + self.assertEqual(d["status"], STATE_THINKING) + self.assertEqual(d["task"], "Test task") + self.assertEqual(d["mood"], "happy") + self.assertEqual(d["last_update"], 12345.0) + + +class TestMascotStateManager(unittest.TestCase): + """Tests for MascotStateManager singleton.""" + + def setUp(self): + """Use temp directory for state persistence.""" + self.temp_dir = tempfile.mkdtemp() + self.state_path = Path(self.temp_dir) / "plugins" / "mascot" / "state.json" + + # Patch the state path + self.patcher = patch( + "plugins.mascot.mascot_state.MascotStateManager._state_path", + new_callable=lambda: self.state_path, + ) + self.patcher.start() + + # Reset singleton + MascotStateManager._instance = None + + def tearDown(self): + self.patcher.stop() + + def test_singleton(self): + """get_manager should return the same instance.""" + m1 = get_manager() + m2 = get_manager() + self.assertIs(m1, m2) + + def test_get_state(self): + """get_state should return a copy.""" + manager = get_manager() + state1 = manager.get_state() + state2 = manager.get_state() + self.assertIsNot(state1, state2) + self.assertEqual(state1.status, state2.status) + + def test_set_state(self): + """set_state should update and persist.""" + manager = get_manager() + manager.reset() # Start from known state + + new_state = manager.set_state(status=STATE_WORKING, task="Testing") + self.assertEqual(new_state.status, STATE_WORKING) + self.assertEqual(new_state.task, "Testing") + + # Check persistence + self.assertTrue(self.state_path.exists()) + data = json.loads(self.state_path.read_text()) + self.assertEqual(data["status"], STATE_WORKING) + + def test_invalid_status(self): + """Invalid status should raise ValueError.""" + manager = get_manager() + with self.assertRaises(ValueError): + manager.set_state(status="invalid_status") + + def test_reset(self): + """reset should return to idle.""" + manager = get_manager() + manager.set_state(status=STATE_WORKING, task="Something") + + new_state = manager.reset() + self.assertEqual(new_state.status, STATE_IDLE) + self.assertIsNone(new_state.task) + self.assertIsNone(new_state.mood) + + def test_subscribers(self): + """Subscribers should be called on state change.""" + manager = get_manager() + manager.reset() + + received = [] + def callback(state): + received.append(state.status) + + manager.subscribe(callback) + manager.set_state(status=STATE_THINKING) + manager.set_state(status=STATE_WORKING) + manager.unsubscribe(callback) + manager.set_state(status=STATE_IDLE) + + self.assertEqual(received, [STATE_THINKING, STATE_WORKING]) + + def test_transient_state_reset(self): + """Transient states should reset on load.""" + manager = get_manager() + manager.set_state(status=STATE_THINKING, task="Was thinking") + + # Persist + data = json.loads(self.state_path.read_text()) + self.assertEqual(data["status"], STATE_THINKING) + + # Reset singleton and reload + MascotStateManager._instance = None + new_manager = get_manager() + + # Thinking should be reset to idle (it's transient) + state = new_manager.get_state() + self.assertEqual(state.status, STATE_IDLE) + + +if __name__ == "__main__": + unittest.main() \ No newline at end of file