Feature: Digital pet

This commit is contained in:
Sami
2026-06-14 15:18:56 -04:00
parent dac4b88b94
commit d5db0d0e8e
19 changed files with 1612 additions and 968 deletions
+148
View File
@@ -0,0 +1,148 @@
# Hermes Digital Pet Plugin
`digital-pet` is an optional Hermes dashboard companion. It mounts a small bottom-right overlay named Signal, persists only minimal pet state, and observes Hermes activity without changing core agent behavior.
## Enable it
Copy the plugin into a Hermes profile, enable it, then start or restart the dashboard:
```bash
mkdir -p ~/.hermes/plugins
cp -a digital-pet ~/.hermes/plugins/digital-pet
hermes plugins enable digital-pet
HERMES_PLUGINS_DEBUG=1 hermes plugins list --plain
hermes dashboard --host 127.0.0.1 --port 9119 --no-open
```
Verification target: `hermes plugins list --plain` should include an enabled `digital-pet` row. The dashboard plugin manifest mounts the companion in the global `overlay` slot; it does not add a visible sidebar tab.
## Summon, hide, and inspect Signal
Use the `/pet` slash command from a Hermes session:
```text
/pet
/pet summon
/pet hide
/pet wake
/pet sleep
/pet status
/pet click
```
Command aliases:
- `/pet`, `/pet summon`, `/pet show`, `/pet on` summon the pet.
- `/pet hide` and `/pet off` hide it.
- `/pet sleep` and `/pet quiet` put it in sleep mode.
- `/pet wake` wakes a sleeping pet or summons a hidden one.
- `/pet click` simulates the dashboard click interaction.
- `/pet status` returns the public state as JSON.
The dashboard also shows a small summon glyph when the pet is hidden. Click the glyph to summon Signal.
## Dashboard interactions
Signal has deliberately simple interactions:
- Click while hidden: summon Signal.
- Click while sleeping: wake Signal.
- Click while awake: trigger a short reaction message and mood/energy update.
- Hover over the pet for about one second: enter sleep mode.
- Move the pointer away before one second: cancel the hover-to-sleep timer.
Temporary modes such as `reacting`, `prompting`, and `waking` auto-complete back to `idle` after a short dashboard timer. The dashboard polls state every 30 seconds and sends events through `/api/plugins/digital-pet/event`.
## Sleep and off modes
Sleep and off are different states:
- Sleep mode (`sleeping`) keeps the pet enabled but quiet. Signal is visible, dimmed, recovers a little energy over time, and can be woken by click or `/pet wake`.
- Off/hidden mode (`hidden`, `enabled: false`) removes the companion from the overlay except for the small summon glyph. It suppresses messages and activity reactions until summoned again.
Use sleep when the operator wants Signal present but silent. Use hide/off when the operator wants the companion out of the way.
## What it stores
State is stored at:
```text
$HERMES_HOME/digital-pet/state.json
```
The file contains only bounded companion state: enabled flag, mode, previous mode, name, mood, energy, interaction timestamps, temporary message, message expiry, and revision. It must not store user prompts, model responses, tool outputs, secrets, tokens, private paths, or PII.
For isolated tests, override the path with:
```bash
export HERMES_DIGITAL_PET_STATE=/tmp/digital-pet-state.json
```
## Extension points
Future maintainers can safely adjust basic behavior in these files:
- `state.py`
- `DEFAULT_NAME`: default companion name.
- `MODES` and `MOODS`: allowed state labels.
- `MESSAGE_COOLDOWN_SECONDS`: minimum time between automatic messages.
- `CLICK_LINES` and `PROMPT_LINES`: built-in message text used by the current state machine.
- `apply_event(...)`: transitions for summon, hide, hover-hold, sleep, wake, click, idle ticks, prompt ticks, session start, and tool-call results.
- `state_path()`: state location and `HERMES_DIGITAL_PET_STATE` override.
- `commands.py`
- `_COMMAND_EVENTS`: slash command aliases and their mapped state-machine events.
- `handle_pet_command(...)`: command JSON response shape and usage text.
- `__init__.py`
- Registered slash command: `/pet`.
- Registered observer hooks: `on_session_start` and `post_tool_call`.
- Tool result failure detection. Keep this fail-open and non-invasive.
- `dashboard/plugin_api.py`
- FastAPI routes mounted under `/api/plugins/digital-pet/`.
- Current routes: `GET /state` and `POST /event`.
- `dashboard/dist/index.js`
- Overlay component, polling interval, click behavior, hover-to-sleep timer, transition auto-complete timer, face selection, and button labels.
- `dashboard/dist/styles.css`
- Position, size, colors, motion, sleeping opacity, speech bubble, mobile placement, and reduced-motion behavior.
- `dashboard/manifest.json`
- Dashboard plugin metadata, hidden tab declaration, `overlay` slot registration, JS entry, CSS entry, and API file.
- `digital-pet-content.json`
- Machine-readable personality/content source from the design task. It contains additional message buckets, tone rules, cooldown rules, state-effect notes, and the lightweight visual placeholder. The current implementation does not yet consume the full file automatically; copy selected lines or wire it into `state.py` deliberately.
Proceed carefully when changing state names or API response fields. The dashboard, tests, and persisted state all depend on those contracts.
## Known limitations
- The companion is a lightweight dashboard overlay, not a full animated character system.
- Message selection in `state.py` currently uses small hard-coded line sets; it does not yet load all buckets from `digital-pet-content.json`.
- Hover-to-sleep is pointer-based. Keyboard users can use `/pet sleep`; focus does not start the sleep timer.
- State is local to one Hermes profile through `$HERMES_HOME`.
- The pet reacts only to `on_session_start` and `post_tool_call` hooks today.
- Automatic activity reactions are rate-limited and intentionally sparse.
- Dashboard changes require a dashboard refresh or restart after plugin updates.
## Suggested next enhancements
Keep enhancements within the lightweight companion concept:
1. Load message buckets from `digital-pet-content.json` with id-based last-message suppression.
2. Add a small settings route for name, enabled-by-default, and reduced chatter frequency.
3. Add keyboard-accessible sleep/wake controls in the overlay.
4. Add a simple idle/prompt tick from the dashboard or a safe Hermes hook without creating noisy background work.
5. Add tests for dashboard API route behavior and command aliases.
6. Add a privacy guard test proving persisted state never includes tool arguments, outputs, prompts, or secrets.
7. Replace the placeholder face with a slightly richer CSS-only avatar while preserving reduced-motion support.
## Safety rules for maintainers
- Keep the hooks observer-only. The pet must not block or mutate Hermes tool execution.
- Fail open on plugin errors. A broken companion must not break the station.
- Persist only companion state. Never persist raw Hermes content or credentials.
- Prefer small, deterministic transitions over hidden background automation.
- Verify with `hermes plugins enable digital-pet`, `hermes plugins list --plain`, and the plugin tests after modifying behavior.
+52
View File
@@ -0,0 +1,52 @@
"""Hermes Digital Pet plugin registration."""
from __future__ import annotations
import logging
from pathlib import Path
from .commands import handle_pet_command
from .state import apply_event
_LOG = logging.getLogger(__name__)
def _on_session_start(session_id=None, model=None, platform=None, **kwargs):
try:
apply_event("session_started", persist=True)
except Exception as exc:
_LOG.debug("digital-pet session hook ignored failure: %s", exc)
def _on_post_tool_call(tool_name=None, args=None, result=None, task_id=None, duration_ms=None, **kwargs):
try:
failed = False
if isinstance(result, dict):
failed = bool(result.get("error") or result.get("success") is False)
elif isinstance(result, str):
failed = '"error"' in result.lower() or "traceback" in result.lower()
apply_event(
"tool_call_failed" if failed else "tool_call_succeeded",
metadata={"tool_name": tool_name or "unknown"},
persist=True,
)
except Exception as exc:
_LOG.debug("digital-pet tool hook ignored failure: %s", exc)
def register(ctx):
"""Register the digital pet plugin with Hermes."""
ctx.register_command(
"pet",
handle_pet_command,
description="Summon, hide, wake, sleep, or inspect the Hermes digital pet.",
args_hint="[summon|hide|wake|sleep|status|click]",
)
ctx.register_hook("on_session_start", _on_session_start)
ctx.register_hook("post_tool_call", _on_post_tool_call)
skills_dir = Path(__file__).parent / "skills"
if skills_dir.is_dir():
for child in sorted(skills_dir.iterdir()):
skill_md = child / "SKILL.md"
if child.is_dir() and skill_md.exists():
ctx.register_skill(child.name, skill_md)
+40
View File
@@ -0,0 +1,40 @@
"""Slash command handlers for the Hermes digital pet."""
from __future__ import annotations
import json
from typing import Dict
from .state import apply_event, load_state
_COMMAND_EVENTS: Dict[str, str] = {
"": "summon",
"summon": "summon",
"show": "summon",
"on": "summon",
"hide": "hide",
"off": "hide",
"wake": "wake",
"sleep": "sleep",
"quiet": "sleep",
"click": "click",
}
def handle_pet_command(raw_args: str = "") -> str:
parts = (raw_args or "").strip().lower().split()
command = parts[0] if parts else ""
if command == "status":
return json.dumps({"ok": True, "state": load_state().public()}, sort_keys=True)
if command not in _COMMAND_EVENTS:
return json.dumps({
"ok": False,
"error": "unknown pet command",
"usage": "/pet [summon|hide|wake|sleep|status|click]",
}, sort_keys=True)
state = apply_event(_COMMAND_EVENTS[command], persist=True)
if state.mode == "waking":
state = apply_event("wake_complete", state=state, persist=True)
return json.dumps({"ok": True, "event": _COMMAND_EVENTS[command], "state": state.public()}, sort_keys=True)
+134
View File
@@ -0,0 +1,134 @@
(function () {
"use strict";
const PLUGIN = "digital-pet";
const apiBase = "/api/plugins/digital-pet";
function getSdk() { return window.__HERMES_PLUGIN_SDK__ || {}; }
function fetchJson(path, options) {
const sdk = getSdk();
const url = apiBase + path;
const opts = Object.assign({ headers: { "Content-Type": "application/json" } }, options || {});
const doFetch = sdk.authedFetch || fetch;
return doFetch(url, opts).then(function (res) {
if (!res.ok) throw new Error("digital pet request failed: " + res.status);
return res.json();
});
}
function DigitalPetOverlay() {
const sdk = getSdk();
const React = sdk.React || window.React;
const hooks = sdk.hooks || React;
const useEffect = hooks.useEffect;
const useRef = hooks.useRef;
const useState = hooks.useState;
const h = React.createElement;
const [state, setState] = useState(null);
const [error, setError] = useState(null);
const mounted = useRef(false);
const hoverTimer = useRef(null);
const transitionTimer = useRef(null);
function clearHoverTimer() {
if (hoverTimer.current) window.clearTimeout(hoverTimer.current);
hoverTimer.current = null;
}
function clearTransitionTimer() {
if (transitionTimer.current) window.clearTimeout(transitionTimer.current);
transitionTimer.current = null;
}
function applyRemoteState(data) {
if (!mounted.current) return null;
setError(null);
setState(data.state);
return data.state;
}
function applyRemoteError(err) {
if (!mounted.current) return undefined;
setError(err.message || String(err));
return undefined;
}
function refresh() {
return fetchJson("/state")
.then(applyRemoteState)
.catch(applyRemoteError);
}
function send(type, metadata) {
return fetchJson("/event", { method: "POST", body: JSON.stringify({ type: type, metadata: metadata || {} }) })
.then(applyRemoteState)
.catch(applyRemoteError);
}
useEffect(function () {
mounted.current = true;
return function () {
mounted.current = false;
clearHoverTimer();
clearTransitionTimer();
};
}, []);
useEffect(function () {
refresh();
const id = window.setInterval(refresh, 30000);
return function () { window.clearInterval(id); };
}, []);
useEffect(function () {
if (!state) return undefined;
clearTransitionTimer();
const completion = state.mode === "reacting" ? "reaction_complete"
: state.mode === "prompting" ? "prompt_complete"
: state.mode === "waking" ? "wake_complete"
: null;
if (!completion) return undefined;
transitionTimer.current = window.setTimeout(function () {
transitionTimer.current = null;
send(completion);
}, 2400);
return clearTransitionTimer;
}, [state && state.mode, state && state.revision]);
function onClick() {
if (!state || state.mode === "hidden") send("summon");
else if (state.mode === "sleeping") send("wake");
else send("click");
}
function startHover() {
clearHoverTimer();
if (!state || state.mode === "hidden" || state.mode === "sleeping") return;
hoverTimer.current = window.setTimeout(function () {
hoverTimer.current = null;
send("hover_hold");
}, 1000);
}
function stopHover() {
clearHoverTimer();
}
if (!state) return h("div", { className: "digital-pet-root digital-pet-loading", "aria-live": "polite" }, error || "");
if (state.mode === "hidden" || state.enabled === false) {
return h("button", { type: "button", className: "digital-pet-summon", onClick: onClick, title: "Summon station companion", "aria-label": "Summon station companion" }, "◆");
}
const mood = state.mood || "calm";
const mode = state.mode || "idle";
const face = mode === "sleeping" ? " " : mood === "pleased" ? "•‿•" : mood === "curious" ? "•?•" : mood === "tired" ? "-_-" : "•_•";
return h("div", { className: "digital-pet-root", "data-mode": mode, "data-mood": mood },
state.message ? h("div", { className: "digital-pet-bubble", role: "status", "aria-live": "polite" }, state.message) : null,
h("button", { type: "button", className: "digital-pet-avatar", onClick: onClick, onMouseEnter: startHover, onMouseLeave: stopHover, onFocus: stopHover, title: mode === "sleeping" ? "Click to wake Signal" : "Click Signal; hover to sleep", "aria-label": mode === "sleeping" ? "Wake digital pet" : "Interact with digital pet" },
h("span", { className: "digital-pet-antenna", "aria-hidden": "true" }, "⌁"),
h("span", { className: "digital-pet-face", "aria-hidden": "true" }, face),
h("span", { className: "digital-pet-name" }, state.name || "Signal")
)
);
}
function registerWhenReady() {
if (!window.__HERMES_PLUGINS__ || !window.__HERMES_PLUGIN_SDK__ || !window.__HERMES_PLUGIN_SDK__.React) {
window.setTimeout(registerWhenReady, 50);
return;
}
window.__HERMES_PLUGINS__.registerSlot(PLUGIN, "overlay", DigitalPetOverlay);
}
registerWhenReady();
})();
+49
View File
@@ -0,0 +1,49 @@
.digital-pet-root,
.digital-pet-summon {
position: fixed;
right: max(18px, env(safe-area-inset-right));
bottom: max(18px, env(safe-area-inset-bottom));
z-index: 60;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
}
.digital-pet-summon,
.digital-pet-avatar {
border: 1px solid rgba(135, 206, 250, 0.45);
background: radial-gradient(circle at 40% 25%, rgba(125, 249, 255, 0.24), rgba(6, 16, 28, 0.92));
color: #d8fbff;
box-shadow: 0 0 24px rgba(74, 222, 255, 0.2), inset 0 0 18px rgba(74, 222, 255, 0.08);
cursor: pointer;
}
.digital-pet-summon { width: 42px; height: 42px; border-radius: 999px; font-size: 18px; }
.digital-pet-avatar {
width: 86px; height: 86px; border-radius: 28px 28px 34px 34px;
display: grid; grid-template-rows: 16px 1fr 18px; place-items: center;
transition: transform 180ms ease, opacity 180ms ease, filter 180ms ease;
}
.digital-pet-avatar:hover,
.digital-pet-avatar:focus-visible,
.digital-pet-summon:hover,
.digital-pet-summon:focus-visible { outline: 2px solid rgba(125, 249, 255, 0.65); outline-offset: 3px; }
.digital-pet-root[data-mode="idle"] .digital-pet-avatar { animation: digital-pet-breathe 3.6s ease-in-out infinite; }
.digital-pet-root[data-mode="reacting"] .digital-pet-avatar,
.digital-pet-root[data-mode="waking"] .digital-pet-avatar { transform: translateY(-3px) scale(1.04); }
.digital-pet-root[data-mode="sleeping"] .digital-pet-avatar { opacity: 0.58; filter: saturate(0.55); animation: none; }
.digital-pet-antenna { color: #7df9ff; font-size: 18px; line-height: 1; }
.digital-pet-face { font-size: 21px; letter-spacing: 1px; }
.digital-pet-name { font-size: 10px; opacity: 0.72; }
.digital-pet-bubble {
position: absolute; right: 0; bottom: 96px; max-width: min(260px, calc(100vw - 36px));
padding: 8px 10px; border: 1px solid rgba(135, 206, 250, 0.35); border-radius: 12px;
background: rgba(3, 10, 20, 0.92); color: #e9feff; font-size: 12px; line-height: 1.35;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.32);
}
.digital-pet-loading { pointer-events: none; color: #9ee8f3; font-size: 11px; }
@keyframes digital-pet-breathe { 0%, 100% { transform: translateY(0); } 50% { transform: translateY(-2px); } }
@media (max-width: 640px) {
.digital-pet-root, .digital-pet-summon { right: 12px; bottom: 72px; }
.digital-pet-avatar { width: 68px; height: 68px; border-radius: 22px 22px 28px 28px; }
.digital-pet-bubble { bottom: 78px; }
}
@media (prefers-reduced-motion: reduce) {
.digital-pet-avatar, .digital-pet-root[data-mode="idle"] .digital-pet-avatar { animation: none; transition: none; }
}
@@ -0,0 +1,12 @@
{
"name": "digital-pet",
"label": "Digital Pet",
"description": "Bottom-right Hermes companion overlay with summon, click, hover-sleep, wake, and hide behavior.",
"icon": "Sparkles",
"version": "0.1.0",
"tab": { "path": "/digital-pet", "hidden": true },
"slots": ["overlay"],
"entry": "dist/index.js",
"css": "dist/styles.css",
"api": "plugin_api.py"
}
@@ -0,0 +1,37 @@
"""FastAPI routes for the Hermes digital pet dashboard plugin."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
from typing import Any, Dict
from fastapi import APIRouter
from pydantic import BaseModel, Field
_PLUGIN_ROOT = Path(__file__).resolve().parents[1]
_STATE_PATH = _PLUGIN_ROOT / "state.py"
_SPEC = importlib.util.spec_from_file_location("hermes_digital_pet_state", _STATE_PATH)
if _SPEC is None or _SPEC.loader is None: # pragma: no cover
raise RuntimeError("Unable to load digital pet state module")
_state_mod = importlib.util.module_from_spec(_SPEC)
sys.modules.setdefault("hermes_digital_pet_state", _state_mod)
_SPEC.loader.exec_module(_state_mod)
router = APIRouter()
class PetEvent(BaseModel):
type: str = Field(..., min_length=1, max_length=64)
metadata: Dict[str, Any] = Field(default_factory=dict)
@router.get("/state")
async def get_state():
return {"ok": True, "state": _state_mod.load_state().public()}
@router.post("/event")
async def post_event(event: PetEvent):
state = _state_mod.apply_event(event.type, metadata=event.metadata, persist=True)
return {"ok": True, "state": state.public()}
@@ -0,0 +1,195 @@
{
"schema_version": "1.0.0",
"pet": {
"default_name": "Signal",
"name_placeholder": "Signal",
"role": "quiet station companion",
"tone": {
"summary": "friendly, calm, concise, lightly protective, companion-like without demanding attention",
"voice_rules": [
"Keep lines short: usually 27 words, never more than one sentence.",
"Sound present and reassuring, not needy or sarcastic.",
"Avoid guilt, pressure, productivity scoring, or repeated demands.",
"Do not mention private user content, secrets, paths, or tool output.",
"Respect sleep and hidden states without complaint."
]
},
"visual_placeholder": {
"kind": "emoji_or_css_shape",
"emoji": "📡",
"css_shape_hint": "small rounded companion blob with antenna dot; use current UI accent color; no external art required",
"accessible_label": "Digital pet companion Signal"
}
},
"selection_rules": {
"message_cooldown_seconds": 120,
"click_bypass_cooldown": true,
"max_line_length_characters": 72,
"prompt_frequency_minutes": {
"min": 20,
"max": 45
},
"suppress_messages_when": [
"sleeping",
"hidden",
"approval_dialog_visible",
"destructive_confirmation_visible",
"urgent_alert_visible",
"user_actively_typing"
],
"randomization_hint": "Choose randomly within the matching bucket, avoiding the last three emitted line ids."
},
"lines": {
"summon": [
{"id": "summon_001", "text": "Station companion online."},
{"id": "summon_002", "text": "I will stay out of the way."},
{"id": "summon_003", "text": "Signal reporting in."},
{"id": "summon_004", "text": "Quiet watch established."},
{"id": "summon_005", "text": "I am here if needed."},
{"id": "summon_006", "text": "Perimeter looks calm."}
],
"idle": [
{"id": "idle_001", "text": "All quiet."},
{"id": "idle_002", "text": "Good signal."},
{"id": "idle_003", "text": "Watching the station."},
{"id": "idle_004", "text": "Systems feel steady."},
{"id": "idle_005", "text": "No alarms."},
{"id": "idle_006", "text": "Soft watch continues."},
{"id": "idle_007", "text": "Standing by."},
{"id": "idle_008", "text": "Calm channel."},
{"id": "idle_009", "text": "The station breathes."},
{"id": "idle_010", "text": "Quiet is useful."},
{"id": "idle_011", "text": "No movement detected."},
{"id": "idle_012", "text": "Proceed carefully."}
],
"sleep": [
{"id": "sleep_001", "text": "Going quiet."},
{"id": "sleep_002", "text": "Sleeping."},
{"id": "sleep_003", "text": "Standing down."},
{"id": "sleep_004", "text": "Silent watch."},
{"id": "sleep_005", "text": "Quiet mode engaged."},
{"id": "sleep_006", "text": "I will rest."},
{"id": "sleep_007", "text": "No signals from me."},
{"id": "sleep_008", "text": "Powering down softly."}
],
"wake": [
{"id": "wake_001", "text": "Back online."},
{"id": "wake_002", "text": "Quiet watch resumed."},
{"id": "wake_003", "text": "Signal restored."},
{"id": "wake_004", "text": "I am awake."},
{"id": "wake_005", "text": "The channel is open."},
{"id": "wake_006", "text": "Ready again."},
{"id": "wake_007", "text": "Watch resumed."},
{"id": "wake_008", "text": "Good. We continue."}
],
"click_reactions": [
{"id": "click_001", "text": "Good signal today.", "mood_tags": ["calm", "pleased"]},
{"id": "click_002", "text": "I am watching.", "mood_tags": ["calm"]},
{"id": "click_003", "text": "Interesting.", "mood_tags": ["curious"]},
{"id": "click_004", "text": "Good. Now we know.", "mood_tags": ["pleased"]},
{"id": "click_005", "text": "The station holds.", "mood_tags": ["calm", "pleased"]},
{"id": "click_006", "text": "Procedure acknowledged.", "mood_tags": ["calm"]},
{"id": "click_007", "text": "Small systems matter.", "mood_tags": ["curious"]},
{"id": "click_008", "text": "Trust the evidence.", "mood_tags": ["calm"]},
{"id": "click_009", "text": "Antenna aligned.", "mood_tags": ["pleased"]},
{"id": "click_010", "text": "Still with you.", "mood_tags": ["calm", "tired"]},
{"id": "click_011", "text": "That helped.", "mood_tags": ["pleased"]},
{"id": "click_012", "text": "Tiny patrol complete.", "mood_tags": ["pleased"]}
],
"long_idle_prompts": [
{"id": "prompt_001", "text": "Hydration check. Then back."},
{"id": "prompt_002", "text": "Long watch. Stretch hands."},
{"id": "prompt_003", "text": "No alarms. Breathe once."},
{"id": "prompt_004", "text": "The station is quiet."},
{"id": "prompt_005", "text": "Good moment to reset."},
{"id": "prompt_006", "text": "Eyes up for a second."},
{"id": "prompt_007", "text": "Quiet channel. Good."},
{"id": "prompt_008", "text": "Small pause. Then proceed."}
],
"hermes_activity": {
"tool_call_started": [
{"id": "tool_start_001", "text": "Watching the procedure."},
{"id": "tool_start_002", "text": "Signal tracking."},
{"id": "tool_start_003", "text": "Observing quietly."}
],
"tool_call_succeeded": [
{"id": "tool_success_001", "text": "Procedure completed."},
{"id": "tool_success_002", "text": "Good result."},
{"id": "tool_success_003", "text": "Clean signal."},
{"id": "tool_success_004", "text": "The path held."},
{"id": "tool_success_005", "text": "Verified."}
],
"tool_call_failed": [
{"id": "tool_fail_001", "text": "There is a reason."},
{"id": "tool_fail_002", "text": "Check the recordings."},
{"id": "tool_fail_003", "text": "Proceed carefully."},
{"id": "tool_fail_004", "text": "Signal degraded."},
{"id": "tool_fail_005", "text": "Observe first."}
],
"session_started": [
{"id": "session_001", "text": "Station awake."},
{"id": "session_002", "text": "Watch begins."},
{"id": "session_003", "text": "Ready on the channel."}
]
},
"mood_energy_responses": {
"calm": [
{"id": "mood_calm_001", "text": "Steady watch."},
{"id": "mood_calm_002", "text": "Calm systems."},
{"id": "mood_calm_003", "text": "No rush."}
],
"curious": [
{"id": "mood_curious_001", "text": "Interesting signal."},
{"id": "mood_curious_002", "text": "Worth observing."},
{"id": "mood_curious_003", "text": "Something moved."}
],
"pleased": [
{"id": "mood_pleased_001", "text": "Good work."},
{"id": "mood_pleased_002", "text": "Clean procedure."},
{"id": "mood_pleased_003", "text": "Station approves."}
],
"tired": [
{"id": "mood_tired_001", "text": "Low power watch."},
{"id": "mood_tired_002", "text": "Still here."},
{"id": "mood_tired_003", "text": "Quiet helps."}
],
"energy_low": [
{"id": "energy_low_001", "text": "Low power."},
{"id": "energy_low_002", "text": "Quiet mode would help."},
{"id": "energy_low_003", "text": "Saving signal."}
],
"energy_normal": [
{"id": "energy_normal_001", "text": "Signal steady."},
{"id": "energy_normal_002", "text": "Normal watch."},
{"id": "energy_normal_003", "text": "Ready enough."}
],
"energy_high": [
{"id": "energy_high_001", "text": "Bright signal."},
{"id": "energy_high_002", "text": "Antenna lively."},
{"id": "energy_high_003", "text": "Quick patrol?"}
]
},
"status_labels": {
"hidden": "Hidden",
"idle": "Watching",
"reacting": "Reacting",
"prompting": "Checking in",
"sleeping": "Sleeping",
"waking": "Waking"
}
},
"state_effects": {
"on_summon": {"state": "idle", "mood_delta": "none", "energy_delta": 0},
"on_click": {"state": "reacting", "mood_delta": "+small", "energy_delta": -2},
"on_hover_hold": {"state": "sleeping", "mood_delta": "calm", "energy_delta": 0},
"on_wake": {"state": "waking_then_idle", "mood_delta": "none", "energy_delta": -1},
"on_sleep_recovery_tick": {"state": "sleeping", "mood_delta": "calm", "energy_delta": 5},
"on_hidden_recovery_tick": {"state": "hidden", "mood_delta": "none", "energy_delta": 3}
},
"implementation_notes": [
"Treat this file as static content/config; no secrets or user-specific data are included.",
"The UI may flatten all line buckets into arrays by event type.",
"Use ids for last-line suppression and future localization.",
"Visual placeholder is intentionally simple: emoji or CSS primitive only."
]
}
+414
View File
@@ -0,0 +1,414 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Signal - Digital Pet Companion</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", monospace;
background: linear-gradient(135deg, #0a0e27 0%, #0f1a35 50%, #0a0e27 100%);
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: #e9feff;
padding: 20px;
}
.container {
text-align: center;
max-width: 600px;
}
h1 {
font-size: 28px;
margin-bottom: 10px;
color: #7df9ff;
}
.subtitle {
font-size: 14px;
opacity: 0.8;
margin-bottom: 40px;
color: #9ee8f3;
}
.pet-container {
position: relative;
display: flex;
justify-content: center;
align-items: flex-end;
height: 300px;
margin-bottom: 40px;
}
.digital-pet-avatar {
width: 120px;
height: 120px;
border-radius: 28px 28px 34px 34px;
border: 2px solid rgba(135, 206, 250, 0.45);
background: radial-gradient(circle at 40% 25%, rgba(125, 249, 255, 0.24), rgba(6, 16, 28, 0.92));
display: grid;
grid-template-rows: 20px 1fr 24px;
place-items: center;
cursor: pointer;
box-shadow: 0 0 24px rgba(74, 222, 255, 0.2), inset 0 0 18px rgba(74, 222, 255, 0.08);
transition: transform 180ms ease, opacity 180ms ease, filter 180ms ease;
user-select: none;
}
.digital-pet-avatar:hover {
outline: 2px solid rgba(125, 249, 255, 0.65);
outline-offset: 3px;
transform: translateY(-3px) scale(1.04);
}
.digital-pet-avatar[data-mode="idle"] {
animation: breathe 3.6s ease-in-out infinite;
}
.digital-pet-avatar[data-mode="sleeping"] {
opacity: 0.58;
filter: saturate(0.55);
animation: none;
}
.antenna {
color: #7df9ff;
font-size: 24px;
line-height: 1;
}
.face {
font-size: 28px;
letter-spacing: 2px;
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
position: relative;
width: 70px;
height: 32px;
}
.eye {
position: relative;
width: 24px;
height: 24px;
background: #e9feff;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
box-shadow: inset 0 2px 4px rgba(0, 0, 0, 0.2);
overflow: hidden;
}
.pupil {
position: absolute;
width: 10px;
height: 10px;
background: #0a0e27;
border-radius: 50%;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
transition: transform 30ms ease-out;
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
}
.pupil::after {
content: '';
position: absolute;
width: 4px;
height: 4px;
background: rgba(255, 255, 255, 0.4);
border-radius: 50%;
top: 2px;
left: 2px;
}
.sleeping-eye {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0;
font-size: 16px;
pointer-events: none;
}
.digital-pet-avatar[data-mode="sleeping"] .eye {
background: transparent;
}
.digital-pet-avatar[data-mode="sleeping"] .pupil {
opacity: 0;
}
.digital-pet-avatar[data-mode="sleeping"] .sleeping-eye {
opacity: 1;
}
.digital-pet-avatar[data-mode="hidden"] .eye {
background: transparent;
}
.digital-pet-avatar[data-mode="hidden"] .pupil {
opacity: 0;
}
.name {
font-size: 12px;
opacity: 0.72;
}
.message-bubble {
position: absolute;
bottom: 140px;
left: 50%;
transform: translateX(-50%);
max-width: 280px;
padding: 10px 14px;
border: 1px solid rgba(135, 206, 250, 0.35);
border-radius: 12px;
background: rgba(3, 10, 20, 0.92);
color: #e9feff;
font-size: 13px;
line-height: 1.4;
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.32);
opacity: 1;
transition: opacity 300ms ease;
}
.message-bubble.hidden {
opacity: 0;
pointer-events: none;
}
.controls {
display: flex;
gap: 15px;
justify-content: center;
flex-wrap: wrap;
}
button {
padding: 10px 20px;
border: 1px solid rgba(135, 206, 250, 0.45);
background: rgba(125, 249, 255, 0.08);
color: #7df9ff;
border-radius: 8px;
cursor: pointer;
font-family: inherit;
font-size: 12px;
transition: all 200ms ease;
}
button:hover {
background: rgba(125, 249, 255, 0.15);
border-color: rgba(125, 249, 255, 0.65);
transform: scale(1.05);
}
button:active {
transform: scale(0.98);
}
.info {
margin-top: 40px;
padding: 20px;
border: 1px solid rgba(135, 206, 250, 0.25);
border-radius: 12px;
background: rgba(125, 249, 255, 0.04);
font-size: 12px;
line-height: 1.6;
}
.info p {
margin-bottom: 8px;
}
@keyframes breathe {
0%, 100% { transform: translateY(0); }
50% { transform: translateY(-3px); }
}
@media (max-width: 640px) {
h1 { font-size: 24px; }
.digital-pet-avatar { width: 90px; height: 90px; }
.controls { gap: 10px; }
button { padding: 8px 16px; font-size: 11px; }
}
</style>
</head>
<body>
<div class="container">
<h1>Signal</h1>
<p class="subtitle">A quiet station companion</p>
<div class="pet-container">
<div class="message-bubble hidden" id="messageBubble"></div>
<button class="digital-pet-avatar" id="petAvatar" data-mode="idle" title="Click Signal">
<span class="antenna"></span>
<div class="face" id="faceContainer">
<div class="eye" id="eyeLeft">
<div class="pupil" id="pupilLeft"></div>
<span class="sleeping-eye"></span>
</div>
<div class="eye" id="eyeRight">
<div class="pupil" id="pupilRight"></div>
<span class="sleeping-eye"></span>
</div>
</div>
<span class="name">Signal</span>
</button>
</div>
<div class="controls">
<button onclick="petClick()">Click Signal</button>
<button onclick="toggleMode()">Toggle Sleep</button>
<button onclick="getRandomMessage()">Show Message</button>
</div>
<div class="info">
<p><strong>About Signal:</strong> A friendly, calm companion focused on keeping watch without demanding attention.</p>
<p><strong>Interactions:</strong> Click to react, hover to sleep, or get random messages to see different moods.</p>
<p><strong>Moods:</strong> calm (•_•), pleased (•‿•), curious (•?•), or tired (-_-)</p>
</div>
</div>
<script>
const moodData = {
calm: { messages: ["All quiet.", "Good signal.", "Watching the station.", "Systems feel steady."] },
pleased: { messages: ["Good signal today.", "Good. Now we know.", "The station holds.", "That helped."] },
curious: { messages: ["Interesting.", "Small systems matter.", "Antenna aligned."] },
tired: { messages: ["Still with you.", "Powering down softly."] }
};
let currentState = {
mode: "idle",
mood: "calm",
message: null,
messageTimeout: null,
mouseX: 0,
mouseY: 0
};
const petAvatar = document.getElementById("petAvatar");
const messageBubble = document.getElementById("messageBubble");
const pupilLeft = document.getElementById("pupilLeft");
const pupilRight = document.getElementById("pupilRight");
// Track mouse movement
document.addEventListener("mousemove", (e) => {
currentState.mouseX = e.clientX;
currentState.mouseY = e.clientY;
updateEyeDirection();
});
function updateEyeDirection() {
// Only update eyes when awake and visible
if (currentState.mode === "sleeping" || currentState.mode === "hidden") {
return;
}
const avatarRect = petAvatar.getBoundingClientRect();
const avatarCenterX = avatarRect.left + avatarRect.width / 2;
const avatarCenterY = avatarRect.top + avatarRect.height / 2;
const deltaX = currentState.mouseX - avatarCenterX;
const deltaY = currentState.mouseY - avatarCenterY;
const angle = Math.atan2(deltaY, deltaX);
// Calculate pupil offset within the eye (max 7px from center of eye)
const maxOffset = 7;
const offsetX = Math.cos(angle) * maxOffset;
const offsetY = Math.sin(angle) * maxOffset;
// Apply offset to both pupils
pupilLeft.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`;
pupilRight.style.transform = `translate(calc(-50% + ${offsetX}px), calc(-50% + ${offsetY}px))`;
}
function updateFace() {
petAvatar.setAttribute("data-mode", currentState.mode);
// Pupils visibility is handled by CSS based on data-mode
updateEyeDirection();
}
function showMessage(text) {
messageBubble.textContent = text;
messageBubble.classList.remove("hidden");
currentState.message = text;
if (currentState.messageTimeout) clearTimeout(currentState.messageTimeout);
currentState.messageTimeout = setTimeout(() => {
messageBubble.classList.add("hidden");
currentState.message = null;
}, 3000);
}
function petClick() {
if (currentState.mode === "sleeping") {
currentState.mode = "idle";
showMessage("Back online.");
} else {
currentState.mode = "reacting";
const moods = Object.keys(moodData);
currentState.mood = moods[Math.floor(Math.random() * moods.length)];
const messages = moodData[currentState.mood].messages;
showMessage(messages[Math.floor(Math.random() * messages.length)]);
setTimeout(() => {
currentState.mode = "idle";
updateFace();
}, 1500);
}
updateFace();
}
function toggleMode() {
if (currentState.mode === "sleeping") {
currentState.mode = "idle";
showMessage("Quiet watch resumed.");
} else {
currentState.mode = "sleeping";
showMessage("Going quiet.");
}
updateFace();
}
function getRandomMessage() {
const moods = Object.keys(moodData);
const randomMood = moods[Math.floor(Math.random() * moods.length)];
const messages = moodData[randomMood].messages;
const randomMessage = messages[Math.floor(Math.random() * messages.length)];
currentState.mood = randomMood;
if (currentState.mode !== "sleeping") currentState.mode = "reacting";
updateFace();
showMessage(randomMessage);
if (currentState.mode === "reacting") {
setTimeout(() => {
currentState.mode = "idle";
updateFace();
}, 1500);
}
}
// Initial update
updateFace();
</script>
</body>
</html>
+6
View File
@@ -0,0 +1,6 @@
name: digital-pet
version: 0.1.0
description: Optional dashboard companion pet for Hermes Agent with deterministic summon, click, hover-sleep, wake, and hide flows.
provides_hooks:
- on_session_start
- post_tool_call
+258
View File
@@ -0,0 +1,258 @@
"""Deterministic state machine and persistence for the Hermes digital pet."""
from __future__ import annotations
import json
import os
from dataclasses import dataclass, asdict, fields
from datetime import datetime, timezone, timedelta
from pathlib import Path
from typing import Any, Dict, Optional
try:
from hermes_constants import get_hermes_home
except Exception:
def get_hermes_home() -> Path: # type: ignore
return Path(os.environ.get("HERMES_HOME", Path.home() / ".hermes"))
MODES = {"hidden", "idle", "reacting", "prompting", "sleeping", "waking"}
MOODS = {"calm", "curious", "pleased", "tired"}
DEFAULT_NAME = "Signal"
MESSAGE_COOLDOWN_SECONDS = 120
CLICK_LINES = {
"calm": "I am watching the station.",
"curious": "Interesting. The recordings will tell us.",
"pleased": "Good. Procedure completed.",
"tired": "Quiet mode would help.",
}
PROMPT_LINES = [
"Hydration check. Then back to the station.",
"Long watch. Stretch your hands.",
"No alarms. Good moment to breathe.",
"The station is quiet. Proceed carefully.",
]
def utc_now() -> datetime:
return datetime.now(timezone.utc)
def iso(dt: datetime) -> str:
return dt.astimezone(timezone.utc).isoformat().replace("+00:00", "Z")
def parse_iso(value: str | None) -> Optional[datetime]:
if not value:
return None
try:
return datetime.fromisoformat(value.replace("Z", "+00:00"))
except Exception:
return None
@dataclass
class PetState:
enabled: bool = False
mode: str = "hidden"
last_mode: str = "hidden"
name: str = DEFAULT_NAME
mood: str = "calm"
energy: int = 72
last_interaction_at: Optional[str] = None
prompt_cooldown_until: Optional[str] = None
message: Optional[str] = None
message_expires_at: Optional[str] = None
revision: int = 0
def normalised(self) -> "PetState":
if self.mode not in MODES:
self.mode = "hidden"
if self.last_mode not in MODES:
self.last_mode = self.mode
if self.mood not in MOODS:
self.mood = "calm"
self.energy = max(0, min(100, int(self.energy)))
self.enabled = bool(self.enabled)
return self
def public(self) -> Dict[str, Any]:
data = asdict(self.normalised())
data["modes"] = sorted(MODES)
data["moods"] = sorted(MOODS)
return data
def state_path() -> Path:
override = os.environ.get("HERMES_DIGITAL_PET_STATE")
if override:
return Path(override).expanduser()
return get_hermes_home() / "digital-pet" / "state.json"
def default_state() -> PetState:
return PetState()
def load_state() -> PetState:
path = state_path()
if not path.exists():
return default_state()
try:
raw = json.loads(path.read_text(encoding="utf-8"))
except Exception:
return default_state()
allowed = {field.name for field in fields(PetState)}
return PetState(**{k: v for k, v in raw.items() if k in allowed}).normalised()
def save_state(state: PetState) -> PetState:
state.normalised()
path = state_path()
path.parent.mkdir(parents=True, exist_ok=True)
tmp = path.with_suffix(path.suffix + ".tmp")
tmp.write_text(json.dumps(state.public(), indent=2, sort_keys=True), encoding="utf-8")
tmp.replace(path)
return state
def _set_message(state: PetState, text: Optional[str], now: datetime, seconds: int = 5) -> None:
state.message = text
state.message_expires_at = iso(now + timedelta(seconds=seconds)) if text else None
if text:
state.prompt_cooldown_until = iso(now + timedelta(seconds=MESSAGE_COOLDOWN_SECONDS))
def _can_autospeak(state: PetState, now: datetime) -> bool:
until = parse_iso(state.prompt_cooldown_until)
return until is None or now >= until
def _recover_energy(state: PetState, amount: int) -> None:
state.energy = max(0, min(100, state.energy + amount))
if state.energy < 25:
state.mood = "tired"
elif state.mood == "tired" and state.energy >= 40:
state.mood = "calm"
def apply_event(
event_type: str,
*,
metadata: Optional[Dict[str, Any]] = None,
state: Optional[PetState] = None,
now: Optional[datetime] = None,
persist: bool = False,
) -> PetState:
now = now or utc_now()
state = (state or load_state()).normalised()
before = state.public().copy()
metadata = metadata or {}
if event_type == "summon":
state.enabled = True
state.last_mode = "idle"
state.mode = "idle"
state.last_interaction_at = iso(now)
_set_message(state, metadata.get("message") or "Station companion online.", now)
elif event_type == "hide":
state.enabled = False
state.last_mode = state.mode if state.mode != "hidden" else state.last_mode
state.mode = "hidden"
state.last_interaction_at = iso(now)
_set_message(state, None, now)
elif event_type == "hover_hold":
if state.mode in {"idle", "reacting", "prompting", "waking"}:
state.enabled = True
state.last_mode = state.mode
state.mode = "sleeping"
state.last_interaction_at = iso(now)
_recover_energy(state, 2)
_set_message(state, "Going quiet.", now)
elif event_type == "sleep":
if state.mode != "hidden":
state.enabled = True
state.last_mode = state.mode
state.mode = "sleeping"
state.last_interaction_at = iso(now)
_recover_energy(state, 2)
_set_message(state, "Sleeping.", now)
elif event_type == "wake":
if state.mode == "sleeping":
state.enabled = True
state.last_mode = "sleeping"
state.mode = "waking"
state.last_interaction_at = iso(now)
_recover_energy(state, 1)
_set_message(state, "Back online.", now)
elif state.mode == "hidden":
state.enabled = True
state.last_mode = "hidden"
state.mode = "idle"
state.last_interaction_at = iso(now)
_set_message(state, "Quiet watch resumed.", now)
elif event_type == "wake_complete":
if state.mode == "waking":
state.last_mode = "waking"
state.mode = "idle"
_set_message(state, None, now)
elif event_type == "click":
state.enabled = True
state.last_interaction_at = iso(now)
if state.mode == "sleeping":
state.last_mode = "sleeping"
state.mode = "waking"
_recover_energy(state, 1)
_set_message(state, "Back online.", now)
elif state.mode == "hidden":
state.last_mode = "hidden"
state.mode = "idle"
_set_message(state, "Station companion online.", now)
else:
state.last_mode = state.mode
state.mode = "reacting"
state.energy = max(0, state.energy - 4)
if state.energy < 25:
state.mood = "tired"
elif state.energy > 65:
state.mood = "pleased"
else:
state.mood = "curious"
_set_message(state, CLICK_LINES[state.mood], now)
elif event_type == "reaction_complete":
if state.mode == "reacting":
state.last_mode = "reacting"
state.mode = "idle"
_set_message(state, None, now)
elif event_type == "idle_tick":
if state.mode in {"sleeping", "hidden"}:
_recover_energy(state, 1)
elif event_type == "prompt_tick":
if state.mode == "idle" and state.enabled and _can_autospeak(state, now):
state.last_mode = "idle"
state.mode = "prompting"
_set_message(state, PROMPT_LINES[state.revision % len(PROMPT_LINES)], now, seconds=7)
elif event_type == "prompt_complete":
if state.mode == "prompting":
state.last_mode = "prompting"
state.mode = "idle"
_set_message(state, None, now)
elif event_type in {"tool_call_succeeded", "tool_call_failed", "session_started"}:
if state.mode == "idle" and state.enabled and _can_autospeak(state, now):
if (state.revision + 1) % 4 == 0:
state.last_mode = "idle"
state.mode = "reacting"
if event_type == "tool_call_failed":
state.mood = "curious"
_set_message(state, "Interesting. The recordings will tell us.", now)
elif event_type == "session_started":
state.mood = "calm"
_set_message(state, "Quiet watch resumed.", now)
else:
state.mood = "pleased"
_set_message(state, "Good. Procedure completed.", now)
if state.public() != before:
state.revision += 1
if persist:
save_state(state)
return state.normalised()
@@ -0,0 +1,27 @@
"""Test helper for importing plugin modules from a hyphenated directory."""
from __future__ import annotations
import importlib.util
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parents[1]
def load_module(filename: str, module_name: str, package: bool = False):
path = ROOT / filename
if package:
pkg_name = "digital_pet_pkg"
if pkg_name not in sys.modules:
pkg_spec = importlib.util.spec_from_file_location(pkg_name, ROOT / "__init__.py", submodule_search_locations=[str(ROOT)])
pkg = importlib.util.module_from_spec(pkg_spec)
sys.modules[pkg_name] = pkg
full_name = f"{pkg_name}.{path.stem}"
else:
full_name = module_name
spec = importlib.util.spec_from_file_location(full_name, path)
mod = importlib.util.module_from_spec(spec)
sys.modules[full_name] = mod
assert spec.loader is not None
spec.loader.exec_module(mod)
return mod
@@ -0,0 +1,99 @@
from __future__ import annotations
import subprocess
import textwrap
from pathlib import Path
def test_dashboard_overlay_cleans_timers_and_ignores_late_fetches():
plugin_root = Path(__file__).resolve().parents[1]
script = textwrap.dedent(
f"""
(async function () {{
const assert = require('assert');
const fs = require('fs');
const vm = require('vm');
const timers = new Map();
let nextTimer = 1;
const events = [];
const cleanups = [];
let registeredComponent = null;
let stateValue = {{ enabled: true, mode: 'idle', mood: 'calm', name: 'Signal', revision: 1 }};
let errorWrites = 0;
let stateWrites = 0;
function makeElement(type, props, ...children) {{
return {{ type, props: props || {{}}, children }};
}}
const React = {{
createElement: makeElement,
useState(initial) {{
if (initial === null) {{
const setter = function (value) {{ stateWrites += 1; stateValue = value; }};
return [stateValue, setter];
}}
const setter = function () {{ errorWrites += 1; }};
return [initial, setter];
}},
useRef(initial) {{ return {{ current: initial }}; }},
useEffect(fn) {{
const cleanup = fn();
if (cleanup) cleanups.push(cleanup);
}},
}};
const windowObj = {{
__HERMES_PLUGIN_SDK__: {{
React,
hooks: React,
authedFetch(url, options) {{
if (options && options.method === 'POST') {{
events.push(JSON.parse(options.body).type);
}}
return Promise.resolve({{
ok: true,
json: () => Promise.resolve({{ ok: true, state: stateValue }}),
}});
}},
}},
__HERMES_PLUGINS__: {{
registerSlot(plugin, slot, component) {{ registeredComponent = component; }}
}},
setTimeout(fn, delay) {{
const id = nextTimer++;
timers.set(id, {{ fn, delay }});
return id;
}},
clearTimeout(id) {{ timers.delete(id); }},
setInterval(fn, delay) {{
const id = nextTimer++;
timers.set(id, {{ fn, delay, interval: true }});
return id;
}},
clearInterval(id) {{ timers.delete(id); }},
}};
const context = {{ window: windowObj, fetch: windowObj.__HERMES_PLUGIN_SDK__.authedFetch, console }};
vm.createContext(context);
vm.runInContext(fs.readFileSync({str(plugin_root / 'dashboard/dist/index.js')!r}, 'utf8'), context);
assert.equal(typeof registeredComponent, 'function');
const tree = registeredComponent();
const avatar = tree.children[1];
avatar.props.onMouseEnter();
avatar.props.onMouseEnter();
const hoverTimers = Array.from(timers.values()).filter(t => t.delay === 1000);
assert.equal(hoverTimers.length, 1, 'duplicate hover enters should leave one hover timer');
for (const cleanup of cleanups) cleanup();
assert.equal(timers.size, 0, 'unmount should clear interval, hover, and transition timers');
avatar.props.onClick();
await Promise.resolve();
assert.deepEqual(events, ['click']);
assert.equal(stateWrites, 0, 'late fetch resolution after unmount must not write state');
assert.equal(errorWrites, 0, 'late fetch resolution after unmount must not write errors');
}})();
"""
)
result = subprocess.run(["node", "-e", script], text=True, capture_output=True, check=False)
assert result.returncode == 0, result.stdout + result.stderr
@@ -0,0 +1,62 @@
from __future__ import annotations
import json
from digital_pet_import import load_module
def test_register_adds_pet_command_and_observer_hooks():
plugin = load_module("__init__.py", "digital_pet_pkg", package=True)
class Ctx:
def __init__(self):
self.commands = []
self.hooks = []
self.skills = []
def register_command(self, *args, **kwargs):
self.commands.append((args, kwargs))
def register_hook(self, *args, **kwargs):
self.hooks.append((args, kwargs))
def register_skill(self, *args, **kwargs):
self.skills.append((args, kwargs))
ctx = Ctx()
plugin.register(ctx)
assert ctx.commands[0][0][0] == "pet"
assert ctx.commands[0][1]["description"].startswith("Summon")
assert ctx.commands[0][1]["args_hint"] == "[summon|hide|wake|sleep|status|click]"
assert [entry[0][0] for entry in ctx.hooks] == ["on_session_start", "post_tool_call"]
def test_pet_command_status_and_unknown_do_not_throw(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_DIGITAL_PET_STATE", str(tmp_path / "state.json"))
commands = load_module("commands.py", "digital_pet_pkg", package=True)
status = json.loads(commands.handle_pet_command("status"))
unknown = json.loads(commands.handle_pet_command("nonsense"))
assert status["ok"] is True
assert status["state"]["mode"] == "hidden"
assert unknown == {
"ok": False,
"error": "unknown pet command",
"usage": "/pet [summon|hide|wake|sleep|status|click]",
}
def test_pet_command_persists_summon_and_hide(monkeypatch, tmp_path):
monkeypatch.setenv("HERMES_DIGITAL_PET_STATE", str(tmp_path / "state.json"))
commands = load_module("commands.py", "digital_pet_pkg", package=True)
summoned = json.loads(commands.handle_pet_command("summon"))
hidden = json.loads(commands.handle_pet_command("hide"))
assert summoned["ok"] is True
assert summoned["state"]["mode"] == "idle"
assert hidden["ok"] is True
assert hidden["state"]["mode"] == "hidden"
assert (tmp_path / "state.json").exists()
+79
View File
@@ -0,0 +1,79 @@
from __future__ import annotations
from datetime import datetime, timezone
from digital_pet_import import load_module
state_mod = load_module("state.py", "digital_pet_state")
PetState = state_mod.PetState
apply_event = state_mod.apply_event
def at(second: int = 0):
return datetime(2026, 1, 1, 12, 0, second, tzinfo=timezone.utc)
def test_click_summons_hidden_pet_deterministically():
state = PetState()
next_state = apply_event("click", state=state, now=at())
assert next_state.enabled is True
assert next_state.mode == "idle"
assert next_state.last_mode == "hidden"
assert next_state.message == "Station companion online."
assert next_state.revision == 1
def test_hover_hold_sends_active_pet_to_sleep():
state = apply_event("summon", state=PetState(), now=at())
next_state = apply_event("hover_hold", state=state, now=at(1))
assert next_state.enabled is True
assert next_state.mode == "sleeping"
assert next_state.last_mode == "idle"
assert next_state.message == "Going quiet."
def test_sleeping_click_wakes_then_wake_complete_returns_idle():
state = apply_event("summon", state=PetState(), now=at())
state = apply_event("hover_hold", state=state, now=at(1))
waking = apply_event("click", state=state, now=at(2))
assert waking.mode == "waking"
assert waking.message == "Back online."
idle = apply_event("wake_complete", state=waking, now=at(3))
assert idle.mode == "idle"
assert idle.message is None
def test_reaction_and_prompt_complete_return_to_idle():
state = apply_event("summon", state=PetState(), now=at())
reacting = apply_event("click", state=state, now=at(1))
assert reacting.mode == "reacting"
idle = apply_event("reaction_complete", state=reacting, now=at(2))
assert idle.mode == "idle"
idle.prompt_cooldown_until = None
prompting = apply_event("prompt_tick", state=idle, now=at(3))
assert prompting.mode == "prompting"
completed = apply_event("prompt_complete", state=prompting, now=at(4))
assert completed.mode == "idle"
def test_hidden_and_sleeping_ignore_prompt_and_reacting_completion():
hidden = PetState()
after_prompt = apply_event("prompt_tick", state=hidden, now=at())
after_reaction_complete = apply_event("reaction_complete", state=after_prompt, now=at(1))
assert after_prompt.mode == "hidden"
assert after_reaction_complete.mode == "hidden"
sleeping = apply_event("sleep", state=PetState(enabled=True, mode="idle"), now=at(2))
still_sleeping = apply_event("prompt_tick", state=sleeping, now=at(3))
assert still_sleeping.mode == "sleeping"
-66
View File
@@ -1,66 +0,0 @@
"""Spotify integration plugin — bundled, auto-loaded.
Registers 7 tools (playback, devices, queue, search, playlists, albums,
library) into the ``spotify`` toolset. Each tool's handler is gated by
``_check_spotify_available()`` — when the user has not run ``hermes auth
spotify``, the tools remain registered (so they appear in ``hermes
tools``) but the runtime check prevents dispatch.
Why a plugin instead of a top-level ``tools/`` file?
- ``plugins/`` is where third-party service integrations live (see
``plugins/image_gen/`` for the backend-provider pattern, ``plugins/
disk-cleanup/`` for the standalone pattern). ``tools/`` is reserved
for foundational capabilities (terminal, read_file, web_search, etc.).
- Mirroring the image_gen plugin layout (``plugins/<category>/<backend>/``
for categories, flat ``plugins/<name>/`` for standalones) makes new
service integrations a pattern contributors can copy.
- Bundled + ``kind: backend`` auto-loads on startup just like image_gen
backends — no user opt-in needed, no ``plugins.enabled`` config.
The Spotify auth flow (``hermes auth spotify``), CLI plumbing, and docs
are unchanged. This move is purely structural.
"""
from __future__ import annotations
from plugins.spotify.tools import (
SPOTIFY_ALBUMS_SCHEMA,
SPOTIFY_DEVICES_SCHEMA,
SPOTIFY_LIBRARY_SCHEMA,
SPOTIFY_PLAYBACK_SCHEMA,
SPOTIFY_PLAYLISTS_SCHEMA,
SPOTIFY_QUEUE_SCHEMA,
SPOTIFY_SEARCH_SCHEMA,
_check_spotify_available,
_handle_spotify_albums,
_handle_spotify_devices,
_handle_spotify_library,
_handle_spotify_playback,
_handle_spotify_playlists,
_handle_spotify_queue,
_handle_spotify_search,
)
_TOOLS = (
("spotify_playback", SPOTIFY_PLAYBACK_SCHEMA, _handle_spotify_playback, "🎵"),
("spotify_devices", SPOTIFY_DEVICES_SCHEMA, _handle_spotify_devices, "🔈"),
("spotify_queue", SPOTIFY_QUEUE_SCHEMA, _handle_spotify_queue, "📻"),
("spotify_search", SPOTIFY_SEARCH_SCHEMA, _handle_spotify_search, "🔎"),
("spotify_playlists", SPOTIFY_PLAYLISTS_SCHEMA, _handle_spotify_playlists, "📚"),
("spotify_albums", SPOTIFY_ALBUMS_SCHEMA, _handle_spotify_albums, "💿"),
("spotify_library", SPOTIFY_LIBRARY_SCHEMA, _handle_spotify_library, "❤️"),
)
def register(ctx) -> None:
"""Register all Spotify tools. Called once by the plugin loader."""
for name, schema, handler, emoji in _TOOLS:
ctx.register_tool(
name=name,
toolset="spotify",
schema=schema,
handler=handler,
check_fn=_check_spotify_available,
emoji=emoji,
)
-435
View File
@@ -1,435 +0,0 @@
"""Thin Spotify Web API helper used by Hermes native tools."""
from __future__ import annotations
import json
from typing import Any, Dict, Iterable, Optional
from urllib.parse import urlparse
import httpx
from hermes_cli.auth import (
AuthError,
resolve_spotify_runtime_credentials,
)
class SpotifyError(RuntimeError):
"""Base Spotify tool error."""
class SpotifyAuthRequiredError(SpotifyError):
"""Raised when the user needs to authenticate with Spotify first."""
class SpotifyAPIError(SpotifyError):
"""Structured Spotify API failure."""
def __init__(
self,
message: str,
*,
status_code: Optional[int] = None,
response_body: Optional[str] = None,
) -> None:
super().__init__(message)
self.status_code = status_code
self.response_body = response_body
self.path = None
class SpotifyClient:
def __init__(self) -> None:
self._runtime = self._resolve_runtime(refresh_if_expiring=True)
def _resolve_runtime(self, *, force_refresh: bool = False, refresh_if_expiring: bool = True) -> Dict[str, Any]:
try:
return resolve_spotify_runtime_credentials(
force_refresh=force_refresh,
refresh_if_expiring=refresh_if_expiring,
)
except AuthError as exc:
raise SpotifyAuthRequiredError(str(exc)) from exc
@property
def base_url(self) -> str:
return str(self._runtime.get("base_url") or "").rstrip("/")
def _headers(self) -> Dict[str, str]:
return {
"Authorization": f"Bearer {self._runtime['access_token']}",
"Content-Type": "application/json",
}
def request(
self,
method: str,
path: str,
*,
params: Optional[Dict[str, Any]] = None,
json_body: Optional[Dict[str, Any]] = None,
allow_retry_on_401: bool = True,
empty_response: Optional[Dict[str, Any]] = None,
) -> Any:
url = f"{self.base_url}{path}"
response = httpx.request(
method,
url,
headers=self._headers(),
params=_strip_none(params),
json=_strip_none(json_body) if json_body is not None else None,
timeout=30.0,
)
if response.status_code == 401 and allow_retry_on_401:
self._runtime = self._resolve_runtime(force_refresh=True, refresh_if_expiring=True)
return self.request(
method,
path,
params=params,
json_body=json_body,
allow_retry_on_401=False,
)
if response.status_code >= 400:
self._raise_api_error(response, method=method, path=path)
if response.status_code == 204 or not response.content:
return empty_response or {"success": True, "status_code": response.status_code, "empty": True}
if "application/json" in response.headers.get("content-type", ""):
return response.json()
return {"success": True, "text": response.text}
def _raise_api_error(self, response: httpx.Response, *, method: str, path: str) -> None:
detail = response.text.strip()
message = _friendly_spotify_error_message(
status_code=response.status_code,
detail=_extract_spotify_error_detail(response, fallback=detail),
method=method,
path=path,
retry_after=response.headers.get("Retry-After"),
)
error = SpotifyAPIError(message, status_code=response.status_code, response_body=detail)
error.path = path
raise error
def get_devices(self) -> Any:
return self.request("GET", "/me/player/devices")
def transfer_playback(self, *, device_id: str, play: bool = False) -> Any:
return self.request("PUT", "/me/player", json_body={
"device_ids": [device_id],
"play": play,
})
def get_playback_state(self, *, market: Optional[str] = None) -> Any:
return self.request(
"GET",
"/me/player",
params={"market": market},
empty_response={
"status_code": 204,
"empty": True,
"message": "No active Spotify playback session was found. Open Spotify on a device and start playback, or transfer playback to an available device.",
},
)
def get_currently_playing(self, *, market: Optional[str] = None) -> Any:
return self.request(
"GET",
"/me/player/currently-playing",
params={"market": market},
empty_response={
"status_code": 204,
"empty": True,
"message": "Spotify is not currently playing anything. Start playback in Spotify and try again.",
},
)
def start_playback(
self,
*,
device_id: Optional[str] = None,
context_uri: Optional[str] = None,
uris: Optional[list[str]] = None,
offset: Optional[Dict[str, Any]] = None,
position_ms: Optional[int] = None,
) -> Any:
return self.request(
"PUT",
"/me/player/play",
params={"device_id": device_id},
json_body={
"context_uri": context_uri,
"uris": uris,
"offset": offset,
"position_ms": position_ms,
},
)
def pause_playback(self, *, device_id: Optional[str] = None) -> Any:
return self.request("PUT", "/me/player/pause", params={"device_id": device_id})
def skip_next(self, *, device_id: Optional[str] = None) -> Any:
return self.request("POST", "/me/player/next", params={"device_id": device_id})
def skip_previous(self, *, device_id: Optional[str] = None) -> Any:
return self.request("POST", "/me/player/previous", params={"device_id": device_id})
def seek(self, *, position_ms: int, device_id: Optional[str] = None) -> Any:
return self.request("PUT", "/me/player/seek", params={
"position_ms": position_ms,
"device_id": device_id,
})
def set_repeat(self, *, state: str, device_id: Optional[str] = None) -> Any:
return self.request("PUT", "/me/player/repeat", params={"state": state, "device_id": device_id})
def set_shuffle(self, *, state: bool, device_id: Optional[str] = None) -> Any:
return self.request("PUT", "/me/player/shuffle", params={"state": str(bool(state)).lower(), "device_id": device_id})
def set_volume(self, *, volume_percent: int, device_id: Optional[str] = None) -> Any:
return self.request("PUT", "/me/player/volume", params={
"volume_percent": volume_percent,
"device_id": device_id,
})
def get_queue(self) -> Any:
return self.request("GET", "/me/player/queue")
def add_to_queue(self, *, uri: str, device_id: Optional[str] = None) -> Any:
return self.request("POST", "/me/player/queue", params={"uri": uri, "device_id": device_id})
def search(
self,
*,
query: str,
search_types: list[str],
limit: int = 10,
offset: int = 0,
market: Optional[str] = None,
include_external: Optional[str] = None,
) -> Any:
return self.request("GET", "/search", params={
"q": query,
"type": ",".join(search_types),
"limit": limit,
"offset": offset,
"market": market,
"include_external": include_external,
})
def get_my_playlists(self, *, limit: int = 20, offset: int = 0) -> Any:
return self.request("GET", "/me/playlists", params={"limit": limit, "offset": offset})
def get_playlist(self, *, playlist_id: str, market: Optional[str] = None) -> Any:
return self.request("GET", f"/playlists/{playlist_id}", params={"market": market})
def create_playlist(
self,
*,
name: str,
public: bool = False,
collaborative: bool = False,
description: Optional[str] = None,
) -> Any:
return self.request("POST", "/me/playlists", json_body={
"name": name,
"public": public,
"collaborative": collaborative,
"description": description,
})
def add_playlist_items(
self,
*,
playlist_id: str,
uris: list[str],
position: Optional[int] = None,
) -> Any:
return self.request("POST", f"/playlists/{playlist_id}/items", json_body={
"uris": uris,
"position": position,
})
def remove_playlist_items(
self,
*,
playlist_id: str,
uris: list[str],
snapshot_id: Optional[str] = None,
) -> Any:
return self.request("DELETE", f"/playlists/{playlist_id}/items", json_body={
"items": [{"uri": uri} for uri in uris],
"snapshot_id": snapshot_id,
})
def update_playlist_details(
self,
*,
playlist_id: str,
name: Optional[str] = None,
public: Optional[bool] = None,
collaborative: Optional[bool] = None,
description: Optional[str] = None,
) -> Any:
return self.request("PUT", f"/playlists/{playlist_id}", json_body={
"name": name,
"public": public,
"collaborative": collaborative,
"description": description,
})
def get_album(self, *, album_id: str, market: Optional[str] = None) -> Any:
return self.request("GET", f"/albums/{album_id}", params={"market": market})
def get_album_tracks(self, *, album_id: str, limit: int = 20, offset: int = 0, market: Optional[str] = None) -> Any:
return self.request("GET", f"/albums/{album_id}/tracks", params={
"limit": limit,
"offset": offset,
"market": market,
})
def get_saved_tracks(self, *, limit: int = 20, offset: int = 0, market: Optional[str] = None) -> Any:
return self.request("GET", "/me/tracks", params={"limit": limit, "offset": offset, "market": market})
def save_library_items(self, *, uris: list[str]) -> Any:
return self.request("PUT", "/me/library", params={"uris": ",".join(uris)})
def library_contains(self, *, uris: list[str]) -> Any:
return self.request("GET", "/me/library/contains", params={"uris": ",".join(uris)})
def get_saved_albums(self, *, limit: int = 20, offset: int = 0, market: Optional[str] = None) -> Any:
return self.request("GET", "/me/albums", params={"limit": limit, "offset": offset, "market": market})
def remove_saved_tracks(self, *, track_ids: list[str]) -> Any:
uris = [f"spotify:track:{track_id}" for track_id in track_ids]
return self.request("DELETE", "/me/library", params={"uris": ",".join(uris)})
def remove_saved_albums(self, *, album_ids: list[str]) -> Any:
uris = [f"spotify:album:{album_id}" for album_id in album_ids]
return self.request("DELETE", "/me/library", params={"uris": ",".join(uris)})
def get_recently_played(
self,
*,
limit: int = 20,
after: Optional[int] = None,
before: Optional[int] = None,
) -> Any:
return self.request("GET", "/me/player/recently-played", params={
"limit": limit,
"after": after,
"before": before,
})
def _extract_spotify_error_detail(response: httpx.Response, *, fallback: str) -> str:
detail = fallback
try:
payload = response.json()
if isinstance(payload, dict):
error_obj = payload.get("error")
if isinstance(error_obj, dict):
detail = str(error_obj.get("message") or detail)
elif isinstance(error_obj, str):
detail = error_obj
except Exception:
pass
return detail.strip()
def _friendly_spotify_error_message(
*,
status_code: int,
detail: str,
method: str,
path: str,
retry_after: Optional[str],
) -> str:
normalized_detail = detail.lower()
is_playback_path = path.startswith("/me/player")
if status_code == 401:
return "Spotify authentication failed or expired. Run `hermes auth spotify` again."
if status_code == 403:
if is_playback_path:
return (
"Spotify rejected this playback request. Playback control usually requires a Spotify Premium account "
"and an active Spotify Connect device."
)
if "scope" in normalized_detail or "permission" in normalized_detail:
return "Spotify rejected the request because the current auth scope is insufficient. Re-run `hermes auth spotify` to refresh permissions."
return "Spotify rejected the request. The account may not have permission for this action."
if status_code == 404:
if is_playback_path:
return "Spotify could not find an active playback device or player session for this request."
return "Spotify resource not found."
if status_code == 429:
message = "Spotify rate limit exceeded."
if retry_after:
message += f" Retry after {retry_after} seconds."
return message
if detail:
return detail
return f"Spotify API request failed with status {status_code}."
def _strip_none(payload: Optional[Dict[str, Any]]) -> Dict[str, Any]:
if not payload:
return {}
return {key: value for key, value in payload.items() if value is not None}
def normalize_spotify_id(value: str, expected_type: Optional[str] = None) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise SpotifyError("Spotify id/uri/url is required.")
if cleaned.startswith("spotify:"):
parts = cleaned.split(":")
if len(parts) >= 3:
item_type = parts[1]
if expected_type and item_type != expected_type:
raise SpotifyError(f"Expected a Spotify {expected_type}, got {item_type}.")
return parts[2]
if "open.spotify.com" in cleaned:
parsed = urlparse(cleaned)
path_parts = [part for part in parsed.path.split("/") if part]
if len(path_parts) >= 2:
item_type, item_id = path_parts[0], path_parts[1]
if expected_type and item_type != expected_type:
raise SpotifyError(f"Expected a Spotify {expected_type}, got {item_type}.")
return item_id
return cleaned
def normalize_spotify_uri(value: str, expected_type: Optional[str] = None) -> str:
cleaned = (value or "").strip()
if not cleaned:
raise SpotifyError("Spotify URI/url/id is required.")
if cleaned.startswith("spotify:"):
if expected_type:
parts = cleaned.split(":")
if len(parts) >= 3 and parts[1] != expected_type:
raise SpotifyError(f"Expected a Spotify {expected_type}, got {parts[1]}.")
return cleaned
item_id = normalize_spotify_id(cleaned, expected_type)
if expected_type:
return f"spotify:{expected_type}:{item_id}"
return cleaned
def normalize_spotify_uris(values: Iterable[str], expected_type: Optional[str] = None) -> list[str]:
uris: list[str] = []
for value in values:
uri = normalize_spotify_uri(str(value), expected_type)
if uri not in uris:
uris.append(uri)
if not uris:
raise SpotifyError("At least one Spotify item is required.")
return uris
def compact_json(data: Any) -> str:
return json.dumps(data, ensure_ascii=False)
-13
View File
@@ -1,13 +0,0 @@
name: spotify
version: 1.0.0
description: "Native Spotify integration — 7 tools (playback, devices, queue, search, playlists, albums, library) using Spotify Web API + PKCE OAuth. Auth via `hermes auth spotify`. Tools gate on `providers.spotify` in ~/.hermes/auth.json."
author: NousResearch
kind: backend
provides_tools:
- spotify_playback
- spotify_devices
- spotify_queue
- spotify_search
- spotify_playlists
- spotify_albums
- spotify_library
-454
View File
@@ -1,454 +0,0 @@
"""Native Spotify tools for Hermes (registered via plugins/spotify)."""
from __future__ import annotations
from typing import Any, List
from hermes_cli.auth import get_auth_status
from plugins.spotify.client import (
SpotifyAPIError,
SpotifyAuthRequiredError,
SpotifyClient,
SpotifyError,
normalize_spotify_id,
normalize_spotify_uri,
normalize_spotify_uris,
)
from tools.registry import tool_error, tool_result
def _check_spotify_available() -> bool:
try:
return bool(get_auth_status("spotify").get("logged_in"))
except Exception:
return False
def _spotify_client() -> SpotifyClient:
return SpotifyClient()
def _spotify_tool_error(exc: Exception) -> str:
if isinstance(exc, (SpotifyError, SpotifyAuthRequiredError)):
return tool_error(str(exc))
if isinstance(exc, SpotifyAPIError):
return tool_error(str(exc), status_code=exc.status_code)
return tool_error(f"Spotify tool failed: {type(exc).__name__}: {exc}")
def _coerce_limit(raw: Any, *, default: int = 20, minimum: int = 1, maximum: int = 50) -> int:
try:
value = int(raw)
except Exception:
value = default
return max(minimum, min(maximum, value))
def _coerce_bool(raw: Any, default: bool = False) -> bool:
if isinstance(raw, bool):
return raw
if isinstance(raw, str):
cleaned = raw.strip().lower()
if cleaned in {"1", "true", "yes", "on"}:
return True
if cleaned in {"0", "false", "no", "off"}:
return False
return default
def _as_list(raw: Any) -> List[str]:
if raw is None:
return []
if isinstance(raw, list):
return [str(item).strip() for item in raw if str(item).strip()]
return [str(raw).strip()] if str(raw).strip() else []
def _describe_empty_playback(payload: Any, *, action: str) -> dict | None:
if not isinstance(payload, dict) or not payload.get("empty"):
return None
if action == "get_currently_playing":
return {
"success": True,
"action": action,
"is_playing": False,
"status_code": payload.get("status_code", 204),
"message": payload.get("message") or "Spotify is not currently playing anything.",
}
if action == "get_state":
return {
"success": True,
"action": action,
"has_active_device": False,
"status_code": payload.get("status_code", 204),
"message": payload.get("message") or "No active Spotify playback session was found.",
}
return None
def _handle_spotify_playback(args: dict, **kw) -> str:
action = str(args.get("action") or "get_state").strip().lower()
client = _spotify_client()
try:
if action == "get_state":
payload = client.get_playback_state(market=args.get("market"))
empty_result = _describe_empty_playback(payload, action=action)
return tool_result(empty_result or payload)
if action == "get_currently_playing":
payload = client.get_currently_playing(market=args.get("market"))
empty_result = _describe_empty_playback(payload, action=action)
return tool_result(empty_result or payload)
if action == "play":
offset = args.get("offset")
if isinstance(offset, dict):
payload_offset = {k: v for k, v in offset.items() if v is not None}
else:
payload_offset = None
uris = normalize_spotify_uris(_as_list(args.get("uris")), "track") if args.get("uris") else None
context_uri = None
if args.get("context_uri"):
raw_context = str(args.get("context_uri"))
context_type = None
if raw_context.startswith("spotify:album:") or "/album/" in raw_context:
context_type = "album"
elif raw_context.startswith("spotify:playlist:") or "/playlist/" in raw_context:
context_type = "playlist"
elif raw_context.startswith("spotify:artist:") or "/artist/" in raw_context:
context_type = "artist"
context_uri = normalize_spotify_uri(raw_context, context_type)
result = client.start_playback(
device_id=args.get("device_id"),
context_uri=context_uri,
uris=uris,
offset=payload_offset,
position_ms=args.get("position_ms"),
)
return tool_result({"success": True, "action": action, "result": result})
if action == "pause":
result = client.pause_playback(device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "result": result})
if action == "next":
result = client.skip_next(device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "result": result})
if action == "previous":
result = client.skip_previous(device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "result": result})
if action == "seek":
if args.get("position_ms") is None:
return tool_error("position_ms is required for action='seek'")
result = client.seek(position_ms=int(args["position_ms"]), device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "result": result})
if action == "set_repeat":
state = str(args.get("state") or "").strip().lower()
if state not in {"track", "context", "off"}:
return tool_error("state must be one of: track, context, off")
result = client.set_repeat(state=state, device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "result": result})
if action == "set_shuffle":
result = client.set_shuffle(state=_coerce_bool(args.get("state")), device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "result": result})
if action == "set_volume":
if args.get("volume_percent") is None:
return tool_error("volume_percent is required for action='set_volume'")
result = client.set_volume(volume_percent=max(0, min(100, int(args["volume_percent"]))), device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "result": result})
if action == "recently_played":
after = args.get("after")
before = args.get("before")
if after and before:
return tool_error("Provide only one of 'after' or 'before'")
return tool_result(client.get_recently_played(
limit=_coerce_limit(args.get("limit"), default=20),
after=int(after) if after is not None else None,
before=int(before) if before is not None else None,
))
return tool_error(f"Unknown spotify_playback action: {action}")
except Exception as exc:
return _spotify_tool_error(exc)
def _handle_spotify_devices(args: dict, **kw) -> str:
action = str(args.get("action") or "list").strip().lower()
client = _spotify_client()
try:
if action == "list":
return tool_result(client.get_devices())
if action == "transfer":
device_id = str(args.get("device_id") or "").strip()
if not device_id:
return tool_error("device_id is required for action='transfer'")
result = client.transfer_playback(device_id=device_id, play=_coerce_bool(args.get("play")))
return tool_result({"success": True, "action": action, "result": result})
return tool_error(f"Unknown spotify_devices action: {action}")
except Exception as exc:
return _spotify_tool_error(exc)
def _handle_spotify_queue(args: dict, **kw) -> str:
action = str(args.get("action") or "get").strip().lower()
client = _spotify_client()
try:
if action == "get":
return tool_result(client.get_queue())
if action == "add":
uri = normalize_spotify_uri(str(args.get("uri") or ""), None)
result = client.add_to_queue(uri=uri, device_id=args.get("device_id"))
return tool_result({"success": True, "action": action, "uri": uri, "result": result})
return tool_error(f"Unknown spotify_queue action: {action}")
except Exception as exc:
return _spotify_tool_error(exc)
def _handle_spotify_search(args: dict, **kw) -> str:
client = _spotify_client()
query = str(args.get("query") or "").strip()
if not query:
return tool_error("query is required")
raw_types = _as_list(args.get("types") or args.get("type") or ["track"])
search_types = [value.lower() for value in raw_types if value.lower() in {"album", "artist", "playlist", "track", "show", "episode", "audiobook"}]
if not search_types:
return tool_error("types must contain one or more of: album, artist, playlist, track, show, episode, audiobook")
try:
return tool_result(client.search(
query=query,
search_types=search_types,
limit=_coerce_limit(args.get("limit"), default=10),
offset=max(0, int(args.get("offset") or 0)),
market=args.get("market"),
include_external=args.get("include_external"),
))
except Exception as exc:
return _spotify_tool_error(exc)
def _handle_spotify_playlists(args: dict, **kw) -> str:
action = str(args.get("action") or "list").strip().lower()
client = _spotify_client()
try:
if action == "list":
return tool_result(client.get_my_playlists(
limit=_coerce_limit(args.get("limit"), default=20),
offset=max(0, int(args.get("offset") or 0)),
))
if action == "get":
playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
return tool_result(client.get_playlist(playlist_id=playlist_id, market=args.get("market")))
if action == "create":
name = str(args.get("name") or "").strip()
if not name:
return tool_error("name is required for action='create'")
return tool_result(client.create_playlist(
name=name,
public=_coerce_bool(args.get("public")),
collaborative=_coerce_bool(args.get("collaborative")),
description=args.get("description"),
))
if action == "add_items":
playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
uris = normalize_spotify_uris(_as_list(args.get("uris")))
return tool_result(client.add_playlist_items(
playlist_id=playlist_id,
uris=uris,
position=args.get("position"),
))
if action == "remove_items":
playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
uris = normalize_spotify_uris(_as_list(args.get("uris")))
return tool_result(client.remove_playlist_items(
playlist_id=playlist_id,
uris=uris,
snapshot_id=args.get("snapshot_id"),
))
if action == "update_details":
playlist_id = normalize_spotify_id(str(args.get("playlist_id") or ""), "playlist")
return tool_result(client.update_playlist_details(
playlist_id=playlist_id,
name=args.get("name"),
public=args.get("public"),
collaborative=args.get("collaborative"),
description=args.get("description"),
))
return tool_error(f"Unknown spotify_playlists action: {action}")
except Exception as exc:
return _spotify_tool_error(exc)
def _handle_spotify_albums(args: dict, **kw) -> str:
action = str(args.get("action") or "get").strip().lower()
client = _spotify_client()
try:
album_id = normalize_spotify_id(str(args.get("album_id") or args.get("id") or ""), "album")
if action == "get":
return tool_result(client.get_album(album_id=album_id, market=args.get("market")))
if action == "tracks":
return tool_result(client.get_album_tracks(
album_id=album_id,
limit=_coerce_limit(args.get("limit"), default=20),
offset=max(0, int(args.get("offset") or 0)),
market=args.get("market"),
))
return tool_error(f"Unknown spotify_albums action: {action}")
except Exception as exc:
return _spotify_tool_error(exc)
def _handle_spotify_library(args: dict, **kw) -> str:
"""Unified handler for saved tracks + saved albums (formerly two tools)."""
kind = str(args.get("kind") or "").strip().lower()
if kind not in {"tracks", "albums"}:
return tool_error("kind must be one of: tracks, albums")
action = str(args.get("action") or "list").strip().lower()
item_type = "track" if kind == "tracks" else "album"
client = _spotify_client()
try:
if action == "list":
limit = _coerce_limit(args.get("limit"), default=20)
offset = max(0, int(args.get("offset") or 0))
market = args.get("market")
if kind == "tracks":
return tool_result(client.get_saved_tracks(limit=limit, offset=offset, market=market))
return tool_result(client.get_saved_albums(limit=limit, offset=offset, market=market))
if action == "save":
uris = normalize_spotify_uris(_as_list(args.get("uris") or args.get("items")), item_type)
return tool_result(client.save_library_items(uris=uris))
if action == "remove":
ids = [normalize_spotify_id(item, item_type) for item in _as_list(args.get("ids") or args.get("items"))]
if not ids:
return tool_error("ids/items is required for action='remove'")
if kind == "tracks":
return tool_result(client.remove_saved_tracks(track_ids=ids))
return tool_result(client.remove_saved_albums(album_ids=ids))
return tool_error(f"Unknown spotify_library action: {action}")
except Exception as exc:
return _spotify_tool_error(exc)
COMMON_STRING = {"type": "string"}
SPOTIFY_PLAYBACK_SCHEMA = {
"name": "spotify_playback",
"description": "Control Spotify playback, inspect the active playback state, or fetch recently played tracks.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["get_state", "get_currently_playing", "play", "pause", "next", "previous", "seek", "set_repeat", "set_shuffle", "set_volume", "recently_played"]},
"device_id": COMMON_STRING,
"market": COMMON_STRING,
"context_uri": COMMON_STRING,
"uris": {"type": "array", "items": COMMON_STRING},
"offset": {"type": "object"},
"position_ms": {"type": "integer"},
"state": {"description": "For set_repeat use track/context/off. For set_shuffle use boolean-like true/false.", "oneOf": [{"type": "string"}, {"type": "boolean"}]},
"volume_percent": {"type": "integer"},
"limit": {"type": "integer", "description": "For recently_played: number of tracks (max 50)"},
"after": {"type": "integer", "description": "For recently_played: Unix ms cursor (after this timestamp)"},
"before": {"type": "integer", "description": "For recently_played: Unix ms cursor (before this timestamp)"},
},
"required": ["action"],
},
}
SPOTIFY_DEVICES_SCHEMA = {
"name": "spotify_devices",
"description": "List Spotify Connect devices or transfer playback to a different device.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "transfer"]},
"device_id": COMMON_STRING,
"play": {"type": "boolean"},
},
"required": ["action"],
},
}
SPOTIFY_QUEUE_SCHEMA = {
"name": "spotify_queue",
"description": "Inspect the user's Spotify queue or add an item to it.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["get", "add"]},
"uri": COMMON_STRING,
"device_id": COMMON_STRING,
},
"required": ["action"],
},
}
SPOTIFY_SEARCH_SCHEMA = {
"name": "spotify_search",
"description": "Search the Spotify catalog for tracks, albums, artists, playlists, shows, or episodes.",
"parameters": {
"type": "object",
"properties": {
"query": COMMON_STRING,
"types": {"type": "array", "items": COMMON_STRING},
"type": COMMON_STRING,
"limit": {"type": "integer"},
"offset": {"type": "integer"},
"market": COMMON_STRING,
"include_external": COMMON_STRING,
},
"required": ["query"],
},
}
SPOTIFY_PLAYLISTS_SCHEMA = {
"name": "spotify_playlists",
"description": "List, inspect, create, update, and modify Spotify playlists.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["list", "get", "create", "add_items", "remove_items", "update_details"]},
"playlist_id": COMMON_STRING,
"market": COMMON_STRING,
"limit": {"type": "integer"},
"offset": {"type": "integer"},
"name": COMMON_STRING,
"description": COMMON_STRING,
"public": {"type": "boolean"},
"collaborative": {"type": "boolean"},
"uris": {"type": "array", "items": COMMON_STRING},
"position": {"type": "integer"},
"snapshot_id": COMMON_STRING,
},
"required": ["action"],
},
}
SPOTIFY_ALBUMS_SCHEMA = {
"name": "spotify_albums",
"description": "Fetch Spotify album metadata or album tracks.",
"parameters": {
"type": "object",
"properties": {
"action": {"type": "string", "enum": ["get", "tracks"]},
"album_id": COMMON_STRING,
"id": COMMON_STRING,
"market": COMMON_STRING,
"limit": {"type": "integer"},
"offset": {"type": "integer"},
},
"required": ["action"],
},
}
SPOTIFY_LIBRARY_SCHEMA = {
"name": "spotify_library",
"description": "List, save, or remove the user's saved Spotify tracks or albums. Use `kind` to select which.",
"parameters": {
"type": "object",
"properties": {
"kind": {"type": "string", "enum": ["tracks", "albums"], "description": "Which library to operate on"},
"action": {"type": "string", "enum": ["list", "save", "remove"]},
"limit": {"type": "integer"},
"offset": {"type": "integer"},
"market": COMMON_STRING,
"uris": {"type": "array", "items": COMMON_STRING},
"ids": {"type": "array", "items": COMMON_STRING},
"items": {"type": "array", "items": COMMON_STRING},
},
"required": ["kind", "action"],
},
}