@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,167 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import {
3
+ planInstall,
4
+ selectModelForRam,
5
+ selectAsset,
6
+ isSharedLibFile,
7
+ MODELS,
8
+ TRANSCRIBE_CPP_VERSION,
9
+ } from "./install.ts";
10
+
11
+ /**
12
+ * Install-planning tests (scribe-fold Phase 2a). Pure selection logic — the
13
+ * RAM-tier matrix (scribe#82 fix), the OS/arch → asset mapping, and the full
14
+ * plan. No network, no fs.
15
+ */
16
+
17
+ const GB = 2 ** 30;
18
+
19
+ describe("selectModelForRam — the tier matrix", () => {
20
+ test("<1GB → refuse (null; steer to remote)", () => {
21
+ expect(selectModelForRam(0.5 * GB)).toBeNull();
22
+ expect(selectModelForRam(0)).toBeNull();
23
+ });
24
+
25
+ test("1–2GB → whisper-tiny.en", () => {
26
+ expect(selectModelForRam(1 * GB)!.name).toBe("whisper-tiny.en");
27
+ expect(selectModelForRam(1.9 * GB)!.name).toBe("whisper-tiny.en");
28
+ });
29
+
30
+ test("2–4GB → whisper-small.en (NOT Parakeet — the scribe#82 floor fix)", () => {
31
+ expect(selectModelForRam(2 * GB)!.name).toBe("whisper-small.en");
32
+ expect(selectModelForRam(3.9 * GB)!.name).toBe("whisper-small.en");
33
+ // The scribe#82 regression: 2GB must NOT pick parakeet (it OOMs there).
34
+ expect(selectModelForRam(2 * GB)!.name).not.toContain("parakeet");
35
+ });
36
+
37
+ test("≥4GB → parakeet-tdt-0.6b-v3", () => {
38
+ expect(selectModelForRam(4 * GB)!.name).toBe("parakeet-tdt-0.6b-v3");
39
+ expect(selectModelForRam(16 * GB)!.name).toBe("parakeet-tdt-0.6b-v3");
40
+ });
41
+
42
+ test("boundaries are inclusive at the tier floor", () => {
43
+ expect(selectModelForRam(1 * GB)!.name).toBe("whisper-tiny.en");
44
+ expect(selectModelForRam(2 * GB)!.name).toBe("whisper-small.en");
45
+ expect(selectModelForRam(4 * GB)!.name).toBe("parakeet-tdt-0.6b-v3");
46
+ });
47
+ });
48
+
49
+ describe("selectAsset — OS/arch → prebuilt release asset", () => {
50
+ test("linux x64", () => {
51
+ const a = selectAsset("linux", "x64")!;
52
+ expect(a.asset).toBe(`transcribe-native-${TRANSCRIBE_CPP_VERSION}-linux-x86_64-cpu-vulkan.tar.gz`);
53
+ expect(a.url).toContain("releases/download");
54
+ });
55
+ test("linux arm64", () => {
56
+ expect(selectAsset("linux", "arm64")!.asset).toContain("linux-aarch64");
57
+ });
58
+ test("macos arm64 → metal", () => {
59
+ expect(selectAsset("darwin", "arm64")!.asset).toContain("macos-arm64-metal");
60
+ });
61
+ test("macos x64", () => {
62
+ expect(selectAsset("darwin", "x64")!.asset).toContain("macos-x86_64");
63
+ });
64
+ test("unsupported platform/arch → null", () => {
65
+ expect(selectAsset("win32", "x64")).toBeNull();
66
+ expect(selectAsset("linux", "riscv64")).toBeNull();
67
+ expect(selectAsset("freebsd", "x64")).toBeNull();
68
+ });
69
+ });
70
+
71
+ describe("planInstall", () => {
72
+ test("supported host: asset + tier model + footprint", () => {
73
+ const plan = planInstall({ platform: "linux", arch: "x64", totalRamBytes: 8 * GB });
74
+ expect(plan.supported).toBe(true);
75
+ expect(plan.asset!.asset).toContain("linux-x86_64");
76
+ expect(plan.model!.name).toBe("parakeet-tdt-0.6b-v3");
77
+ expect(plan.footprintMb).toBe(MODELS["parakeet-tdt-0.6b-v3"]!.approxSizeMb);
78
+ expect(plan.totalRamGb).toBe(8);
79
+ });
80
+
81
+ test("2GB box picks whisper-small, not parakeet (scribe#82)", () => {
82
+ const plan = planInstall({ platform: "linux", arch: "arm64", totalRamBytes: 2 * GB });
83
+ expect(plan.supported).toBe(true);
84
+ expect(plan.model!.name).toBe("whisper-small.en");
85
+ });
86
+
87
+ test("<1GB → refused with a remote steer (not a hard 'unsupported')", () => {
88
+ const plan = planInstall({ platform: "linux", arch: "x64", totalRamBytes: 0.5 * GB });
89
+ expect(plan.supported).toBe(false);
90
+ expect(plan.refused).toBe(true);
91
+ expect(plan.asset).toBeDefined(); // platform IS supported; RAM isn't
92
+ expect(plan.reason).toContain("scribe-http");
93
+ });
94
+
95
+ test("unsupported platform → not supported, steer to remote, no asset", () => {
96
+ const plan = planInstall({ platform: "win32", arch: "x64", totalRamBytes: 16 * GB });
97
+ expect(plan.supported).toBe(false);
98
+ expect(plan.refused).toBeUndefined();
99
+ expect(plan.asset).toBeUndefined();
100
+ expect(plan.reason).toContain("scribe-http");
101
+ });
102
+
103
+ test("--model override wins over the RAM tier", () => {
104
+ const plan = planInstall({
105
+ platform: "darwin",
106
+ arch: "arm64",
107
+ totalRamBytes: 16 * GB,
108
+ overrideModel: "whisper-tiny.en",
109
+ });
110
+ expect(plan.supported).toBe(true);
111
+ expect(plan.model!.name).toBe("whisper-tiny.en");
112
+ });
113
+
114
+ test("--model override with an unknown id → not supported, lists valid models", () => {
115
+ const plan = planInstall({
116
+ platform: "linux",
117
+ arch: "x64",
118
+ totalRamBytes: 8 * GB,
119
+ overrideModel: "whisper-huge",
120
+ });
121
+ expect(plan.supported).toBe(false);
122
+ expect(plan.reason).toContain("whisper-tiny.en");
123
+ });
124
+
125
+ test("override lets a 1GB box request a big model (operator's call)", () => {
126
+ const plan = planInstall({
127
+ platform: "linux",
128
+ arch: "x64",
129
+ totalRamBytes: 1 * GB,
130
+ overrideModel: "parakeet-tdt-0.6b-v3",
131
+ });
132
+ expect(plan.supported).toBe(true);
133
+ expect(plan.model!.name).toBe("parakeet-tdt-0.6b-v3");
134
+ });
135
+ });
136
+
137
+ describe("MODELS registry", () => {
138
+ test("each model has a resolvable HuggingFace URL + a gguf file", () => {
139
+ for (const m of Object.values(MODELS)) {
140
+ expect(m.file).toMatch(/\.gguf$/);
141
+ expect(m.url).toContain("huggingface.co/handy-computer");
142
+ expect(m.url).toContain(m.file);
143
+ expect(m.approxSizeMb).toBeGreaterThan(0);
144
+ }
145
+ });
146
+
147
+ test("whisper-tiny.en footprint reflects the real Q5_K_M size (~42MB)", () => {
148
+ // Live-verified: whisper-tiny.en-Q5_K_M.gguf is 44,135,072 B ≈ 42MB (was 35).
149
+ expect(MODELS["whisper-tiny.en"]!.approxSizeMb).toBe(42);
150
+ });
151
+ });
152
+
153
+ describe("isSharedLibFile — what we keep from the v0.1.1 library tarball", () => {
154
+ test("matches macOS + Linux shared libs (incl. versioned .so)", () => {
155
+ expect(isSharedLibFile("libtranscribe.dylib")).toBe(true);
156
+ expect(isSharedLibFile("libggml-metal.dylib")).toBe(true);
157
+ expect(isSharedLibFile("libtranscribe.so")).toBe(true);
158
+ expect(isSharedLibFile("libggml.so.1")).toBe(true);
159
+ expect(isSharedLibFile("libggml.so.0.9.4")).toBe(true);
160
+ });
161
+ test("rejects non-libs (the CLI, manifests, licenses)", () => {
162
+ expect(isSharedLibFile("transcribe-cli")).toBe(false);
163
+ expect(isSharedLibFile("contract.json")).toBe(false);
164
+ expect(isSharedLibFile("LICENSE")).toBe(false);
165
+ expect(isSharedLibFile("model.gguf")).toBe(false);
166
+ });
167
+ });
@@ -0,0 +1,296 @@
1
+ /**
2
+ * Install planning for the `transcribe-cpp` local transcription provider
3
+ * (scribe-fold Phase 2a).
4
+ *
5
+ * This module is the PURE decision layer for `parachute-vault transcription
6
+ * install`: given the host OS/arch and total RAM, which prebuilt
7
+ * `transcribe-cli` release asset and which GGUF model should we fetch? It has
8
+ * NO side effects (no network, no fs writes) so the CLI's `--dry-run`/plan path
9
+ * — and the tests — exercise the full selection matrix without downloading a
10
+ * byte.
11
+ *
12
+ * ## Why transcribe.cpp, and why the SUBPROCESS path
13
+ *
14
+ * Ratified 2026-07-03 (Aaron): adopt **transcribe.cpp**
15
+ * (github.com/handy-computer/transcribe.cpp — CJ Pais / Mozilla.ai, MIT,
16
+ * "llama.cpp for STT") as the recommended low-RAM / no-Python local provider,
17
+ * ahead of whisper.cpp. Its in-process N-API/TypeScript binding crashes on Bun,
18
+ * so we drive a `transcribe-cli` as a subprocess instead — structurally like
19
+ * the `scribe-http` provider but local. See `providers/transcribe-cpp.ts`.
20
+ *
21
+ * ## What v0.1.1 actually ships (live-verified 2026-07-03)
22
+ *
23
+ * IMPORTANT: the v0.1.1 macOS/Linux release tarballs contain a **library** —
24
+ * `libtranscribe.{dylib,so}` + `libggml*.{dylib,so}` + `contract.json` +
25
+ * licenses — and **NO prebuilt `transcribe-cli` executable** (the CLI is
26
+ * build-from-source in v0.1.1). So the install verb fetches the tarball for its
27
+ * runtime shared libraries, downloads the GGUF model, and then honestly reports
28
+ * the CLI-acquisition gap: activate transcribe-cpp only when a runnable CLI is
29
+ * available (`TRANSCRIBE_CPP_BIN`, or a binary a future asset includes, or one
30
+ * built from source against these libs). GGUF models ARE published under the
31
+ * handy-computer HuggingFace org and download normally.
32
+ *
33
+ * ## The RAM-tier matrix (scribe#82 fix)
34
+ *
35
+ * scribe#82: today scribe auto-installs Parakeet-0.6b at 2GB and OOMs on long
36
+ * audio. The floor here is the fix — Parakeet is gated to ≥4GB; 2–4GB gets
37
+ * whisper-small, 1–2GB whisper-tiny, and <1GB is refused (steer to the
38
+ * scribe-http remote provider). Model picks below are the quantized GGUF
39
+ * variants published under the handy-computer HuggingFace org.
40
+ */
41
+
42
+ import { existsSync, readFileSync } from "fs";
43
+ import { totalmem } from "os";
44
+
45
+ /**
46
+ * Pinned upstream release. The prebuilt `transcribe-cli` bundles + the GGUF
47
+ * model quants are pulled from this tag. Bump deliberately (a newer CLI may
48
+ * change flags/output) — the provider's arg + output parsing is validated
49
+ * against this version.
50
+ */
51
+ export const TRANSCRIBE_CPP_VERSION = "0.1.1";
52
+
53
+ const RELEASE_BASE = `https://github.com/handy-computer/transcribe.cpp/releases/download/v${TRANSCRIBE_CPP_VERSION}`;
54
+ const HF_BASE = "https://huggingface.co/handy-computer";
55
+
56
+ const GB = 2 ** 30;
57
+
58
+ /** A concrete GGUF model choice: where to fetch it and its rough footprint. */
59
+ export interface ModelChoice {
60
+ /** Human/config id (e.g. "whisper-tiny.en"). Used for `--model` + the manifest. */
61
+ name: string;
62
+ /** HuggingFace repo under handy-computer. */
63
+ repo: string;
64
+ /** The GGUF filename within the repo (a quantized variant). */
65
+ file: string;
66
+ /** Full resolve URL for the GGUF. */
67
+ url: string;
68
+ /** Approx on-disk size of the GGUF, MB (for the "footprint" print). */
69
+ approxSizeMb: number;
70
+ /** Approx peak process RAM while transcribing, GB — the tier gate rationale. */
71
+ approxRuntimeGb: number;
72
+ /** Short human quality/latency note surfaced in the plan. */
73
+ note: string;
74
+ }
75
+
76
+ function model(
77
+ name: string,
78
+ repo: string,
79
+ file: string,
80
+ approxSizeMb: number,
81
+ approxRuntimeGb: number,
82
+ note: string,
83
+ ): ModelChoice {
84
+ return { name, repo, file, url: `${HF_BASE}/${repo}/resolve/main/${file}`, approxSizeMb, approxRuntimeGb, note };
85
+ }
86
+
87
+ /**
88
+ * The selectable models, keyed by `--model` id. Quantized (Q5_K_M / Q8_0)
89
+ * variants keep the footprint honest for the low-RAM tiers. Only models
90
+ * actually published under handy-computer are listed — whisper-base is NOT
91
+ * on that org, so the 2–4GB tier uses whisper-small.
92
+ */
93
+ export const MODELS: Record<string, ModelChoice> = {
94
+ "whisper-tiny.en": model(
95
+ "whisper-tiny.en",
96
+ "whisper-tiny.en-gguf",
97
+ "whisper-tiny.en-Q5_K_M.gguf",
98
+ 42,
99
+ 0.4,
100
+ "fastest, lowest quality — English only",
101
+ ),
102
+ "whisper-small.en": model(
103
+ "whisper-small.en",
104
+ "whisper-small.en-gguf",
105
+ "whisper-small.en-Q5_K_M.gguf",
106
+ 190,
107
+ 1.3,
108
+ "good quality/speed balance — English only",
109
+ ),
110
+ "parakeet-tdt-0.6b-v3": model(
111
+ "parakeet-tdt-0.6b-v3",
112
+ "parakeet-tdt-0.6b-v3-gguf",
113
+ "parakeet-tdt-0.6b-v3-Q8_0.gguf",
114
+ 660,
115
+ 3.5,
116
+ "highest quality (NVIDIA Parakeet) — needs headroom for long audio",
117
+ ),
118
+ };
119
+
120
+ /**
121
+ * RAM tiers, richest-first. A host with `total RAM >= minGb` gets `model`.
122
+ * Below the smallest tier (<1GB) local transcription is refused and the
123
+ * operator is steered to the scribe-http remote provider.
124
+ *
125
+ * The Parakeet floor at 4GB is the scribe#82 fix: it used to auto-install at
126
+ * 2GB and OOM on long audio.
127
+ */
128
+ export const RAM_TIERS: ReadonlyArray<{ minGb: number; model: string }> = [
129
+ { minGb: 4, model: "parakeet-tdt-0.6b-v3" },
130
+ { minGb: 2, model: "whisper-small.en" },
131
+ { minGb: 1, model: "whisper-tiny.en" },
132
+ ];
133
+
134
+ /** The lowest RAM (bytes) at which we'll still install a local model. */
135
+ export const MIN_LOCAL_RAM_BYTES = 1 * GB;
136
+
137
+ /**
138
+ * Pick the tier-appropriate model for `totalRamBytes`, or `null` when there
139
+ * isn't enough RAM to run any local model (steer to remote). Selection is by
140
+ * TOTAL RAM (not free) — a 2GB box shouldn't pick a 4GB model just because it's
141
+ * momentarily idle.
142
+ */
143
+ export function selectModelForRam(totalRamBytes: number): ModelChoice | null {
144
+ const gb = totalRamBytes / GB;
145
+ for (const tier of RAM_TIERS) {
146
+ if (gb >= tier.minGb) return MODELS[tier.model]!;
147
+ }
148
+ return null;
149
+ }
150
+
151
+ /** A release asset: its filename + full download URL. */
152
+ export interface AssetChoice {
153
+ asset: string;
154
+ url: string;
155
+ }
156
+
157
+ /**
158
+ * Does `name` look like a shared library we keep from the release tarball?
159
+ * transcribe.cpp v0.1.1's macOS/Linux assets ship `libtranscribe.{dylib,so}` +
160
+ * `libggml*.{dylib,so}` (the ggml runtime the CLI links against) — NOT a
161
+ * prebuilt `transcribe-cli`. The install verb extracts these so a
162
+ * build-from-source or `TRANSCRIBE_CPP_BIN` CLI can link against them.
163
+ */
164
+ export function isSharedLibFile(name: string): boolean {
165
+ return /\.(dylib|so)(\.\d+)*$/i.test(name);
166
+ }
167
+
168
+ /**
169
+ * Map a Node `platform`/`arch` to the matching `transcribe-native` release
170
+ * asset, or `null` when no asset exists for the host (→ steer to the scribe-http
171
+ * remote provider). Asset names follow the upstream
172
+ * `transcribe-native-<version>-<platform>-<backend>.tar.gz` convention. NOTE: in
173
+ * v0.1.1 this asset is a **library** bundle (see the module header), not a
174
+ * prebuilt CLI.
175
+ *
176
+ * `arch` is Node's `os.arch()` value: `"x64"` / `"arm64"`.
177
+ */
178
+ export function selectAsset(platform: string, arch: string): AssetChoice | null {
179
+ const v = TRANSCRIBE_CPP_VERSION;
180
+ let asset: string | null = null;
181
+ if (platform === "linux") {
182
+ if (arch === "x64") asset = `transcribe-native-${v}-linux-x86_64-cpu-vulkan.tar.gz`;
183
+ else if (arch === "arm64") asset = `transcribe-native-${v}-linux-aarch64-cpu-vulkan.tar.gz`;
184
+ } else if (platform === "darwin") {
185
+ if (arch === "arm64") asset = `transcribe-native-${v}-macos-arm64-metal.tar.gz`;
186
+ else if (arch === "x64") asset = `transcribe-native-${v}-macos-x86_64-cpu.tar.gz`;
187
+ }
188
+ if (!asset) return null;
189
+ return { asset, url: `${RELEASE_BASE}/${asset}` };
190
+ }
191
+
192
+ /** The resolved install plan — what `transcription install` would fetch. */
193
+ export interface InstallPlan {
194
+ /** True when a binary + model were selected and the install can proceed. */
195
+ supported: boolean;
196
+ /** Host platform (Node `os.platform()`). */
197
+ platform: string;
198
+ /** Host arch (Node `os.arch()`). */
199
+ arch: string;
200
+ /** Detected total RAM, GB (rounded to 1dp) for the human print. */
201
+ totalRamGb: number;
202
+ /** Set when `supported` is false: why, and (for refused) the remote steer. */
203
+ reason?: string;
204
+ /**
205
+ * True when the host COULD run a binary but has too little RAM for any
206
+ * model (<1GB) — distinct from an unsupported platform. Steer to remote.
207
+ */
208
+ refused?: boolean;
209
+ /** The prebuilt binary asset (present whenever the platform is supported). */
210
+ asset?: AssetChoice;
211
+ /** The chosen GGUF model (absent when refused/unsupported). */
212
+ model?: ModelChoice;
213
+ /** Approx total download footprint, MB (model GGUF; the binary is small). */
214
+ footprintMb?: number;
215
+ }
216
+
217
+ export interface PlanInput {
218
+ platform: string;
219
+ arch: string;
220
+ totalRamBytes: number;
221
+ /** Operator `--model <id>` override; must key into `MODELS`. */
222
+ overrideModel?: string;
223
+ }
224
+
225
+ /**
226
+ * Resolve the full install plan from host facts. Pure — the CLI prints this on
227
+ * the `--dry-run`/plan path and only fetches when the operator confirms.
228
+ *
229
+ * Order of refusal:
230
+ * 1. Unsupported platform/arch (no prebuilt) → `supported:false`, steer remote.
231
+ * 2. `--model` override naming an unknown model → `supported:false`.
232
+ * 3. RAM below the 1GB floor with no override → `supported:false, refused:true`.
233
+ */
234
+ export function planInstall(input: PlanInput): InstallPlan {
235
+ const { platform, arch, totalRamBytes, overrideModel } = input;
236
+ const totalRamGb = Math.round((totalRamBytes / GB) * 10) / 10;
237
+ const base = { platform, arch, totalRamGb };
238
+
239
+ const asset = selectAsset(platform, arch);
240
+ if (!asset) {
241
+ return {
242
+ ...base,
243
+ supported: false,
244
+ reason: `no transcribe.cpp release asset for ${platform}/${arch}. Use the scribe-http remote provider (set TRANSCRIPTION_PROVIDER=scribe-http and point at a scribe install).`,
245
+ };
246
+ }
247
+
248
+ let chosen: ModelChoice | null;
249
+ if (overrideModel) {
250
+ chosen = MODELS[overrideModel] ?? null;
251
+ if (!chosen) {
252
+ return {
253
+ ...base,
254
+ supported: false,
255
+ asset,
256
+ reason: `unknown model "${overrideModel}". Choose one of: ${Object.keys(MODELS).join(", ")}.`,
257
+ };
258
+ }
259
+ } else {
260
+ chosen = selectModelForRam(totalRamBytes);
261
+ if (!chosen) {
262
+ return {
263
+ ...base,
264
+ supported: false,
265
+ refused: true,
266
+ asset,
267
+ reason: `only ${totalRamGb}GB RAM detected — local transcription needs at least 1GB. Use the scribe-http remote provider instead (set TRANSCRIPTION_PROVIDER=scribe-http).`,
268
+ };
269
+ }
270
+ }
271
+
272
+ return {
273
+ ...base,
274
+ supported: true,
275
+ asset,
276
+ model: chosen,
277
+ footprintMb: chosen.approxSizeMb,
278
+ };
279
+ }
280
+
281
+ /**
282
+ * Detect total physical RAM in bytes. Prefers `/proc/meminfo` on Linux (the
283
+ * source `os.totalmem()` reads there anyway, but reading it directly is the
284
+ * explicit contract) and falls back to `os.totalmem()` everywhere else.
285
+ */
286
+ export function detectTotalRamBytes(): number {
287
+ try {
288
+ if (process.platform === "linux" && existsSync("/proc/meminfo")) {
289
+ const m = readFileSync("/proc/meminfo", "utf8").match(/MemTotal:\s+(\d+)\s+kB/);
290
+ if (m && m[1]) return parseInt(m[1], 10) * 1024;
291
+ }
292
+ } catch {
293
+ // fall through to os.totalmem()
294
+ }
295
+ return totalmem();
296
+ }
@@ -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
+ });