// Shared server-side helpers for the OpenCode AI class. // // OpenCode (github.com/sst/opencode) runs as a local headless server // (`opencode serve`) — the same runtime Paperclip drives as an agent adapter. // Here it is used only as a one-shot chat backend for the mail assistant, so // its HTTP surface is enough and no subprocess needs spawning from this app. // // IT IS NOT OpenAI-COMPATIBLE, despite `/v1/models` and `/v1/chat/completions` // both answering 200: opencode serves a web UI from the same port with a // catch-all route, so ANY unknown path returns the SPA's index.html with a 200. // Checking `res.ok` alone therefore "verifies" endpoints that do not exist — // verified the hard way, by believing exactly that before reading a body. // Every probe here validates the parsed SHAPE, never the status code alone. // // The real API (from the server's own /doc OpenAPI spec): // GET /api/model -> { data: [{ id, providerID, name, ... }] } // POST /session -> { id: "ses_..." } // POST /session/{id}/message -> { info, parts: [{ type: 'text', text }, ...] } // // Address resolution is deliberately narrow: loopback only. This class exists // to reach a runtime on the user's OWN machine — pointing it at a remote host // would silently turn "local, no keys, nothing leaves the device" into the // opposite, so a non-loopback OPENCODE_BASE_URL is refused rather than honoured. const DEFAULT_BASE_URLS = ['http://127.0.0.1:4096', 'http://localhost:4096']; const PROBE_TIMEOUT_MS = 2500; const PROMPT_TIMEOUT_MS = 120_000; function isLoopback(raw: string): boolean { try { const url = new URL(raw); return url.hostname === '127.0.0.1' || url.hostname === 'localhost' || url.hostname === '::1'; } catch { return false; } } /** Candidate addresses, honouring an explicit OPENCODE_BASE_URL when it is * loopback. `opencode serve` defaults to a RANDOM port (`--port 0`), so the * conventional 4096 only finds a server deliberately started there; the env * var is how someone on another port points us at it. */ export function opencodeBaseUrls(): string[] { const configured = process.env.OPENCODE_BASE_URL?.trim(); if (configured) { if (!isLoopback(configured)) { console.error('[opencode] ignoring non-loopback OPENCODE_BASE_URL:', configured); return DEFAULT_BASE_URLS; } return [configured.replace(/\/+$/, ''), ...DEFAULT_BASE_URLS]; } return DEFAULT_BASE_URLS; } interface OpencodeModelListResponse { data?: Array<{ id?: string; providerID?: string; name?: string }>; } export interface OpencodeModel { /** "providerID/modelID" — the reference shown in the picker and stored in * settings, matching how opencode itself names models on the CLI. */ ref: string; providerID: string; modelID: string; label: string; } /** Splits the stored "providerID/modelID" reference back into the pair the * message API wants. Returns null for anything malformed rather than * guessing, so a corrupted setting surfaces as a clear error. */ export function parseModelRef(ref: string): { providerID: string; modelID: string } | null { const slash = ref.indexOf('/'); if (slash <= 0 || slash === ref.length - 1) return null; return { providerID: ref.slice(0, slash), modelID: ref.slice(slash + 1) }; } async function fetchJson(url: string, init: RequestInit, timeoutMs: number): Promise { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const res = await fetch(url, { ...init, signal: controller.signal }); if (!res.ok) return null; // The SPA catch-all returns HTML with a 200 for unknown paths — see the // module header. Content-type is what actually distinguishes a real API // response from the web UI. const contentType = res.headers.get('content-type') ?? ''; if (!contentType.includes('application/json')) return null; return await res.json(); } catch { return null; } finally { clearTimeout(timer); } } /** First reachable candidate that answers /api/model with a real model list. */ export async function findOpencodeServer(): Promise<{ baseUrl: string; models: OpencodeModel[] } | null> { for (const baseUrl of opencodeBaseUrls()) { const body = (await fetchJson(`${baseUrl}/api/model`, {}, PROBE_TIMEOUT_MS)) as OpencodeModelListResponse | null; if (!body || !Array.isArray(body.data)) continue; const models: OpencodeModel[] = body.data .filter((m): m is { id: string; providerID: string; name?: string } => typeof m?.id === 'string' && !!m.id && typeof m?.providerID === 'string' && !!m.providerID) .map((m) => ({ ref: `${m.providerID}/${m.id}`, providerID: m.providerID, modelID: m.id, label: m.name ? `${m.name} (${m.providerID})` : `${m.providerID}/${m.id}`, })); if (models.length > 0) return { baseUrl, models }; } return null; } interface OpencodeMessageResponse { parts?: Array<{ type?: string; text?: string }>; } /** * One prompt, one answer. Creates a throwaway session per question — this is * a stateless "ask about my mail" box, not a running conversation, and a fresh * session keeps one question's context from leaking into the next. * * `system` is passed as opencode's own system field rather than as a message * part, so the retrieved-mail prompt keeps the same shape it has for every * other provider class (see buildPrompt in lib/ai/local-client.ts). */ export async function opencodePrompt( baseUrl: string, model: { providerID: string; modelID: string }, system: string | undefined, userText: string, ): Promise<{ ok: true; answer: string } | { ok: false; error: string }> { const session = (await fetchJson( `${baseUrl}/session`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: '{}' }, PROBE_TIMEOUT_MS, )) as { id?: string } | null; if (!session?.id) return { ok: false, error: 'OpenCode would not start a session' }; const body = (await fetchJson( `${baseUrl}/session/${encodeURIComponent(session.id)}/message`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ model, ...(system ? { system } : {}), parts: [{ type: 'text', text: userText }], }), }, PROMPT_TIMEOUT_MS, )) as OpencodeMessageResponse | null; if (!body) return { ok: false, error: 'OpenCode returned no usable response' }; // A reply carries several parts (step-start / reasoning / text / step-finish). // Only the `text` parts are the answer; `reasoning` is the model's private // chain of thought and must not be shown as the reply. const answer = (body.parts ?? []) .filter((p) => p.type === 'text' && typeof p.text === 'string' && p.text.trim()) .map((p) => (p.text as string).trim()) .join('\n\n'); if (!answer) return { ok: false, error: 'OpenCode returned no message content' }; return { ok: true, answer }; }