@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,314 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ TranscribeCppProvider,
4
+ buildTranscribeArgs,
5
+ buildFfmpegArgs,
6
+ parseTranscribeCliOutput,
7
+ type SpawnRunner,
8
+ type SubprocessResult,
9
+ } from "./transcribe-cpp.ts";
10
+ import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
11
+
12
+ /**
13
+ * Conformance tests for the `transcribe-cpp` local provider (scribe-fold
14
+ * Phase 2a). The subprocess (`Bun.spawn`) is mocked so no real binary runs and
15
+ * no bytes are downloaded — we pin the argv shape, the WAV/transcode decision,
16
+ * the stdout parse, and the terminal-vs-retriable error mapping the worker
17
+ * depends on.
18
+ */
19
+
20
+ const AUDIO = new Uint8Array([1, 2, 3, 4]);
21
+
22
+ /** A spawn mock that records every argv and returns canned results per call. */
23
+ function recordingSpawn(results: SubprocessResult[] | ((cmd: string[]) => SubprocessResult)): {
24
+ spawn: SpawnRunner;
25
+ calls: string[][];
26
+ } {
27
+ const calls: string[][] = [];
28
+ let i = 0;
29
+ const spawn: SpawnRunner = async (cmd) => {
30
+ calls.push(cmd);
31
+ if (typeof results === "function") return results(cmd);
32
+ return results[Math.min(i++, results.length - 1)]!;
33
+ };
34
+ return { spawn, calls };
35
+ }
36
+
37
+ const ok = (stdout: string): SubprocessResult => ({ exitCode: 0, stdout, stderr: "" });
38
+
39
+ /**
40
+ * A real `transcribe-cli` v0.1.1 stdout report (macOS arm64, whisper-tiny.en).
41
+ * The transcript is the single `text:` line; library `[info]`/`[debug]` logs go
42
+ * to STDERR (not shown here). Build one with a given `text:` value.
43
+ */
44
+ function cliReport(text: string): string {
45
+ return [
46
+ "audio: test.wav",
47
+ " samples: 48422",
48
+ " duration: 3.026 s",
49
+ " sample rate 16000 Hz mono float32",
50
+ "model: /models/whisper-tiny.en-Q5_K_M.gguf -> ok",
51
+ " backend: MTL0",
52
+ " name: Whisper Tiny (English)",
53
+ " license: apache-2.0",
54
+ " max audio: unbounded (long audio chunked internally)",
55
+ "run: ok",
56
+ `text: ${text}`,
57
+ "segments: 1",
58
+ ` [ 0.00 -> 3.00] ${text}`,
59
+ " realtime: 9x (332.1 ms for 3.0 s)",
60
+ "",
61
+ ].join("\n");
62
+ }
63
+
64
+ /** A provider whose binary + model both "exist", with an injected spawn. */
65
+ function provider(spawn: SpawnRunner, opts: Partial<{ existsImpl: (p: string) => boolean }> = {}) {
66
+ return new TranscribeCppProvider({
67
+ binPath: "/tc/bin/transcribe-cli",
68
+ modelPath: "/tc/models/whisper-tiny.en-Q5_K_M.gguf",
69
+ ffmpegPath: "ffmpeg",
70
+ spawn,
71
+ existsImpl: opts.existsImpl ?? (() => true),
72
+ });
73
+ }
74
+
75
+ // ---------------------------------------------------------------------------
76
+ // Pure helpers
77
+ // ---------------------------------------------------------------------------
78
+
79
+ describe("buildTranscribeArgs", () => {
80
+ test("matches upstream usage: transcribe-cli -m <model> <wav>", () => {
81
+ expect(buildTranscribeArgs("/bin/transcribe-cli", "/m.gguf", "/a.wav")).toEqual([
82
+ "/bin/transcribe-cli",
83
+ "-m",
84
+ "/m.gguf",
85
+ "/a.wav",
86
+ ]);
87
+ });
88
+ });
89
+
90
+ describe("buildFfmpegArgs", () => {
91
+ test("transcodes to 16kHz mono WAV", () => {
92
+ const args = buildFfmpegArgs("ffmpeg", "/in.webm", "/out.wav");
93
+ expect(args).toContain("-ar");
94
+ expect(args[args.indexOf("-ar") + 1]).toBe("16000");
95
+ expect(args).toContain("-ac");
96
+ expect(args[args.indexOf("-ac") + 1]).toBe("1");
97
+ expect(args[args.length - 1]).toBe("/out.wav");
98
+ });
99
+ });
100
+
101
+ describe("parseTranscribeCliOutput", () => {
102
+ test("extracts exactly the `text:` line from the real v0.1.1 report", () => {
103
+ const report = cliReport("Hello parachute. Testing transcribe CPP.");
104
+ expect(parseTranscribeCliOutput(report)).toBe("Hello parachute. Testing transcribe CPP.");
105
+ });
106
+ test("ignores the indented per-segment line (starts with a bracket, not `text:`)", () => {
107
+ // The segment line repeats the transcript but is indented + bracketed; only
108
+ // the column-0 `text:` line is the transcript, and we return it once.
109
+ const report = cliReport("just once");
110
+ expect(parseTranscribeCliOutput(report)).toBe("just once");
111
+ });
112
+ test("the `(empty)` no-result sentinel maps to \"\"", () => {
113
+ expect(parseTranscribeCliOutput(cliReport("(empty)"))).toBe("");
114
+ });
115
+ test("no `text:` line at all → empty string", () => {
116
+ expect(parseTranscribeCliOutput("audio: test.wav\nrun: ok\nsegments: 0\n")).toBe("");
117
+ });
118
+ test("empty / whitespace stdout → empty string", () => {
119
+ expect(parseTranscribeCliOutput(" \n ")).toBe("");
120
+ });
121
+ });
122
+
123
+ // ---------------------------------------------------------------------------
124
+ // available() — cheap, no spawn
125
+ // ---------------------------------------------------------------------------
126
+
127
+ describe("TranscribeCppProvider.available", () => {
128
+ test("name is stable 'transcribe-cpp'", () => {
129
+ expect(provider(recordingSpawn([]).spawn).name).toBe("transcribe-cpp");
130
+ });
131
+
132
+ test("ok:true when binary + model exist — and NEVER spawns", async () => {
133
+ const { spawn, calls } = recordingSpawn([]);
134
+ const p = provider(spawn);
135
+ expect(await p.available()).toEqual({ ok: true });
136
+ expect(calls.length).toBe(0); // no subprocess on the hot path
137
+ });
138
+
139
+ test("ok:false with reason when the binary is missing", async () => {
140
+ const { spawn, calls } = recordingSpawn([]);
141
+ const p = provider(spawn, { existsImpl: (path) => !path.includes("transcribe-cli") });
142
+ const avail = await p.available();
143
+ expect(avail.ok).toBe(false);
144
+ expect(avail.reason).toContain("transcribe-cli");
145
+ expect(calls.length).toBe(0);
146
+ });
147
+
148
+ test("ok:false when the model is missing", async () => {
149
+ const { spawn } = recordingSpawn([]);
150
+ const p = provider(spawn, { existsImpl: (path) => !path.endsWith(".gguf") });
151
+ expect((await p.available()).ok).toBe(false);
152
+ });
153
+
154
+ test("dylib-only install (model exists, no runnable CLI) → ok:false (honesty)", async () => {
155
+ // v0.1.1 ships a library, not a CLI: libs + model land on disk but no
156
+ // transcribe-cli. available() must gate on the binary so the capability
157
+ // flag reports enabled:false rather than a capability we can't deliver.
158
+ const { spawn } = recordingSpawn([]);
159
+ const p = provider(spawn, { existsImpl: (path) => path.endsWith(".gguf") }); // only the model exists
160
+ const avail = await p.available();
161
+ expect(avail.ok).toBe(false);
162
+ expect(avail.reason).toContain("transcribe-cli");
163
+ });
164
+
165
+ test("unconfigured (no binPath) → ok:false, no throw", async () => {
166
+ const p = new TranscribeCppProvider({});
167
+ expect((await p.available()).ok).toBe(false);
168
+ });
169
+
170
+ test("caches the once-true result (no repeated existsSync after ok)", async () => {
171
+ let calls = 0;
172
+ const p = provider(recordingSpawn([]).spawn, {
173
+ existsImpl: () => {
174
+ calls++;
175
+ return true;
176
+ },
177
+ });
178
+ await p.available();
179
+ const afterFirst = calls;
180
+ await p.available();
181
+ expect(calls).toBe(afterFirst); // second call short-circuits the cache
182
+ });
183
+ });
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // transcribe() — happy paths
187
+ // ---------------------------------------------------------------------------
188
+
189
+ describe("TranscribeCppProvider.transcribe — happy path", () => {
190
+ test("non-WAV input transcodes via ffmpeg THEN runs transcribe-cli", async () => {
191
+ const { spawn, calls } = recordingSpawn([ok(""), ok(cliReport("the transcript"))]);
192
+ const p = provider(spawn);
193
+ const res = await p.transcribe({ audio: AUDIO, filename: "memo.webm", mimeType: "audio/webm" });
194
+
195
+ expect(res.text).toBe("the transcript");
196
+ expect(calls.length).toBe(2);
197
+ // First call: ffmpeg transcode to 16kHz mono WAV.
198
+ expect(calls[0]![0]).toBe("ffmpeg");
199
+ expect(calls[0]).toContain("16000");
200
+ // Second call: transcribe-cli -m <model> <wav>.
201
+ expect(calls[1]![0]).toBe("/tc/bin/transcribe-cli");
202
+ expect(calls[1]![1]).toBe("-m");
203
+ expect(calls[1]![2]).toBe("/tc/models/whisper-tiny.en-Q5_K_M.gguf");
204
+ expect(calls[1]![3]).toMatch(/\.16k\.wav$/);
205
+ });
206
+
207
+ test("WAV input ALSO transcodes (strict 16kHz — we can't trust its rate)", async () => {
208
+ // Regression for the live-verified bug: a 44.1kHz .wav made transcribe-cli
209
+ // exit 1. We now ALWAYS run ffmpeg first, even for a .wav → two spawns, and
210
+ // the CLI reads the 16k .wav ffmpeg produced (not the raw input).
211
+ const { spawn, calls } = recordingSpawn([ok(""), ok(cliReport("wav text"))]);
212
+ const p = provider(spawn);
213
+ const res = await p.transcribe({ audio: AUDIO, filename: "clip.wav", mimeType: "audio/wav" });
214
+
215
+ expect(res.text).toBe("wav text");
216
+ expect(calls.length).toBe(2);
217
+ expect(calls[0]![0]).toBe("ffmpeg");
218
+ expect(calls[0]).toContain("16000");
219
+ expect(calls[0]).toContain("1"); // -ac 1 mono
220
+ expect(calls[1]![0]).toBe("/tc/bin/transcribe-cli");
221
+ expect(calls[1]![3]).toMatch(/\.16k\.wav$/);
222
+ });
223
+ });
224
+
225
+ // ---------------------------------------------------------------------------
226
+ // transcribe() — error mapping
227
+ // ---------------------------------------------------------------------------
228
+
229
+ describe("TranscribeCppProvider.transcribe — error mapping", () => {
230
+ async function catchErr(fn: () => Promise<unknown>): Promise<unknown> {
231
+ try {
232
+ await fn();
233
+ } catch (err) {
234
+ return err;
235
+ }
236
+ throw new Error("expected transcribe() to throw");
237
+ }
238
+
239
+ test("binary missing → non-retriable missing_provider, NEVER spawns", async () => {
240
+ const { spawn, calls } = recordingSpawn([ok("nope")]);
241
+ const p = provider(spawn, { existsImpl: (path) => !path.includes("transcribe-cli") });
242
+ const err = (await catchErr(() =>
243
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
244
+ )) as TranscriptionError;
245
+ expect(err).toBeInstanceOf(TranscriptionError);
246
+ expect(err.code).toBe("missing_provider");
247
+ expect(err.retriable).toBe(false);
248
+ expect(calls.length).toBe(0);
249
+ });
250
+
251
+ test("model missing → non-retriable missing_provider", async () => {
252
+ const { spawn } = recordingSpawn([ok("nope")]);
253
+ const p = provider(spawn, { existsImpl: (path) => !path.endsWith(".gguf") });
254
+ const err = (await catchErr(() =>
255
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
256
+ )) as TranscriptionError;
257
+ expect(err.code).toBe("missing_provider");
258
+ expect(err.retriable).toBe(false);
259
+ });
260
+
261
+ test("ffmpeg not found (exit 127) → non-retriable ffmpeg_missing", async () => {
262
+ const { spawn } = recordingSpawn([{ exitCode: 127, stdout: "", stderr: "not found" }]);
263
+ const p = provider(spawn);
264
+ const err = (await catchErr(() =>
265
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
266
+ )) as TranscriptionError;
267
+ expect(err.code).toBe("ffmpeg_missing");
268
+ expect(err.retriable).toBe(false);
269
+ });
270
+
271
+ test("ffmpeg transcode failure (non-127) → non-retriable transcode_failed", async () => {
272
+ const { spawn } = recordingSpawn([{ exitCode: 1, stdout: "", stderr: "bad input" }]);
273
+ const p = provider(spawn);
274
+ const err = (await catchErr(() =>
275
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
276
+ )) as TranscriptionError;
277
+ expect(err.code).toBe("transcode_failed");
278
+ expect(err.retriable).toBe(false);
279
+ });
280
+
281
+ test("transcribe-cli non-zero exit → RETRIABLE transcribe_cli_error", async () => {
282
+ // ffmpeg succeeds; transcribe-cli then fails with a generic exit code.
283
+ const { spawn } = recordingSpawn([ok(""), { exitCode: 2, stdout: "", stderr: "decode error" }]);
284
+ const p = provider(spawn);
285
+ const err = (await catchErr(() =>
286
+ p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
287
+ )) as TranscriptionError;
288
+ expect(err).toBeInstanceOf(TranscriptionError);
289
+ expect(err.code).toBe("transcribe_cli_error");
290
+ expect(err.retriable).toBe(true);
291
+ });
292
+
293
+ test("transcribe-cli launch failure (127) → non-retriable missing_provider", async () => {
294
+ const { spawn } = recordingSpawn([ok(""), { exitCode: 127, stdout: "", stderr: "gone" }]);
295
+ const p = provider(spawn);
296
+ const err = (await catchErr(() =>
297
+ p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
298
+ )) as TranscriptionError;
299
+ expect(err.code).toBe("missing_provider");
300
+ expect(err.retriable).toBe(false);
301
+ });
302
+
303
+ test("`text: (empty)` no-result → plain Error (retriable via worker), not TranscriptionError", async () => {
304
+ // ffmpeg ok; transcribe-cli returns its (empty) sentinel → parses to "".
305
+ const { spawn } = recordingSpawn([ok(""), ok(cliReport("(empty)"))]);
306
+ const p = provider(spawn);
307
+ const err = (await catchErr(() =>
308
+ p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
309
+ )) as Error;
310
+ expect(err).toBeInstanceOf(Error);
311
+ expect(err).not.toBeInstanceOf(TranscriptionError);
312
+ expect(err.message).toContain("no transcript text");
313
+ });
314
+ });
@@ -0,0 +1,293 @@
1
+ /**
2
+ * `transcribe-cpp` — the local, no-Python transcription provider
3
+ * (scribe-fold Phase 2a).
4
+ *
5
+ * Turns audio into text by subprocessing the prebuilt `transcribe-cli` from
6
+ * transcribe.cpp (github.com/handy-computer/transcribe.cpp — CJ Pais /
7
+ * Mozilla.ai, MIT). transcribe.cpp's in-process N-API binding crashes on Bun,
8
+ * so we drive the CLI as a subprocess — structurally like the `scribe-http`
9
+ * provider, but local. It's the recommended low-RAM / no-Python provider,
10
+ * opt-in via `parachute-vault transcription install` + `TRANSCRIPTION_PROVIDER`.
11
+ *
12
+ * ## The audio path
13
+ *
14
+ * `transcribe-cli` STRICTLY requires **16kHz mono WAV** input — it rejects other
15
+ * sample rates (a 44.1kHz .wav exits non-zero; verified against v0.1.1). So we
16
+ * ALWAYS transcode the incoming audio through `ffmpeg -ar 16000 -ac 1` first,
17
+ * even when it's already a `.wav` — we can't trust an incoming WAV's sample
18
+ * rate. The install verb prints an ffmpeg hint when it's missing; here a missing
19
+ * ffmpeg surfaces as a terminal `ffmpeg_missing` error the operator must fix.
20
+ *
21
+ * ## Reading the output
22
+ *
23
+ * `transcribe-cli` prints a human-readable report to STDOUT (audio/model/run
24
+ * lines, a `text: <transcript>` line, then per-segment lines) and sends its
25
+ * library `[info]`/`[debug]` logs to STDERR. The transcript is the single
26
+ * `text:` line; `text: (empty)` is its no-result sentinel. See
27
+ * `parseTranscribeCliOutput`.
28
+ *
29
+ * ## Error mapping (mirrors scribe-http's terminal-vs-retriable contract)
30
+ *
31
+ * - binary / model not installed → non-retriable `missing_provider` (operator
32
+ * runs `transcription install`); never spawns.
33
+ * - ffmpeg not found → non-retriable `ffmpeg_missing`.
34
+ * - ffmpeg transcode non-zero exit → non-retriable `transcode_failed`
35
+ * (a re-run on the same undecodable blob won't help).
36
+ * - `transcribe-cli` non-zero exit → RETRIABLE `transcribe_cli_error` (could
37
+ * be a transient resource blip; the worker backs off, and a deterministic
38
+ * failure still terminates after maxAttempts).
39
+ * - empty/unparseable stdout → plain `Error` (retriable via the worker),
40
+ * matching scribe-http's "missing text field".
41
+ */
42
+
43
+ import { randomUUID } from "crypto";
44
+ import { existsSync } from "fs";
45
+ import { writeFile, unlink } from "fs/promises";
46
+ import { tmpdir } from "os";
47
+ import { join } from "path";
48
+ import {
49
+ TranscriptionError,
50
+ type TranscriptionProvider,
51
+ type TranscribeInput,
52
+ type TranscribeResult,
53
+ type ProviderAvailability,
54
+ } from "../../../core/src/transcription/provider.ts";
55
+
56
+ /** transcribe-cli can be slow on CPU for long audio; generous default. */
57
+ const DEFAULT_TIMEOUT_MS = 600_000;
58
+
59
+ /** Result of running a subprocess: exit code + captured streams (as text). */
60
+ export interface SubprocessResult {
61
+ exitCode: number;
62
+ stdout: string;
63
+ stderr: string;
64
+ }
65
+
66
+ /**
67
+ * Injection seam for the subprocess call. Production uses `defaultSpawnRunner`
68
+ * (Bun.spawn); tests pass a mock so no real binary runs. A launch failure
69
+ * (binary not found) resolves to `exitCode: 127` rather than throwing, so the
70
+ * provider maps it to a friendly terminal error.
71
+ */
72
+ export type SpawnRunner = (
73
+ cmd: string[],
74
+ opts?: { timeoutMs?: number },
75
+ ) => Promise<SubprocessResult>;
76
+
77
+ /** Production spawn runner over `Bun.spawn`. */
78
+ export const defaultSpawnRunner: SpawnRunner = async (cmd, opts) => {
79
+ let proc: ReturnType<typeof Bun.spawn>;
80
+ try {
81
+ proc = Bun.spawn(cmd, { stdin: "ignore", stdout: "pipe", stderr: "pipe" });
82
+ } catch (err) {
83
+ // ENOENT etc. — the binary couldn't be launched at all.
84
+ return { exitCode: 127, stdout: "", stderr: err instanceof Error ? err.message : String(err) };
85
+ }
86
+ let timedOut = false;
87
+ const timer = opts?.timeoutMs
88
+ ? setTimeout(() => {
89
+ timedOut = true;
90
+ proc.kill();
91
+ }, opts.timeoutMs)
92
+ : null;
93
+ try {
94
+ const [stdout, stderr, exitCode] = await Promise.all([
95
+ new Response(proc.stdout as ReadableStream).text(),
96
+ new Response(proc.stderr as ReadableStream).text(),
97
+ proc.exited,
98
+ ]);
99
+ if (timedOut) {
100
+ return { exitCode: 124, stdout, stderr: `${stderr}\n[transcribe-cli timed out]` };
101
+ }
102
+ return { exitCode, stdout, stderr };
103
+ } finally {
104
+ if (timer) clearTimeout(timer);
105
+ }
106
+ };
107
+
108
+ export interface TranscribeCppProviderOpts {
109
+ /** Path to the `transcribe-cli` binary. Undefined ⇒ unavailable + terminal. */
110
+ binPath?: string;
111
+ /** Path to the GGUF model. Undefined ⇒ unavailable + terminal. */
112
+ modelPath?: string;
113
+ /** ffmpeg binary (default "ffmpeg"; resolved on PATH). */
114
+ ffmpegPath?: string;
115
+ /** Per-transcription timeout (ms). Default 600s. */
116
+ timeoutMs?: number;
117
+ /** Subprocess runner (tests inject a mock). Default `defaultSpawnRunner`. */
118
+ spawn?: SpawnRunner;
119
+ /** Existence probe (tests inject). Default `fs.existsSync`. */
120
+ existsImpl?: (p: string) => boolean;
121
+ /** Scratch dir for the temp audio/wav files. Default `os.tmpdir()`. */
122
+ tmpDir?: string;
123
+ }
124
+
125
+ /**
126
+ * Build the `transcribe-cli` argv. Upstream usage:
127
+ * `transcribe-cli -m <model.gguf> <audio.wav>`
128
+ * (model via `-m`, audio as a positional). Exported so a test pins the shape.
129
+ */
130
+ export function buildTranscribeArgs(binPath: string, modelPath: string, wavPath: string): string[] {
131
+ return [binPath, "-m", modelPath, wavPath];
132
+ }
133
+
134
+ /** Build the ffmpeg transcode argv → 16kHz mono WAV. Exported for tests. */
135
+ export function buildFfmpegArgs(ffmpegPath: string, inputPath: string, wavPath: string): string[] {
136
+ return [ffmpegPath, "-nostdin", "-y", "-i", inputPath, "-ar", "16000", "-ac", "1", wavPath];
137
+ }
138
+
139
+ /**
140
+ * Extract the transcript from `transcribe-cli`'s stdout report. The CLI prints a
141
+ * human report whose transcript is the single `text: <transcript>` line at
142
+ * column 0 (library `[info]`/`[debug]` logs go to STDERR, and the indented
143
+ * per-segment lines start with a bracket, not `text:`). We return the first
144
+ * `text:` line trimmed; the `text: (empty)` no-result sentinel maps to "" so the
145
+ * caller treats it as a retriable empty-output failure. Returns "" when no
146
+ * `text:` line is present. Validated against transcribe.cpp v0.1.1. Exported for
147
+ * tests.
148
+ */
149
+ export function parseTranscribeCliOutput(stdout: string): string {
150
+ for (const line of stdout.split("\n")) {
151
+ const m = line.match(/^text:\s?(.*)$/);
152
+ if (m) {
153
+ const t = m[1]!.trim();
154
+ return t === "(empty)" ? "" : t;
155
+ }
156
+ }
157
+ return "";
158
+ }
159
+
160
+ export class TranscribeCppProvider implements TranscriptionProvider {
161
+ readonly name = "transcribe-cpp";
162
+
163
+ private readonly binPath: string | undefined;
164
+ private readonly modelPath: string | undefined;
165
+ private readonly ffmpegPath: string;
166
+ private readonly timeoutMs: number;
167
+ private readonly spawn: SpawnRunner;
168
+ private readonly existsImpl: (p: string) => boolean;
169
+ private readonly tmpDir: string;
170
+ /** Once available, stays available for this process — avoids re-statting. */
171
+ private cachedAvailable = false;
172
+
173
+ constructor(opts: TranscribeCppProviderOpts = {}) {
174
+ this.binPath = opts.binPath;
175
+ this.modelPath = opts.modelPath;
176
+ this.ffmpegPath = opts.ffmpegPath ?? "ffmpeg";
177
+ this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
178
+ this.spawn = opts.spawn ?? defaultSpawnRunner;
179
+ this.existsImpl = opts.existsImpl ?? existsSync;
180
+ this.tmpDir = opts.tmpDir ?? tmpdir();
181
+ }
182
+
183
+ /**
184
+ * Cheap readiness probe — a runnable `transcribe-cli` binary AND the model
185
+ * both present on disk. NEVER spawns (it gates the capability flag on every
186
+ * `/api/vault` GET). Memoizes the once-true result so repeated landing reads
187
+ * don't re-stat.
188
+ *
189
+ * The binary check is load-bearing for honesty: transcribe.cpp v0.1.1 ships a
190
+ * *library*, not a prebuilt CLI, so `transcription install` can leave the libs
191
+ * + model in place with no runnable CLI. `binPath` resolves to
192
+ * `TRANSCRIBE_CPP_BIN` or a located binary; when neither exists we report
193
+ * `ok:false` (capability `enabled:false`) rather than claiming a capability we
194
+ * can't deliver.
195
+ */
196
+ async available(): Promise<ProviderAvailability> {
197
+ if (this.cachedAvailable) return { ok: true };
198
+ if (!this.binPath || !this.existsImpl(this.binPath)) {
199
+ return {
200
+ ok: false,
201
+ reason:
202
+ "no runnable transcribe-cli (run `parachute-vault transcription install` to build one against the prebuilt libs — needs a C++ compiler — or set TRANSCRIBE_CPP_BIN; see `parachute-vault transcription status`)",
203
+ };
204
+ }
205
+ if (!this.modelPath || !this.existsImpl(this.modelPath)) {
206
+ return {
207
+ ok: false,
208
+ reason: "no GGUF model installed (run `parachute-vault transcription install`)",
209
+ };
210
+ }
211
+ this.cachedAvailable = true;
212
+ return { ok: true };
213
+ }
214
+
215
+ async transcribe(input: TranscribeInput): Promise<TranscribeResult> {
216
+ if (!this.binPath || !this.existsImpl(this.binPath)) {
217
+ throw new TranscriptionError("transcribe-cli not installed", {
218
+ code: "missing_provider",
219
+ retriable: false,
220
+ });
221
+ }
222
+ if (!this.modelPath || !this.existsImpl(this.modelPath)) {
223
+ throw new TranscriptionError("no GGUF model installed for transcribe-cpp", {
224
+ code: "missing_provider",
225
+ retriable: false,
226
+ });
227
+ }
228
+
229
+ const id = randomUUID();
230
+ const inExt = extFor(input.filename, input.mimeType);
231
+ const inputPath = join(this.tmpDir, `parachute-tc-${id}.${inExt}`);
232
+ const wavPath = join(this.tmpDir, `parachute-tc-${id}.16k.wav`);
233
+ const cleanup: string[] = [inputPath, wavPath];
234
+
235
+ try {
236
+ await writeFile(inputPath, input.audio);
237
+
238
+ // transcribe-cli STRICTLY requires 16kHz mono WAV and rejects other sample
239
+ // rates (a 44.1kHz .wav exits non-zero). ALWAYS transcode — even an
240
+ // incoming .wav, whose sample rate we can't trust — via ffmpeg.
241
+ const ff = await this.spawn(buildFfmpegArgs(this.ffmpegPath, inputPath, wavPath), {
242
+ timeoutMs: this.timeoutMs,
243
+ });
244
+ if (ff.exitCode !== 0) {
245
+ const missing = ff.exitCode === 127;
246
+ throw new TranscriptionError(
247
+ missing
248
+ ? "ffmpeg not found — install it to transcode audio to 16kHz mono WAV for transcribe-cpp (apt install ffmpeg / brew install ffmpeg)"
249
+ : `ffmpeg transcode failed (exit ${ff.exitCode}): ${ff.stderr.trim().slice(0, 500)}`,
250
+ { code: missing ? "ffmpeg_missing" : "transcode_failed", retriable: false },
251
+ );
252
+ }
253
+
254
+ const res = await this.spawn(buildTranscribeArgs(this.binPath, this.modelPath, wavPath), {
255
+ timeoutMs: this.timeoutMs,
256
+ });
257
+ if (res.exitCode !== 0) {
258
+ // Launch failure (127) means the binary vanished under us despite the
259
+ // existsSync above — terminal. Any other non-zero is retriable so a
260
+ // transient resource blip backs off; a deterministic failure still
261
+ // terminates after the worker's maxAttempts.
262
+ if (res.exitCode === 127) {
263
+ throw new TranscriptionError(`transcribe-cli could not be launched: ${res.stderr.trim()}`, {
264
+ code: "missing_provider",
265
+ retriable: false,
266
+ });
267
+ }
268
+ throw new TranscriptionError(
269
+ `transcribe-cli exited ${res.exitCode}: ${res.stderr.trim().slice(0, 500)}`,
270
+ { code: "transcribe_cli_error", retriable: true },
271
+ );
272
+ }
273
+
274
+ const text = parseTranscribeCliOutput(res.stdout);
275
+ if (!text) {
276
+ // Empty/unparseable — plain Error is treated as retriable by the worker.
277
+ throw new Error("transcribe-cli produced no transcript text");
278
+ }
279
+ return { text };
280
+ } finally {
281
+ await Promise.all(cleanup.map((p) => unlink(p).catch(() => {})));
282
+ }
283
+ }
284
+ }
285
+
286
+ /** Choose a temp-file extension from the filename or mime type. */
287
+ function extFor(filename: string, mimeType: string): string {
288
+ const fromName = filename.split(".").pop();
289
+ if (fromName && fromName.length <= 5 && /^[a-z0-9]+$/i.test(fromName)) return fromName.toLowerCase();
290
+ const sub = mimeType.split("/")[1]?.split(";")[0]?.trim();
291
+ if (sub && /^[a-z0-9]+$/i.test(sub)) return sub.toLowerCase();
292
+ return "bin";
293
+ }