@openparachute/vault 0.6.4 → 0.6.5-rc.10

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (53) hide show
  1. package/core/src/__fixtures__/golden-vault.ts +125 -0
  2. package/core/src/__fixtures__/portable-export-golden.json +10 -0
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +306 -314
  6. package/core/src/notes.ts +54 -62
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/portable-md-batching.test.ts +67 -0
  9. package/core/src/portable-md-golden.test.ts +55 -0
  10. package/core/src/portable-md.ts +579 -435
  11. package/core/src/schema.ts +21 -43
  12. package/core/src/seed-packs.test.ts +191 -0
  13. package/core/src/seed-packs.ts +559 -0
  14. package/core/src/sql-in.test.ts +58 -0
  15. package/core/src/sql-in.ts +76 -0
  16. package/core/src/store.ts +14 -9
  17. package/core/src/transcription/provider.ts +141 -0
  18. package/core/src/txn.test.ts +229 -0
  19. package/core/src/txn.ts +105 -0
  20. package/core/src/types.ts +9 -0
  21. package/core/src/vault-projection.ts +1 -1
  22. package/core/src/wikilinks.ts +10 -4
  23. package/package.json +1 -1
  24. package/src/add-pack.test.ts +142 -0
  25. package/src/cli.ts +778 -7
  26. package/src/onboarding-seed.test.ts +126 -40
  27. package/src/onboarding-seed.ts +41 -46
  28. package/src/routes.ts +25 -7
  29. package/src/server.ts +108 -31
  30. package/src/transcription/build.test.ts +224 -0
  31. package/src/transcription/build.ts +252 -0
  32. package/src/transcription/capability.test.ts +118 -0
  33. package/src/transcription/capability.ts +96 -0
  34. package/src/transcription/install-python.test.ts +366 -0
  35. package/src/transcription/install-python.ts +471 -0
  36. package/src/transcription/install.test.ts +167 -0
  37. package/src/transcription/install.ts +296 -0
  38. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  39. package/src/transcription/providers/onnx-asr.ts +239 -0
  40. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  41. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  42. package/src/transcription/providers/scribe-http.test.ts +195 -0
  43. package/src/transcription/providers/scribe-http.ts +144 -0
  44. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  45. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  46. package/src/transcription/select.test.ts +259 -0
  47. package/src/transcription/select.ts +334 -0
  48. package/src/transcription/tiers.test.ts +197 -0
  49. package/src/transcription/tiers.ts +184 -0
  50. package/src/transcription-worker.test.ts +44 -0
  51. package/src/transcription-worker.ts +57 -122
  52. package/src/vault-create.test.ts +38 -10
  53. package/src/vault.test.ts +48 -0
