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:
@@ -0,0 +1,72 @@
|
||||
"""Shared helpers for pirate tools."""
|
||||
|
||||
import sys
|
||||
import os
|
||||
from urllib import request as urlreq, error as urlerror, parse as urlparse
|
||||
import json
|
||||
|
||||
# Add parent agents/ to path so we can import shared.py
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))))
|
||||
from shared import DASHBOARD_API, api_request
|
||||
|
||||
|
||||
def get_service_config(service_name):
|
||||
"""Fetch admin-configured creds for a service (sonarr, radarr, qbittorrent, etc.)."""
|
||||
try:
|
||||
return api_request(f"{DASHBOARD_API}/api/internal/services/{service_name}", retries=1)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Service '{service_name}' is not configured in admin panel: {e}")
|
||||
|
||||
|
||||
def arr_get(service_name, path, params=None):
|
||||
"""GET from an *arr API (Sonarr/Radarr/Lidarr/Whisparr — all use the same v3 API shape)."""
|
||||
cfg = get_service_config(service_name)
|
||||
base = cfg["base_url"].rstrip("/")
|
||||
key = cfg["api_key"]
|
||||
if not base or not key:
|
||||
raise RuntimeError(f"{service_name} not configured (base_url + api_key required)")
|
||||
url = f"{base}{path}"
|
||||
if params:
|
||||
url += "?" + urlparse.urlencode(params)
|
||||
req = urlreq.Request(url, headers={"X-Api-Key": key})
|
||||
with urlreq.urlopen(req, timeout=15) as resp:
|
||||
return json.loads(resp.read().decode())
|
||||
|
||||
|
||||
def qbit_request(path, method="GET", params=None, data=None, cookies=None):
|
||||
"""qBittorrent Web API helper. Login (if password set) returns SID cookie string."""
|
||||
cfg = get_service_config("qbittorrent")
|
||||
base = cfg["base_url"].rstrip("/")
|
||||
if not base:
|
||||
raise RuntimeError("qbittorrent not configured")
|
||||
url = f"{base}{path}"
|
||||
if params:
|
||||
url += "?" + urlparse.urlencode(params)
|
||||
body = None
|
||||
headers = {}
|
||||
if cookies:
|
||||
headers["Cookie"] = cookies
|
||||
if data is not None:
|
||||
body = urlparse.urlencode(data).encode()
|
||||
headers["Content-Type"] = "application/x-www-form-urlencoded"
|
||||
req = urlreq.Request(url, data=body, headers=headers, method=method)
|
||||
with urlreq.urlopen(req, timeout=15) as resp:
|
||||
raw = resp.read().decode()
|
||||
try:
|
||||
return json.loads(raw), resp.headers
|
||||
except json.JSONDecodeError:
|
||||
return raw, resp.headers
|
||||
|
||||
|
||||
def qbit_login_if_needed():
|
||||
"""If qBit has auth enabled, login and return the SID cookie string. Else return None."""
|
||||
cfg = get_service_config("qbittorrent")
|
||||
user = cfg.get("username", "")
|
||||
pw = cfg.get("password", "")
|
||||
if not user or not pw:
|
||||
return None # LAN no-auth mode
|
||||
_, hdrs = qbit_request("/api/v2/auth/login", method="POST",
|
||||
data={"username": user, "password": pw})
|
||||
cookies = hdrs.get("Set-Cookie", "")
|
||||
# Strip everything after the first semicolon (path/expiry)
|
||||
return cookies.split(";")[0] if cookies else None
|
||||
Reference in New Issue
Block a user