@openparachute/vault 0.6.4 → 0.6.5-rc.10

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 (53) hide show
  1. package/core/src/__fixtures__/golden-vault.ts +125 -0
  2. package/core/src/__fixtures__/portable-export-golden.json +10 -0
  3. package/core/src/do-param-cap.test.ts +161 -0
  4. package/core/src/links.ts +8 -13
  5. package/core/src/mcp.ts +306 -314
  6. package/core/src/notes.ts +54 -62
  7. package/core/src/onboarding.ts +14 -296
  8. package/core/src/portable-md-batching.test.ts +67 -0
  9. package/core/src/portable-md-golden.test.ts +55 -0
  10. package/core/src/portable-md.ts +579 -435
  11. package/core/src/schema.ts +21 -43
  12. package/core/src/seed-packs.test.ts +191 -0
  13. package/core/src/seed-packs.ts +559 -0
  14. package/core/src/sql-in.test.ts +58 -0
  15. package/core/src/sql-in.ts +76 -0
  16. package/core/src/store.ts +14 -9
  17. package/core/src/transcription/provider.ts +141 -0
  18. package/core/src/txn.test.ts +229 -0
  19. package/core/src/txn.ts +105 -0
  20. package/core/src/types.ts +9 -0
  21. package/core/src/vault-projection.ts +1 -1
  22. package/core/src/wikilinks.ts +10 -4
  23. package/package.json +1 -1
  24. package/src/add-pack.test.ts +142 -0
  25. package/src/cli.ts +778 -7
  26. package/src/onboarding-seed.test.ts +126 -40
  27. package/src/onboarding-seed.ts +41 -46
  28. package/src/routes.ts +25 -7
  29. package/src/server.ts +108 -31
  30. package/src/transcription/build.test.ts +224 -0
  31. package/src/transcription/build.ts +252 -0
  32. package/src/transcription/capability.test.ts +118 -0
  33. package/src/transcription/capability.ts +96 -0
  34. package/src/transcription/install-python.test.ts +366 -0
  35. package/src/transcription/install-python.ts +471 -0
  36. package/src/transcription/install.test.ts +167 -0
  37. package/src/transcription/install.ts +296 -0
  38. package/src/transcription/providers/onnx-asr.test.ts +229 -0
  39. package/src/transcription/providers/onnx-asr.ts +239 -0
  40. package/src/transcription/providers/parakeet-mlx.test.ts +290 -0
  41. package/src/transcription/providers/parakeet-mlx.ts +242 -0
  42. package/src/transcription/providers/scribe-http.test.ts +195 -0
  43. package/src/transcription/providers/scribe-http.ts +144 -0
  44. package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
  45. package/src/transcription/providers/transcribe-cpp.ts +293 -0
  46. package/src/transcription/select.test.ts +259 -0
  47. package/src/transcription/select.ts +334 -0
  48. package/src/transcription/tiers.test.ts +197 -0
  49. package/src/transcription/tiers.ts +184 -0
  50. package/src/transcription-worker.test.ts +44 -0
  51. package/src/transcription-worker.ts +57 -122
  52. package/src/vault-create.test.ts +38 -10
  53. package/src/vault.test.ts +48 -0
