The Pirate — Phase 1.a: conversational read-only media agent

Ships a chat-based agent at /pirate that LLM-routes user questions to media-stack tools and returns natural-language answers grounded in real data. Foundation built on top of the existing API-tokens + dual-auth infrastructure so other apps (Open WebUI, HA voice, Synap) can consume the same Pirate API.

New subsystem (not the standard trigger/result pattern):
- pirate_conversations + pirate_messages tables
- service_configs table (admin-wide creds shared by media agents)
- /api/pirate/chat + /api/pirate/conversations/* (dual-auth: user session OR Bearer token scoped to user's pirate instance)
- /api/internal/pirate/* endpoints used by runtime subprocess
- /api/admin/services + Services tab in admin.html for cred management
- Auto-seeded service_configs on startup from Media Stack Reference defaults (never overwrite admin edits)
- Auto-seeded pirate catalog entry + per-user pirate instance on startup

Pirate package (agents/pirate/):
- prompts.py: system prompt, enforces read-only in Phase 1
- runtime.py: Anthropic-native tool-use loop (max 8 iterations, persists every turn)
- tools/_common.py: service_configs fetch + qBit session auth
- tools/sonarr.py: queue, upcoming, series_search, library_stats
- tools/radarr.py: queue, movie_search, library_stats
- tools/qbittorrent.py: torrents, transfer_stats, categories
- tools/storage.py: disk_space (via Sonarr diskspace API)
- Default model: claude-sonnet-4-5 (Haiku fumbles multi-step chains)

Dashboard:
- static/pirate.html — full chat UI with conversation sidebar, suggestion chips, inline tool-call visualization, 24h idle reset + New Chat button
- Pirate button added to main dashboard header

Wiki reorg: Agents / Developer Guides / Plans parent docs, per-agent reference docs, The Pirate doc. API Clients + Calling Agents docs moved under Developer Guides.

Working folder: PIRATE_PHASE_1A.md + NEXT_SESSION_PROMPT.md for fast bootstrap.

Smoke tested end-to-end: real tool calls against qBittorrent (13 active torrents correctly reported) and Sonarr disk-space; multi-turn conversation state preserved across follow-up questions.

On deck: Phase 1.b (Lidarr/Whisparr/Overseerr/Plex tools), then 1.d (OWUI pipeline), then 1.c (HA voice).
This commit is contained in:
Eric Jungbauer
2026-04-20 19:01:50 +00:00
parent 043aa18f3f
commit eac7b64a90
14 changed files with 1474 additions and 3 deletions
+88
View File
@@ -0,0 +1,88 @@
"""qBittorrent tools — torrent queue and transfer stats (read-only)."""
from ._common import qbit_login_if_needed, qbit_request
def _compact_torrent(t):
return {
"name": t.get("name"),
"state": t.get("state"), # downloading, uploading, stalledDL, pausedDL, completed, ...
"progress_pct": round((t.get("progress", 0) or 0) * 100, 1),
"size_gb": round((t.get("size", 0) or 0) / 1e9, 2),
"dl_speed_mbps": round((t.get("dlspeed", 0) or 0) / 1e6, 2),
"up_speed_mbps": round((t.get("upspeed", 0) or 0) / 1e6, 2),
"eta_seconds": t.get("eta"),
"category": t.get("category"),
"ratio": round(t.get("ratio", 0) or 0, 2),
"num_seeds": t.get("num_seeds"),
"num_peers": t.get("num_leechs") or t.get("num_peers"),
"added_on_epoch": t.get("added_on"),
}
def qbit_torrents(filter="all", category=None, limit=30):
"""List torrents. Filter is one of: all, downloading, seeding, completed, paused, stalled, errored."""
cookie = qbit_login_if_needed()
params = {"filter": filter}
if category:
params["category"] = category
data, _ = qbit_request("/api/v2/torrents/info", params=params, cookies=cookie)
if isinstance(data, str):
return {"error": data}
items = [_compact_torrent(t) for t in data[:limit]]
return {"filter": filter, "category": category, "count": len(data), "torrents": items}
def qbit_transfer_stats():
"""Global transfer stats: overall download / upload speed, session totals, DHT nodes."""
cookie = qbit_login_if_needed()
data, _ = qbit_request("/api/v2/transfer/info", cookies=cookie)
if isinstance(data, str):
return {"error": data}
return {
"dl_speed_mbps": round((data.get("dl_info_speed", 0) or 0) / 1e6, 2),
"up_speed_mbps": round((data.get("up_info_speed", 0) or 0) / 1e6, 2),
"dl_session_gb": round((data.get("dl_info_data", 0) or 0) / 1e9, 2),
"up_session_gb": round((data.get("up_info_data", 0) or 0) / 1e9, 2),
"dht_nodes": data.get("dht_nodes"),
"connection_status": data.get("connection_status"),
}
def qbit_categories():
"""Configured torrent categories (e.g., sonarr, radarr, lidarr, whisparr)."""
cookie = qbit_login_if_needed()
data, _ = qbit_request("/api/v2/torrents/categories", cookies=cookie)
return data if isinstance(data, dict) else {"error": str(data)}
TOOLS = [
{
"name": "qbit_torrents",
"description": "List torrents in qBittorrent with their progress, state, speed, and ETA. Filter options: all, downloading, seeding, completed, paused, stalled, errored. Category options: sonarr, radarr, lidarr, whisparr (which *arr requested it).",
"input_schema": {
"type": "object",
"properties": {
"filter": {"type": "string", "default": "all", "enum": ["all", "downloading", "seeding", "completed", "paused", "stalled", "errored"]},
"category": {"type": "string", "description": "Optional category filter"},
"limit": {"type": "integer", "default": 30},
},
},
"read_only": True,
"fn": qbit_torrents,
},
{
"name": "qbit_transfer_stats",
"description": "Current global download / upload speed and session totals from qBittorrent. Use when user asks 'how fast is the download', 'what's my current download speed', 'total downloaded this session'.",
"input_schema": {"type": "object", "properties": {}},
"read_only": True,
"fn": qbit_transfer_stats,
},
{
"name": "qbit_categories",
"description": "List the torrent categories configured in qBittorrent. Shows which *arr is pulling what.",
"input_schema": {"type": "object", "properties": {}},
"read_only": True,
"fn": qbit_categories,
},
]