@openparachute/vault 0.6.5-rc.2 → 0.6.5-rc.9

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 (34) hide show
  1. package/core/src/do-param-cap.test.ts +161 -0
  2. package/core/src/links.ts +8 -13
  3. package/core/src/notes.ts +33 -15
  4. package/core/src/onboarding.ts +14 -296
  5. package/core/src/seed-packs.test.ts +191 -0
  6. package/core/src/seed-packs.ts +559 -0
  7. package/core/src/sql-in.test.ts +58 -0
  8. package/core/src/sql-in.ts +76 -0
  9. package/core/src/transcription/provider.ts +141 -0
  10. package/core/src/vault-projection.ts +1 -1
  11. package/core/src/wikilinks.ts +10 -4
  12. package/package.json +1 -1
  13. package/src/add-pack.test.ts +142 -0
  14. package/src/cli.ts +542 -7
  15. package/src/onboarding-seed.test.ts +126 -40
  16. package/src/onboarding-seed.ts +41 -46
  17. package/src/routes.ts +17 -0
  18. package/src/server.ts +77 -31
  19. package/src/transcription/build.test.ts +224 -0
  20. package/src/transcription/build.ts +252 -0
  21. package/src/transcription/capability.test.ts +93 -0
  22. package/src/transcription/capability.ts +71 -0
  23. package/src/transcription/install.test.ts +167 -0
  24. package/src/transcription/install.ts +296 -0
  25. package/src/transcription/providers/scribe-http.test.ts +195 -0
  26. package/src/transcription/providers/scribe-http.ts +144 -0
  27. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  28. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  29. package/src/transcription/select.test.ts +134 -0
  30. package/src/transcription/select.ts +164 -0
  31. package/src/transcription-worker.test.ts +44 -0
  32. package/src/transcription-worker.ts +57 -122
  33. package/src/vault-create.test.ts +38 -10
  34. package/src/vault.test.ts +48 -0
@@ -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
+ }
@@ -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
+ });