@openparachute/vault 0.7.4 → 0.7.5-rc.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.
@@ -0,0 +1,237 @@
1
+ /**
2
+ * Executing a {@link WhisperInstallPlan}.
3
+ *
4
+ * The planner decides; this does. Kept separate so every decision stays
5
+ * testable without a network, and so the one genuinely irreversible-feeling
6
+ * step — spawning a package manager — sits in a small, obvious place.
7
+ *
8
+ * ## The verification step is the point
9
+ *
10
+ * The provider this replaces was activated by an install verb that never
11
+ * checked whether what it configured could run. That is precisely how
12
+ * `TRANSCRIPTION_PROVIDER=transcribe-cpp` came to point at a CLI that has
13
+ * never shipped, and why local transcription silently did nothing for anyone
14
+ * who "installed" it.
15
+ *
16
+ * So {@link verifyTranscription} generates a real WAV, runs the real CLI
17
+ * against the real model, and requires a real exit code. `TRANSCRIPTION_
18
+ * PROVIDER` flips only after that passes. An install that cannot transcribe is
19
+ * a failed install, and it says so at install time rather than silently at the
20
+ * operator's first voice memo.
21
+ */
22
+
23
+ import { existsSync, mkdirSync, renameSync } from "fs";
24
+ import { tmpdir } from "os";
25
+ import { join } from "path";
26
+ import { downloadTo } from "./download.ts";
27
+ import type { WhisperInstallPlan } from "./install-whisper-cpp.ts";
28
+ import { binaryNameFor } from "./resolve-binary.ts";
29
+ import { defaultSpawnRunner, type SpawnRunner } from "./providers/transcribe-cpp.ts";
30
+
31
+ export interface ExecDeps {
32
+ spawn?: SpawnRunner;
33
+ download?: (url: string, dest: string) => Promise<void>;
34
+ existsImpl?: (p: string) => boolean;
35
+ log?: (line: string) => void;
36
+ }
37
+
38
+ export interface StepResult {
39
+ ok: boolean;
40
+ /** What happened, for the operator. */
41
+ message: string;
42
+ }
43
+
44
+ /**
45
+ * Ensure the CLI binaries exist.
46
+ *
47
+ * `already-present` is a no-op by design — re-running install shouldn't drag a
48
+ * package manager through a reinstall, and an operator who installed
49
+ * whisper.cpp themselves has already solved this.
50
+ */
51
+ export async function ensureBinaries(
52
+ plan: WhisperInstallPlan,
53
+ deps: ExecDeps = {},
54
+ ): Promise<StepResult> {
55
+ const spawn = deps.spawn ?? defaultSpawnRunner;
56
+ const exists = deps.existsImpl ?? existsSync;
57
+ const log = deps.log ?? (() => {});
58
+
59
+ switch (plan.binary.kind) {
60
+ case "already-present":
61
+ return { ok: true, message: `${binaryNameFor(plan.model.engine)} already installed at ${plan.binary.path}` };
62
+
63
+ case "unsupported":
64
+ return { ok: false, message: plan.binary.reason };
65
+
66
+ case "homebrew": {
67
+ // Probe brew first so a box without it gets an honest refusal rather
68
+ // than an opaque exit 127 from the install attempt.
69
+ const probe = await spawn(["brew", "--version"], { timeoutMs: 30_000 });
70
+ if (probe.exitCode !== 0) {
71
+ return {
72
+ ok: false,
73
+ message:
74
+ "Homebrew isn't installed, and it's the only way to get whisper.cpp's CLIs on macOS " +
75
+ "(upstream ships no macOS CLI tarball — the macOS artifact is an xcframework for " +
76
+ "embedding). Install Homebrew from https://brew.sh, then re-run this. Or build " +
77
+ "whisper.cpp yourself and set WHISPER_CPP_BIN_DIR.",
78
+ };
79
+ }
80
+ log(`Running: brew install ${plan.binary.formula} …`);
81
+ const res = await spawn(["brew", "install", plan.binary.formula], { timeoutMs: 900_000 });
82
+ if (res.exitCode !== 0) {
83
+ return { ok: false, message: `brew install ${plan.binary.formula} failed: ${tail(res.stderr)}` };
84
+ }
85
+ return { ok: true, message: `Installed ${plan.binary.formula} (whisper-cli + parakeet-cli)` };
86
+ }
87
+
88
+ case "tarball": {
89
+ const download = deps.download ?? downloadTo;
90
+ mkdirSync(plan.binDir, { recursive: true });
91
+ const marker = join(plan.binDir, binaryNameFor(plan.model.engine));
92
+ if (exists(marker)) {
93
+ return { ok: true, message: `Binaries already present in ${plan.binDir}` };
94
+ }
95
+ const tmp = join(plan.binDir, `.${plan.binary.assetName}.part`);
96
+ log(`Downloading ${plan.binary.assetName} …`);
97
+ await download(plan.binary.url, tmp);
98
+ // Extract with --strip-components=1: the tarball nests everything under
99
+ // `whisper-bin-ubuntu-<arch>/`, and we want the binaries and their
100
+ // shared objects side by side in binDir so the loader finds them.
101
+ const res = await spawn(["tar", "xzf", tmp, "-C", plan.binDir, "--strip-components=1"], {
102
+ timeoutMs: 300_000,
103
+ });
104
+ try {
105
+ const { unlinkSync } = await import("fs");
106
+ unlinkSync(tmp);
107
+ } catch {
108
+ /* best effort */
109
+ }
110
+ if (res.exitCode !== 0) {
111
+ return { ok: false, message: `extracting ${plan.binary.assetName} failed: ${tail(res.stderr)}` };
112
+ }
113
+ if (!exists(marker)) {
114
+ return {
115
+ ok: false,
116
+ message: `${plan.binary.assetName} extracted but ${binaryNameFor(plan.model.engine)} isn't in ${plan.binDir}`,
117
+ };
118
+ }
119
+ await spawn(["chmod", "+x", marker], { timeoutMs: 30_000 });
120
+ return { ok: true, message: `Installed whisper.cpp binaries to ${plan.binDir}` };
121
+ }
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Ensure the model file is on disk.
127
+ *
128
+ * Downloads to a `.part` and renames on success, so an interrupted install
129
+ * never leaves a truncated file that looks complete to the readiness probe —
130
+ * which would present as a mystery CLI failure at the first transcription
131
+ * rather than an obvious missing download.
132
+ */
133
+ export async function ensureModel(
134
+ plan: WhisperInstallPlan,
135
+ deps: ExecDeps = {},
136
+ ): Promise<StepResult> {
137
+ const exists = deps.existsImpl ?? existsSync;
138
+ const download = deps.download ?? downloadTo;
139
+ const log = deps.log ?? (() => {});
140
+
141
+ if (exists(plan.modelPath)) {
142
+ return { ok: true, message: `Model already downloaded (${plan.model.label})` };
143
+ }
144
+ mkdirSync(dirOf(plan.modelPath), { recursive: true });
145
+ const part = `${plan.modelPath}.part`;
146
+ log(`Downloading ${plan.model.label} (${plan.model.sizeMb} MB) …`);
147
+ await download(plan.model.url, part);
148
+ renameSync(part, plan.modelPath);
149
+ return { ok: true, message: `Downloaded ${plan.model.label} to ${plan.modelPath}` };
150
+ }
151
+
152
+ /**
153
+ * Prove the install can actually transcribe.
154
+ *
155
+ * Generates a one-second silent 16 kHz mono WAV in-process (no ffmpeg needed —
156
+ * the CLIs take WAV directly, and this sidesteps making verification depend on
157
+ * a second tool) and runs the real CLI against the real model. We assert on the
158
+ * EXIT CODE, not on transcript content: silence legitimately produces no text,
159
+ * and a model that loads and runs cleanly is what we're checking.
160
+ */
161
+ export async function verifyTranscription(
162
+ plan: WhisperInstallPlan,
163
+ binPath: string,
164
+ deps: ExecDeps = {},
165
+ ): Promise<StepResult> {
166
+ const spawn = deps.spawn ?? defaultSpawnRunner;
167
+ const log = deps.log ?? (() => {});
168
+ // Scratch, so it belongs in tmp — NOT beside the models, where a leftover
169
+ // from an interrupted run would sit next to real downloads forever.
170
+ const wav = join(tmpdir(), `parachute-verify-${process.pid}-16k-mono.wav`);
171
+ try {
172
+ await Bun.write(wav, silentWav16kMono(1));
173
+ log("Verifying the install can transcribe …");
174
+ const args =
175
+ plan.model.engine === "whisper"
176
+ ? [binPath, "-m", plan.modelPath, "-f", wav, "-np", "-nt"]
177
+ : [binPath, "-m", plan.modelPath, "-f", wav, "-np"];
178
+ const res = await spawn(args, { timeoutMs: 300_000 });
179
+ if (res.exitCode !== 0) {
180
+ return {
181
+ ok: false,
182
+ message:
183
+ `${binaryNameFor(plan.model.engine)} could not run the model (exit ${res.exitCode}). ` +
184
+ `This usually means a truncated download or a model/binary mismatch: ${tail(res.stderr)}`,
185
+ };
186
+ }
187
+ return { ok: true, message: "Verified — the model loads and transcribes." };
188
+ } finally {
189
+ try {
190
+ const { unlinkSync } = await import("fs");
191
+ unlinkSync(wav);
192
+ } catch {
193
+ /* best effort */
194
+ }
195
+ }
196
+ }
197
+
198
+ /**
199
+ * A minimal valid RIFF/WAVE file: `seconds` of 16-bit PCM silence at 16 kHz
200
+ * mono — exactly the shape both CLIs want.
201
+ *
202
+ * Written by hand rather than shelling ffmpeg so verification doesn't depend
203
+ * on the very tool whose absence we're trying to report clearly elsewhere.
204
+ */
205
+ export function silentWav16kMono(seconds: number): Uint8Array {
206
+ const sampleRate = 16000;
207
+ const samples = Math.max(1, Math.floor(sampleRate * seconds));
208
+ const dataBytes = samples * 2; // 16-bit mono
209
+ const buf = new ArrayBuffer(44 + dataBytes);
210
+ const view = new DataView(buf);
211
+ const ascii = (off: number, s: string) => {
212
+ for (let i = 0; i < s.length; i++) view.setUint8(off + i, s.charCodeAt(i));
213
+ };
214
+ ascii(0, "RIFF");
215
+ view.setUint32(4, 36 + dataBytes, true);
216
+ ascii(8, "WAVE");
217
+ ascii(12, "fmt ");
218
+ view.setUint32(16, 16, true); // PCM chunk size
219
+ view.setUint16(20, 1, true); // PCM
220
+ view.setUint16(22, 1, true); // mono
221
+ view.setUint32(24, sampleRate, true);
222
+ view.setUint32(28, sampleRate * 2, true); // byte rate
223
+ view.setUint16(32, 2, true); // block align
224
+ view.setUint16(34, 16, true); // bits per sample
225
+ ascii(36, "data");
226
+ view.setUint32(40, dataBytes, true);
227
+ // Samples stay zero — silence.
228
+ return new Uint8Array(buf);
229
+ }
230
+
231
+ function dirOf(p: string): string {
232
+ return p.slice(0, p.lastIndexOf("/"));
233
+ }
234
+
235
+ function tail(s: string, lines = 3): string {
236
+ return s.trim().split("\n").slice(-lines).join(" | ").slice(0, 400);
237
+ }
@@ -0,0 +1,87 @@
1
+ /**
2
+ * The model catalog.
3
+ *
4
+ * These tests care about two things a catalog gets wrong quietly: URLs that
5
+ * don't match the filename we save to (so a re-run re-downloads forever), and
6
+ * a default-picker that hands a 1.5 GB model to a 1 GB box.
7
+ */
8
+
9
+ import { describe, expect, test } from "bun:test";
10
+ import {
11
+ DEFAULT_MODEL_ID,
12
+ findModel,
13
+ pickDefaultModel,
14
+ TRANSCRIPTION_MODELS,
15
+ } from "./models.ts";
16
+
17
+ describe("catalog integrity", () => {
18
+ test("ids are unique", () => {
19
+ const ids = TRANSCRIPTION_MODELS.map((m) => m.id);
20
+ expect(ids.length).toBe(new Set(ids).size);
21
+ });
22
+
23
+ test("every URL's basename matches the filename we write", () => {
24
+ // Drift here means the downloader saves to a name the resolver never
25
+ // looks for, so every boot re-downloads and nothing ever becomes ready.
26
+ for (const m of TRANSCRIPTION_MODELS) {
27
+ expect(m.url.split("/").pop()).toBe(m.filename);
28
+ }
29
+ });
30
+
31
+ test("Parakeet models come from ggml-org, Whisper from ggerganov", () => {
32
+ // Load-bearing: handy-computer's GGUFs are NOT loadable by whisper.cpp's
33
+ // parakeet-cli (verified — "failed to load Parakeet model"). Pointing a
34
+ // Parakeet entry at handy-computer would produce a model that downloads
35
+ // fine and then fails at every transcription.
36
+ for (const m of TRANSCRIPTION_MODELS) {
37
+ if (m.engine === "parakeet") expect(m.url).toContain("ggml-org/parakeet-GGUF");
38
+ else expect(m.url).toContain("ggerganov/whisper.cpp");
39
+ }
40
+ });
41
+
42
+ test("catalog is ordered smallest-first", () => {
43
+ const sizes = TRANSCRIPTION_MODELS.map((m) => m.sizeMb);
44
+ expect([...sizes].sort((a, b) => a - b)).toEqual(sizes);
45
+ });
46
+
47
+ test("the default exists and is a Parakeet model", () => {
48
+ const d = findModel(DEFAULT_MODEL_ID);
49
+ expect(d).toBeDefined();
50
+ expect(d!.engine).toBe("parakeet");
51
+ });
52
+
53
+ test("covers the small and mid size classes an operator asks for", () => {
54
+ const sizes = TRANSCRIPTION_MODELS.map((m) => m.sizeMb);
55
+ expect(sizes.some((s) => s < 150)).toBe(true); // ~100 MB class
56
+ expect(sizes.some((s) => s >= 300 && s <= 700)).toBe(true); // ~400–700 MB class
57
+ });
58
+ });
59
+
60
+ describe("pickDefaultModel", () => {
61
+ test("a comfortable box gets the recommended Parakeet", () => {
62
+ expect(pickDefaultModel(16384).id).toBe(DEFAULT_MODEL_ID);
63
+ });
64
+
65
+ test("a small box steps DOWN rather than swapping itself to death", () => {
66
+ const picked = pickDefaultModel(1024);
67
+ expect(picked.minRamMb).toBeLessThanOrEqual(1024);
68
+ expect(picked.sizeMb).toBeLessThan(200);
69
+ });
70
+
71
+ test("never returns undefined, even on an absurdly small box", () => {
72
+ const picked = pickDefaultModel(64);
73
+ expect(picked).toBeDefined();
74
+ expect(picked.id).toBe(TRANSCRIPTION_MODELS[0]!.id);
75
+ });
76
+
77
+ test("a mid box gets something that fits its RAM floor", () => {
78
+ const picked = pickDefaultModel(2048);
79
+ expect(picked.minRamMb).toBeLessThanOrEqual(2048);
80
+ });
81
+ });
82
+
83
+ describe("findModel", () => {
84
+ test("unknown id → undefined, not a throw", () => {
85
+ expect(findModel("nope")).toBeUndefined();
86
+ });
87
+ });
@@ -0,0 +1,191 @@
1
+ /**
2
+ * The local-transcription model catalog (whisper.cpp era).
3
+ *
4
+ * ## Why this replaced the transcribe.cpp plan
5
+ *
6
+ * The 2026-07-03 ratification adopted transcribe.cpp as the local provider.
7
+ * That decision assumed a `transcribe-cli` binary would ship. It never did:
8
+ * v0.1.3 (2026-07-12, latest as of this writing) still publishes only
9
+ * `libtranscribe.{dylib,so}` + `libggml*` + `contract.json` in its release
10
+ * tarballs — the CLI is build-from-source — and its N-API binding crashes on
11
+ * Bun, so the in-process route is closed too. Four weeks and three releases
12
+ * after the ratification, `parachute-vault transcription install` still could
13
+ * not produce a runnable setup.
14
+ *
15
+ * whisper.cpp ships what transcribe.cpp doesn't: **prebuilt CLIs on both
16
+ * platforms**. `brew install whisper-cpp` (bottled for arm64 macOS) installs
17
+ * BOTH `whisper-cli` and `parakeet-cli`, and the Linux release tarballs carry
18
+ * the same two binaries. Models are prebuilt too — `ggml-org/parakeet-GGUF`
19
+ * and `ggerganov/whisper.cpp` — so nothing has to be converted, and no Python
20
+ * is involved anywhere.
21
+ *
22
+ * Note that handy-computer's own GGUFs (68 repos, 16 model families) are NOT
23
+ * format-compatible with whisper.cpp's `parakeet-cli` — verified: loading
24
+ * `parakeet-tdt-0.6b-v3-Q4_K_M.gguf` fails with "failed to load Parakeet
25
+ * model". They are two ecosystems, and this catalog lives in whisper.cpp's.
26
+ * If transcribe.cpp ships a CLI later, its 16-family catalog becomes a
27
+ * genuinely attractive swap — the provider seam is where that would land.
28
+ *
29
+ * ## Why Parakeet is the default
30
+ *
31
+ * On the Open ASR Leaderboard, Parakeet TDT 0.6b v3 beats Whisper large-v3 on
32
+ * average WER (6.32% vs 7.44%) at roughly a third of the parameters, and runs
33
+ * substantially faster. The property that matters most for voice memos:
34
+ * **Parakeet does not hallucinate during silence**, which is Whisper's
35
+ * best-known failure mode and precisely what a recording with pauses triggers.
36
+ *
37
+ * The trade-off is language coverage — Parakeet v3 handles 25 European
38
+ * languages, Whisper 99 — which is why the Whisper models stay in the catalog
39
+ * rather than being dropped. A vault whose audio isn't in Parakeet's range
40
+ * picks a Whisper model and everything else works the same.
41
+ */
42
+
43
+ /**
44
+ * Which whisper.cpp CLI runs a given model. The two binaries take slightly
45
+ * different flags and read different model formats, so this is not cosmetic —
46
+ * it selects the argv builder and the readiness probe.
47
+ */
48
+ export type TranscriptionEngine = "parakeet" | "whisper";
49
+
50
+ /** A model an operator can pick, with everything needed to fetch and run it. */
51
+ export interface TranscriptionModel {
52
+ /** Stable id — what `TRANSCRIPTION_MODEL` is set to. */
53
+ id: string;
54
+ /** Short label for the admin UI. */
55
+ label: string;
56
+ /** Which CLI runs it. */
57
+ engine: TranscriptionEngine;
58
+ /** Download URL for the ggml `.bin`. */
59
+ url: string;
60
+ /** On-disk filename under `<root>/transcription/models/`. */
61
+ filename: string;
62
+ /** Approximate download size, MB. Real measured values, not estimates. */
63
+ sizeMb: number;
64
+ /**
65
+ * Rough floor of usable system RAM, MB. Used to pick a sane default on a
66
+ * given box and to warn when an operator picks something their machine will
67
+ * struggle with — NOT a hard refusal; an operator may know better.
68
+ */
69
+ minRamMb: number;
70
+ /** One line for the picker — what this trades away. */
71
+ note: string;
72
+ }
73
+
74
+ /**
75
+ * The catalog, smallest first.
76
+ *
77
+ * Sizes are measured from the HuggingFace API, not estimated. The Parakeet
78
+ * entries come from `ggml-org/parakeet-GGUF` (whisper.cpp's own conversions,
79
+ * which is what makes them loadable by `parakeet-cli`); the Whisper entries
80
+ * from `ggerganov/whisper.cpp`, the canonical source `download-ggml-model.sh`
81
+ * uses.
82
+ */
83
+ export const TRANSCRIPTION_MODELS: readonly TranscriptionModel[] = [
84
+ {
85
+ id: "whisper-tiny.en",
86
+ label: "Whisper Tiny (English)",
87
+ engine: "whisper",
88
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-tiny.en.bin",
89
+ filename: "ggml-tiny.en.bin",
90
+ sizeMb: 74,
91
+ minRamMb: 1024,
92
+ note: "Smallest option. Noticeably less accurate — for very constrained boxes.",
93
+ },
94
+ {
95
+ id: "whisper-base.en",
96
+ label: "Whisper Base (English)",
97
+ engine: "whisper",
98
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-base.en.bin",
99
+ filename: "ggml-base.en.bin",
100
+ sizeMb: 141,
101
+ minRamMb: 2048,
102
+ note: "Small and quick. English only.",
103
+ },
104
+ {
105
+ id: "parakeet-tdt-0.6b-v3-q4",
106
+ label: "Parakeet TDT 0.6b v3 (q4)",
107
+ engine: "parakeet",
108
+ url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
109
+ filename: "ggml-parakeet-tdt-0.6b-v3-q4_0.bin",
110
+ sizeMb: 339,
111
+ // 1.5 GB rather than 2: the q4 weights are 339 MB and peak usage lands
112
+ // well under 1 GB, so the common 2 GB VPS should get Parakeet rather than
113
+ // being stepped down to Whisper Tiny — a large accuracy loss to buy
114
+ // headroom it didn't need.
115
+ minRamMb: 1536,
116
+ note: "Parakeet at the smallest quantization. 25 European languages.",
117
+ },
118
+ {
119
+ id: "parakeet-tdt-0.6b-v3",
120
+ label: "Parakeet TDT 0.6b v3 (recommended)",
121
+ engine: "parakeet",
122
+ url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q4_k.bin",
123
+ filename: "ggml-parakeet-tdt-0.6b-v3-q4_k.bin",
124
+ sizeMb: 396,
125
+ minRamMb: 3072,
126
+ note: "Best accuracy-per-byte, and doesn't hallucinate on silence. 25 European languages.",
127
+ },
128
+ {
129
+ id: "whisper-small.en",
130
+ label: "Whisper Small (English)",
131
+ engine: "whisper",
132
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-small.en.bin",
133
+ filename: "ggml-small.en.bin",
134
+ sizeMb: 465,
135
+ minRamMb: 4096,
136
+ note: "More accurate Whisper. English only.",
137
+ },
138
+ {
139
+ id: "parakeet-tdt-0.6b-v3-q8",
140
+ label: "Parakeet TDT 0.6b v3 (q8)",
141
+ engine: "parakeet",
142
+ url: "https://huggingface.co/ggml-org/parakeet-GGUF/resolve/main/ggml-parakeet-tdt-0.6b-v3-q8_0.bin",
143
+ filename: "ggml-parakeet-tdt-0.6b-v3-q8_0.bin",
144
+ sizeMb: 638,
145
+ minRamMb: 6144,
146
+ note: "Parakeet at higher precision. Marginal gain over q4_k for most audio.",
147
+ },
148
+ {
149
+ id: "whisper-large-v3-turbo",
150
+ label: "Whisper Large v3 Turbo (multilingual)",
151
+ engine: "whisper",
152
+ url: "https://huggingface.co/ggerganov/whisper.cpp/resolve/main/ggml-large-v3-turbo.bin",
153
+ filename: "ggml-large-v3-turbo.bin",
154
+ sizeMb: 1549,
155
+ minRamMb: 8192,
156
+ note: "All 99 Whisper languages. Pick this when Parakeet's 25 aren't enough.",
157
+ },
158
+ ] as const;
159
+
160
+ /**
161
+ * The default model when nothing is configured.
162
+ *
163
+ * Parakeet q4_k rather than the smallest option: at 396 MB it is a reasonable
164
+ * download on any machine that can host a vault, and choosing accuracy by
165
+ * default is the right call for transcripts an operator will read months
166
+ * later. `pickDefaultModel` steps down on low-RAM boxes.
167
+ */
168
+ export const DEFAULT_MODEL_ID = "parakeet-tdt-0.6b-v3";
169
+
170
+ /** Look a model up by id. `undefined` for an unknown id. */
171
+ export function findModel(id: string): TranscriptionModel | undefined {
172
+ return TRANSCRIPTION_MODELS.find((m) => m.id === id);
173
+ }
174
+
175
+ /**
176
+ * Pick a sensible default for a machine with `totalRamMb`.
177
+ *
178
+ * Walks DOWN from the default to the largest model the box comfortably fits,
179
+ * so a small VPS gets something that runs rather than something that swaps.
180
+ * Never returns undefined — the smallest entry is the floor, and a box too
181
+ * small even for that has bigger problems than model choice.
182
+ */
183
+ export function pickDefaultModel(totalRamMb: number): TranscriptionModel {
184
+ const preferred = findModel(DEFAULT_MODEL_ID);
185
+ if (preferred && totalRamMb >= preferred.minRamMb) return preferred;
186
+ // Largest model that fits, else the smallest we have.
187
+ const fits = TRANSCRIPTION_MODELS.filter((m) => totalRamMb >= m.minRamMb);
188
+ return fits.length > 0
189
+ ? fits.reduce((a, b) => (b.sizeMb > a.sizeMb ? b : a))
190
+ : TRANSCRIPTION_MODELS[0]!;
191
+ }