@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,224 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { join } from "path";
3
+ import {
4
+ buildTranscribeCli,
5
+ buildCompileCommand,
6
+ detectCompiler,
7
+ compilerInstallHint,
8
+ cliSourceUrl,
9
+ CLI_SOURCE_FILES,
10
+ CXX_CANDIDATES,
11
+ TRANSCRIBE_CPP_SOURCE_REF,
12
+ type BuildCliDeps,
13
+ type CompileResult,
14
+ } from "./build.ts";
15
+
16
+ /**
17
+ * transcribe-cli build tests (scribe-fold Phase 2b). Pure recipe shape +
18
+ * fully-mocked orchestration — NO network, NO real compiler (the real compile
19
+ * is exercised only by the install-time live verify). Covers the toolchain-
20
+ * absent, fetch-failure, compile-failure, and success branches, and the
21
+ * "never leave a half-built binary" invariant.
22
+ */
23
+
24
+ describe("detectCompiler", () => {
25
+ test("returns the first candidate found (c++ preferred)", () => {
26
+ const which = (c: string) => (c === "c++" ? "/usr/bin/c++" : null);
27
+ expect(detectCompiler(which)).toBe("/usr/bin/c++");
28
+ });
29
+ test("falls through to a later candidate when earlier ones are absent", () => {
30
+ const which = (c: string) => (c === "g++" ? "/usr/bin/g++" : null);
31
+ expect(detectCompiler(which)).toBe("/usr/bin/g++");
32
+ });
33
+ test("returns null when NO compiler is on PATH", () => {
34
+ expect(detectCompiler(() => null)).toBeNull();
35
+ expect(detectCompiler(() => undefined)).toBeNull();
36
+ });
37
+ test("probes the documented candidate order", () => {
38
+ expect([...CXX_CANDIDATES]).toEqual(["c++", "clang++", "g++"]);
39
+ });
40
+ });
41
+
42
+ describe("compilerInstallHint", () => {
43
+ test("macOS → xcode-select", () => {
44
+ expect(compilerInstallHint("darwin")).toContain("xcode-select --install");
45
+ });
46
+ test("linux → build-essential / gcc-c++", () => {
47
+ const h = compilerInstallHint("linux");
48
+ expect(h).toContain("build-essential");
49
+ expect(h).toContain("gcc-c++");
50
+ });
51
+ test("other → generic compiler hint", () => {
52
+ expect(compilerInstallHint("win32")).toContain("c++/clang++/g++");
53
+ });
54
+ });
55
+
56
+ describe("cliSourceUrl + CLI_SOURCE_FILES", () => {
57
+ test("URL pins the exact v0.1.1 source ref", () => {
58
+ const url = cliSourceUrl("examples/cli/main.cpp");
59
+ expect(url).toContain("raw.githubusercontent.com/handy-computer/transcribe.cpp");
60
+ expect(url).toContain(TRANSCRIBE_CPP_SOURCE_REF);
61
+ expect(url.endsWith("/examples/cli/main.cpp")).toBe(true);
62
+ });
63
+ test("source set covers the CLI + WAV loader + public header", () => {
64
+ expect(CLI_SOURCE_FILES).toContain("examples/cli/main.cpp");
65
+ expect(CLI_SOURCE_FILES).toContain("examples/common/wav.cpp");
66
+ expect(CLI_SOURCE_FILES).toContain("examples/common/wav.h");
67
+ expect(CLI_SOURCE_FILES).toContain("examples/common/dr_wav.h");
68
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe.h");
69
+ // headers main.cpp includes directly
70
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe/parakeet.h");
71
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe/whisper.h");
72
+ expect(CLI_SOURCE_FILES).toContain("include/transcribe/voxtral_realtime.h");
73
+ });
74
+ });
75
+
76
+ describe("buildCompileCommand — the proven recipe shape", () => {
77
+ const base = {
78
+ compiler: "/usr/bin/c++",
79
+ srcDir: "/root/.parachute/transcription/.src",
80
+ libsDir: "/root/.parachute/transcription/libs",
81
+ outPath: "/root/.parachute/transcription/bin/transcribe-cli",
82
+ };
83
+
84
+ test("macOS → @loader_path/../libs rpath", () => {
85
+ const cmd = buildCompileCommand({ ...base, platform: "darwin" });
86
+ expect(cmd[0]).toBe("/usr/bin/c++");
87
+ expect(cmd).toContain("-std=c++17");
88
+ expect(cmd).toContain(`-I${join(base.srcDir, "include")}`);
89
+ expect(cmd).toContain(`-I${join(base.srcDir, "examples", "common")}`);
90
+ expect(cmd).toContain(join(base.srcDir, "examples", "cli", "main.cpp"));
91
+ expect(cmd).toContain(join(base.srcDir, "examples", "common", "wav.cpp"));
92
+ expect(cmd).toContain(`-L${base.libsDir}`);
93
+ expect(cmd).toContain("-ltranscribe");
94
+ expect(cmd).toContain("-Wl,-rpath,@loader_path/../libs");
95
+ // output is the final -o <path>
96
+ expect(cmd[cmd.length - 2]).toBe("-o");
97
+ expect(cmd[cmd.length - 1]).toBe(base.outPath);
98
+ });
99
+
100
+ test("Linux → $ORIGIN/../libs rpath (literal — passed to argv, not a shell)", () => {
101
+ const cmd = buildCompileCommand({ ...base, platform: "linux" });
102
+ expect(cmd).toContain("-Wl,-rpath,$ORIGIN/../libs");
103
+ expect(cmd).not.toContain("-Wl,-rpath,@loader_path/../libs");
104
+ });
105
+ });
106
+
107
+ /** Build a deps object with call-recording mocks; overrides win. */
108
+ function mkDeps(over: Partial<BuildCliDeps> = {}): BuildCliDeps & {
109
+ calls: { fetch: number; compile: number; removed: string[]; renamed: Array<[string, string]> };
110
+ } {
111
+ const calls = { fetch: 0, compile: 0, removed: [] as string[], renamed: [] as Array<[string, string]> };
112
+ const deps: BuildCliDeps = {
113
+ platform: "darwin",
114
+ which: (c) => (c === "c++" ? "/usr/bin/c++" : null),
115
+ fetchSource: async () => {
116
+ calls.fetch++;
117
+ },
118
+ compile: async (): Promise<CompileResult> => {
119
+ calls.compile++;
120
+ return { exitCode: 0, stderr: "" };
121
+ },
122
+ exists: () => true,
123
+ removeBin: (p) => {
124
+ calls.removed.push(p);
125
+ },
126
+ rename: (from, to) => {
127
+ calls.renamed.push([from, to]);
128
+ },
129
+ ...over,
130
+ };
131
+ return Object.assign(deps, { calls });
132
+ }
133
+
134
+ const INPUT = { srcDir: "/t/.src", libsDir: "/t/libs", binPath: "/t/bin/transcribe-cli" };
135
+ const TMP = `${INPUT.binPath}.building`; // the temp output the build compiles to
136
+
137
+ describe("buildTranscribeCli — orchestration branches", () => {
138
+ test("SUCCESS: fetch → compile ok → temp exists ⇒ promote (rename) + { ok:true }", async () => {
139
+ const deps = mkDeps();
140
+ const r = await buildTranscribeCli(INPUT, deps);
141
+ expect(r.ok).toBe(true);
142
+ if (r.ok) {
143
+ expect(r.binPath).toBe(INPUT.binPath);
144
+ expect(r.compiler).toBe("/usr/bin/c++");
145
+ // reported command targets the FINAL binPath (reproducible by hand), not the temp
146
+ expect(r.command).toContain("-ltranscribe");
147
+ expect(r.command).toContain(`-o ${INPUT.binPath}`);
148
+ expect(r.command).not.toContain(TMP);
149
+ }
150
+ expect(deps.calls.fetch).toBe(1);
151
+ expect(deps.calls.compile).toBe(1);
152
+ // only the TEMP is cleared pre-compile; the fresh temp is renamed into place
153
+ expect(deps.calls.removed).toEqual([TMP]);
154
+ expect(deps.calls.renamed).toEqual([[TMP, INPUT.binPath]]);
155
+ });
156
+
157
+ test("TOOLCHAIN ABSENT: no compiler ⇒ { ok:false, stage:'toolchain' }, no fetch/compile", async () => {
158
+ const deps = mkDeps({ which: () => null });
159
+ const r = await buildTranscribeCli(INPUT, deps);
160
+ expect(r.ok).toBe(false);
161
+ if (!r.ok) {
162
+ expect(r.stage).toBe("toolchain");
163
+ expect(r.message).toContain("no C++ compiler");
164
+ expect(r.message).toContain("xcode-select"); // darwin hint embedded
165
+ }
166
+ expect(deps.calls.fetch).toBe(0);
167
+ expect(deps.calls.compile).toBe(0);
168
+ });
169
+
170
+ test("FETCH FAILURE: fetchSource throws ⇒ { ok:false, stage:'fetch' }, no compile", async () => {
171
+ const deps = mkDeps({
172
+ fetchSource: async () => {
173
+ throw new Error("network down");
174
+ },
175
+ });
176
+ const r = await buildTranscribeCli(INPUT, deps);
177
+ expect(r.ok).toBe(false);
178
+ if (!r.ok) {
179
+ expect(r.stage).toBe("fetch");
180
+ expect(r.message).toContain("network down");
181
+ expect(r.message).toContain(TRANSCRIBE_CPP_SOURCE_REF);
182
+ }
183
+ expect(deps.calls.compile).toBe(0);
184
+ });
185
+
186
+ test("COMPILE FAILURE (nonzero exit): keeps provider + surfaces stderr + command; only the temp is cleaned", async () => {
187
+ const deps = mkDeps({
188
+ compile: async () => ({ exitCode: 1, stderr: "main.cpp:9: fatal error: transcribe.h not found" }),
189
+ });
190
+ const r = await buildTranscribeCli(INPUT, deps);
191
+ expect(r.ok).toBe(false);
192
+ if (!r.ok) {
193
+ expect(r.stage).toBe("compile");
194
+ expect(r.message).toContain("transcribe.h not found");
195
+ expect(r.command).toContain("-ltranscribe");
196
+ }
197
+ // ONLY the temp is touched (pre-clear + post-cleanup) — the real binPath is never removed
198
+ expect(deps.calls.removed).toEqual([TMP, TMP]);
199
+ expect(deps.calls.removed).not.toContain(INPUT.binPath);
200
+ expect(deps.calls.renamed).toEqual([]);
201
+ });
202
+
203
+ test("COMPILE 'succeeds' but output missing ⇒ { ok:false, stage:'compile' } + only temp cleaned", async () => {
204
+ const deps = mkDeps({ exists: () => false });
205
+ const r = await buildTranscribeCli(INPUT, deps);
206
+ expect(r.ok).toBe(false);
207
+ if (!r.ok) expect(r.stage).toBe("compile");
208
+ expect(deps.calls.removed).toEqual([TMP, TMP]);
209
+ expect(deps.calls.renamed).toEqual([]);
210
+ });
211
+
212
+ test("ATOMIC: a failed rebuild NEVER removes/renames the existing binary (--force safety)", async () => {
213
+ // Simulate an existing working binary present at binPath; the rebuild fails.
214
+ const deps = mkDeps({
215
+ exists: (p) => p === INPUT.binPath, // real binary exists; the temp never appears
216
+ compile: async () => ({ exitCode: 1, stderr: "boom" }),
217
+ });
218
+ const r = await buildTranscribeCli(INPUT, deps);
219
+ expect(r.ok).toBe(false);
220
+ // The existing binary is untouched: no removeBin/rename ever names binPath.
221
+ expect(deps.calls.removed).not.toContain(INPUT.binPath);
222
+ expect(deps.calls.renamed).toEqual([]);
223
+ });
224
+ });
@@ -0,0 +1,252 @@
1
+ /**
2
+ * Building `transcribe-cli` from source at install time (scribe-fold Phase 2b).
3
+ *
4
+ * transcribe.cpp v0.1.1 ships a **library** (`libtranscribe.{dylib,so}` +
5
+ * `libggml*`), NOT a prebuilt `transcribe-cli` executable — see
6
+ * `install.ts` + the provider-seam design doc. This module closes that gap: it
7
+ * fetches the CLI's source (`examples/cli/main.cpp` + `examples/common/wav.cpp`
8
+ * + the public headers) from the transcribe.cpp repo pinned at the v0.1.1 tag
9
+ * commit, then compiles it with the host C++ compiler against the extracted
10
+ * prebuilt dylibs. The result is a ~127KB driver that links the prebuilt
11
+ * `libtranscribe` at runtime — **no ggml / Metal rebuild**.
12
+ *
13
+ * ## The proven recipe (live-verified 2026-07-03, macOS arm64 M4)
14
+ *
15
+ * c++ -std=c++17 -O2 \
16
+ * -I<srcDir>/include \
17
+ * -I<srcDir>/examples/common \
18
+ * <srcDir>/examples/cli/main.cpp \
19
+ * <srcDir>/examples/common/wav.cpp \
20
+ * -L<libsDir> -ltranscribe \
21
+ * -Wl,-rpath,@loader_path/../libs \ # Linux: $ORIGIN/../libs
22
+ * -o <binDir>/transcribe-cli
23
+ *
24
+ * The rpath is load-bearing: `libtranscribe.dylib`'s install_name is
25
+ * `@rpath/libtranscribe.dylib` (with no rpath of its own), so the CLI carries
26
+ * `@loader_path/../libs` to resolve it from a sibling `libs/` dir. In turn
27
+ * `libtranscribe` references `libggml*` via `@loader_path/libggml*.dylib`, so
28
+ * having all dylibs in the same `libs/` dir is enough — the CLI never needs to
29
+ * know about libggml. On Linux the analogous `$ORIGIN/../libs` is passed as a
30
+ * single argv element (we spawn the compiler directly, NOT through a shell, so
31
+ * `$ORIGIN` is NOT expanded — it lands literally in the ELF RUNPATH for the
32
+ * runtime loader to expand).
33
+ *
34
+ * ## Testability
35
+ *
36
+ * `buildTranscribeCli` is a pure orchestrator over injected side effects
37
+ * (`which` / `fetchSource` / `compile` / `exists` / `removeBin`), mirroring the
38
+ * provider's `SpawnRunner` seam. Tests exercise every branch — toolchain
39
+ * absent, fetch failure, compile failure, success — with no network and no real
40
+ * compiler. The real compile is exercised only by the install-time live verify.
41
+ */
42
+
43
+ import { join } from "path";
44
+
45
+ /**
46
+ * Pinned source ref for the CLI build: the transcribe.cpp **v0.1.1 tag commit**.
47
+ * Kept in lockstep with `TRANSCRIBE_CPP_VERSION` in `install.ts` — bump both
48
+ * together (a newer CLI source may need different headers or flags, and the
49
+ * provider's output parsing is validated against a specific version).
50
+ */
51
+ export const TRANSCRIBE_CPP_SOURCE_REF = "d89ecb75062e8457681c563994675dc60e31db80";
52
+
53
+ const RAW_BASE = `https://raw.githubusercontent.com/handy-computer/transcribe.cpp/${TRANSCRIBE_CPP_SOURCE_REF}`;
54
+
55
+ /**
56
+ * Repo-relative source files needed to build `transcribe-cli`, laid out under a
57
+ * local source dir so the include paths resolve exactly as upstream expects.
58
+ * `main.cpp` includes `transcribe.h` + `transcribe/{parakeet,voxtral_realtime,
59
+ * whisper}.h` + `wav.h`; `wav.cpp` includes `wav.h` + `dr_wav.h`. We fetch the
60
+ * complete `include/` header set (the two extra sub-headers are tiny and keep
61
+ * the tree self-consistent). Verified sufficient by a real compile at this ref.
62
+ */
63
+ export const CLI_SOURCE_FILES: readonly string[] = [
64
+ "include/transcribe.h",
65
+ "include/transcribe/extensions.h",
66
+ "include/transcribe/moonshine_streaming.h",
67
+ "include/transcribe/parakeet.h",
68
+ "include/transcribe/voxtral_realtime.h",
69
+ "include/transcribe/whisper.h",
70
+ "examples/common/wav.h",
71
+ "examples/common/wav.cpp",
72
+ "examples/common/dr_wav.h",
73
+ "examples/cli/main.cpp",
74
+ ];
75
+
76
+ /** Full raw.githubusercontent URL for a repo-relative path at the pinned ref. */
77
+ export function cliSourceUrl(repoPath: string): string {
78
+ return `${RAW_BASE}/${repoPath}`;
79
+ }
80
+
81
+ /** C++ compiler binaries we probe for, in preference order. */
82
+ export const CXX_CANDIDATES = ["c++", "clang++", "g++"] as const;
83
+
84
+ /**
85
+ * Resolve a C++ compiler path via the injected `which` (production:
86
+ * `Bun.which`). Returns the first candidate found, or `null` when none is on
87
+ * PATH — the caller keeps the current provider and prints a platform hint.
88
+ */
89
+ export function detectCompiler(which: (cmd: string) => string | null | undefined): string | null {
90
+ for (const c of CXX_CANDIDATES) {
91
+ const p = which(c);
92
+ if (p) return p;
93
+ }
94
+ return null;
95
+ }
96
+
97
+ /** A short "install a compiler" hint tailored to the host platform. */
98
+ export function compilerInstallHint(platform: string): string {
99
+ if (platform === "darwin") return "install the Xcode command-line tools: xcode-select --install";
100
+ if (platform === "linux") {
101
+ return "install a C++ toolchain: apt install build-essential (Debian/Ubuntu) | dnf install gcc-c++ (Fedora/RHEL)";
102
+ }
103
+ return "install a C++ compiler (c++/clang++/g++) on PATH";
104
+ }
105
+
106
+ export interface CompileCommandInput {
107
+ /** Host platform (`process.platform`) — selects the rpath flavor. */
108
+ platform: string;
109
+ /** Resolved compiler path (`detectCompiler`). */
110
+ compiler: string;
111
+ /** The local source dir the CLI sources were fetched into. */
112
+ srcDir: string;
113
+ /** The extracted prebuilt dylibs dir to link `-ltranscribe` from. */
114
+ libsDir: string;
115
+ /** Where the compiled `transcribe-cli` lands. */
116
+ outPath: string;
117
+ }
118
+
119
+ /**
120
+ * Build the compiler argv (the proven recipe). Pure — a test pins the shape,
121
+ * mirroring `buildTranscribeArgs` for the provider. The rpath resolves the
122
+ * sibling `libs/` dir from `bin/`: `@loader_path/../libs` (macOS) /
123
+ * `$ORIGIN/../libs` (Linux, passed literally — no shell expansion).
124
+ */
125
+ export function buildCompileCommand(input: CompileCommandInput): string[] {
126
+ const includeDir = join(input.srcDir, "include");
127
+ const commonDir = join(input.srcDir, "examples", "common");
128
+ const mainCpp = join(input.srcDir, "examples", "cli", "main.cpp");
129
+ const wavCpp = join(input.srcDir, "examples", "common", "wav.cpp");
130
+ const rpath = input.platform === "darwin" ? "@loader_path/../libs" : "$ORIGIN/../libs";
131
+ return [
132
+ input.compiler,
133
+ "-std=c++17",
134
+ "-O2",
135
+ `-I${includeDir}`,
136
+ `-I${commonDir}`,
137
+ mainCpp,
138
+ wavCpp,
139
+ `-L${input.libsDir}`,
140
+ "-ltranscribe",
141
+ `-Wl,-rpath,${rpath}`,
142
+ "-o",
143
+ input.outPath,
144
+ ];
145
+ }
146
+
147
+ /** Result of running the compiler: exit code + captured stderr. */
148
+ export interface CompileResult {
149
+ exitCode: number;
150
+ stderr: string;
151
+ }
152
+
153
+ /** Injected side effects for `buildTranscribeCli` (tests pass mocks). */
154
+ export interface BuildCliDeps {
155
+ /** Host platform (`process.platform`). */
156
+ platform: string;
157
+ /** Compiler probe (production: `Bun.which`). */
158
+ which: (cmd: string) => string | null | undefined;
159
+ /** Fetch `files` (repo-relative) into `srcDir`. Throws on network failure. */
160
+ fetchSource: (files: readonly string[], srcDir: string) => Promise<void>;
161
+ /** Run the compiler argv. */
162
+ compile: (cmd: string[]) => CompileResult | Promise<CompileResult>;
163
+ /** Existence probe (production: `fs.existsSync`). */
164
+ exists: (p: string) => boolean;
165
+ /** Remove a stale / half-built binary (production: `fs.rmSync{force}`). */
166
+ removeBin: (p: string) => void;
167
+ /** Move a file into place (production: `fs.renameSync`). Used to promote the
168
+ * freshly-built binary atomically, so a failed rebuild never clobbers a
169
+ * previously working binary. */
170
+ rename: (from: string, to: string) => void;
171
+ }
172
+
173
+ export interface BuildCliInput {
174
+ /** Local dir to fetch the CLI sources into. */
175
+ srcDir: string;
176
+ /** The extracted prebuilt dylibs dir. */
177
+ libsDir: string;
178
+ /** Output binary path. */
179
+ binPath: string;
180
+ }
181
+
182
+ /** The outcome of a build attempt — a discriminated union the CLI branches on. */
183
+ export type CliBuildResult =
184
+ | { ok: true; binPath: string; compiler: string; command: string }
185
+ | {
186
+ ok: false;
187
+ /** Which stage failed — drives the operator guidance. */
188
+ stage: "toolchain" | "fetch" | "compile";
189
+ message: string;
190
+ compiler?: string;
191
+ /** The compile argv (as a string) when we got far enough to build one. */
192
+ command?: string;
193
+ };
194
+
195
+ /**
196
+ * Fetch the CLI source at the pinned ref and compile it against the extracted
197
+ * dylibs. Non-throwing: every failure is a typed `{ ok: false }` so the caller
198
+ * keeps the current provider and reports honestly. Never leaves a half-built
199
+ * binary — a nonzero compile (or a missing output) removes `binPath` so
200
+ * `available()` can't mistake a partial artifact for a runnable CLI.
201
+ */
202
+ export async function buildTranscribeCli(
203
+ input: BuildCliInput,
204
+ deps: BuildCliDeps,
205
+ ): Promise<CliBuildResult> {
206
+ const compiler = detectCompiler(deps.which);
207
+ if (!compiler) {
208
+ return {
209
+ ok: false,
210
+ stage: "toolchain",
211
+ message: `no C++ compiler on PATH (looked for ${CXX_CANDIDATES.join(", ")}) — ${compilerInstallHint(deps.platform)}`,
212
+ };
213
+ }
214
+
215
+ try {
216
+ await deps.fetchSource(CLI_SOURCE_FILES, input.srcDir);
217
+ } catch (err) {
218
+ return {
219
+ ok: false,
220
+ stage: "fetch",
221
+ compiler,
222
+ message: `failed to fetch transcribe-cli source at ${TRANSCRIBE_CPP_SOURCE_REF}: ${err instanceof Error ? err.message : String(err)}`,
223
+ };
224
+ }
225
+
226
+ const common = { platform: deps.platform, compiler, srcDir: input.srcDir, libsDir: input.libsDir };
227
+ // The reported command targets the FINAL binPath (what an operator would run
228
+ // by hand to reproduce). The actual compile writes to a temp sibling and is
229
+ // promoted on success — so a failed rebuild (e.g. `--force` that then fails)
230
+ // never clobbers a previously working binary, and no half-built artifact is
231
+ // ever left where available() would treat it as runnable. The temp is a
232
+ // sibling in the same dir, so the @loader_path/../libs rpath is unaffected.
233
+ const command = buildCompileCommand({ ...common, outPath: input.binPath }).join(" ");
234
+ const tmpOut = `${input.binPath}.building`;
235
+ const argv = buildCompileCommand({ ...common, outPath: tmpOut });
236
+
237
+ deps.removeBin(tmpOut); // clear any stale temp from a prior interrupted build
238
+ const res = await deps.compile(argv);
239
+ if (res.exitCode !== 0 || !deps.exists(tmpOut)) {
240
+ deps.removeBin(tmpOut); // drop any partial output; the existing binary stands
241
+ return {
242
+ ok: false,
243
+ stage: "compile",
244
+ compiler,
245
+ command,
246
+ message: (res.stderr || "").trim() || `compiler exited ${res.exitCode}`,
247
+ };
248
+ }
249
+
250
+ deps.rename(tmpOut, input.binPath); // atomic promote over any prior binary
251
+ return { ok: true, binPath: input.binPath, compiler, command };
252
+ }
@@ -0,0 +1,118 @@
1
+ import { describe, test, expect, afterEach } from "bun:test";
2
+ import { resolveTranscriptionCapability, defaultTranscriptionProvider } from "./capability.ts";
3
+ import { ScribeHttpProvider } from "./providers/scribe-http.ts";
4
+ import { TranscribeCppProvider } from "./providers/transcribe-cpp.ts";
5
+ import type { TranscriptionProvider } from "../../core/src/transcription/provider.ts";
6
+
7
+ /**
8
+ * Capability-flag tests (scribe-fold Phase 1). The vault landing surfaces
9
+ * `transcription: { enabled, provider? }`; `enabled` iff a provider is
10
+ * configured AND available. Notes gates its mic on this.
11
+ */
12
+
13
+ /** A minimal fake provider so tests don't depend on live scribe discovery. */
14
+ function fakeProvider(available: boolean, name = "fake"): TranscriptionProvider {
15
+ return {
16
+ name,
17
+ available: async () => (available ? { ok: true } : { ok: false, reason: "off" }),
18
+ transcribe: async () => ({ text: "" }),
19
+ };
20
+ }
21
+
22
+ describe("resolveTranscriptionCapability", () => {
23
+ test("enabled:true + provider name when the provider is available", async () => {
24
+ const cap = await resolveTranscriptionCapability(fakeProvider(true, "scribe-http"));
25
+ expect(cap).toEqual({ enabled: true, provider: "scribe-http" });
26
+ });
27
+
28
+ test("enabled:false + no provider name when the provider is unavailable", async () => {
29
+ const cap = await resolveTranscriptionCapability(fakeProvider(false));
30
+ expect(cap).toEqual({ enabled: false });
31
+ expect(cap.provider).toBeUndefined();
32
+ });
33
+
34
+ test("no provider configured (scribe-http with no URL) resolves to disabled, no throw", async () => {
35
+ // The "no provider configured" path: a scribe-http provider whose URL is
36
+ // undefined reports unavailable rather than throwing, so the landing never
37
+ // crashes when scribe is absent.
38
+ const cap = await resolveTranscriptionCapability(new ScribeHttpProvider({ url: undefined }));
39
+ expect(cap.enabled).toBe(false);
40
+ expect(cap.provider).toBeUndefined();
41
+ });
42
+
43
+ test("a configured scribe-http provider resolves to enabled: scribe-http", async () => {
44
+ const cap = await resolveTranscriptionCapability(new ScribeHttpProvider({ url: "http://scribe.test" }));
45
+ expect(cap).toEqual({ enabled: true, provider: "scribe-http" });
46
+ });
47
+
48
+ test("never omits minutes_remaining as an unmetered self-host concern", async () => {
49
+ const cap = await resolveTranscriptionCapability(fakeProvider(true));
50
+ expect("minutes_remaining" in cap).toBe(false);
51
+ });
52
+
53
+ test("a transcribe-cpp provider (installed) resolves to enabled: transcribe-cpp", async () => {
54
+ // existsImpl stubs the binary + model as present so available() is ok
55
+ // without touching disk.
56
+ const p = new TranscribeCppProvider({
57
+ binPath: "/tc/transcribe-cli",
58
+ modelPath: "/tc/model.gguf",
59
+ existsImpl: () => true,
60
+ });
61
+ const cap = await resolveTranscriptionCapability(p);
62
+ expect(cap).toEqual({ enabled: true, provider: "transcribe-cpp" });
63
+ });
64
+
65
+ test("a transcribe-cpp provider (not installed) resolves to disabled", async () => {
66
+ const p = new TranscribeCppProvider({ binPath: "/tc/transcribe-cli", modelPath: "/tc/model.gguf", existsImpl: () => false });
67
+ const cap = await resolveTranscriptionCapability(p);
68
+ expect(cap.enabled).toBe(false);
69
+ expect(cap.provider).toBeUndefined();
70
+ });
71
+ });
72
+
73
+ /**
74
+ * `defaultTranscriptionProvider` honors `TRANSCRIPTION_PROVIDER` so the
75
+ * capability flag reflects whichever provider is configured (scribe-fold 2a).
76
+ */
77
+ describe("defaultTranscriptionProvider — provider selection", () => {
78
+ const saved = process.env.TRANSCRIPTION_PROVIDER;
79
+ afterEach(() => {
80
+ if (saved === undefined) delete process.env.TRANSCRIPTION_PROVIDER;
81
+ else process.env.TRANSCRIPTION_PROVIDER = saved;
82
+ });
83
+
84
+ test("default (unset) → the scribe-http provider", () => {
85
+ delete process.env.TRANSCRIPTION_PROVIDER;
86
+ expect(defaultTranscriptionProvider().name).toBe("scribe-http");
87
+ });
88
+
89
+ test("TRANSCRIPTION_PROVIDER=transcribe-cpp → the transcribe-cpp provider", () => {
90
+ process.env.TRANSCRIPTION_PROVIDER = "transcribe-cpp";
91
+ expect(defaultTranscriptionProvider().name).toBe("transcribe-cpp");
92
+ });
93
+
94
+ test("TRANSCRIPTION_PROVIDER=parakeet-mlx → the parakeet-mlx provider (2b)", () => {
95
+ process.env.TRANSCRIPTION_PROVIDER = "parakeet-mlx";
96
+ expect(defaultTranscriptionProvider().name).toBe("parakeet-mlx");
97
+ });
98
+
99
+ test("TRANSCRIPTION_PROVIDER=onnx-asr → the onnx-asr provider (2b)", () => {
100
+ process.env.TRANSCRIPTION_PROVIDER = "onnx-asr";
101
+ expect(defaultTranscriptionProvider().name).toBe("onnx-asr");
102
+ });
103
+
104
+ test("an unconfigured python provider resolves to disabled (no throw)", async () => {
105
+ // No venv/PATH binary in a fresh env → available() is ok:false and the
106
+ // landing capability is {enabled:false} rather than a crash.
107
+ process.env.TRANSCRIPTION_PROVIDER = "parakeet-mlx";
108
+ const prevBin = process.env.PARAKEET_MLX_BIN;
109
+ process.env.PARAKEET_MLX_BIN = "/definitely/not/a/real/parakeet-mlx";
110
+ try {
111
+ const cap = await resolveTranscriptionCapability(defaultTranscriptionProvider());
112
+ expect(cap.enabled).toBe(false);
113
+ } finally {
114
+ if (prevBin === undefined) delete process.env.PARAKEET_MLX_BIN;
115
+ else process.env.PARAKEET_MLX_BIN = prevBin;
116
+ }
117
+ });
118
+ });