extract speech-to-text into its own plugin
Moves platform STT out into a plugin that owns the whole whisper.cpp contract: sidecar, config (a JSON file under DATA_PATH/transcribe/, validated against the server before storing), a thin proxy router, a skill so an agent knows the contract without being told, and a script-mode file-browser task (not agentic — no model turn per audio file). sidecar/transcribe.ts and its test moved unchanged from src/servers/api/stt/ — every constant in it has a reason attached and none of it was touched. The platform-side removal (routes, dead useAudioRecording path, Settings screen, locale keys) lands in a separate, uncommitted change in the platform repo for review. Not yet reachable from the file browser: the platform does not load tasks from a plugin's directory yet (skills/affordances/notifications do). Ships anyway per instructions. Officer-Session: 656f5c85-e49a-4f1f-b9f3-042a04b4d043
This commit is contained in:
@@ -0,0 +1,170 @@
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<Response> {
|
||||
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<string, unknown>).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'));
|
||||
Reference in New Issue
Block a user