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
+52
View File
@@ -152,3 +152,55 @@ class APIClientCall(Base):
endpoint = Column(String, default="") # e.g. "POST /api/instances/2/trigger"
status_code = Column(Integer, default=0)
called_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class ServiceConfig(Base):
"""System-wide service credentials used by agents (Sonarr, Radarr, qBit, Plex, etc.).
Keyed by service name (e.g. 'sonarr'). Admin manages these centrally, shared across users."""
__tablename__ = "service_configs"
service_name = Column(String, primary_key=True) # 'sonarr', 'radarr', 'qbittorrent', 'plex', ...
base_url = Column(String, default="") # e.g. http://192.168.1.203:8989
api_key = Column(String, default="") # API key or token (varies by service)
username = Column(String, default="") # some services need both (qBit, Plex)
password = Column(String, default="")
extra = Column(JSON, default=dict) # any service-specific extras
updated_at = Column(DateTime, default=lambda: datetime.now(timezone.utc),
onupdate=lambda: datetime.now(timezone.utc))
class PirateConversation(Base):
"""A chat thread with The Pirate agent. Auto-resets 24h after last message unless
user explicitly picks an old thread (client decides which conversation_id to use)."""
__tablename__ = "pirate_conversations"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id", ondelete="CASCADE"), nullable=False)
title = Column(String, default="") # auto-set from first user message
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
last_message_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
messages = relationship("PirateMessage", back_populates="conversation",
cascade="all, delete-orphan", order_by="PirateMessage.created_at")
class PirateMessage(Base):
"""One turn in a Pirate conversation. Role is 'user', 'assistant', or 'tool'.
For 'assistant' turns with tool calls, tool_calls holds the LLM's requested calls.
For 'tool' turns, tool_name + tool_result hold the execution output."""
__tablename__ = "pirate_messages"
id = Column(Integer, primary_key=True, autoincrement=True)
conversation_id = Column(Integer, ForeignKey("pirate_conversations.id", ondelete="CASCADE"), nullable=False)
role = Column(String, nullable=False) # 'user', 'assistant', 'tool'
content = Column(Text, default="") # text content (user or assistant message)
tool_calls = Column(JSON, nullable=True) # list of {id, name, input} for assistant turns
tool_call_id = Column(String, default="") # matches an assistant.tool_calls[*].id on 'tool' turns
tool_name = Column(String, default="") # which tool was called on 'tool' turns
tool_result = Column(JSON, nullable=True) # structured result of the tool call
model = Column(String, default="") # LLM model that produced this assistant turn
input_tokens = Column(Integer, default=0)
output_tokens = Column(Integer, default=0)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
conversation = relationship("PirateConversation", back_populates="messages")