// 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 { rename, mkdir } from 'node:fs/promises'; 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 }; const slugify = (name: string) => name .trim() .toLowerCase() .replace(/\s+/g, '-') .replace(/[^a-z0-9-]/g, '') .replace(/-+/g, '-') .replace(/^-|-$/g, ''); 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' }; } /** `/`, walking `-2`, `-3`, … past a collision. Same shape as the file browser's own. */ async function uniqueDir(parentDir: string, name: string): Promise { let candidate = join(parentDir, name); let n = 2; while (existsSync(candidate)) { candidate = join(parentDir, `${name}-${n}`); n += 1; } return candidate; } 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}`); const parentDir = dirname(path); const ext = extname(path); const base = basename(path, ext); const now = new Date(); const date = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`; // No model turn to ask for a title (see TASK.md), so the slug comes from the original filename rather // than the transcription's contents. const slug = slugify(base) || 'recording'; const archiveDir = await uniqueDir(parentDir, `${date}_${slug}`); await mkdir(archiveDir, { recursive: true }); const archivedAudioPath = join(archiveDir, basename(path)); await rename(path, archivedAudioPath); const transcriptPath = join(archiveDir, `${base}.md`); await Bun.write(transcriptPath, text); console.log(`Filed under ${archiveDir}`); } main().catch((err) => { console.error(err instanceof Error ? err.message : String(err)); process.exit(1); });