@@ -0,0 +1,242 @@
1
+ /**
2
+ * `parakeet-mlx` — the local Apple-Silicon transcription provider
3
+ * (scribe-fold Phase 2b).
4
+ *
5
+ * Turns audio into text by subprocessing the `parakeet-mlx` CLI
6
+ * (github.com/senstella/parakeet-mlx — NVIDIA Parakeet on Apple's MLX,
7
+ * macOS/Apple-Silicon only). Ported from scribe's proven
8
+ * `src/transcribe/parakeet-mlx.ts`; structurally a sibling of the
9
+ * `transcribe-cpp` provider (same SpawnRunner seam, same terminal-vs-retriable
10
+ * error contract).
11
+ *
12
+ * ## The audio path
13
+ *
14
+ * `parakeet-mlx` decodes the input itself (shelling to ffmpeg internally for
15
+ * non-WAV audio), so unlike transcribe-cpp we do NOT pre-transcode — the raw
16
+ * capture bytes are written to a temp file and handed over directly:
17
+ *
18
+ * parakeet-mlx <audio> --output-format txt --output-dir <tmpout> [--model <id>]
19
+ *
20
+ * The transcript comes back as a `.txt` file in the output dir — named after
21
+ * the input file's stem, though some versions use their own stem, so we fall
22
+ * back to "any .txt in the dir" (scribe's proven fallback).
23
+ *
24
+ * ## The ffmpeg-missing mask (scribe's classic silent failure)
25
+ *
26
+ * parakeet-mlx shells to ffmpeg internally and, when ffmpeg is missing,
27
+ * prints the error then **exits 0** with no output file — the exitCode gate
28
+ * never fires. The signature in the combined stdout+stderr is the only
29
+ * signal, so "no output + ffmpeg-missing signature" maps to a terminal
30
+ * `ffmpeg_missing` error the operator can actually fix (ported from scribe's
31
+ * `looksLikeFfmpegMissing`).
32
+ *
33
+ * ## Error mapping (mirrors transcribe-cpp)
34
+ *
35
+ * - binary not installed → non-retriable `missing_provider` (never spawns).
36
+ * - launch failure (127) → non-retriable `missing_provider`.
37
+ * - non-zero exit → RETRIABLE `parakeet_mlx_error` (transient resource blip
38
+ * backs off; a deterministic failure still terminates after maxAttempts).
39
+ * - exit 0 + no output + ffmpeg signature → non-retriable `ffmpeg_missing`.
40
+ * - exit 0 + no output otherwise / empty transcript → plain `Error`
41
+ * (retriable via the worker).
42
+ *
43
+ * NOTE on first use: the model (~2.5GB for parakeet-tdt-0.6b-v3) downloads
44
+ * into the HuggingFace cache on the first transcription. A slow first run may
45
+ * hit the subprocess timeout; the download resumes on the worker's retry.
46
+ */
47
+
48
+ import { randomUUID } from "crypto";
49
+ import { existsSync, mkdirSync, readdirSync } from "fs";
50
+ import { writeFile, readFile, rm } from "fs/promises";
51
+ import { tmpdir } from "os";
52
+ import { join } from "path";
53
+ import {
54
+ TranscriptionError,
55
+ type TranscriptionProvider,
56
+ type TranscribeInput,
57
+ type TranscribeResult,
58
+ type ProviderAvailability,
59
+ } from "../../../core/src/transcription/provider.ts";
60
+ import { defaultSpawnRunner, type SpawnRunner } from "./transcribe-cpp.ts";
61
+
62
+ /** Model download on first use + MLX inference can be slow; generous default. */
63
+ const DEFAULT_TIMEOUT_MS = 600_000;
64
+
65
+ /**
66
+ * Detect an ffmpeg-missing signature in a tool's combined stdout+stderr.
67
+ * Ported verbatim from scribe's `transcribe/backend-error.ts`: tolerant by
68
+ * design — requires the co-occurrence of an `ffmpeg` mention with a "not
69
+ * installed / not found / no such file" phrasing anywhere in the output
70
+ * (e.g. `[Errno 2] No such file or directory: 'ffmpeg'`).
71
+ */
72
+ const FFMPEG_TOKEN = /ffmpeg/i;
73
+ const NOT_PRESENT = /not (?:installed|found)|no such file/i;
74
+
75
+ export function looksLikeFfmpegMissing(combinedOutput: string): boolean {
76
+ if (!combinedOutput) return false;
77
+ return FFMPEG_TOKEN.test(combinedOutput) && NOT_PRESENT.test(combinedOutput);
78
+ }
79
+
80
+ export interface ParakeetMlxProviderOpts {
81
+ /** Path to the `parakeet-mlx` binary. Undefined ⇒ unavailable + terminal. */
82
+ binPath?: string;
83
+ /** HF model repo id passed as `--model`. Undefined ⇒ the CLI's own default. */
84
+ model?: string;
85
+ /** Per-transcription timeout (ms). Default 600s. */
86
+ timeoutMs?: number;
87
+ /** Subprocess runner (tests inject a mock). Default `defaultSpawnRunner`. */
88
+ spawn?: SpawnRunner;
89
+ /** Existence probe (tests inject). Default `fs.existsSync`. */
90
+ existsImpl?: (p: string) => boolean;
91
+ /** Scratch dir for the temp audio + output files. Default `os.tmpdir()`. */
92
+ tmpDir?: string;
93
+ }
94
+
95
+ /**
96
+ * Build the `parakeet-mlx` argv. Scribe's proven invocation:
97
+ * `parakeet-mlx <audio> --output-format txt --output-dir <dir>`
98
+ * plus an optional `--model <hf-repo>` (the ratified default is
99
+ * mlx-community/parakeet-tdt-0.6b-v3). Exported so a test pins the shape.
100
+ */
101
+ export function buildParakeetMlxArgs(
102
+ binPath: string,
103
+ audioPath: string,
104
+ outDir: string,
105
+ model?: string,
106
+ ): string[] {
107
+ const args = [binPath, audioPath, "--output-format", "txt", "--output-dir", outDir];
108
+ if (model) args.push("--model", model);
109
+ return args;
110
+ }
111
+
112
+ export class ParakeetMlxProvider implements TranscriptionProvider {
113
+ readonly name = "parakeet-mlx";
114
+
115
+ private readonly binPath: string | undefined;
116
+ private readonly model: string | undefined;
117
+ private readonly timeoutMs: number;
118
+ private readonly spawn: SpawnRunner;
119
+ private readonly existsImpl: (p: string) => boolean;
120
+ private readonly tmpDir: string;
121
+ /** Once available, stays available for this process — avoids re-statting. */
122
+ private cachedAvailable = false;
123
+
124
+ constructor(opts: ParakeetMlxProviderOpts = {}) {
125
+ this.binPath = opts.binPath;
126
+ this.model = opts.model;
127
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
128
+ this.spawn = opts.spawn ?? defaultSpawnRunner;
129
+ this.existsImpl = opts.existsImpl ?? existsSync;
130
+ this.tmpDir = opts.tmpDir ?? tmpdir();
131
+ }
132
+
133
+ /**
134
+ * Cheap readiness probe — a runnable `parakeet-mlx` binary on disk. NEVER
135
+ * spawns (it gates the capability flag on every `/api/vault` GET). The
136
+ * MODEL is not checked: it lives in the HF cache and downloads on first
137
+ * use, so binary presence is the honest cheap signal. Memoizes the
138
+ * once-true result so repeated landing reads don't re-stat.
139
+ */
140
+ async available(): Promise<ProviderAvailability> {
141
+ if (this.cachedAvailable) return { ok: true };
142
+ if (!this.binPath || !this.existsImpl(this.binPath)) {
143
+ return {
144
+ ok: false,
145
+ reason:
146
+ "parakeet-mlx is not installed (run `parachute-vault transcription install` on this Mac, or set PARAKEET_MLX_BIN)",
147
+ };
148
+ }
149
+ this.cachedAvailable = true;
150
+ return { ok: true };
151
+ }
152
+
153
+ async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
154
+ if (!this.binPath || !this.existsImpl(this.binPath)) {
155
+ throw new TranscriptionError("parakeet-mlx not installed", {
156
+ code: "missing_provider",
157
+ retriable: false,
158
+ });
159
+ }
160
+
161
+ const id = randomUUID();
162
+ const inExt = extFor(input.filename, input.mimeType);
163
+ const stem = `parachute-pk-${id}`;
164
+ const inputPath = join(this.tmpDir, `${stem}.${inExt}`);
165
+ const outDir = join(this.tmpDir, `${stem}-out`);
166
+
167
+ try {
168
+ await writeFile(inputPath, input.audio);
169
+ mkdirSync(outDir, { recursive: true });
170
+
171
+ const res = await this.spawn(
172
+ buildParakeetMlxArgs(this.binPath, inputPath, outDir, this.model),
173
+ { timeoutMs: this.timeoutMs },
174
+ );
175
+ const combined = `${res.stdout}\n${res.stderr}`;
176
+
177
+ if (res.exitCode !== 0) {
178
+ if (res.exitCode === 127) {
179
+ throw new TranscriptionError(
180
+ `parakeet-mlx could not be launched: ${res.stderr.trim()}`,
181
+ { code: "missing_provider", retriable: false },
182
+ );
183
+ }
184
+ throw new TranscriptionError(
185
+ `parakeet-mlx exited ${res.exitCode}: ${res.stderr.trim().slice(0, 500)}`,
186
+ { code: "parakeet_mlx_error", retriable: true },
187
+ );
188
+ }
189
+
190
+ // Find the transcript: <stem>.txt, else any .txt the tool wrote (some
191
+ // versions name outputs after their own stem — scribe's proven fallback).
192
+ const expected = join(outDir, `${stem}.txt`);
193
+ let outFile: string | undefined = this.existsImpl(expected) ? expected : undefined;
194
+ if (!outFile) {
195
+ const txts = safeListTxt(outDir);
196
+ if (txts.length > 0) outFile = join(outDir, txts[0]!);
197
+ }
198
+
199
+ if (!outFile) {
200
+ // No output AND exit 0 — the classic ffmpeg-missing mask. Promote to a
201
+ // terminal, actionable error instead of an opaque retry loop.
202
+ if (looksLikeFfmpegMissing(combined)) {
203
+ throw new TranscriptionError(
204
+ "parakeet-mlx needs `ffmpeg` to decode this audio, but ffmpeg isn't installed or isn't on the daemon's PATH (brew install ffmpeg)",
205
+ { code: "ffmpeg_missing", retriable: false },
206
+ );
207
+ }
208
+ // Plain Error is treated as retriable by the worker.
209
+ throw new Error("parakeet-mlx produced no transcript output");
210
+ }
211
+
212
+ const text = (await readFile(outFile, "utf8")).trim();
213
+ if (!text) {
214
+ throw new Error("parakeet-mlx produced no transcript text");
215
+ }
216
+ return { text };
217
+ } finally {
218
+ await Promise.all([
219
+ rm(inputPath, { force: true }).catch(() => {}),
220
+ rm(outDir, { recursive: true, force: true }).catch(() => {}),
221
+ ]);
222
+ }
223
+ }
224
+ }
225
+
226
+ /** List `.txt` files in a dir, tolerating a dir the tool never created. */
227
+ function safeListTxt(dir: string): string[] {
228
+ try {
229
+ return (readdirSync(dir) as string[]).filter((f) => f.endsWith(".txt"));
230
+ } catch {
231
+ return [];
232
+ }
233
+ }
234
+
235
+ /** Choose a temp-file extension from the filename or mime type. */
236
+ function extFor(filename: string, mimeType: string): string {
237
+ const fromName = filename.split(".").pop();
238
+ if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/i.test(fromName)) return fromName.toLowerCase();
239
+ const sub = mimeType.split("/")[1]?.split(";")[0]?.trim();
240
+ if (sub && /^[a-z0-9]+$/i.test(sub)) return sub.toLowerCase();
241
+ return "bin";
242
+ }
@@ -0,0 +1,195 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { ScribeHttpProvider } from "./scribe-http.ts";
3
+ import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
4
+
5
+ /**
6
+ * Conformance tests for the `scribe-http` provider (scribe-fold Phase 1).
7
+ *
8
+ * These pin that the provider reproduces the former `callScribe` behavior
9
+ * EXACTLY — endpoint shape, auth header, context part, and the terminal-vs-
10
+ * retriable error mapping the worker depends on. If these drift, existing
11
+ * scribe installs change behavior across the fold, which Phase 1 forbids.
12
+ */
13
+
14
+ function jsonResponse(body: unknown, status = 200): Response {
15
+ return new Response(JSON.stringify(body), {
16
+ status,
17
+ headers: { "content-type": "application/json" },
18
+ });
19
+ }
20
+
21
+ /** Capture the last fetch call so we can assert endpoint/headers/body. */
22
+ function recordingFetch(response: Response | (() => Response | Promise<Response>)): {
23
+ fetchImpl: typeof fetch;
24
+ calls: Array<{ url: string; init: RequestInit | undefined }>;
25
+ } {
26
+ const calls: Array<{ url: string; init: RequestInit | undefined }> = [];
27
+ const fetchImpl = (async (url: RequestInfo | URL, init?: RequestInit) => {
28
+ calls.push({ url: String(url), init });
29
+ return typeof response === "function" ? await response() : response;
30
+ }) as typeof fetch;
31
+ return { fetchImpl, calls };
32
+ }
33
+
34
+ const AUDIO = new Uint8Array([1, 2, 3, 4]);
35
+
36
+ describe("ScribeHttpProvider.available", () => {
37
+ test("ok:true when a URL is configured", async () => {
38
+ const p = new ScribeHttpProvider({ url: "http://scribe.test" });
39
+ expect(await p.available()).toEqual({ ok: true });
40
+ });
41
+
42
+ test("ok:false with a reason when no URL is configured", async () => {
43
+ const p = new ScribeHttpProvider({ url: undefined });
44
+ const avail = await p.available();
45
+ expect(avail.ok).toBe(false);
46
+ expect(avail.reason).toBeTruthy();
47
+ });
48
+
49
+ test("ok:false for an empty/whitespace URL", async () => {
50
+ expect((await new ScribeHttpProvider({ url: " " }).available()).ok).toBe(false);
51
+ });
52
+
53
+ test("name is stable 'scribe-http'", () => {
54
+ expect(new ScribeHttpProvider({ url: "http://scribe.test" }).name).toBe("scribe-http");
55
+ });
56
+ });
57
+
58
+ describe("ScribeHttpProvider.transcribe — happy path", () => {
59
+ test("POSTs multipart to /v1/audio/transcriptions and returns { text }", async () => {
60
+ const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "hello world" }));
61
+ const p = new ScribeHttpProvider({ url: "http://scribe.test/", fetchImpl });
62
+
63
+ const result = await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
64
+
65
+ expect(result.text).toBe("hello world");
66
+ // Trailing slash on the base URL is normalized away (no `//v1`).
67
+ expect(calls[0]!.url).toBe("http://scribe.test/v1/audio/transcriptions");
68
+ expect(calls[0]!.init?.method).toBe("POST");
69
+ expect(calls[0]!.init?.body).toBeInstanceOf(FormData);
70
+ const form = calls[0]!.init?.body as FormData;
71
+ expect(form.get("file")).toBeInstanceOf(File);
72
+ });
73
+
74
+ test("sends Authorization: Bearer when a token is set", async () => {
75
+ const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
76
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", token: "sekret", fetchImpl });
77
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
78
+ expect((calls[0]!.init?.headers as Record<string, string>)["Authorization"]).toBe("Bearer sekret");
79
+ });
80
+
81
+ test("omits Authorization when no token (loopback-trust)", async () => {
82
+ const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
83
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
84
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
85
+ expect((calls[0]!.init?.headers as Record<string, string>)["Authorization"]).toBeUndefined();
86
+ });
87
+
88
+ test("attaches a context part when context has entries", async () => {
89
+ const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
90
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
91
+ await p.transcribe({
92
+ audio: AUDIO,
93
+ filename: "a.webm",
94
+ mimeType: "audio/webm",
95
+ context: { entries: [{ name: "Aaron", summary: "founder" }] },
96
+ });
97
+ const form = calls[0]!.init?.body as FormData;
98
+ expect(form.get("context")).toBeInstanceOf(Blob);
99
+ });
100
+
101
+ test("no context part when entries is empty", async () => {
102
+ const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "x" }));
103
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
104
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm", context: { entries: [] } });
105
+ expect((calls[0]!.init?.body as FormData).get("context")).toBeNull();
106
+ });
107
+ });
108
+
109
+ describe("ScribeHttpProvider.transcribe — error mapping", () => {
110
+ test("4xx with error_code → non-retriable TranscriptionError carrying code", async () => {
111
+ const { fetchImpl } = recordingFetch(
112
+ jsonResponse({ error: "no transcription provider configured", error_code: "missing_provider" }, 400),
113
+ );
114
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
115
+
116
+ let thrown: unknown;
117
+ try {
118
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
119
+ } catch (err) {
120
+ thrown = err;
121
+ }
122
+ expect(thrown).toBeInstanceOf(TranscriptionError);
123
+ const e = thrown as TranscriptionError;
124
+ expect(e.code).toBe("missing_provider");
125
+ expect(e.httpStatus).toBe(400);
126
+ expect(e.retriable).toBe(false);
127
+ // error message prefers the JSON `error` field verbatim.
128
+ expect(e.message).toBe("no transcription provider configured");
129
+ });
130
+
131
+ test("4xx without error_code → non-retriable with 'scribe returned 400: <body>' message", async () => {
132
+ const { fetchImpl } = recordingFetch(new Response("bad multipart", { status: 400 }));
133
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
134
+
135
+ let thrown: unknown;
136
+ try {
137
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
138
+ } catch (err) {
139
+ thrown = err;
140
+ }
141
+ const e = thrown as TranscriptionError;
142
+ expect(e).toBeInstanceOf(TranscriptionError);
143
+ expect(e.code).toBeUndefined();
144
+ expect(e.retriable).toBe(false);
145
+ expect(e.message).toBe("scribe returned 400: bad multipart");
146
+ });
147
+
148
+ test("5xx → retriable TranscriptionError", async () => {
149
+ const { fetchImpl } = recordingFetch(new Response("upstream boom", { status: 503 }));
150
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
151
+
152
+ let thrown: unknown;
153
+ try {
154
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
155
+ } catch (err) {
156
+ thrown = err;
157
+ }
158
+ const e = thrown as TranscriptionError;
159
+ expect(e).toBeInstanceOf(TranscriptionError);
160
+ expect(e.retriable).toBe(true);
161
+ expect(e.httpStatus).toBe(503);
162
+ });
163
+
164
+ test("200 with missing text field → plain Error (retriable via worker)", async () => {
165
+ const { fetchImpl } = recordingFetch(jsonResponse({ not_text: 1 }));
166
+ const p = new ScribeHttpProvider({ url: "http://scribe.test", fetchImpl });
167
+
168
+ let thrown: unknown;
169
+ try {
170
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
171
+ } catch (err) {
172
+ thrown = err;
173
+ }
174
+ expect(thrown).toBeInstanceOf(Error);
175
+ expect(thrown).not.toBeInstanceOf(TranscriptionError);
176
+ expect((thrown as Error).message).toBe("scribe response missing text field");
177
+ });
178
+
179
+ test("no URL configured → non-retriable missing_provider (never fetches)", async () => {
180
+ const { fetchImpl, calls } = recordingFetch(jsonResponse({ text: "should not run" }));
181
+ const p = new ScribeHttpProvider({ url: undefined, fetchImpl });
182
+
183
+ let thrown: unknown;
184
+ try {
185
+ await p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" });
186
+ } catch (err) {
187
+ thrown = err;
188
+ }
189
+ const e = thrown as TranscriptionError;
190
+ expect(e).toBeInstanceOf(TranscriptionError);
191
+ expect(e.code).toBe("missing_provider");
192
+ expect(e.retriable).toBe(false);
193
+ expect(calls.length).toBe(0);
194
+ });
195
+ });
@@ -0,0 +1,144 @@
1
+ /**
2
+ * `scribe-http` — the whisper-compatible remote transcription provider
3
+ * (scribe-fold Phase 1).
4
+ *
5
+ * This is a byte-for-byte port of the transcription worker's former
6
+ * `callScribe`: discover a scribe URL (via `SCRIBE_URL` / `services.json`,
7
+ * resolved by the caller and handed in), POST the audio as multipart
8
+ * `file` to `<url>/v1/audio/transcriptions` (the Whisper API shape) with an
9
+ * optional `context` part and an optional `Authorization: Bearer` header,
10
+ * and read `{ text }` back. It is BOTH the default provider for existing
11
+ * installs (so their behavior is unchanged across the fold) and the migration
12
+ * bridge to any remote/whisper-compatible endpoint.
13
+ *
14
+ * Error mapping (unchanged from the old `callScribe`, now expressed as the
15
+ * runtime-agnostic `TranscriptionError`):
16
+ * - 4xx with a JSON `error_code` → non-retriable `TranscriptionError`
17
+ * carrying that `code` (e.g. `missing_provider`). Re-POSTing the same
18
+ * audio at the same broken scribe just fails again; the operator must act.
19
+ * - 4xx without `error_code` (auth, malformed multipart) → non-retriable
20
+ * `TranscriptionError` with the body text.
21
+ * - 5xx → retriable `TranscriptionError` (upstream hiccup; worker backs off).
22
+ * - network error / timeout → the raw `Error` propagates (worker treats a
23
+ * plain Error as retriable).
24
+ * - 200 with a missing/invalid `text` field → plain `Error` (retriable).
25
+ */
26
+
27
+ import { appendContextPart } from "../../context.ts";
28
+ import {
29
+ TranscriptionError,
30
+ type TranscriptionProvider,
31
+ type TranscribeInput,
32
+ type TranscribeResult,
33
+ type ProviderAvailability,
34
+ } from "../../../core/src/transcription/provider.ts";
35
+
36
+ const DEFAULT_TIMEOUT_MS = 120_000;
37
+
38
+ export interface ScribeHttpProviderOpts {
39
+ /**
40
+ * Resolved scribe base URL (no trailing slash required — trimmed here).
41
+ * `undefined`/empty means scribe isn't discoverable → `available()` is false
42
+ * and `transcribe()` throws a non-retriable `missing_provider`-style error.
43
+ */
44
+ url?: string;
45
+ /** Optional bearer token for scribe (the shared loopback secret / hub JWT). */
46
+ token?: string;
47
+ /** Per-request timeout (ms). Default 120s — matches the former worker default. */
48
+ timeoutMs?: number;
49
+ /** Injection seam for tests; production omits it and uses global `fetch`. */
50
+ fetchImpl?: typeof fetch;
51
+ }
52
+
53
+ export class ScribeHttpProvider implements TranscriptionProvider {
54
+ readonly name = "scribe-http";
55
+
56
+ private readonly url: string | undefined;
57
+ private readonly token: string | undefined;
58
+ private readonly timeoutMs: number;
59
+ private readonly fetchImpl: typeof fetch;
60
+
61
+ constructor(opts: ScribeHttpProviderOpts = {}) {
62
+ this.url = opts.url?.trim() ? opts.url.trim().replace(/\/$/, "") : undefined;
63
+ this.token = opts.token;
64
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
65
+ this.fetchImpl = opts.fetchImpl ?? fetch;
66
+ }
67
+
68
+ /**
69
+ * Availability is purely "can we resolve a scribe URL" — no network probe.
70
+ * The capability flag is read on config reads, so this must stay cheap.
71
+ */
72
+ async available(): Promise<ProviderAvailability> {
73
+ if (!this.url) {
74
+ return {
75
+ ok: false,
76
+ reason: "no scribe URL configured (set SCRIBE_URL or install scribe so it registers in services.json)",
77
+ };
78
+ }
79
+ return { ok: true };
80
+ }
81
+
82
+ async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
83
+ if (!this.url) {
84
+ // Mirrors scribe's own `missing_provider` contract: terminal, operator
85
+ // must act. Reaching here means the worker was constructed without a
86
+ // resolvable scribe URL — a config problem, not a transient one.
87
+ throw new TranscriptionError("no scribe URL configured", {
88
+ code: "missing_provider",
89
+ retriable: false,
90
+ });
91
+ }
92
+
93
+ const controller = new AbortController();
94
+ const timer = setTimeout(() => controller.abort(), this.timeoutMs);
95
+ try {
96
+ const file = new File([input.audio], input.filename, { type: input.mimeType });
97
+ const form = new FormData();
98
+ form.append("file", file);
99
+ if (input.context) appendContextPart(form, input.context);
100
+
101
+ const endpoint = `${this.url}/v1/audio/transcriptions`;
102
+ const headers: Record<string, string> = {};
103
+ if (this.token) headers["Authorization"] = `Bearer ${this.token}`;
104
+
105
+ const resp = await this.fetchImpl(endpoint, {
106
+ method: "POST",
107
+ headers,
108
+ body: form,
109
+ signal: controller.signal,
110
+ });
111
+ if (!resp.ok) {
112
+ const body = await resp.text().catch(() => "");
113
+ // Try to extract a structured error_code from a JSON body (scribe#47).
114
+ let errorCode: string | undefined;
115
+ let errorMessage: string | undefined;
116
+ try {
117
+ const parsed = JSON.parse(body) as { error?: string; error_code?: string; message?: string };
118
+ if (typeof parsed.error_code === "string") errorCode = parsed.error_code;
119
+ if (typeof parsed.error === "string") errorMessage = parsed.error;
120
+ else if (typeof parsed.message === "string") errorMessage = parsed.message;
121
+ } catch {
122
+ // Not JSON — leave errorCode undefined; the raw body becomes the message.
123
+ }
124
+ // 4xx is terminal (re-POSTing the same audio at the same broken scribe
125
+ // will just fail again). 5xx is retriable — provider hiccup.
126
+ const retriable = resp.status >= 500;
127
+ const message = errorMessage
128
+ ?? (errorCode ? `scribe ${errorCode}` : `scribe returned ${resp.status}: ${body}`);
129
+ throw new TranscriptionError(message, {
130
+ code: errorCode,
131
+ httpStatus: resp.status,
132
+ retriable,
133
+ });
134
+ }
135
+ const result = await resp.json() as { text?: string };
136
+ if (typeof result.text !== "string") {
137
+ throw new Error("scribe response missing text field");
138
+ }
139
+ return { text: result.text };
140
+ } finally {
141
+ clearTimeout(timer);
142
+ }
143
+ }
144
+ }