// Transcribe Audio File — a SCRIPT task, so this is a plain program, not an agent. It is materialized // alone into a temp dir and run with `bun run index.ts` (see platform/CLAUDE.md → "Task-runner data flow"), // which means it must be fully self-contained: no sibling imports, everything inline. // // Calls Officer's own API rather than talking to whisper.cpp directly — the platform hands every script // task OFFICER_API_URL / OFFICER_API_HOST / OFFICER_AUTH_TOKEN for exactly this (see // src/servers/api/tasks/task-api-env.ts). The transcribe plugin owns the Whisper contract; this script // only owns filing the result. import { existsSync } from 'node:fs'; import { basename, dirname, join, extname } from 'node:path'; const filePath = process.env.INPUT_FILE_PATH; if (!filePath) { console.error('file_path is required'); process.exit(1); } const apiUrl = process.env.OFFICER_API_URL; const apiHost = process.env.OFFICER_API_HOST; const authToken = process.env.OFFICER_AUTH_TOKEN; if (!apiUrl || !apiHost || !authToken) { console.error('missing OFFICER_API_URL / OFFICER_API_HOST / OFFICER_AUTH_TOKEN — not running as a task?'); process.exit(1); } const authHeaders = { Authorization: `Bearer ${authToken}`, Host: apiHost }; async function getSpokenLanguages(): Promise { try { const res = await fetch(`${apiUrl}/api/user/settings`, { headers: authHeaders }); if (!res.ok) return []; const settings = (await res.json()) as { languages?: { spoken?: string[] } }; return settings.languages?.spoken ?? []; } catch { // Not fatal — the plugin's own detection still runs, spoken languages only break a genuine tie. return []; } } async function transcribe(path: string, spokenLanguages: string[]): Promise<{ text: string; detectedLanguage: string }> { const form = new FormData(); form.append('file', Bun.file(path), basename(path)); for (const lang of spokenLanguages) form.append('spokenLanguages', lang); const res = await fetch(`${apiUrl}/api/transcribe/transcribe`, { method: 'POST', headers: authHeaders, body: form, }); const json = (await res.json().catch(() => null)) as { text?: string; detectedLanguage?: string; error?: string } | null; if (!res.ok || !json) { throw new Error(json?.error ?? `transcription failed (${res.status})`); } return { text: json.text ?? '', detectedLanguage: json.detectedLanguage ?? 'unknown' }; } async function main() { const path = filePath!; if (!existsSync(path)) { console.error(`no such file: ${path}`); process.exit(1); } const spokenLanguages = await getSpokenLanguages(); console.log(`Transcribing ${basename(path)}…`); const { text, detectedLanguage } = await transcribe(path, spokenLanguages); console.log(`Detected language: ${detectedLanguage}`); // Beside the audio, same name, `.md`. Nothing is moved, nothing is created around it — this is the // behaviour the file viewer's transcribe button had, and the one thing anybody wants from a // transcription is the text next to the recording it came from. // // It overwrites a previous transcription of the same file deliberately: a transcription is derived, // running it again means you wanted it again, and a `-2` beside it would only be a second file to // read and choose between. const transcriptPath = join(dirname(path), `${basename(path, extname(path))}.md`); await Bun.write(transcriptPath, text); console.log(transcriptPath); } main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); });