Files
Eric Jungbauer eac7b64a90 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).
2026-04-20 19:01:50 +00:00

207 lines
9.6 KiB
Python

from sqlalchemy import Column, String, Text, DateTime, Integer, Boolean, ForeignKey, JSON
from sqlalchemy.orm import relationship
from datetime import datetime, timezone
from database import Base
class User(Base):
__tablename__ = "users"
id = Column(Integer, primary_key=True, autoincrement=True)
username = Column(String, unique=True, nullable=False)
email = Column(String, unique=True, nullable=True)
password_hash = Column(String, nullable=False)
display_name = Column(String, default="")
role = Column(String, default="user") # admin or user
llm_config = Column(JSON, default=dict) # user's own LLM provider config
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
instances = relationship("AgentInstance", back_populates="user")
class AgentCatalog(Base):
__tablename__ = "agent_catalog"
id = Column(String, primary_key=True)
name = Column(String, nullable=False)
description = Column(Text, default="")
category = Column(String, default="utility") # data, briefing, utility
config_schema = Column(JSON, default=dict)
default_config = Column(JSON, default=dict)
supports_schedule = Column(Boolean, default=True)
is_sub_agent = Column(Boolean, default=False)
requires_llm = Column(Boolean, default=False)
result_schema = Column(JSON, default=dict) # shape of the agent's structured result
instances = relationship("AgentInstance", back_populates="catalog_entry")
class AgentInstance(Base):
__tablename__ = "agent_instances"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
catalog_id = Column(String, ForeignKey("agent_catalog.id"), nullable=False)
name = Column(String, nullable=False)
config = Column(JSON, default=dict)
schedule = Column(String, default="manual")
status = Column(String, default="active")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
user = relationship("User", back_populates="instances")
catalog_entry = relationship("AgentCatalog", back_populates="instances")
runs = relationship("Run", back_populates="instance", order_by="Run.started_at.desc()")
class Run(Base):
__tablename__ = "runs"
id = Column(Integer, primary_key=True, autoincrement=True)
instance_id = Column(Integer, ForeignKey("agent_instances.id"), nullable=False)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
started_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
finished_at = Column(DateTime, nullable=True)
status = Column(String, default="running")
output = Column(Text, default="") # markdown rendering (for wiki)
result = Column(JSON, nullable=True) # structured data for API consumers
error = Column(Text, default="")
metadata_ = Column("metadata", JSON, default=dict)
triggered_by = Column(String, default="") # "user:eric", "api_client:synap", "cron"
instance = relationship("AgentInstance", back_populates="runs")
class Bridge(Base):
__tablename__ = "bridges"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
api_key = Column(String, nullable=False) # Auth token for bridge requests
bridge_url = Column(String, default="") # http://ip:port
hostname = Column(String, default="") # e.g. "Jungbauers-MBP"
platform = Column(String, default="macos") # macos, ios (future)
capabilities = Column(JSON, default=list) # ["notes", "reading-list"]
status = Column(String, default="offline") # online, offline
last_heartbeat = Column(DateTime, nullable=True)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
user = relationship("User")
class RouteLog(Base):
__tablename__ = "route_log"
id = Column(Integer, primary_key=True, autoincrement=True)
user_id = Column(Integer, ForeignKey("users.id"), nullable=False)
request_text = Column(Text, nullable=False)
recommended_agent = Column(String, default="")
action = Column(String, default="")
reasoning = Column(Text, default="")
outcome = Column(String, default="pending") # pending, accepted, rejected, success, failed
metadata_ = Column("metadata", JSON, default=dict)
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
class LLMProvider(Base):
__tablename__ = "llm_providers"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False)
provider_type = Column(String, default="anthropic") # anthropic, openai, litellm, ollama
api_url = Column(String, default="")
api_key = Column(String, default="")
default_model = Column(String, default="")
is_default = Column(Boolean, default=False)
class APIClient(Base):
"""App-level API client. Each external app (Synap, WSIT, etc.) gets one.
Scoped to specific agent instances — see APIClientScope."""
__tablename__ = "api_clients"
id = Column(Integer, primary_key=True, autoincrement=True)
name = Column(String, nullable=False, unique=True) # "Synap", "WSIT"
token_hash = Column(String, nullable=False, unique=True) # SHA-256 of the plaintext token
token_prefix = Column(String, default="") # first 8 chars of token, shown in admin UI
description = Column(Text, default="")
created_at = Column(DateTime, default=lambda: datetime.now(timezone.utc))
last_used_at = Column(DateTime, nullable=True)
revoked_at = Column(DateTime, nullable=True)
scopes = relationship("APIClientScope", back_populates="client", cascade="all, delete-orphan")
class APIClientScope(Base):
"""Join table: which instances can an API client trigger/read?"""
__tablename__ = "api_client_scopes"
id = Column(Integer, primary_key=True, autoincrement=True)
api_client_id = Column(Integer, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False)
instance_id = Column(Integer, ForeignKey("agent_instances.id", ondelete="CASCADE"), nullable=False)
client = relationship("APIClient", back_populates="scopes")
class APIClientCall(Base):
"""Audit log for every authenticated API call by an API client."""
__tablename__ = "api_client_calls"
id = Column(Integer, primary_key=True, autoincrement=True)
api_client_id = Column(Integer, ForeignKey("api_clients.id", ondelete="CASCADE"), nullable=False)
instance_id = Column(Integer, nullable=True) # may be null for non-instance endpoints
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")