eac7b64a90
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).
207 lines
9.6 KiB
Python
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")
|