// 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 { mkdir } from 'node:fs/promises'; import { basename, dirname, isAbsolute, 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}`); /* Where the text goes. By default, beside the audio with the same name — the behaviour the file viewer's transcribe button had, and what a person right-clicking a recording wants: the text turns up next to the thing it came from. `output_dir` overrides it, and exists for callers that are not a person clicking a file. A commander keeps its results in its OWN directory: writing them into the folder it watches would put its work in the middle of its subject, where nobody asked for it and where its next pass has to tell the two apart. Passing a destination is one input; making every such caller move the file afterwards is a step in every loop, forever, and a window where the file is in the wrong place. Created if missing, so a caller does not have to make the directory first — the first run of a new commander otherwise fails on a directory only this script knows the name of. Either way it overwrites a previous transcription of the same recording, 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 outputDir = process.env.INPUT_OUTPUT_DIR?.trim(); const destDir = outputDir ? (isAbsolute(outputDir) ? outputDir : join(dirname(path), outputDir)) : dirname(path); if (outputDir) await mkdir(destDir, { recursive: true }); const transcriptPath = join(destDir, `${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); });