feat: add mascot dashboard plugin with animated sprites

This commit is contained in:
BarnacleBoy 2026-05-23 00:02:22 +00:00
parent a84cec61ca
commit 05d02a6501
15 changed files with 1322 additions and 0 deletions

85
plugins/mascot/README.md Normal file
View file

@ -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=<session_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.

View file

@ -0,0 +1 @@
name = "mascot"

View file

@ -0,0 +1,2 @@
# Mascot dashboard plugin
from .plugin_api import router # noqa: F401

447
plugins/mascot/dashboard/dist/index.js vendored Normal file
View file

@ -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);
}
})();

227
plugins/mascot/dashboard/dist/style.css vendored Normal file
View file

@ -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);
}
}

View file

@ -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"
}

View file

@ -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=<session> 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))

View file

@ -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)

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 264 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

View file

@ -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

View file

@ -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()