@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.
- package/core/src/__fixtures__/golden-vault.ts +125 -0
- package/core/src/__fixtures__/portable-export-golden.json +10 -0
- package/core/src/do-param-cap.test.ts +161 -0
- package/core/src/links.ts +8 -13
- package/core/src/mcp.ts +306 -314
- package/core/src/notes.ts +54 -62
- package/core/src/onboarding.ts +14 -296
- package/core/src/portable-md-batching.test.ts +67 -0
- package/core/src/portable-md-golden.test.ts +55 -0
- package/core/src/portable-md.ts +579 -435
- package/core/src/schema.ts +21 -43
- package/core/src/seed-packs.test.ts +191 -0
- package/core/src/seed-packs.ts +559 -0
- package/core/src/sql-in.test.ts +58 -0
- package/core/src/sql-in.ts +76 -0
- package/core/src/store.ts +14 -9
- package/core/src/transcription/provider.ts +141 -0
- package/core/src/txn.test.ts +229 -0
- package/core/src/txn.ts +105 -0
- package/core/src/types.ts +9 -0
- package/core/src/vault-projection.ts +1 -1
- package/core/src/wikilinks.ts +10 -4
- package/package.json +1 -1
- package/src/add-pack.test.ts +142 -0
- package/src/cli.ts +778 -7
- package/src/onboarding-seed.test.ts +126 -40
- package/src/onboarding-seed.ts +41 -46
- package/src/routes.ts +25 -7
- package/src/server.ts +108 -31
- package/src/transcription/build.test.ts +224 -0
- package/src/transcription/build.ts +252 -0
- package/src/transcription/capability.test.ts +118 -0
- package/src/transcription/capability.ts +96 -0
- package/src/transcription/install-python.test.ts +366 -0
- package/src/transcription/install-python.ts +471 -0
- package/src/transcription/install.test.ts +167 -0
- package/src/transcription/install.ts +296 -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/providers/scribe-http.test.ts +195 -0
- package/src/transcription/providers/scribe-http.ts +144 -0
- package/src/transcription/providers/transcribe-cpp.test.ts +314 -0
- package/src/transcription/providers/transcribe-cpp.ts +293 -0
- package/src/transcription/select.test.ts +259 -0
- package/src/transcription/select.ts +334 -0
- package/src/transcription/tiers.test.ts +197 -0
- package/src/transcription/tiers.ts +184 -0
- package/src/transcription-worker.test.ts +44 -0
- package/src/transcription-worker.ts +57 -122
- package/src/vault-create.test.ts +38 -10
- 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
|
+
});
|