@@ -0,0 +1,259 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { mkdirSync, writeFileSync, rmSync } from "fs";
3
+ import { join } from "path";
4
+ import { tmpdir } from "os";
5
+ import {
6
+ resolveTranscriptionProviderName,
7
+ resolveTranscribeCppPaths,
8
+ transcribeCppInstalled,
9
+ readManifest,
10
+ transcriptionHomeDir,
11
+ pythonVenvDir,
12
+ pythonManifestPath,
13
+ readPythonManifest,
14
+ resolveParakeetMlxBin,
15
+ resolveOnnxAsrBin,
16
+ resolveParakeetMlxModel,
17
+ resolveOnnxAsrModel,
18
+ parakeetMlxInstalled,
19
+ onnxAsrInstalled,
20
+ DEFAULT_PARAKEET_MLX_MODEL,
21
+ DEFAULT_ONNX_ASR_MODEL,
22
+ } from "./select.ts";
23
+
24
+ /**
25
+ * Provider-selection + path-resolution tests (scribe-fold Phase 2a). All env is
26
+ * passed explicitly so the shared bun-test process isn't polluted.
27
+ */
28
+
29
+ const silent = { warn: () => {} };
30
+
31
+ describe("resolveTranscriptionProviderName", () => {
32
+ test("unset → scribe-http (behavior-preserving default)", () => {
33
+ expect(resolveTranscriptionProviderName({}, silent)).toBe("scribe-http");
34
+ });
35
+ test("blank → scribe-http", () => {
36
+ expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: " " }, silent)).toBe("scribe-http");
37
+ });
38
+ test("explicit transcribe-cpp", () => {
39
+ expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "transcribe-cpp" }, silent)).toBe(
40
+ "transcribe-cpp",
41
+ );
42
+ });
43
+ test("explicit parakeet-mlx / onnx-asr (scribe-fold Phase 2b)", () => {
44
+ expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "parakeet-mlx" }, silent)).toBe(
45
+ "parakeet-mlx",
46
+ );
47
+ expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "onnx-asr" }, silent)).toBe(
48
+ "onnx-asr",
49
+ );
50
+ });
51
+ test("explicit scribe-http", () => {
52
+ expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "scribe-http" }, silent)).toBe(
53
+ "scribe-http",
54
+ );
55
+ });
56
+ test("unknown value → warns + falls back to scribe-http", () => {
57
+ let warned = false;
58
+ const name = resolveTranscriptionProviderName(
59
+ { TRANSCRIPTION_PROVIDER: "whisper-magic" },
60
+ { warn: () => (warned = true) },
61
+ );
62
+ expect(name).toBe("scribe-http");
63
+ expect(warned).toBe(true);
64
+ });
65
+ });
66
+
67
+ describe("transcriptionHomeDir + resolveTranscribeCppPaths — PARACHUTE_HOME + overrides", () => {
68
+ test("honors PARACHUTE_HOME", () => {
69
+ const dir = transcriptionHomeDir({ PARACHUTE_HOME: "/custom/home" });
70
+ expect(dir).toBe(join("/custom/home", "transcription"));
71
+ });
72
+
73
+ test("default binPath/modelsDir under the transcription dir", () => {
74
+ const paths = resolveTranscribeCppPaths({ PARACHUTE_HOME: "/h" });
75
+ expect(paths.binPath).toBe(join("/h", "transcription", "bin", "transcribe-cli"));
76
+ expect(paths.modelsDir).toBe(join("/h", "transcription", "models"));
77
+ expect(paths.manifestPath).toBe(join("/h", "transcription", "install.json"));
78
+ });
79
+
80
+ test("TRANSCRIBE_CPP_BIN + TRANSCRIBE_CPP_MODEL env overrides win", () => {
81
+ const paths = resolveTranscribeCppPaths({
82
+ PARACHUTE_HOME: "/h",
83
+ TRANSCRIBE_CPP_BIN: "/opt/tc",
84
+ TRANSCRIBE_CPP_MODEL: "/models/custom.gguf",
85
+ });
86
+ expect(paths.binPath).toBe("/opt/tc");
87
+ expect(paths.modelPath).toBe("/models/custom.gguf");
88
+ });
89
+
90
+ test("model path resolves from the install manifest when no env override", () => {
91
+ const home = mkTmpHome();
92
+ writeManifest(home, { modelFile: "whisper-small.en-Q5_K_M.gguf", model: "whisper-small.en" });
93
+ const paths = resolveTranscribeCppPaths({ PARACHUTE_HOME: home });
94
+ expect(paths.modelPath).toBe(join(home, "transcription", "models", "whisper-small.en-Q5_K_M.gguf"));
95
+ });
96
+
97
+ test("no manifest, no env → modelPath undefined", () => {
98
+ const home = mkTmpHome();
99
+ expect(resolveTranscribeCppPaths({ PARACHUTE_HOME: home }).modelPath).toBeUndefined();
100
+ });
101
+ });
102
+
103
+ describe("transcribeCppInstalled", () => {
104
+ test("true only when both binary AND model exist on disk", () => {
105
+ const home = mkTmpHome();
106
+ const tcDir = join(home, "transcription");
107
+ mkdirSync(join(tcDir, "bin"), { recursive: true });
108
+ mkdirSync(join(tcDir, "models"), { recursive: true });
109
+ // Neither present yet.
110
+ expect(transcribeCppInstalled(resolveTranscribeCppPaths({ PARACHUTE_HOME: home }))).toBe(false);
111
+
112
+ // Binary only → still false.
113
+ writeFileSync(join(tcDir, "bin", "transcribe-cli"), "#!/bin/sh\n");
114
+ writeManifest(home, { modelFile: "m.gguf", model: "whisper-tiny.en" });
115
+ expect(transcribeCppInstalled(resolveTranscribeCppPaths({ PARACHUTE_HOME: home }))).toBe(false);
116
+
117
+ // Model too → true.
118
+ writeFileSync(join(tcDir, "models", "m.gguf"), "gguf");
119
+ expect(transcribeCppInstalled(resolveTranscribeCppPaths({ PARACHUTE_HOME: home }))).toBe(true);
120
+ });
121
+ });
122
+
123
+ describe("readManifest", () => {
124
+ test("returns null for a missing/unreadable manifest", () => {
125
+ expect(readManifest(join(tmpdir(), "does-not-exist-xyz.json"))).toBeNull();
126
+ });
127
+ test("parses a written manifest", () => {
128
+ const home = mkTmpHome();
129
+ writeManifest(home, { model: "whisper-tiny.en", modelFile: "t.gguf" });
130
+ const m = readManifest(join(home, "transcription", "install.json"));
131
+ expect(m?.model).toBe("whisper-tiny.en");
132
+ });
133
+ });
134
+
135
+ // --- python providers (parakeet-mlx / onnx-asr) — scribe-fold Phase 2b ------
136
+
137
+ describe("python provider bin resolution (env → venv → PATH)", () => {
138
+ const noHit = { existsImpl: () => false, whichImpl: () => null };
139
+
140
+ test("pythonVenvDir lives under the transcription home", () => {
141
+ expect(pythonVenvDir({ PARACHUTE_HOME: "/h" })).toBe(join("/h", "transcription", "venv"));
142
+ });
143
+
144
+ test("env override wins and is returned verbatim (existence is the caller's check)", () => {
145
+ expect(resolveParakeetMlxBin({ PARACHUTE_HOME: "/h", PARAKEET_MLX_BIN: "/opt/pk" }, noHit)).toBe("/opt/pk");
146
+ expect(resolveOnnxAsrBin({ PARACHUTE_HOME: "/h", ONNX_ASR_BIN: "/opt/ox" }, noHit)).toBe("/opt/ox");
147
+ });
148
+
149
+ test("managed venv binary beats PATH", () => {
150
+ const venvPk = join("/h", "transcription", "venv", "bin", "parakeet-mlx");
151
+ const bin = resolveParakeetMlxBin(
152
+ { PARACHUTE_HOME: "/h" },
153
+ { existsImpl: (p) => p === venvPk, whichImpl: () => "/usr/local/bin/parakeet-mlx" },
154
+ );
155
+ expect(bin).toBe(venvPk);
156
+ });
157
+
158
+ test("falls back to PATH when no venv binary exists", () => {
159
+ const bin = resolveOnnxAsrBin(
160
+ { PARACHUTE_HOME: "/h" },
161
+ { existsImpl: () => false, whichImpl: (b) => (b === "onnx-asr" ? "/usr/local/bin/onnx-asr" : null) },
162
+ );
163
+ expect(bin).toBe("/usr/local/bin/onnx-asr");
164
+ });
165
+
166
+ test("nothing anywhere → undefined (provider reports unavailable)", () => {
167
+ expect(resolveParakeetMlxBin({ PARACHUTE_HOME: "/h" }, noHit)).toBeUndefined();
168
+ expect(resolveOnnxAsrBin({ PARACHUTE_HOME: "/h" }, noHit)).toBeUndefined();
169
+ });
170
+ });
171
+
172
+ describe("parakeetMlxInstalled / onnxAsrInstalled", () => {
173
+ test("true only when the resolved binary exists on disk", () => {
174
+ const home = mkTmpHome();
175
+ const env = { PARACHUTE_HOME: home };
176
+ expect(parakeetMlxInstalled(env, { whichImpl: () => null })).toBe(false);
177
+ expect(onnxAsrInstalled(env, { whichImpl: () => null })).toBe(false);
178
+
179
+ const binDir = join(home, "transcription", "venv", "bin");
180
+ mkdirSync(binDir, { recursive: true });
181
+ writeFileSync(join(binDir, "onnx-asr"), "#!/bin/sh\n");
182
+ expect(onnxAsrInstalled(env, { whichImpl: () => null })).toBe(true);
183
+ expect(parakeetMlxInstalled(env, { whichImpl: () => null })).toBe(false);
184
+ });
185
+
186
+ test("an env override pointing at a missing file is NOT installed (honesty)", () => {
187
+ const home = mkTmpHome();
188
+ expect(
189
+ onnxAsrInstalled({ PARACHUTE_HOME: home, ONNX_ASR_BIN: "/nope/onnx-asr" }, { whichImpl: () => null }),
190
+ ).toBe(false);
191
+ });
192
+ });
193
+
194
+ describe("python model resolution (env → manifest → ratified default)", () => {
195
+ test("defaults are the ratified v3 models", () => {
196
+ const home = mkTmpHome();
197
+ expect(resolveParakeetMlxModel({ PARACHUTE_HOME: home })).toBe(DEFAULT_PARAKEET_MLX_MODEL);
198
+ expect(resolveOnnxAsrModel({ PARACHUTE_HOME: home })).toBe(DEFAULT_ONNX_ASR_MODEL);
199
+ });
200
+
201
+ test("env overrides win", () => {
202
+ const home = mkTmpHome();
203
+ expect(resolveParakeetMlxModel({ PARACHUTE_HOME: home, PARAKEET_MLX_MODEL: "mlx-community/custom" })).toBe(
204
+ "mlx-community/custom",
205
+ );
206
+ expect(resolveOnnxAsrModel({ PARACHUTE_HOME: home, ONNX_ASR_MODEL: "whisper-base" })).toBe("whisper-base");
207
+ });
208
+
209
+ test("the install manifest's model applies to ITS provider only", () => {
210
+ const home = mkTmpHome();
211
+ const tcDir = join(home, "transcription");
212
+ mkdirSync(tcDir, { recursive: true });
213
+ writeFileSync(
214
+ join(tcDir, "install-python.json"),
215
+ JSON.stringify({ provider: "onnx-asr", model: "manifest-model" }),
216
+ );
217
+ expect(resolveOnnxAsrModel({ PARACHUTE_HOME: home })).toBe("manifest-model");
218
+ // A parakeet lookup ignores an onnx manifest.
219
+ expect(resolveParakeetMlxModel({ PARACHUTE_HOME: home })).toBe(DEFAULT_PARAKEET_MLX_MODEL);
220
+ });
221
+ });
222
+
223
+ describe("readPythonManifest", () => {
224
+ test("returns null for a missing manifest", () => {
225
+ const home = mkTmpHome();
226
+ expect(readPythonManifest(pythonManifestPath({ PARACHUTE_HOME: home }))).toBeNull();
227
+ });
228
+ test("parses a written manifest", () => {
229
+ const home = mkTmpHome();
230
+ const tcDir = join(home, "transcription");
231
+ mkdirSync(tcDir, { recursive: true });
232
+ writeFileSync(
233
+ join(tcDir, "install-python.json"),
234
+ JSON.stringify({ provider: "parakeet-mlx", model: "m", pipTarget: "parakeet-mlx" }),
235
+ );
236
+ const m = readPythonManifest(pythonManifestPath({ PARACHUTE_HOME: home }));
237
+ expect(m?.provider).toBe("parakeet-mlx");
238
+ });
239
+ });
240
+
241
+ // --- helpers ---------------------------------------------------------------
242
+
243
+ const tmpHomes: string[] = [];
244
+ afterEach(() => {
245
+ for (const h of tmpHomes.splice(0)) rmSync(h, { recursive: true, force: true });
246
+ });
247
+
248
+ function mkTmpHome(): string {
249
+ const home = join(tmpdir(), `tc-select-${Date.now()}-${Math.random().toString(36).slice(2)}`);
250
+ mkdirSync(home, { recursive: true });
251
+ tmpHomes.push(home);
252
+ return home;
253
+ }
254
+
255
+ function writeManifest(home: string, fields: Record<string, unknown>): void {
256
+ const tcDir = join(home, "transcription");
257
+ mkdirSync(tcDir, { recursive: true });
258
+ writeFileSync(join(tcDir, "install.json"), JSON.stringify(fields));
259
+ }
@@ -0,0 +1,334 @@
1
+ /**
2
+ * Transcription provider selection + `transcribe-cpp` path resolution
3
+ * (scribe-fold Phase 2a).
4
+ *
5
+ * Vault now ships TWO transcription providers behind the Phase 1
6
+ * `TranscriptionProvider` seam:
7
+ *
8
+ * - `scribe-http` — the remote/compat provider (the DEFAULT; existing scribe
9
+ * installs are unchanged);
10
+ * - `transcribe-cpp` — a local, no-Python provider that subprocesses the
11
+ * prebuilt `transcribe-cli` (opt-in via `transcription install` + config).
12
+ *
13
+ * The active provider is chosen by the `TRANSCRIPTION_PROVIDER` env var
14
+ * (persisted in `~/.parachute/vault/.env`), resolved here so the worker boot
15
+ * (`server.ts`) and the capability flag (`capability.ts`) agree on one source
16
+ * of truth. Unset ⇒ `scribe-http`, so no config change means no behavior
17
+ * change.
18
+ *
19
+ * The `transcribe-cli` binary, GGUF model, and runtime shared libraries live
20
+ * under `$PARACHUTE_HOME/transcription/` (parallel to the vault's other
21
+ * ecosystem state), written there by `transcription install`. NOTE:
22
+ * transcribe.cpp v0.1.1 ships a *library*, not a prebuilt CLI, so after an
23
+ * install the `libs/` + `models/` are present but `bin/transcribe-cli` may be
24
+ * absent until a CLI is built or `TRANSCRIBE_CPP_BIN` is set — the readiness
25
+ * checks below gate on the binary existing. Paths are resolved per-call so
26
+ * `PARACHUTE_HOME` overrides (tests, Docker) apply.
27
+ */
28
+
29
+ import { join } from "path";
30
+ import { homedir } from "os";
31
+ import { existsSync, readFileSync } from "fs";
32
+
33
+ export const TRANSCRIPTION_PROVIDERS = [
34
+ "scribe-http",
35
+ "transcribe-cpp",
36
+ "parakeet-mlx",
37
+ "onnx-asr",
38
+ ] as const;
39
+ export type TranscriptionProviderName = (typeof TRANSCRIPTION_PROVIDERS)[number];
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
+
55
+ /**
56
+ * Resolve the configured provider name. `TRANSCRIPTION_PROVIDER` selects it;
57
+ * unset (or blank) ⇒ `scribe-http` (the behavior-preserving default). An
58
+ * unrecognized value warns once and falls back to `scribe-http` rather than
59
+ * failing boot — a typo shouldn't take transcription offline hard.
60
+ */
61
+ export function resolveTranscriptionProviderName(
62
+ env: NodeJS.ProcessEnv = process.env,
63
+ logger: { warn?: (...args: unknown[]) => void } = console,
64
+ ): TranscriptionProviderName {
65
+ const raw = env.TRANSCRIPTION_PROVIDER?.trim();
66
+ if (!raw) return "scribe-http";
67
+ if ((TRANSCRIPTION_PROVIDERS as readonly string[]).includes(raw)) {
68
+ return raw as TranscriptionProviderName;
69
+ }
70
+ logger.warn?.(
71
+ `[transcribe] unknown TRANSCRIPTION_PROVIDER="${raw}" — falling back to scribe-http. ` +
72
+ `Valid values: ${TRANSCRIPTION_PROVIDERS.join(", ")}.`,
73
+ );
74
+ return "scribe-http";
75
+ }
76
+
77
+ /** The ecosystem root (shared with `config.ts`'s `configDirPath`), per-call so
78
+ * `PARACHUTE_HOME` overrides apply in tests / Docker. */
79
+ function ecosystemRoot(env: NodeJS.ProcessEnv): string {
80
+ return env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
81
+ }
82
+
83
+ /** The cache dir for transcribe-cpp's binary + models: `<root>/transcription/`. */
84
+ export function transcriptionHomeDir(env: NodeJS.ProcessEnv = process.env): string {
85
+ return join(ecosystemRoot(env), "transcription");
86
+ }
87
+
88
+ /** Resolved filesystem locations for the transcribe-cpp install. */
89
+ export interface TranscribeCppPaths {
90
+ /** `<root>/transcription/`. */
91
+ dir: string;
92
+ /** `<dir>/bin/`. */
93
+ binDir: string;
94
+ /** The `transcribe-cli` binary path (env `TRANSCRIBE_CPP_BIN` overrides).
95
+ * May not exist on disk — v0.1.1 ships a library, not a CLI. */
96
+ binPath: string;
97
+ /** `<dir>/libs/` — the extracted runtime shared libraries (libtranscribe +
98
+ * libggml) the CLI links against. */
99
+ libsDir: string;
100
+ /** `<dir>/models/`. */
101
+ modelsDir: string;
102
+ /** `<dir>/install.json` — the install manifest. */
103
+ manifestPath: string;
104
+ /**
105
+ * The active GGUF model path, or `undefined` when nothing is installed.
106
+ * Env `TRANSCRIBE_CPP_MODEL` overrides; otherwise resolved from the
107
+ * manifest's `modelFile` under `modelsDir`.
108
+ */
109
+ modelPath: string | undefined;
110
+ }
111
+
112
+ /** Persisted `install.json` shape (written by `transcription install`). */
113
+ export interface TranscribeCppManifest {
114
+ version: string;
115
+ asset: string;
116
+ /** Binary filename under `bin/` (usually "transcribe-cli"). */
117
+ binFile: string;
118
+ /** Model id (e.g. "whisper-small.en"). */
119
+ model: string;
120
+ /** GGUF filename under `models/`. */
121
+ modelFile: string;
122
+ /** Runtime shared-library filenames extracted under `libs/` (libtranscribe +
123
+ * libggml). v0.1.1 ships these instead of a CLI. */
124
+ libFiles?: string[];
125
+ /** When the `transcribe-cli` was built from source at install, the pinned
126
+ * upstream source ref it was compiled from. Absent when no CLI was built
127
+ * (build skipped/failed, or an operator-supplied `TRANSCRIBE_CPP_BIN`). */
128
+ binBuiltFrom?: string;
129
+ os: string;
130
+ arch: string;
131
+ ram_gb: number;
132
+ installedAt: string;
133
+ }
134
+
135
+ /**
136
+ * Resolve the transcribe-cpp binary + model paths. Env overrides
137
+ * (`TRANSCRIBE_CPP_BIN`, `TRANSCRIBE_CPP_MODEL`) win; otherwise the binary is
138
+ * `<dir>/bin/transcribe-cli` and the model comes from the install manifest.
139
+ */
140
+ export function resolveTranscribeCppPaths(
141
+ env: NodeJS.ProcessEnv = process.env,
142
+ ): TranscribeCppPaths {
143
+ const dir = transcriptionHomeDir(env);
144
+ const binDir = join(dir, "bin");
145
+ const libsDir = join(dir, "libs");
146
+ const modelsDir = join(dir, "models");
147
+ const manifestPath = join(dir, "install.json");
148
+
149
+ const binPath = env.TRANSCRIBE_CPP_BIN?.trim() || join(binDir, "transcribe-cli");
150
+
151
+ let modelPath = env.TRANSCRIBE_CPP_MODEL?.trim() || undefined;
152
+ if (!modelPath) {
153
+ const manifest = readManifest(manifestPath);
154
+ if (manifest?.modelFile) modelPath = join(modelsDir, manifest.modelFile);
155
+ }
156
+
157
+ return { dir, binDir, binPath, libsDir, modelsDir, manifestPath, modelPath };
158
+ }
159
+
160
+ /** Read + parse the install manifest, or `null` when absent/unreadable. */
161
+ export function readManifest(manifestPath: string): TranscribeCppManifest | null {
162
+ try {
163
+ if (!existsSync(manifestPath)) return null;
164
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as TranscribeCppManifest;
165
+ return parsed && typeof parsed === "object" ? parsed : null;
166
+ } catch {
167
+ return null;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Cheap, spawn-free readiness check: a runnable `transcribe-cli` binary AND a
173
+ * model both exist on disk. Used by the worker-boot gate (`server.ts`) and
174
+ * mirrors the provider's `available()`. Because v0.1.1 ships a library (no CLI),
175
+ * this stays `false` after an install until a CLI is built or
176
+ * `TRANSCRIBE_CPP_BIN` points at one — so the worker never starts only to
177
+ * terminal-fail every item.
178
+ */
179
+ export function transcribeCppInstalled(
180
+ paths: TranscribeCppPaths = resolveTranscribeCppPaths(),
181
+ ): boolean {
182
+ return existsSync(paths.binPath) && !!paths.modelPath && existsSync(paths.modelPath);
183
+ }
184
+
185
+ // ---------------------------------------------------------------------------
186
+ // Python-based local providers (parakeet-mlx / onnx-asr) — scribe-fold 2b
187
+ // ---------------------------------------------------------------------------
188
+
189
+ /**
190
+ * The dedicated venv `transcription install` creates for the Python-based
191
+ * providers: `<root>/transcription/venv/`. A deliberate deviation from
192
+ * scribe's installer (which preferred `uv tool install` + a `~/.venvs`
193
+ * fallback and then had to caveat "add the venv to your PATH"): vault resolves
194
+ * the venv binary by ABSOLUTE path, so the daemon needs no PATH surgery and
195
+ * the whole install is sandboxed under `PARACHUTE_HOME` (tests, Docker).
196
+ */
197
+ export function pythonVenvDir(env: NodeJS.ProcessEnv = process.env): string {
198
+ return join(transcriptionHomeDir(env), "venv");
199
+ }
200
+
201
+ /** `<root>/transcription/install-python.json` — the python-provider install manifest. */
202
+ export function pythonManifestPath(env: NodeJS.ProcessEnv = process.env): string {
203
+ return join(transcriptionHomeDir(env), "install-python.json");
204
+ }
205
+
206
+ /** Persisted manifest for a python-provider install (written by `transcription install`). */
207
+ export interface PythonInstallManifest {
208
+ /** Which provider this install set up ("parakeet-mlx" | "onnx-asr"). */
209
+ provider: string;
210
+ /** The pip install target (e.g. "onnx-asr[cpu,hub]"). */
211
+ pipTarget: string;
212
+ /** Resolved binary path at install time. */
213
+ bin: string;
214
+ /** The venv the package landed in ("" when the binary came from PATH). */
215
+ venv: string;
216
+ /** Model id the provider was configured with. */
217
+ model: string;
218
+ os: string;
219
+ arch: string;
220
+ ram_gb: number;
221
+ installedAt: string;
222
+ }
223
+
224
+ /** Read + parse the python install manifest, or `null` when absent/unreadable. */
225
+ export function readPythonManifest(
226
+ manifestPath: string = pythonManifestPath(),
227
+ ): PythonInstallManifest | null {
228
+ try {
229
+ if (!existsSync(manifestPath)) return null;
230
+ const parsed = JSON.parse(readFileSync(manifestPath, "utf8")) as PythonInstallManifest;
231
+ return parsed && typeof parsed === "object" ? parsed : null;
232
+ } catch {
233
+ return null;
234
+ }
235
+ }
236
+
237
+ /** Injection seams for binary resolution (tests stub the fs + PATH probes). */
238
+ export interface BinResolveDeps {
239
+ existsImpl?: (p: string) => boolean;
240
+ whichImpl?: (bin: string) => string | null;
241
+ }
242
+
243
+ /**
244
+ * Resolve a python-provider binary. Ladder (first hit wins):
245
+ *
246
+ * 1. an explicit env override (`PARAKEET_MLX_BIN` / `ONNX_ASR_BIN`) —
247
+ * returned verbatim, existence-checked by the caller (mirrors
248
+ * `TRANSCRIBE_CPP_BIN`'s contract);
249
+ * 2. the managed venv binary `<root>/transcription/venv/bin/<bin>` when it
250
+ * exists (vault's own install — deterministic under the daemon);
251
+ * 3. a PATH-installed binary via `Bun.which` (an operator's own
252
+ * `uv tool install` / pipx install keeps working).
253
+ *
254
+ * Returns `undefined` when nothing is found — the provider reports itself
255
+ * unavailable and the worker-boot gate stays closed.
256
+ */
257
+ function resolvePythonBin(
258
+ envVar: string,
259
+ bin: string,
260
+ env: NodeJS.ProcessEnv,
261
+ deps: BinResolveDeps,
262
+ ): string | undefined {
263
+ const override = env[envVar]?.trim();
264
+ if (override) return override;
265
+ const exists = deps.existsImpl ?? existsSync;
266
+ const which = deps.whichImpl ?? ((b: string) => Bun.which(b));
267
+ const venvBin = join(pythonVenvDir(env), "bin", bin);
268
+ if (exists(venvBin)) return venvBin;
269
+ return which(bin) ?? undefined;
270
+ }
271
+
272
+ /** Resolve the `parakeet-mlx` binary (env `PARAKEET_MLX_BIN` → venv → PATH). */
273
+ export function resolveParakeetMlxBin(
274
+ env: NodeJS.ProcessEnv = process.env,
275
+ deps: BinResolveDeps = {},
276
+ ): string | undefined {
277
+ return resolvePythonBin("PARAKEET_MLX_BIN", "parakeet-mlx", env, deps);
278
+ }
279
+
280
+ /** Resolve the `onnx-asr` binary (env `ONNX_ASR_BIN` → venv → PATH). */
281
+ export function resolveOnnxAsrBin(
282
+ env: NodeJS.ProcessEnv = process.env,
283
+ deps: BinResolveDeps = {},
284
+ ): string | undefined {
285
+ return resolvePythonBin("ONNX_ASR_BIN", "onnx-asr", env, deps);
286
+ }
287
+
288
+ /** Model resolution: env override → install manifest → ratified default. */
289
+ function resolvePythonModel(
290
+ envVar: string,
291
+ provider: string,
292
+ fallback: string,
293
+ env: NodeJS.ProcessEnv,
294
+ ): string {
295
+ const override = env[envVar]?.trim();
296
+ if (override) return override;
297
+ const manifest = readPythonManifest(pythonManifestPath(env));
298
+ if (manifest?.provider === provider && manifest.model) return manifest.model;
299
+ return fallback;
300
+ }
301
+
302
+ /** The parakeet-mlx model id (`PARAKEET_MLX_MODEL` → manifest → default v3). */
303
+ export function resolveParakeetMlxModel(env: NodeJS.ProcessEnv = process.env): string {
304
+ return resolvePythonModel("PARAKEET_MLX_MODEL", "parakeet-mlx", DEFAULT_PARAKEET_MLX_MODEL, env);
305
+ }
306
+
307
+ /** The onnx-asr model id (`ONNX_ASR_MODEL` → manifest → default parakeet v3 int8). */
308
+ export function resolveOnnxAsrModel(env: NodeJS.ProcessEnv = process.env): string {
309
+ return resolvePythonModel("ONNX_ASR_MODEL", "onnx-asr", DEFAULT_ONNX_ASR_MODEL, env);
310
+ }
311
+
312
+ /**
313
+ * Cheap, spawn-free readiness check for a python provider: a resolved binary
314
+ * that exists on disk. (The MODEL is fetched into the HF cache on first use —
315
+ * there is no cheap on-disk check for it, so readiness is binary-only,
316
+ * matching the providers' `available()`.) Used by the worker-boot gate and
317
+ * `transcription status`.
318
+ */
319
+ export function parakeetMlxInstalled(
320
+ env: NodeJS.ProcessEnv = process.env,
321
+ deps: BinResolveDeps = {},
322
+ ): boolean {
323
+ const bin = resolveParakeetMlxBin(env, deps);
324
+ return !!bin && (deps.existsImpl ?? existsSync)(bin);
325
+ }
326
+
327
+ /** See `parakeetMlxInstalled`. */
328
+ export function onnxAsrInstalled(
329
+ env: NodeJS.ProcessEnv = process.env,
330
+ deps: BinResolveDeps = {},
331
+ ): boolean {
332
+ const bin = resolveOnnxAsrBin(env, deps);
333
+ return !!bin && (deps.existsImpl ?? existsSync)(bin);
334
+ }