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
+60 -1
View File
@@ -66,6 +66,7 @@ tr:hover td{background:var(--surface2)}
<div class="tab" onclick="switchTab('llm')">LLM Providers</div>
<div class="tab" onclick="switchTab('bridges')">Bridges</div>
<div class="tab" onclick="switchTab('api-clients')">API Clients</div>
<div class="tab" onclick="switchTab('services')">Services</div>
<div class="tab" onclick="switchTab('system')">System</div>
</div>
@@ -179,6 +180,21 @@ tr:hover td{background:var(--surface2)}
</table>
</div>
<!-- Services (system-wide creds used by agents like The Pirate) -->
<div class="panel" id="panel-services">
<div class="form-card">
<h3>Service Credentials</h3>
<p style="font-size:.8rem;color:var(--text-dim);margin-bottom:.75rem">
Admin-level URLs + API keys shared across all users. The Pirate agent (and future media agents) use these to query Sonarr, Radarr, qBittorrent, Plex, etc.
Leaving <code>api_key</code> / <code>password</code> blank preserves the existing stored value (useful when editing just the URL).
</p>
</div>
<table id="services-table">
<thead><tr><th>Service</th><th>Base URL</th><th>API Key / Password</th><th>Updated</th><th>Actions</th></tr></thead>
<tbody></tbody>
</table>
</div>
<!-- System -->
<div class="panel" id="panel-system">
<div class="stat-grid" id="sys-stats"></div>
@@ -396,6 +412,49 @@ async function editApiClient(id){
else{const e=await res2.json();alert(e.detail||'Error')}
}
// --- Services ---
async function loadServices(){
const res=await fetch(API+'/api/admin/services');
if(!res.ok)return;
const services=await res.json();
document.querySelector('#services-table tbody').innerHTML=services.map(s=>{
const needsCreds=['qbittorrent','plex'].includes(s.service_name);
const secretField=needsCreds
? `<input placeholder="password" type="password" id="svc-pw-${s.service_name}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem">`
: `<input placeholder="${s.api_key==='set'?'(stored — leave blank to keep)':'api key'}" type="password" id="svc-key-${s.service_name}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem">`;
const userField=needsCreds
? `<input placeholder="username" id="svc-user-${s.service_name}" value="${s.username||''}" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem;margin-bottom:.25rem">`
: '';
const statusColor=s.configured?'var(--green)':'var(--yellow)';
return `<tr>
<td><strong>${s.label||s.service_name}</strong> <span style="color:${statusColor};font-size:.7rem">${s.configured?'●':'○'}</span>
<div style="font-size:.7rem;color:var(--text-dim)">${s.description||''}</div></td>
<td><input value="${s.base_url||''}" id="svc-url-${s.service_name}" placeholder="http://host:port" style="width:100%;padding:.4rem;background:var(--bg);border:1px solid var(--border);border-radius:4px;color:var(--text);font-size:.8rem"></td>
<td>${userField}${secretField}</td>
<td style="font-size:.7rem;color:var(--text-dim)">${s.updated_at?new Date(s.updated_at).toLocaleDateString():'-'}</td>
<td><button class="btn btn-sm btn-primary" onclick="saveService('${s.service_name}')">Save</button></td>
</tr>`;
}).join('');
}
async function saveService(slug){
const base_url=document.getElementById('svc-url-'+slug).value.trim();
const keyEl=document.getElementById('svc-key-'+slug);
const pwEl=document.getElementById('svc-pw-'+slug);
const userEl=document.getElementById('svc-user-'+slug);
const body={
service_name:slug,
base_url,
api_key:keyEl?keyEl.value:'',
username:userEl?userEl.value:'',
password:pwEl?pwEl.value:'',
extra:{},
};
const res=await fetch(API+'/api/admin/services/'+slug,{method:'PUT',headers:{'Content-Type':'application/json'},body:JSON.stringify(body)});
if(res.ok){loadServices()}
else{const e=await res.json();alert(e.detail||'Error')}
}
// --- System ---
async function loadSystem(){
const[usersRes,instRes]=await Promise.all([fetch(API+'/api/admin/users'),fetch(API+'/api/health')]);
@@ -408,7 +467,7 @@ async function loadSystem(){
}
// Init
loadUsers();loadCatalog();loadProviders();loadBridges();loadAllInstancesForPicker().then(loadApiClients);loadSystem();
loadUsers();loadCatalog();loadProviders();loadBridges();loadAllInstancesForPicker().then(loadApiClients);loadServices();loadSystem();
</script>
</body>
</html>
+1
View File
@@ -121,6 +121,7 @@ body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;b
<div class="dot"></div>
<span id="agent-count">0 agents</span>
<span class="user-name" id="user-display"></span>
<button class="small-btn" onclick="location.href='/pirate'" title="The Pirate — conversational media agent">Pirate</button>
<button class="small-btn" onclick="showLLMSettings()">LLM</button>
<button class="small-btn" id="admin-btn" style="display:none" onclick="location.href='/admin'">Admin</button>
<button class="small-btn" onclick="logout()">Logout</button>
+223
View File
@@ -0,0 +1,223 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>The Pirate — Agent Command Center</title>
<style>
:root{--bg:#0f1117;--surface:#1a1d27;--surface2:#232733;--border:#2e3345;--text:#e4e6ed;--text-dim:#8b8fa3;--accent:#6c5ce7;--accent-hover:#7c6ef0;--green:#00b894;--red:#e17055;--yellow:#fdcb6e;--blue:#74b9ff}
*{margin:0;padding:0;box-sizing:border-box}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;overflow:hidden}
.header{background:var(--surface);border-bottom:1px solid var(--border);padding:.75rem 1.5rem;display:flex;align-items:center;justify-content:space-between}
.header h1{font-size:1.1rem;font-weight:600}
.header-right{display:flex;gap:.5rem;align-items:center;font-size:.85rem;color:var(--text-dim)}
.small-btn{background:none;border:1px solid var(--border);color:var(--text-dim);padding:.35rem .75rem;border-radius:6px;font-size:.8rem;cursor:pointer}
.small-btn:hover{border-color:var(--text-dim);color:var(--text)}
.btn-primary{background:var(--accent);color:#fff;border:none;padding:.5rem 1rem;border-radius:6px;font-size:.85rem;cursor:pointer}
.btn-primary:hover{background:var(--accent-hover)}
.layout{flex:1;display:grid;grid-template-columns:260px 1fr;min-height:0}
.sidebar{background:var(--surface);border-right:1px solid var(--border);display:flex;flex-direction:column;min-height:0}
.sidebar-header{padding:.75rem 1rem;border-bottom:1px solid var(--border);display:flex;justify-content:space-between;align-items:center}
.sidebar-header h2{font-size:.8rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-dim)}
.convo-list{flex:1;overflow-y:auto}
.convo-item{padding:.65rem 1rem;border-bottom:1px solid var(--border);cursor:pointer;font-size:.85rem}
.convo-item:hover{background:var(--surface2)}
.convo-item.active{background:var(--surface2);border-left:3px solid var(--accent);padding-left:calc(1rem - 3px)}
.convo-item .title{color:var(--text);line-height:1.3;max-height:2.6em;overflow:hidden;margin-bottom:.2rem}
.convo-item .when{color:var(--text-dim);font-size:.7rem}
.chat{display:flex;flex-direction:column;min-height:0}
.messages{flex:1;overflow-y:auto;padding:1.25rem 1.5rem}
.msg-group{margin-bottom:1.25rem}
.msg-role{font-size:.7rem;font-weight:600;text-transform:uppercase;letter-spacing:.05em;color:var(--text-dim);margin-bottom:.25rem}
.msg-role.user{color:var(--blue)}
.msg-role.assistant{color:var(--accent)}
.msg-role.tool{color:var(--yellow)}
.msg-body{font-size:.9rem;line-height:1.55;white-space:pre-wrap}
.msg-body code{background:var(--surface2);padding:.1rem .35rem;border-radius:4px;font-size:.85em;font-family:'SF Mono',Monaco,Consolas,monospace}
.msg-tool{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.65rem .85rem;font-family:'SF Mono',Monaco,Consolas,monospace;font-size:.75rem;color:var(--text-dim);margin-top:.4rem}
.msg-tool .tool-head{color:var(--yellow);font-weight:600;margin-bottom:.4rem}
.msg-tool pre{white-space:pre-wrap;word-break:break-word;max-height:260px;overflow-y:auto}
.empty{text-align:center;color:var(--text-dim);margin-top:3rem;font-size:.95rem}
.empty .suggestion{display:inline-block;background:var(--surface);border:1px solid var(--border);border-radius:20px;padding:.4rem .9rem;margin:.3rem;cursor:pointer;font-size:.85rem;color:var(--text)}
.empty .suggestion:hover{border-color:var(--accent)}
.composer{border-top:1px solid var(--border);padding:1rem 1.5rem;background:var(--surface)}
.composer-row{display:flex;gap:.5rem;align-items:flex-end}
.composer textarea{flex:1;background:var(--bg);border:1px solid var(--border);color:var(--text);border-radius:8px;padding:.65rem .85rem;font-size:.9rem;resize:none;outline:none;font-family:inherit;min-height:44px;max-height:200px}
.composer textarea:focus{border-color:var(--accent)}
.composer .hint{font-size:.7rem;color:var(--text-dim);margin-top:.4rem}
.spinner{display:inline-block;width:14px;height:14px;border:2px solid var(--border);border-top-color:var(--accent);border-radius:50%;animation:spin 1s linear infinite;vertical-align:middle}
@keyframes spin{to{transform:rotate(360deg)}}
.thinking{color:var(--text-dim);font-size:.85rem;padding:.5rem 0}
</style>
</head>
<body>
<div class="header">
<h1>The Pirate <span style="color:var(--text-dim);font-weight:400;font-size:.85rem;margin-left:.5rem">Phase 1 — read-only</span></h1>
<div class="header-right">
<span id="user-display"></span>
<button class="small-btn" onclick="newChat()">+ New Chat</button>
<button class="small-btn" onclick="location.href='/'">Dashboard</button>
</div>
</div>
<div class="layout">
<div class="sidebar">
<div class="sidebar-header">
<h2>Conversations</h2>
</div>
<div class="convo-list" id="convo-list"></div>
</div>
<div class="chat">
<div class="messages" id="messages"></div>
<div class="composer">
<div class="composer-row">
<textarea id="input" placeholder="Ask about downloads, shows, movies, storage..." rows="1"
onkeydown="if(event.key==='Enter' && !event.shiftKey){event.preventDefault();send()}"
oninput="autoGrow(this)"></textarea>
<button class="btn-primary" id="send-btn" onclick="send()">Send</button>
</div>
<div class="hint">Enter to send · Shift+Enter for newline · Phase 1 is read-only (no writes yet)</div>
</div>
</div>
</div>
<script>
const API='';
let currentConv=null;
let sending=false;
function autoGrow(el){el.style.height='auto';el.style.height=Math.min(el.scrollHeight,200)+'px'}
function timeAgo(s){if(!s)return'';const d=new Date(s+(s.endsWith('Z')?'':'Z')),sec=Math.floor((new Date()-d)/1000);if(sec<60)return'just now';if(sec<3600)return Math.floor(sec/60)+'m ago';if(sec<86400)return Math.floor(sec/3600)+'h ago';return Math.floor(sec/86400)+'d ago'}
async function loadMe(){
const r=await fetch(API+'/api/me');
if(r.status===401){location.href='/login';return}
const u=await r.json();
document.getElementById('user-display').textContent=u.display_name||u.username;
}
async function loadConvos(){
const r=await fetch(API+'/api/pirate/conversations');
if(!r.ok)return;
const convs=await r.json();
const list=document.getElementById('convo-list');
if(!convs.length){list.innerHTML='<div style="padding:1rem;color:var(--text-dim);font-size:.85rem">No conversations yet. Start below.</div>';return}
list.innerHTML=convs.map(c=>`
<div class="convo-item ${currentConv===c.id?'active':''}" onclick="openConvo(${c.id})">
<div class="title">${(c.title||'Untitled').replace(/</g,'&lt;')}</div>
<div class="when">${timeAgo(c.last_message_at)}</div>
</div>`).join('');
}
function renderMessages(messages){
const container=document.getElementById('messages');
if(!messages || !messages.length){
container.innerHTML=`<div class="empty">
<div style="font-size:1rem;margin-bottom:1rem">Ask The Pirate about your media.</div>
<div class="suggestion" onclick="fillInput('What\\'s downloading right now?')">What's downloading right now?</div>
<div class="suggestion" onclick="fillInput('Any new TV episodes in the queue?')">Any new TV episodes in the queue?</div>
<div class="suggestion" onclick="fillInput('How much space is left?')">How much space is left?</div>
<div class="suggestion" onclick="fillInput('Do we have Dune 2?')">Do we have Dune 2?</div>
<div class="suggestion" onclick="fillInput('How big is our TV library?')">How big is our TV library?</div>
</div>`;
return;
}
container.innerHTML=messages.map(m=>{
if(m.role==='tool'){
const result=m.tool_result;
const body=typeof result==='object'?JSON.stringify(result,null,2):String(result||'');
return `<div class="msg-group">
<div class="msg-tool">
<div class="tool-head">→ tool: ${m.tool_name}</div>
<pre>${body.replace(/</g,'&lt;').substring(0,4000)}</pre>
</div></div>`;
}
const label=m.role==='user'?'You':(m.role==='assistant'?'Pirate':m.role);
const tools=(m.tool_calls||[]).map(t=>`<div class="msg-tool"><div class="tool-head">← calling ${t.name}(${JSON.stringify(t.input).substring(0,120)})</div></div>`).join('');
return `<div class="msg-group">
<div class="msg-role ${m.role}">${label}</div>
<div class="msg-body">${(m.content||'').replace(/</g,'&lt;')}</div>
${tools}
</div>`;
}).join('');
container.scrollTop=container.scrollHeight;
}
async function openConvo(id){
currentConv=id;
const r=await fetch(API+'/api/pirate/conversations/'+id);
if(!r.ok)return;
const conv=await r.json();
renderMessages(conv.messages);
loadConvos();
}
async function newChat(){
const r=await fetch(API+'/api/pirate/conversations/new',{method:'POST'});
if(!r.ok)return;
const c=await r.json();
currentConv=c.id;
renderMessages([]);
loadConvos();
document.getElementById('input').focus();
}
function fillInput(t){document.getElementById('input').value=t;autoGrow(document.getElementById('input'));document.getElementById('input').focus()}
async function send(){
if(sending)return;
const input=document.getElementById('input');
const msg=input.value.trim();
if(!msg)return;
sending=true;
document.getElementById('send-btn').disabled=true;
// Optimistic: show user message + thinking indicator
const container=document.getElementById('messages');
const placeholder=document.createElement('div');
placeholder.innerHTML=`<div class="msg-group"><div class="msg-role user">You</div><div class="msg-body">${msg.replace(/</g,'&lt;')}</div></div>
<div class="msg-group"><div class="msg-role assistant">Pirate</div><div class="thinking"><span class="spinner"></span> thinking...</div></div>`;
if(container.querySelector('.empty'))container.innerHTML='';
container.appendChild(placeholder);
container.scrollTop=container.scrollHeight;
input.value='';
autoGrow(input);
try{
const r=await fetch(API+'/api/pirate/chat',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({message:msg,conversation_id:currentConv})});
if(!r.ok){
const err=await r.json().catch(()=>({detail:r.statusText}));
placeholder.querySelector('.thinking').innerHTML=`<span style="color:var(--red)">Error: ${err.detail||'unknown'}</span>`;
return;
}
const conv=await r.json();
currentConv=conv.id;
renderMessages(conv.messages);
loadConvos();
}catch(e){
placeholder.querySelector('.thinking').innerHTML=`<span style="color:var(--red)">Error: ${e.message}</span>`;
}finally{
sending=false;
document.getElementById('send-btn').disabled=false;
input.focus();
}
}
// Init
loadMe();
loadConvos().then(()=>{
// Auto-open most recent conversation if any
fetch(API+'/api/pirate/conversations').then(r=>r.json()).then(convs=>{
if(convs.length){openConvo(convs[0].id)} else {renderMessages([])}
});
});
</script>
</body>
</html>