@openparachute/vault 0.6.5-rc.9 → 0.6.5

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 (52) hide show
  1. package/.parachute/module.json +1 -0
  2. package/core/src/core.test.ts +7 -7
  3. package/core/src/mcp.ts +1 -1
  4. package/core/src/schema.ts +5 -5
  5. package/core/src/seed-packs.test.ts +222 -56
  6. package/core/src/seed-packs.ts +334 -70
  7. package/core/src/tag-hierarchy.ts +1 -1
  8. package/core/src/tag-schemas.ts +2 -2
  9. package/core/src/types.ts +1 -1
  10. package/package.json +1 -1
  11. package/src/admin-spa.ts +2 -1
  12. package/src/auth.ts +1 -1
  13. package/src/cli.ts +317 -53
  14. package/src/export-watch.ts +1 -1
  15. package/src/live-frame-parity.test.ts +201 -0
  16. package/src/module-manifest.ts +8 -0
  17. package/src/onboarding-seed.test.ts +82 -20
  18. package/src/onboarding-seed.ts +2 -2
  19. package/src/routes.ts +3 -3
  20. package/src/routing.test.ts +2 -2
  21. package/src/routing.ts +18 -6
  22. package/src/self-register.test.ts +19 -0
  23. package/src/self-register.ts +6 -1
  24. package/src/server.ts +56 -0
  25. package/src/services-manifest.ts +8 -0
  26. package/src/subscriptions.ts +100 -42
  27. package/src/tag-scope.ts +3 -3
  28. package/src/test-support/live-frame-corpus.ts +72 -0
  29. package/src/token-store.ts +2 -2
  30. package/src/transcription/build.test.ts +86 -5
  31. package/src/transcription/build.ts +87 -7
  32. package/src/transcription/capability.test.ts +25 -0
  33. package/src/transcription/capability.ts +27 -2
  34. package/src/transcription/download.test.ts +101 -0
  35. package/src/transcription/download.ts +71 -0
  36. package/src/transcription/install-python.test.ts +366 -0
  37. package/src/transcription/install-python.ts +471 -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/select.test.ts +166 -1
  43. package/src/transcription/select.ts +234 -1
  44. package/src/transcription/tiers.test.ts +197 -0
  45. package/src/transcription/tiers.ts +184 -0
  46. package/src/vault-create.test.ts +19 -14
  47. package/src/vault.test.ts +1 -1
  48. package/src/ws-server.ts +408 -0
  49. package/src/ws-subscribe.test.ts +474 -0
  50. package/src/ws-subscribe.ts +242 -0
  51. package/src/subscribe.test.ts +0 -609
  52. package/src/subscribe.ts +0 -248
