@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.
- package/.parachute/module.json +1 -0
- package/core/src/core.test.ts +7 -7
- package/core/src/mcp.ts +1 -1
- package/core/src/schema.ts +5 -5
- package/core/src/seed-packs.test.ts +222 -56
- package/core/src/seed-packs.ts +334 -70
- package/core/src/tag-hierarchy.ts +1 -1
- package/core/src/tag-schemas.ts +2 -2
- package/core/src/types.ts +1 -1
- package/package.json +1 -1
- package/src/admin-spa.ts +2 -1
- package/src/auth.ts +1 -1
- package/src/cli.ts +317 -53
- package/src/export-watch.ts +1 -1
- package/src/live-frame-parity.test.ts +201 -0
- package/src/module-manifest.ts +8 -0
- package/src/onboarding-seed.test.ts +82 -20
- package/src/onboarding-seed.ts +2 -2
- package/src/routes.ts +3 -3
- package/src/routing.test.ts +2 -2
- package/src/routing.ts +18 -6
- package/src/self-register.test.ts +19 -0
- package/src/self-register.ts +6 -1
- package/src/server.ts +56 -0
- package/src/services-manifest.ts +8 -0
- package/src/subscriptions.ts +100 -42
- package/src/tag-scope.ts +3 -3
- package/src/test-support/live-frame-corpus.ts +72 -0
- package/src/token-store.ts +2 -2
- package/src/transcription/build.test.ts +86 -5
- package/src/transcription/build.ts +87 -7
- package/src/transcription/capability.test.ts +25 -0
- package/src/transcription/capability.ts +27 -2
- package/src/transcription/download.test.ts +101 -0
- package/src/transcription/download.ts +71 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/providers/onnx-asr.test.ts +229 -0
- package/src/transcription/providers/onnx-asr.ts +239 -0
- package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
- package/src/transcription/providers/parakeet-mlx.ts +242 -0
- package/src/transcription/select.test.ts +166 -1
- package/src/transcription/select.ts +234 -1
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/vault-create.test.ts +19 -14
- package/src/vault.test.ts +1 -1
- package/src/ws-server.ts +408 -0
- package/src/ws-subscribe.test.ts +474 -0
- package/src/ws-subscribe.ts +242 -0
- package/src/subscribe.test.ts +0 -609
- package/src/subscribe.ts +0 -248
|
@@ -1,13 +1,25 @@
|
|
|
1
1
|
import { describe, test, expect, afterEach } from "bun:test";
|
|
2
|
-
import { mkdirSync, writeFileSync, rmSync } from "fs";
|
|
2
|
+
import { mkdirSync, writeFileSync, rmSync, chmodSync } from "fs";
|
|
3
3
|
import { join } from "path";
|
|
4
4
|
import { tmpdir } from "os";
|
|
5
5
|
import {
|
|
6
6
|
resolveTranscriptionProviderName,
|
|
7
7
|
resolveTranscribeCppPaths,
|
|
8
8
|
transcribeCppInstalled,
|
|
9
|
+
probeTranscribeCliRunnable,
|
|
9
10
|
readManifest,
|
|
10
11
|
transcriptionHomeDir,
|
|
12
|
+
pythonVenvDir,
|
|
13
|
+
pythonManifestPath,
|
|
14
|
+
readPythonManifest,
|
|
15
|
+
resolveParakeetMlxBin,
|
|
16
|
+
resolveOnnxAsrBin,
|
|
17
|
+
resolveParakeetMlxModel,
|
|
18
|
+
resolveOnnxAsrModel,
|
|
19
|
+
parakeetMlxInstalled,
|
|
20
|
+
onnxAsrInstalled,
|
|
21
|
+
DEFAULT_PARAKEET_MLX_MODEL,
|
|
22
|
+
DEFAULT_ONNX_ASR_MODEL,
|
|
11
23
|
} from "./select.ts";
|
|
12
24
|
|
|
13
25
|
/**
|
|
@@ -29,6 +41,14 @@ describe("resolveTranscriptionProviderName", () => {
|
|
|
29
41
|
"transcribe-cpp",
|
|
30
42
|
);
|
|
31
43
|
});
|
|
44
|
+
test("explicit parakeet-mlx / onnx-asr (scribe-fold Phase 2b)", () => {
|
|
45
|
+
expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "parakeet-mlx" }, silent)).toBe(
|
|
46
|
+
"parakeet-mlx",
|
|
47
|
+
);
|
|
48
|
+
expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "onnx-asr" }, silent)).toBe(
|
|
49
|
+
"onnx-asr",
|
|
50
|
+
);
|
|
51
|
+
});
|
|
32
52
|
test("explicit scribe-http", () => {
|
|
33
53
|
expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "scribe-http" }, silent)).toBe(
|
|
34
54
|
"scribe-http",
|
|
@@ -101,6 +121,45 @@ describe("transcribeCppInstalled", () => {
|
|
|
101
121
|
});
|
|
102
122
|
});
|
|
103
123
|
|
|
124
|
+
describe("probeTranscribeCliRunnable — EXECUTED, not stat'd (vault#534)", () => {
|
|
125
|
+
// Tiny real shell scripts stand in for transcribe-cli: the probe's whole
|
|
126
|
+
// point is that it actually runs the binary, so mocks would test nothing.
|
|
127
|
+
function script(home: string, body: string): string {
|
|
128
|
+
const p = join(home, "fake-cli");
|
|
129
|
+
writeFileSync(p, `#!/bin/sh\n${body}\n`);
|
|
130
|
+
chmodSync(p, 0o755);
|
|
131
|
+
return p;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
test("exit 0 ⇒ ok", async () => {
|
|
135
|
+
const bin = script(mkTmpHome(), "exit 0");
|
|
136
|
+
expect(await probeTranscribeCliRunnable(bin)).toEqual({ ok: true });
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("nonzero exit ⇒ not ok, reason carries the exit code + first stderr line", async () => {
|
|
140
|
+
// Mirrors the vault#534 Linux failure shape: binary present + launches,
|
|
141
|
+
// but errors out (there: empty ggml backend registry, exit 1).
|
|
142
|
+
const bin = script(mkTmpHome(), 'echo "whisper: failed to initialize CPU backend" >&2\nexit 1');
|
|
143
|
+
const r = await probeTranscribeCliRunnable(bin);
|
|
144
|
+
expect(r.ok).toBe(false);
|
|
145
|
+
expect(r.reason).toContain("exited 1");
|
|
146
|
+
expect(r.reason).toContain("failed to initialize CPU backend");
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
test("unlaunchable binary (missing) ⇒ not ok with a reason", async () => {
|
|
150
|
+
const r = await probeTranscribeCliRunnable(join(mkTmpHome(), "no-such-cli"));
|
|
151
|
+
expect(r.ok).toBe(false);
|
|
152
|
+
expect(r.reason).toBeTruthy();
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("hang ⇒ not ok after the timeout", async () => {
|
|
156
|
+
const bin = script(mkTmpHome(), "sleep 30");
|
|
157
|
+
const r = await probeTranscribeCliRunnable(bin, { timeoutMs: 250 });
|
|
158
|
+
expect(r.ok).toBe(false);
|
|
159
|
+
expect(r.reason).toContain("250ms");
|
|
160
|
+
});
|
|
161
|
+
});
|
|
162
|
+
|
|
104
163
|
describe("readManifest", () => {
|
|
105
164
|
test("returns null for a missing/unreadable manifest", () => {
|
|
106
165
|
expect(readManifest(join(tmpdir(), "does-not-exist-xyz.json"))).toBeNull();
|
|
@@ -113,6 +172,112 @@ describe("readManifest", () => {
|
|
|
113
172
|
});
|
|
114
173
|
});
|
|
115
174
|
|
|
175
|
+
// --- python providers (parakeet-mlx / onnx-asr) — scribe-fold Phase 2b ------
|
|
176
|
+
|
|
177
|
+
describe("python provider bin resolution (env → venv → PATH)", () => {
|
|
178
|
+
const noHit = { existsImpl: () => false, whichImpl: () => null };
|
|
179
|
+
|
|
180
|
+
test("pythonVenvDir lives under the transcription home", () => {
|
|
181
|
+
expect(pythonVenvDir({ PARACHUTE_HOME: "/h" })).toBe(join("/h", "transcription", "venv"));
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
test("env override wins and is returned verbatim (existence is the caller's check)", () => {
|
|
185
|
+
expect(resolveParakeetMlxBin({ PARACHUTE_HOME: "/h", PARAKEET_MLX_BIN: "/opt/pk" }, noHit)).toBe("/opt/pk");
|
|
186
|
+
expect(resolveOnnxAsrBin({ PARACHUTE_HOME: "/h", ONNX_ASR_BIN: "/opt/ox" }, noHit)).toBe("/opt/ox");
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
test("managed venv binary beats PATH", () => {
|
|
190
|
+
const venvPk = join("/h", "transcription", "venv", "bin", "parakeet-mlx");
|
|
191
|
+
const bin = resolveParakeetMlxBin(
|
|
192
|
+
{ PARACHUTE_HOME: "/h" },
|
|
193
|
+
{ existsImpl: (p) => p === venvPk, whichImpl: () => "/usr/local/bin/parakeet-mlx" },
|
|
194
|
+
);
|
|
195
|
+
expect(bin).toBe(venvPk);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("falls back to PATH when no venv binary exists", () => {
|
|
199
|
+
const bin = resolveOnnxAsrBin(
|
|
200
|
+
{ PARACHUTE_HOME: "/h" },
|
|
201
|
+
{ existsImpl: () => false, whichImpl: (b) => (b === "onnx-asr" ? "/usr/local/bin/onnx-asr" : null) },
|
|
202
|
+
);
|
|
203
|
+
expect(bin).toBe("/usr/local/bin/onnx-asr");
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
test("nothing anywhere → undefined (provider reports unavailable)", () => {
|
|
207
|
+
expect(resolveParakeetMlxBin({ PARACHUTE_HOME: "/h" }, noHit)).toBeUndefined();
|
|
208
|
+
expect(resolveOnnxAsrBin({ PARACHUTE_HOME: "/h" }, noHit)).toBeUndefined();
|
|
209
|
+
});
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
describe("parakeetMlxInstalled / onnxAsrInstalled", () => {
|
|
213
|
+
test("true only when the resolved binary exists on disk", () => {
|
|
214
|
+
const home = mkTmpHome();
|
|
215
|
+
const env = { PARACHUTE_HOME: home };
|
|
216
|
+
expect(parakeetMlxInstalled(env, { whichImpl: () => null })).toBe(false);
|
|
217
|
+
expect(onnxAsrInstalled(env, { whichImpl: () => null })).toBe(false);
|
|
218
|
+
|
|
219
|
+
const binDir = join(home, "transcription", "venv", "bin");
|
|
220
|
+
mkdirSync(binDir, { recursive: true });
|
|
221
|
+
writeFileSync(join(binDir, "onnx-asr"), "#!/bin/sh\n");
|
|
222
|
+
expect(onnxAsrInstalled(env, { whichImpl: () => null })).toBe(true);
|
|
223
|
+
expect(parakeetMlxInstalled(env, { whichImpl: () => null })).toBe(false);
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
test("an env override pointing at a missing file is NOT installed (honesty)", () => {
|
|
227
|
+
const home = mkTmpHome();
|
|
228
|
+
expect(
|
|
229
|
+
onnxAsrInstalled({ PARACHUTE_HOME: home, ONNX_ASR_BIN: "/nope/onnx-asr" }, { whichImpl: () => null }),
|
|
230
|
+
).toBe(false);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("python model resolution (env → manifest → ratified default)", () => {
|
|
235
|
+
test("defaults are the ratified v3 models", () => {
|
|
236
|
+
const home = mkTmpHome();
|
|
237
|
+
expect(resolveParakeetMlxModel({ PARACHUTE_HOME: home })).toBe(DEFAULT_PARAKEET_MLX_MODEL);
|
|
238
|
+
expect(resolveOnnxAsrModel({ PARACHUTE_HOME: home })).toBe(DEFAULT_ONNX_ASR_MODEL);
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
test("env overrides win", () => {
|
|
242
|
+
const home = mkTmpHome();
|
|
243
|
+
expect(resolveParakeetMlxModel({ PARACHUTE_HOME: home, PARAKEET_MLX_MODEL: "mlx-community/custom" })).toBe(
|
|
244
|
+
"mlx-community/custom",
|
|
245
|
+
);
|
|
246
|
+
expect(resolveOnnxAsrModel({ PARACHUTE_HOME: home, ONNX_ASR_MODEL: "whisper-base" })).toBe("whisper-base");
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("the install manifest's model applies to ITS provider only", () => {
|
|
250
|
+
const home = mkTmpHome();
|
|
251
|
+
const tcDir = join(home, "transcription");
|
|
252
|
+
mkdirSync(tcDir, { recursive: true });
|
|
253
|
+
writeFileSync(
|
|
254
|
+
join(tcDir, "install-python.json"),
|
|
255
|
+
JSON.stringify({ provider: "onnx-asr", model: "manifest-model" }),
|
|
256
|
+
);
|
|
257
|
+
expect(resolveOnnxAsrModel({ PARACHUTE_HOME: home })).toBe("manifest-model");
|
|
258
|
+
// A parakeet lookup ignores an onnx manifest.
|
|
259
|
+
expect(resolveParakeetMlxModel({ PARACHUTE_HOME: home })).toBe(DEFAULT_PARAKEET_MLX_MODEL);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
describe("readPythonManifest", () => {
|
|
264
|
+
test("returns null for a missing manifest", () => {
|
|
265
|
+
const home = mkTmpHome();
|
|
266
|
+
expect(readPythonManifest(pythonManifestPath({ PARACHUTE_HOME: home }))).toBeNull();
|
|
267
|
+
});
|
|
268
|
+
test("parses a written manifest", () => {
|
|
269
|
+
const home = mkTmpHome();
|
|
270
|
+
const tcDir = join(home, "transcription");
|
|
271
|
+
mkdirSync(tcDir, { recursive: true });
|
|
272
|
+
writeFileSync(
|
|
273
|
+
join(tcDir, "install-python.json"),
|
|
274
|
+
JSON.stringify({ provider: "parakeet-mlx", model: "m", pipTarget: "parakeet-mlx" }),
|
|
275
|
+
);
|
|
276
|
+
const m = readPythonManifest(pythonManifestPath({ PARACHUTE_HOME: home }));
|
|
277
|
+
expect(m?.provider).toBe("parakeet-mlx");
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
|
|
116
281
|
// --- helpers ---------------------------------------------------------------
|
|
117
282
|
|
|
118
283
|
const tmpHomes: string[] = [];
|
|
@@ -30,9 +30,28 @@ import { join } from "path";
|
|
|
30
30
|
import { homedir } from "os";
|
|
31
31
|
import { existsSync, readFileSync } from "fs";
|
|
32
32
|
|
|
33
|
-
export const TRANSCRIPTION_PROVIDERS = [
|
|
33
|
+
export const TRANSCRIPTION_PROVIDERS = [
|
|
34
|
+
"scribe-http",
|
|
35
|
+
"transcribe-cpp",
|
|
36
|
+
"parakeet-mlx",
|
|
37
|
+
"onnx-asr",
|
|
38
|
+
] as const;
|
|
34
39
|
export type TranscriptionProviderName = (typeof TRANSCRIPTION_PROVIDERS)[number];
|
|
35
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Default models for the Python-based local providers (scribe-fold Phase 2b —
|
|
43
|
+
* the ratified RAM-tier table, 2026-07-03).
|
|
44
|
+
*
|
|
45
|
+
* - parakeet-mlx drives the `parakeet-mlx` CLI (MLX, Apple Silicon); the
|
|
46
|
+
* model is an mlx-community HuggingFace repo fetched into the HF cache on
|
|
47
|
+
* first use (~2.5GB).
|
|
48
|
+
* - onnx-asr drives the `onnx-asr` CLI (ONNX Runtime, CPU); the model id is
|
|
49
|
+
* onnx-asr's own registry name for NVIDIA Parakeet TDT 0.6b v3 (int8 ONNX,
|
|
50
|
+
* ~670MB), fetched on first use / warm-pulled at install.
|
|
51
|
+
*/
|
|
52
|
+
export const DEFAULT_PARAKEET_MLX_MODEL = "mlx-community/parakeet-tdt-0.6b-v3";
|
|
53
|
+
export const DEFAULT_ONNX_ASR_MODEL = "nemo-parakeet-tdt-0.6b-v3";
|
|
54
|
+
|
|
36
55
|
/**
|
|
37
56
|
* Resolve the configured provider name. `TRANSCRIPTION_PROVIDER` selects it;
|
|
38
57
|
* unset (or blank) ⇒ `scribe-http` (the behavior-preserving default). An
|
|
@@ -162,3 +181,217 @@ export function transcribeCppInstalled(
|
|
|
162
181
|
): boolean {
|
|
163
182
|
return existsSync(paths.binPath) && !!paths.modelPath && existsSync(paths.modelPath);
|
|
164
183
|
}
|
|
184
|
+
|
|
185
|
+
/** Outcome of the EXECUTED runnable probe (`probeTranscribeCliRunnable`). */
|
|
186
|
+
export interface RunnableProbeResult {
|
|
187
|
+
ok: boolean;
|
|
188
|
+
/** Why the probe failed, for the `runnable: no (<reason>)` print. */
|
|
189
|
+
reason?: string;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* EXECUTED runnable probe: actually launch `transcribe-cli --help` and check
|
|
194
|
+
* the exit code. Cheap (no model load, no inference — upstream's `--help`
|
|
195
|
+
* prints usage and exits 0) but honest where a stat can't be: it catches a
|
|
196
|
+
* binary whose shared libraries don't resolve (broken/missing `libs/`, a bad
|
|
197
|
+
* rpath), a wrong-arch or corrupt binary, and a missing exec bit — all cases
|
|
198
|
+
* where `existsSync` says "yes" while every real run fails (the vault#534
|
|
199
|
+
* honesty bug: Linux installs reported `runnable: yes` while the CLI exited 1
|
|
200
|
+
* on every transcription).
|
|
201
|
+
*
|
|
202
|
+
* Deliberately NOT used by the per-request capability gate or the provider's
|
|
203
|
+
* `available()` (both are documented spawn-free); this is for the human-paced
|
|
204
|
+
* paths — `transcription status` and the install verb's activation check.
|
|
205
|
+
*/
|
|
206
|
+
export async function probeTranscribeCliRunnable(
|
|
207
|
+
binPath: string,
|
|
208
|
+
opts: { timeoutMs?: number } = {},
|
|
209
|
+
): Promise<RunnableProbeResult> {
|
|
210
|
+
const timeoutMs = opts.timeoutMs ?? 10_000;
|
|
211
|
+
let proc: ReturnType<typeof Bun.spawn>;
|
|
212
|
+
try {
|
|
213
|
+
proc = Bun.spawn([binPath, "--help"], { stdin: "ignore", stdout: "ignore", stderr: "pipe" });
|
|
214
|
+
} catch (err) {
|
|
215
|
+
return {
|
|
216
|
+
ok: false,
|
|
217
|
+
reason: `could not launch ${binPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
let timedOut = false;
|
|
221
|
+
const timer = setTimeout(() => {
|
|
222
|
+
timedOut = true;
|
|
223
|
+
proc.kill("SIGKILL"); // diagnostic probe — no graceful shutdown to preserve
|
|
224
|
+
}, timeoutMs);
|
|
225
|
+
try {
|
|
226
|
+
const exitCode = await proc.exited;
|
|
227
|
+
if (timedOut) return { ok: false, reason: `--help did not exit within ${timeoutMs}ms` };
|
|
228
|
+
// stderr normally EOFs at exit; the race guards against a wrapper-script
|
|
229
|
+
// binary whose orphaned grandchild still holds the pipe open.
|
|
230
|
+
const stderr = await Promise.race([
|
|
231
|
+
new Response(proc.stderr as ReadableStream).text(),
|
|
232
|
+
new Promise<string>((resolve) => setTimeout(resolve, 2_000, "")),
|
|
233
|
+
]);
|
|
234
|
+
const detail = stderr.trim().split("\n")[0]?.slice(0, 200);
|
|
235
|
+
if (proc.signalCode) {
|
|
236
|
+
// e.g. macOS dyld aborts with SIGABRT when a linked dylib is missing.
|
|
237
|
+
return { ok: false, reason: `--help died on ${proc.signalCode}${detail ? `: ${detail}` : ""}` };
|
|
238
|
+
}
|
|
239
|
+
if (exitCode !== 0) {
|
|
240
|
+
return { ok: false, reason: `--help exited ${exitCode}${detail ? `: ${detail}` : ""}` };
|
|
241
|
+
}
|
|
242
|
+
return { ok: true };
|
|
243
|
+
} finally {
|
|
244
|
+
clearTimeout(timer);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// ---------------------------------------------------------------------------
|
|
249
|
+
// Python-based local providers (parakeet-mlx / onnx-asr) — scribe-fold 2b
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* The dedicated venv `transcription install` creates for the Python-based
|
|
254
|
+
* providers: `<root>/transcription/venv/`. A deliberate deviation from
|
|
255
|
+
* scribe's installer (which preferred `uv tool install` + a `~/.venvs`
|
|
256
|
+
* fallback and then had to caveat "add the venv to your PATH"): vault resolves
|
|
257
|
+
* the venv binary by ABSOLUTE path, so the daemon needs no PATH surgery and
|
|
258
|
+
* the whole install is sandboxed under `PARACHUTE_HOME` (tests, Docker).
|
|
259
|
+
*/
|
|
260
|
+
export function pythonVenvDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
261
|
+
return join(transcriptionHomeDir(env), "venv");
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** `<root>/transcription/install-python.json` — the python-provider install manifest. */
|
|
265
|
+
export function pythonManifestPath(env: NodeJS.ProcessEnv = process.env): string {
|
|
266
|
+
return join(transcriptionHomeDir(env), "install-python.json");
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
/** Persisted manifest for a python-provider install (written by `transcription install`). */
|
|
270
|
+
export interface PythonInstallManifest {
|
|
271
|
+
/** Which provider this install set up ("parakeet-mlx" | "onnx-asr"). */
|
|
272
|
+
provider: string;
|
|
273
|
+
/** The pip install target (e.g. "onnx-asr[cpu,hub]"). */
|
|
274
|
+
pipTarget: string;
|
|
275
|
+
/** Resolved binary path at install time. */
|
|
276
|
+
bin: string;
|
|
277
|
+
/** The venv the package landed in ("" when the binary came from PATH). */
|
|
278
|
+
venv: string;
|
|
279
|
+
/** Model id the provider was configured with. */
|
|
280
|
+
model: string;
|
|
281
|
+
os: string;
|
|
282
|
+
arch: string;
|
|
283
|
+
ram_gb: number;
|
|
284
|
+
installedAt: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/** Read + parse the python install manifest, or `null` when absent/unreadable. */
|
|
288
|
+
export function readPythonManifest(
|
|
289
|
+
manifestPath: string = pythonManifestPath(),
|
|
290
|
+
): PythonInstallManifest | null {
|
|
291
|
+
try {
|
|
292
|
+
if (!existsSync(manifestPath)) return null;
|
|
293
|
+
const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as PythonInstallManifest;
|
|
294
|
+
return parsed && typeof parsed === "object" ? parsed : null;
|
|
295
|
+
} catch {
|
|
296
|
+
return null;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Injection seams for binary resolution (tests stub the fs + PATH probes). */
|
|
301
|
+
export interface BinResolveDeps {
|
|
302
|
+
existsImpl?: (p: string) => boolean;
|
|
303
|
+
whichImpl?: (bin: string) => string | null;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
/**
|
|
307
|
+
* Resolve a python-provider binary. Ladder (first hit wins):
|
|
308
|
+
*
|
|
309
|
+
* 1. an explicit env override (`PARAKEET_MLX_BIN` / `ONNX_ASR_BIN`) —
|
|
310
|
+
* returned verbatim, existence-checked by the caller (mirrors
|
|
311
|
+
* `TRANSCRIBE_CPP_BIN`'s contract);
|
|
312
|
+
* 2. the managed venv binary `<root>/transcription/venv/bin/<bin>` when it
|
|
313
|
+
* exists (vault's own install — deterministic under the daemon);
|
|
314
|
+
* 3. a PATH-installed binary via `Bun.which` (an operator's own
|
|
315
|
+
* `uv tool install` / pipx install keeps working).
|
|
316
|
+
*
|
|
317
|
+
* Returns `undefined` when nothing is found — the provider reports itself
|
|
318
|
+
* unavailable and the worker-boot gate stays closed.
|
|
319
|
+
*/
|
|
320
|
+
function resolvePythonBin(
|
|
321
|
+
envVar: string,
|
|
322
|
+
bin: string,
|
|
323
|
+
env: NodeJS.ProcessEnv,
|
|
324
|
+
deps: BinResolveDeps,
|
|
325
|
+
): string | undefined {
|
|
326
|
+
const override = env[envVar]?.trim();
|
|
327
|
+
if (override) return override;
|
|
328
|
+
const exists = deps.existsImpl ?? existsSync;
|
|
329
|
+
const which = deps.whichImpl ?? ((b: string) => Bun.which(b));
|
|
330
|
+
const venvBin = join(pythonVenvDir(env), "bin", bin);
|
|
331
|
+
if (exists(venvBin)) return venvBin;
|
|
332
|
+
return which(bin) ?? undefined;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/** Resolve the `parakeet-mlx` binary (env `PARAKEET_MLX_BIN` → venv → PATH). */
|
|
336
|
+
export function resolveParakeetMlxBin(
|
|
337
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
338
|
+
deps: BinResolveDeps = {},
|
|
339
|
+
): string | undefined {
|
|
340
|
+
return resolvePythonBin("PARAKEET_MLX_BIN", "parakeet-mlx", env, deps);
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
/** Resolve the `onnx-asr` binary (env `ONNX_ASR_BIN` → venv → PATH). */
|
|
344
|
+
export function resolveOnnxAsrBin(
|
|
345
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
346
|
+
deps: BinResolveDeps = {},
|
|
347
|
+
): string | undefined {
|
|
348
|
+
return resolvePythonBin("ONNX_ASR_BIN", "onnx-asr", env, deps);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Model resolution: env override → install manifest → ratified default. */
|
|
352
|
+
function resolvePythonModel(
|
|
353
|
+
envVar: string,
|
|
354
|
+
provider: string,
|
|
355
|
+
fallback: string,
|
|
356
|
+
env: NodeJS.ProcessEnv,
|
|
357
|
+
): string {
|
|
358
|
+
const override = env[envVar]?.trim();
|
|
359
|
+
if (override) return override;
|
|
360
|
+
const manifest = readPythonManifest(pythonManifestPath(env));
|
|
361
|
+
if (manifest?.provider === provider && manifest.model) return manifest.model;
|
|
362
|
+
return fallback;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
/** The parakeet-mlx model id (`PARAKEET_MLX_MODEL` → manifest → default v3). */
|
|
366
|
+
export function resolveParakeetMlxModel(env: NodeJS.ProcessEnv = process.env): string {
|
|
367
|
+
return resolvePythonModel("PARAKEET_MLX_MODEL", "parakeet-mlx", DEFAULT_PARAKEET_MLX_MODEL, env);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** The onnx-asr model id (`ONNX_ASR_MODEL` → manifest → default parakeet v3 int8). */
|
|
371
|
+
export function resolveOnnxAsrModel(env: NodeJS.ProcessEnv = process.env): string {
|
|
372
|
+
return resolvePythonModel("ONNX_ASR_MODEL", "onnx-asr", DEFAULT_ONNX_ASR_MODEL, env);
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/**
|
|
376
|
+
* Cheap, spawn-free readiness check for a python provider: a resolved binary
|
|
377
|
+
* that exists on disk. (The MODEL is fetched into the HF cache on first use —
|
|
378
|
+
* there is no cheap on-disk check for it, so readiness is binary-only,
|
|
379
|
+
* matching the providers' `available()`.) Used by the worker-boot gate and
|
|
380
|
+
* `transcription status`.
|
|
381
|
+
*/
|
|
382
|
+
export function parakeetMlxInstalled(
|
|
383
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
384
|
+
deps: BinResolveDeps = {},
|
|
385
|
+
): boolean {
|
|
386
|
+
const bin = resolveParakeetMlxBin(env, deps);
|
|
387
|
+
return !!bin && (deps.existsImpl ?? existsSync)(bin);
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
/** See `parakeetMlxInstalled`. */
|
|
391
|
+
export function onnxAsrInstalled(
|
|
392
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
393
|
+
deps: BinResolveDeps = {},
|
|
394
|
+
): boolean {
|
|
395
|
+
const bin = resolveOnnxAsrBin(env, deps);
|
|
396
|
+
return !!bin && (deps.existsImpl ?? existsSync)(bin);
|
|
397
|
+
}
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
import { describe, test, expect } from "bun:test";
|
|
2
|
+
import { selectDefaultProvider, NOMINAL_SLACK_GB, type TierPlan } from "./tiers.ts";
|
|
3
|
+
import { DEFAULT_ONNX_ASR_MODEL, DEFAULT_PARAKEET_MLX_MODEL } from "./select.ts";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The ratified RAM/arch/OS tier matrix (scribe-fold Phase 2b, 2026-07-03).
|
|
7
|
+
* Pure decision layer — no host probes, no network. The load-bearing
|
|
8
|
+
* invariants:
|
|
9
|
+
*
|
|
10
|
+
* - macOS Apple Silicon 8GB+ → parakeet-mlx
|
|
11
|
+
* - Linux 4GB+ → onnx-asr
|
|
12
|
+
* - Linux ~2GB → transcribe-cpp with a SMALL WHISPER GGUF, never a Parakeet
|
|
13
|
+
* variant (scribe#82: Parakeet peak RAM exceeds 2GB on meeting-length
|
|
14
|
+
* audio → OOM)
|
|
15
|
+
* - Linux ~1GB → remote default, tiny local model only as explicit opt-in
|
|
16
|
+
* - below floor / unknown host → remote guidance, never a forced local pick
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
const GB = 2 ** 30;
|
|
20
|
+
|
|
21
|
+
function pick(platform: string, arch: string, gb: number): TierPlan {
|
|
22
|
+
return selectDefaultProvider({ platform, arch, totalRamBytes: gb * GB });
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** True when the plan's model is any Parakeet variant. */
|
|
26
|
+
function isParakeet(plan: TierPlan): boolean {
|
|
27
|
+
return !!plan.model && /parakeet/i.test(plan.model);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
describe("selectDefaultProvider — macOS Apple Silicon", () => {
|
|
31
|
+
test("darwin/arm64 8GB → parakeet-mlx (ratified default)", () => {
|
|
32
|
+
const plan = pick("darwin", "arm64", 8);
|
|
33
|
+
expect(plan.provider).toBe("parakeet-mlx");
|
|
34
|
+
expect(plan.model).toBe(DEFAULT_PARAKEET_MLX_MODEL);
|
|
35
|
+
expect(plan.approxDiskMb).toBe(2500);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("darwin/arm64 16GB → parakeet-mlx", () => {
|
|
39
|
+
expect(pick("darwin", "arm64", 16).provider).toBe("parakeet-mlx");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test("darwin/arm64 just under 8GB nominal (7.7GB reported) → still parakeet-mlx (slack)", () => {
|
|
43
|
+
expect(pick("darwin", "arm64", 8 - NOMINAL_SLACK_GB).provider).toBe("parakeet-mlx");
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
test("darwin/arm64 well under 8GB → transcribe-cpp fallback, NOT parakeet-mlx", () => {
|
|
47
|
+
const plan = pick("darwin", "arm64", 6);
|
|
48
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
test("darwin/x64 (Intel — no MLX) 16GB → transcribe-cpp fallback, never parakeet-mlx", () => {
|
|
52
|
+
const plan = pick("darwin", "x64", 16);
|
|
53
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
54
|
+
});
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("selectDefaultProvider — Linux", () => {
|
|
58
|
+
test("linux/x64 4GB → onnx-asr (ratified default)", () => {
|
|
59
|
+
const plan = pick("linux", "x64", 4);
|
|
60
|
+
expect(plan.provider).toBe("onnx-asr");
|
|
61
|
+
expect(plan.model).toBe(DEFAULT_ONNX_ASR_MODEL);
|
|
62
|
+
expect(plan.approxDiskMb).toBe(670);
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
test("linux/x64 8GB → onnx-asr, NOT parakeet-mlx (MLX is Apple-only)", () => {
|
|
66
|
+
expect(pick("linux", "x64", 8).provider).toBe("onnx-asr");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("linux/arm64 4GB → onnx-asr", () => {
|
|
70
|
+
expect(pick("linux", "arm64", 4).provider).toBe("onnx-asr");
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test("nominal-4GB box reporting 3.8GB (kernel reservation) → still onnx-asr (slack)", () => {
|
|
74
|
+
expect(pick("linux", "x64", 3.8).provider).toBe("onnx-asr");
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test("linux 3.4GB (below the 4GB tier even with slack) → transcribe-cpp small whisper", () => {
|
|
78
|
+
const plan = pick("linux", "x64", 3.4);
|
|
79
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
80
|
+
expect(plan.model).toBe("whisper-small.en");
|
|
81
|
+
});
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
describe("selectDefaultProvider — the 2GB-not-Parakeet floor (scribe#82)", () => {
|
|
85
|
+
test("linux 2GB → transcribe-cpp whisper-small.en — NEVER a Parakeet variant", () => {
|
|
86
|
+
const plan = pick("linux", "x64", 2);
|
|
87
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
88
|
+
expect(plan.model).toBe("whisper-small.en");
|
|
89
|
+
expect(isParakeet(plan)).toBe(false);
|
|
90
|
+
expect(plan.reason).toContain("scribe#82");
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
test("nominal-2GB box reporting 1.9GB → still the whisper-small tier (slack), not tiny/remote", () => {
|
|
94
|
+
const plan = pick("linux", "x64", 1.9);
|
|
95
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
96
|
+
expect(plan.model).toBe("whisper-small.en");
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("the whole 2–4GB Linux band never defaults to any Parakeet variant", () => {
|
|
100
|
+
for (const gb of [1.7, 2, 2.5, 3, 3.5]) {
|
|
101
|
+
const plan = pick("linux", "x64", gb);
|
|
102
|
+
expect(isParakeet(plan)).toBe(false);
|
|
103
|
+
expect(plan.provider).not.toBe("onnx-asr");
|
|
104
|
+
expect(plan.provider).not.toBe("parakeet-mlx");
|
|
105
|
+
}
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// Regression for the double-applied-slack bug (reviewer, PR #537): the
|
|
109
|
+
// transcribe-cpp fallback used to add NOMINAL_SLACK_GB a SECOND time before
|
|
110
|
+
// install.ts's 4GB Parakeet-GGUF floor, so a real-3.6GB Darwin box picked a
|
|
111
|
+
// ~3.5GB-peak Parakeet model (~0.1GB headroom — the scribe#82 OOM class).
|
|
112
|
+
// Slack belongs to tier-entry boundaries only, never to model-floor checks.
|
|
113
|
+
test("darwin/x64 in the 3.6–3.9GB band → whisper-small.en, NEVER a Parakeet variant", () => {
|
|
114
|
+
for (const gb of [3.6, 3.8, 3.9]) {
|
|
115
|
+
const plan = pick("darwin", "x64", gb);
|
|
116
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
117
|
+
expect(plan.model).toBe("whisper-small.en");
|
|
118
|
+
expect(isParakeet(plan)).toBe(false);
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test("darwin/arm64 in the 3.6–3.9GB band → whisper-small.en, NEVER a Parakeet variant", () => {
|
|
123
|
+
for (const gb of [3.6, 3.9]) {
|
|
124
|
+
const plan = pick("darwin", "arm64", gb);
|
|
125
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
126
|
+
expect(plan.model).toBe("whisper-small.en");
|
|
127
|
+
expect(isParakeet(plan)).toBe(false);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
test("darwin just over a TRUE 4GB → the un-inflated Phase 2a pick (parakeet GGUF)", () => {
|
|
132
|
+
// At a genuine ≥4GB, install.ts's own matrix governs — same model an
|
|
133
|
+
// explicit `--provider transcribe-cpp` install would choose.
|
|
134
|
+
for (const [arch, gb] of [["x64", 4], ["x64", 4.1], ["arm64", 4]] as const) {
|
|
135
|
+
const plan = pick("darwin", arch, gb);
|
|
136
|
+
expect(plan.provider).toBe("transcribe-cpp");
|
|
137
|
+
expect(plan.model).toBe("parakeet-tdt-0.6b-v3");
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe("selectDefaultProvider — the ~1GB remote tier", () => {
|
|
143
|
+
test("linux 1GB → scribe-http by default (never a forced local install)", () => {
|
|
144
|
+
const plan = pick("linux", "x64", 1);
|
|
145
|
+
expect(plan.provider).toBe("scribe-http");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
test("linux 1GB carries the explicit whisper-tiny local opt-in", () => {
|
|
149
|
+
const plan = pick("linux", "x64", 1);
|
|
150
|
+
expect(plan.localOptIn).toBeDefined();
|
|
151
|
+
expect(plan.localOptIn!.provider).toBe("transcribe-cpp");
|
|
152
|
+
expect(plan.localOptIn!.model).toBe("whisper-tiny.en");
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
test("nominal-1GB box reporting 0.95GB → still the opt-in tier", () => {
|
|
156
|
+
expect(pick("linux", "x64", 0.95).localOptIn).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
test("linux 1.4GB (below the 2GB tier) → remote default + tiny opt-in", () => {
|
|
160
|
+
const plan = pick("linux", "x64", 1.4);
|
|
161
|
+
expect(plan.provider).toBe("scribe-http");
|
|
162
|
+
expect(plan.localOptIn?.model).toBe("whisper-tiny.en");
|
|
163
|
+
});
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
describe("selectDefaultProvider — below floor / unknown hosts", () => {
|
|
167
|
+
test("linux 0.5GB → remote guidance with NO local opt-in", () => {
|
|
168
|
+
const plan = pick("linux", "x64", 0.5);
|
|
169
|
+
expect(plan.provider).toBe("scribe-http");
|
|
170
|
+
expect(plan.localOptIn).toBeUndefined();
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("unknown platform (win32) → remote guidance regardless of RAM", () => {
|
|
174
|
+
const plan = pick("win32", "x64", 32);
|
|
175
|
+
expect(plan.provider).toBe("scribe-http");
|
|
176
|
+
expect(plan.localOptIn).toBeUndefined();
|
|
177
|
+
expect(plan.reason).toContain("win32");
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("unknown arch (linux/riscv64) → remote guidance", () => {
|
|
181
|
+
expect(pick("linux", "riscv64", 8).provider).toBe("scribe-http");
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
|
|
185
|
+
describe("selectDefaultProvider — plan shape", () => {
|
|
186
|
+
test("carries the host facts for the human print", () => {
|
|
187
|
+
const plan = pick("linux", "x64", 4);
|
|
188
|
+
expect(plan.platform).toBe("linux");
|
|
189
|
+
expect(plan.arch).toBe("x64");
|
|
190
|
+
expect(plan.totalRamGb).toBe(4);
|
|
191
|
+
expect(plan.reason.length).toBeGreaterThan(0);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("rounds detected RAM to 1dp", () => {
|
|
195
|
+
expect(selectDefaultProvider({ platform: "linux", arch: "x64", totalRamBytes: 3.84 * GB }).totalRamGb).toBe(3.8);
|
|
196
|
+
});
|
|
197
|
+
});
|