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:
@@ -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")
|
||||
|
||||
Reference in New Issue
Block a user