@@ -0,0 +1,290 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { mkdtempSync, rmSync, writeFileSync, existsSync } from "fs";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
5
+ import {
6
+ ParakeetMlxProvider,
7
+ buildParakeetMlxArgs,
8
+ looksLikeFfmpegMissing,
9
+ } from "./parakeet-mlx.ts";
10
+ import type { SpawnRunner, SubprocessResult } from "./transcribe-cpp.ts";
11
+ import { TranscriptionError } from "../../../core/src/transcription/provider.ts";
12
+
13
+ /**
14
+ * Conformance tests for the `parakeet-mlx` local provider (scribe-fold
15
+ * Phase 2b). The subprocess is mocked so no real binary runs; the temp
16
+ * input/output files are REAL (in a per-test tmp dir) so the output-discovery
17
+ * logic — including scribe's proven "the tool may name the .txt after its own
18
+ * stem" fallback and the ffmpeg-missing exit-0 mask — is exercised for real.
19
+ */
20
+
21
+ const AUDIO = new Uint8Array([1, 2, 3, 4]);
22
+
23
+ const tmpDirs: string[] = [];
24
+ function freshTmp(): string {
25
+ const d = mkdtempSync(join(tmpdir(), "pk-test-"));
26
+ tmpDirs.push(d);
27
+ return d;
28
+ }
29
+ afterEach(() => {
30
+ for (const d of tmpDirs.splice(0)) rmSync(d, { recursive: true, force: true });
31
+ });
32
+
33
+ /** A spawn mock that records argv and lets the test act on the out dir. */
34
+ function recordingSpawn(
35
+ handler: (cmd: string[]) => SubprocessResult,
36
+ ): { spawn: SpawnRunner; calls: string[][] } {
37
+ const calls: string[][] = [];
38
+ const spawn: SpawnRunner = async (cmd) => {
39
+ calls.push(cmd);
40
+ return handler(cmd);
41
+ };
42
+ return { spawn, calls };
43
+ }
44
+
45
+ /** Extract the --output-dir value from a recorded argv. */
46
+ function outDirOf(cmd: string[]): string {
47
+ return cmd[cmd.indexOf("--output-dir") + 1]!;
48
+ }
49
+
50
+ const ok: SubprocessResult = { exitCode: 0, stdout: "", stderr: "" };
51
+
52
+ function provider(
53
+ spawn: SpawnRunner,
54
+ tmpDir: string,
55
+ opts: Partial<{ existsImpl: (p: string) => boolean; model: string; binPath: string | undefined }> = {},
56
+ ) {
57
+ return new ParakeetMlxProvider({
58
+ binPath: "binPath" in opts ? opts.binPath : "/venv/bin/parakeet-mlx",
59
+ model: opts.model,
60
+ spawn,
61
+ tmpDir,
62
+ // Binary existence is stubbed; output-file discovery uses the REAL fs.
63
+ existsImpl: opts.existsImpl ?? ((p) => (p.includes("parakeet-mlx") ? true : existsSync(p))),
64
+ });
65
+ }
66
+
67
+ // ---------------------------------------------------------------------------
68
+ // Pure helpers
69
+ // ---------------------------------------------------------------------------
70
+
71
+ describe("buildParakeetMlxArgs", () => {
72
+ test("matches scribe's proven invocation: <bin> <audio> --output-format txt --output-dir <dir>", () => {
73
+ expect(buildParakeetMlxArgs("/bin/parakeet-mlx", "/a.webm", "/out")).toEqual([
74
+ "/bin/parakeet-mlx",
75
+ "/a.webm",
76
+ "--output-format",
77
+ "txt",
78
+ "--output-dir",
79
+ "/out",
80
+ ]);
81
+ });
82
+
83
+ test("appends --model when a model id is set", () => {
84
+ const args = buildParakeetMlxArgs("/bin/pk", "/a.wav", "/out", "mlx-community/parakeet-tdt-0.6b-v3");
85
+ expect(args).toContain("--model");
86
+ expect(args[args.indexOf("--model") + 1]).toBe("mlx-community/parakeet-tdt-0.6b-v3");
87
+ });
88
+ });
89
+
90
+ describe("looksLikeFfmpegMissing (ported from scribe backend-error.ts)", () => {
91
+ test("classic python ENOENT traceback", () => {
92
+ expect(looksLikeFfmpegMissing("[Errno 2] No such file or directory: 'ffmpeg'")).toBe(true);
93
+ });
94
+ test("shell not-found phrasing", () => {
95
+ expect(looksLikeFfmpegMissing("ffmpeg: command not found")).toBe(true);
96
+ });
97
+ test("multi-line co-occurrence matches", () => {
98
+ expect(looksLikeFfmpegMissing("error decoding audio\nffmpeg is required\nnot installed on PATH")).toBe(true);
99
+ });
100
+ test("ffmpeg mentioned without a missing phrasing → false", () => {
101
+ expect(looksLikeFfmpegMissing("ffmpeg version 6.0 Copyright")).toBe(false);
102
+ });
103
+ test("empty output → false", () => {
104
+ expect(looksLikeFfmpegMissing("")).toBe(false);
105
+ });
106
+ });
107
+
108
+ // ---------------------------------------------------------------------------
109
+ // available() — cheap, no spawn
110
+ // ---------------------------------------------------------------------------
111
+
112
+ describe("ParakeetMlxProvider.available", () => {
113
+ test("name is stable 'parakeet-mlx'", () => {
114
+ expect(provider(recordingSpawn(() => ok).spawn, freshTmp()).name).toBe("parakeet-mlx");
115
+ });
116
+
117
+ test("ok:true when the binary exists — and NEVER spawns", async () => {
118
+ const { spawn, calls } = recordingSpawn(() => ok);
119
+ const p = provider(spawn, freshTmp());
120
+ expect(await p.available()).toEqual({ ok: true });
121
+ expect(calls.length).toBe(0);
122
+ });
123
+
124
+ test("ok:false with an actionable reason when the binary is missing", async () => {
125
+ const { spawn, calls } = recordingSpawn(() => ok);
126
+ const p = provider(spawn, freshTmp(), { existsImpl: () => false });
127
+ const avail = await p.available();
128
+ expect(avail.ok).toBe(false);
129
+ expect(avail.reason).toContain("transcription install");
130
+ expect(calls.length).toBe(0);
131
+ });
132
+
133
+ test("unconfigured (no binPath) → ok:false, no throw", async () => {
134
+ const p = provider(recordingSpawn(() => ok).spawn, freshTmp(), { binPath: undefined });
135
+ expect((await p.available()).ok).toBe(false);
136
+ });
137
+ });
138
+
139
+ // ---------------------------------------------------------------------------
140
+ // transcribe() — happy paths
141
+ // ---------------------------------------------------------------------------
142
+
143
+ describe("ParakeetMlxProvider.transcribe — happy path", () => {
144
+ test("hands the raw audio file over (no pre-transcode) and reads <stem>.txt", async () => {
145
+ const tmp = freshTmp();
146
+ const { spawn, calls } = recordingSpawn((cmd) => {
147
+ // Simulate the tool writing the transcript named after the input stem.
148
+ const outDir = outDirOf(cmd);
149
+ const stem = cmd[1]!.split("/").pop()!.replace(/\.webm$/, "");
150
+ writeFileSync(join(outDir, `${stem}.txt`), "hello from parakeet\n");
151
+ return ok;
152
+ });
153
+ const p = provider(spawn, tmp);
154
+ const res = await p.transcribe({ audio: AUDIO, filename: "memo.webm", mimeType: "audio/webm" });
155
+
156
+ expect(res.text).toBe("hello from parakeet");
157
+ // Exactly ONE spawn — parakeet-mlx decodes internally; no ffmpeg pre-pass.
158
+ expect(calls.length).toBe(1);
159
+ expect(calls[0]![0]).toBe("/venv/bin/parakeet-mlx");
160
+ expect(calls[0]![1]).toMatch(/\.webm$/); // the raw input, not a transcoded wav
161
+ expect(calls[0]).toContain("--output-format");
162
+ expect(calls[0]).toContain("txt");
163
+ });
164
+
165
+ test("falls back to any .txt when the tool uses its own output stem", async () => {
166
+ const tmp = freshTmp();
167
+ const { spawn } = recordingSpawn((cmd) => {
168
+ writeFileSync(join(outDirOf(cmd), "some-other-stem.txt"), " fallback transcript ");
169
+ return ok;
170
+ });
171
+ const p = provider(spawn, tmp);
172
+ const res = await p.transcribe({ audio: AUDIO, filename: "a.m4a", mimeType: "audio/mp4" });
173
+ expect(res.text).toBe("fallback transcript");
174
+ });
175
+
176
+ test("passes --model when configured", async () => {
177
+ const tmp = freshTmp();
178
+ const { spawn, calls } = recordingSpawn((cmd) => {
179
+ writeFileSync(join(outDirOf(cmd), "x.txt"), "t");
180
+ return ok;
181
+ });
182
+ const p = provider(spawn, tmp, { model: "mlx-community/parakeet-tdt-0.6b-v3" });
183
+ await p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" });
184
+ expect(calls[0]).toContain("--model");
185
+ expect(calls[0]).toContain("mlx-community/parakeet-tdt-0.6b-v3");
186
+ });
187
+
188
+ test("cleans up the temp input + output dir afterwards", async () => {
189
+ const tmp = freshTmp();
190
+ let seenOutDir = "";
191
+ const { spawn, calls } = recordingSpawn((cmd) => {
192
+ seenOutDir = outDirOf(cmd);
193
+ writeFileSync(join(seenOutDir, "x.txt"), "t");
194
+ return ok;
195
+ });
196
+ await provider(spawn, tmp).transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" });
197
+ expect(existsSync(calls[0]![1]!)).toBe(false);
198
+ expect(existsSync(seenOutDir)).toBe(false);
199
+ });
200
+ });
201
+
202
+ // ---------------------------------------------------------------------------
203
+ // transcribe() — error mapping
204
+ // ---------------------------------------------------------------------------
205
+
206
+ describe("ParakeetMlxProvider.transcribe — error mapping", () => {
207
+ async function catchErr(fn: () => Promise<unknown>): Promise<unknown> {
208
+ try {
209
+ await fn();
210
+ } catch (err) {
211
+ return err;
212
+ }
213
+ throw new Error("expected transcribe() to throw");
214
+ }
215
+
216
+ test("binary missing → non-retriable missing_provider, NEVER spawns", async () => {
217
+ const { spawn, calls } = recordingSpawn(() => ok);
218
+ const p = provider(spawn, freshTmp(), { existsImpl: () => false });
219
+ const err = (await catchErr(() =>
220
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
221
+ )) as TranscriptionError;
222
+ expect(err).toBeInstanceOf(TranscriptionError);
223
+ expect(err.code).toBe("missing_provider");
224
+ expect(err.retriable).toBe(false);
225
+ expect(calls.length).toBe(0);
226
+ });
227
+
228
+ test("launch failure (127) → non-retriable missing_provider", async () => {
229
+ const { spawn } = recordingSpawn(() => ({ exitCode: 127, stdout: "", stderr: "gone" }));
230
+ const p = provider(spawn, freshTmp());
231
+ const err = (await catchErr(() =>
232
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
233
+ )) as TranscriptionError;
234
+ expect(err.code).toBe("missing_provider");
235
+ expect(err.retriable).toBe(false);
236
+ });
237
+
238
+ test("non-zero exit → RETRIABLE parakeet_mlx_error", async () => {
239
+ const { spawn } = recordingSpawn(() => ({ exitCode: 1, stdout: "", stderr: "boom" }));
240
+ const p = provider(spawn, freshTmp());
241
+ const err = (await catchErr(() =>
242
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
243
+ )) as TranscriptionError;
244
+ expect(err).toBeInstanceOf(TranscriptionError);
245
+ expect(err.code).toBe("parakeet_mlx_error");
246
+ expect(err.retriable).toBe(true);
247
+ });
248
+
249
+ test("exit 0 + no output + ffmpeg signature → non-retriable ffmpeg_missing (the scribe mask)", async () => {
250
+ // parakeet-mlx shells to ffmpeg internally; when ffmpeg is missing it
251
+ // prints the error then EXITS 0 with no output file. The signature in the
252
+ // combined output is the only signal.
253
+ const { spawn } = recordingSpawn(() => ({
254
+ exitCode: 0,
255
+ stdout: "",
256
+ stderr: "[Errno 2] No such file or directory: 'ffmpeg'",
257
+ }));
258
+ const p = provider(spawn, freshTmp());
259
+ const err = (await catchErr(() =>
260
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
261
+ )) as TranscriptionError;
262
+ expect(err).toBeInstanceOf(TranscriptionError);
263
+ expect(err.code).toBe("ffmpeg_missing");
264
+ expect(err.retriable).toBe(false);
265
+ });
266
+
267
+ test("exit 0 + no output, no ffmpeg signature → plain Error (retriable via worker)", async () => {
268
+ const { spawn } = recordingSpawn(() => ({ exitCode: 0, stdout: "done?", stderr: "" }));
269
+ const p = provider(spawn, freshTmp());
270
+ const err = (await catchErr(() =>
271
+ p.transcribe({ audio: AUDIO, filename: "a.webm", mimeType: "audio/webm" }),
272
+ )) as Error;
273
+ expect(err).toBeInstanceOf(Error);
274
+ expect(err).not.toBeInstanceOf(TranscriptionError);
275
+ expect(err.message).toContain("no transcript output");
276
+ });
277
+
278
+ test("empty transcript file → plain Error (retriable via worker)", async () => {
279
+ const { spawn } = recordingSpawn((cmd) => {
280
+ writeFileSync(join(outDirOf(cmd), "x.txt"), " \n ");
281
+ return ok;
282
+ });
283
+ const p = provider(spawn, freshTmp());
284
+ const err = (await catchErr(() =>
285
+ p.transcribe({ audio: AUDIO, filename: "a.wav", mimeType: "audio/wav" }),
286
+ )) as Error;
287
+ expect(err).not.toBeInstanceOf(TranscriptionError);
288
+ expect(err.message).toContain("no transcript text");
289
+ });
290
+ });
@@ -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
+ }