import type { SidecarCommand, SidecarEvent } from '@@/sidecar/protocol'; import { createSidecarConnector } from '@@/sidecar/connect'; import { API_URL } from '@@/officer-url.mjs'; import { getConfig, setConfig, redactUrl, probeWhisper } from './config'; import { transcribeAudio } from './transcribe'; // The officer-transcribe sidecar. Owns the whole whisper.cpp contract for Officer: the server URL and // the request tuning that makes it usable for a person talking into a phone (see transcribe.ts — moved // here unchanged from the platform, and every constant in it has a reason attached in its own comments). // // The platform API is a thin auth-gated forwarder (../api/router.ts) holding no Whisper knowledge at all. // // ───────────────────────────────────────────────────────────────────────────────────────────────── // HTTP CONTRACT — the platform strips its /api/transcribe mount prefix before forwarding. // // GET /_health { ok, configured, url? } // GET /config { configured, url } — url has its userinfo stripped, never a secret // PUT /config { url } — validated against the server before storing // POST /transcribe multipart: file, spokenLanguages[] → { text, detectedLanguage } // // anything else 404 // // Every route needs `X-Officer-User`, which the platform proxy sets after authenticating the caller. We // bind loopback only, so its presence is the trust signal — a request without it did not come through // the platform. // ───────────────────────────────────────────────────────────────────────────────────────────────── /** Grab an ephemeral free port by briefly binding one and releasing it. */ function getFreePort(): number { const probeServer = Bun.serve({ port: 0, hostname: '127.0.0.1', fetch: () => new Response('') }); const p = probeServer.port; probeServer.stop(true); if (p == null) throw new Error('failed to acquire a free port'); return p; } const port = getFreePort(); async function handleConfigGet(): Promise { const cfg = await getConfig(); return Response.json({ configured: !!cfg, url: cfg ? redactUrl(cfg.url) : null }); } function isHttpUrl(url: string): boolean { return /^https?:\/\//i.test(url); } async function handleConfigPut(req: Request): Promise { const body = ((await req.json().catch(() => null)) as { url?: unknown } | null) ?? {}; const url = typeof body.url === 'string' ? body.url.trim() : ''; if (!url) return Response.json({ error: 'url is required' }, { status: 400 }); if (!isHttpUrl(url)) return Response.json({ error: 'url must start with http:// or https://' }, { status: 400 }); const probe = await probeWhisper(url); if (!probe.ok) return Response.json({ error: probe.error }, { status: 400 }); await setConfig({ url }); return Response.json({ configured: true, url: redactUrl(url) }); } async function handleTranscribe(req: Request): Promise { const cfg = await getConfig(); if (!cfg) { return Response.json({ error: 'Whisper not configured — PUT /api/transcribe/config first' }, { status: 400 }); } const body = await req.formData().catch(() => null); const file = body?.get('file'); if (!file || !(file instanceof File)) return Response.json({ error: 'file is required' }, { status: 400 }); const spokenLanguages = (body?.getAll('spokenLanguages') ?? []).map(String); try { const result = await transcribeAudio({ file, whisperUrl: cfg.url, spokenLanguages }); return Response.json(result); } catch (err) { console.error('[transcribe] transcription failed', err); return Response.json( { error: err instanceof Error ? err.message : 'Transcription failed' }, { status: 502 }, ); } } const server = Bun.serve({ port, hostname: '127.0.0.1', // A phone's voice memo is small; this just needs to be well above any single recording. maxRequestBodySize: 1024 * 1024 * 1024, async fetch(req) { const url = new URL(req.url); const officerUser = req.headers.get('X-Officer-User'); const userId = Number(officerUser); if (!officerUser || !Number.isInteger(userId) || userId <= 0) { return Response.json({ error: 'missing or invalid X-Officer-User' }, { status: 401 }); } if (url.pathname === '/_health') { const cfg = await getConfig(); if (!cfg) return Response.json({ ok: false, configured: false }, { status: 503 }); const probe = await probeWhisper(cfg.url); if (!probe.ok) return Response.json({ ok: false, configured: true, error: probe.error }, { status: 502 }); return Response.json({ ok: true, configured: true, url: redactUrl(cfg.url) }); } if (url.pathname === '/config') { try { if (req.method === 'GET') return await handleConfigGet(); if (req.method === 'PUT' || req.method === 'POST') return await handleConfigPut(req); return Response.json({ error: 'method not allowed' }, { status: 405 }); } catch (err) { console.error(`[transcribe] ${req.method} /config failed`, err); return Response.json({ error: 'internal error' }, { status: 500 }); } } if (url.pathname === '/transcribe') { if (req.method !== 'POST') return Response.json({ error: 'method not allowed' }, { status: 405 }); return handleTranscribe(req); } return Response.json({ error: 'not found' }, { status: 404 }); }, }); console.log(`[transcribe] listening on 127.0.0.1:${port} (whisper.cpp URL configured from PUT /config)`); type ReplyFn = (msg: SidecarEvent) => void; function handleCommand(cmd: SidecarCommand, reply: ReplyFn) { switch (cmd.type) { case 'ping': reply({ type: 'pong', id: cmd.id }); break; default: reply({ type: 'error', id: (cmd as SidecarCommand).id, error: `Unknown command type: ${(cmd as Record).type}`, }); } } const connection = createSidecarConnector({ apiUrl: `${API_URL}/api/sidecar/register`, name: 'transcribe', handles: ['transcribe'], onCommand(cmd, reply) { handleCommand(cmd as SidecarCommand, reply as ReplyFn); }, onConnected() { connection.send({ type: 'transcribe:server', port }); console.log(`[transcribe] reported server port ${port} to API`); }, }); function shutdown(signal: string) { console.log(`[transcribe] ${signal} received, shutting down...`); try { server.stop(true); } catch { /* already stopped */ } connection.destroy(); process.exit(0); } process.on('SIGTERM', () => shutdown('SIGTERM')); process.on('SIGINT', () => shutdown('SIGINT'));