@openparachute/vault 0.7.4 → 0.7.5-rc.4
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/package.json +1 -1
- package/src/mirror-import-jobs.ts +197 -0
- package/src/mirror-import.test.ts +102 -2
- package/src/mirror-import.ts +182 -22
- package/src/mirror-routes.test.ts +222 -3
- package/src/mirror-routes.ts +228 -86
- package/src/routing.ts +22 -6
- package/src/server.ts +62 -1
- package/src/transcription/models.test.ts +87 -0
- package/src/transcription/models.ts +187 -0
- package/src/transcription/providers/whisper-cpp.test.ts +218 -0
- package/src/transcription/providers/whisper-cpp.ts +241 -0
- package/src/transcription/resolve-binary.test.ts +131 -0
- package/src/transcription/resolve-binary.ts +111 -0
- package/src/transcription/select.ts +26 -3
- package/web/ui/dist/assets/index-CD4kPSY9.js +61 -0
- package/web/ui/dist/index.html +1 -1
- package/web/ui/dist/assets/index-NvwxfZcu.js +0 -61
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Binary resolution across the routes whisper.cpp actually arrives by.
|
|
3
|
+
*
|
|
4
|
+
* The case worth the most care is Homebrew: a launchd-supervised vault does
|
|
5
|
+
* NOT inherit a login shell's PATH, so on macOS `whisper-cli` is routinely
|
|
6
|
+
* installed and simultaneously invisible to the running daemon. Probing brew's
|
|
7
|
+
* prefixes explicitly is what stops "I installed it and it still says not
|
|
8
|
+
* configured" — the worst failure available here, because the operator did the
|
|
9
|
+
* work and got told no.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { describe, expect, test } from "bun:test";
|
|
13
|
+
import { join } from "path";
|
|
14
|
+
import {
|
|
15
|
+
binaryNameFor,
|
|
16
|
+
candidateBinDirs,
|
|
17
|
+
managedBinDir,
|
|
18
|
+
managedModelDir,
|
|
19
|
+
resolveCliBinary,
|
|
20
|
+
resolveFfmpeg,
|
|
21
|
+
} from "./resolve-binary.ts";
|
|
22
|
+
|
|
23
|
+
/** An existsImpl that only knows about an explicit allow-list. */
|
|
24
|
+
function only(...present: string[]) {
|
|
25
|
+
const set = new Set(present);
|
|
26
|
+
return (p: string) => set.has(p);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
describe("binaryNameFor", () => {
|
|
30
|
+
test("maps engine → CLI name", () => {
|
|
31
|
+
expect(binaryNameFor("whisper")).toBe("whisper-cli");
|
|
32
|
+
expect(binaryNameFor("parakeet")).toBe("parakeet-cli");
|
|
33
|
+
});
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
describe("candidateBinDirs — the ladder", () => {
|
|
37
|
+
const env = { PARACHUTE_HOME: "/ph", PATH: "/usr/bin:/bin" } as NodeJS.ProcessEnv;
|
|
38
|
+
|
|
39
|
+
test("managed dir precedes brew, which precedes PATH", () => {
|
|
40
|
+
const dirs = candidateBinDirs({ env });
|
|
41
|
+
expect(dirs.indexOf("/ph/transcription/bin")).toBeLessThan(dirs.indexOf("/opt/homebrew/bin"));
|
|
42
|
+
expect(dirs.indexOf("/opt/homebrew/bin")).toBeLessThan(dirs.indexOf("/usr/bin"));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("an explicit override leads everything", () => {
|
|
46
|
+
const dirs = candidateBinDirs({ env: { ...env, WHISPER_CPP_BIN_DIR: "/custom" } });
|
|
47
|
+
expect(dirs[0]).toBe("/custom");
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
test("brew prefixes are probed even when absent from PATH — the launchd case", () => {
|
|
51
|
+
// A launchd-supervised daemon gets a minimal PATH with no /opt/homebrew.
|
|
52
|
+
const dirs = candidateBinDirs({ env: { PATH: "/usr/bin:/bin" } as NodeJS.ProcessEnv });
|
|
53
|
+
expect(dirs).toContain("/opt/homebrew/bin");
|
|
54
|
+
expect(dirs).toContain("/usr/local/bin");
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("no duplicates, order preserved", () => {
|
|
58
|
+
const dirs = candidateBinDirs({
|
|
59
|
+
env: { PATH: "/usr/bin:/opt/homebrew/bin:/usr/bin" } as NodeJS.ProcessEnv,
|
|
60
|
+
});
|
|
61
|
+
expect(dirs.length).toBe(new Set(dirs).size);
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("an empty PATH doesn't produce empty-string dirs", () => {
|
|
65
|
+
const dirs = candidateBinDirs({ env: { PATH: "" } as NodeJS.ProcessEnv });
|
|
66
|
+
expect(dirs).not.toContain("");
|
|
67
|
+
});
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
describe("resolveCliBinary", () => {
|
|
71
|
+
const env = { PARACHUTE_HOME: "/ph", PATH: "/usr/bin" } as NodeJS.ProcessEnv;
|
|
72
|
+
|
|
73
|
+
test("finds a brew-installed binary a bare PATH lookup would miss", () => {
|
|
74
|
+
const got = resolveCliBinary("parakeet", {
|
|
75
|
+
env,
|
|
76
|
+
existsImpl: only("/opt/homebrew/bin/parakeet-cli"),
|
|
77
|
+
});
|
|
78
|
+
expect(got).toBe("/opt/homebrew/bin/parakeet-cli");
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test("our managed install wins over a brew one", () => {
|
|
82
|
+
const got = resolveCliBinary("whisper", {
|
|
83
|
+
env,
|
|
84
|
+
existsImpl: only("/ph/transcription/bin/whisper-cli", "/opt/homebrew/bin/whisper-cli"),
|
|
85
|
+
});
|
|
86
|
+
expect(got).toBe("/ph/transcription/bin/whisper-cli");
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
test("the override beats everything", () => {
|
|
90
|
+
const got = resolveCliBinary("whisper", {
|
|
91
|
+
env: { ...env, WHISPER_CPP_BIN_DIR: "/custom" },
|
|
92
|
+
existsImpl: only("/custom/whisper-cli", "/ph/transcription/bin/whisper-cli"),
|
|
93
|
+
});
|
|
94
|
+
expect(got).toBe("/custom/whisper-cli");
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
test("returns an ABSOLUTE path, never a bare name", () => {
|
|
98
|
+
const got = resolveCliBinary("parakeet", { env, existsImpl: only("/usr/bin/parakeet-cli") });
|
|
99
|
+
// A bare name would be re-resolved against the spawn's PATH, which may
|
|
100
|
+
// differ from the one we probed.
|
|
101
|
+
expect(got?.startsWith("/")).toBe(true);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test("undefined when genuinely absent", () => {
|
|
105
|
+
expect(resolveCliBinary("whisper", { env, existsImpl: () => false })).toBeUndefined();
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test("engine selects the binary — a whisper install doesn't satisfy parakeet", () => {
|
|
109
|
+
const deps = { env, existsImpl: only("/usr/bin/whisper-cli") };
|
|
110
|
+
expect(resolveCliBinary("whisper", deps)).toBe("/usr/bin/whisper-cli");
|
|
111
|
+
expect(resolveCliBinary("parakeet", deps)).toBeUndefined();
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe("resolveFfmpeg", () => {
|
|
116
|
+
test("uses the same ladder, so a brew ffmpeg is found under launchd too", () => {
|
|
117
|
+
const got = resolveFfmpeg({
|
|
118
|
+
env: { PATH: "/usr/bin" } as NodeJS.ProcessEnv,
|
|
119
|
+
existsImpl: only("/opt/homebrew/bin/ffmpeg"),
|
|
120
|
+
});
|
|
121
|
+
expect(got).toBe("/opt/homebrew/bin/ffmpeg");
|
|
122
|
+
});
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
describe("managed paths honour PARACHUTE_HOME", () => {
|
|
126
|
+
test("bin + model dirs sit under the ecosystem root", () => {
|
|
127
|
+
const env = { PARACHUTE_HOME: "/custom/root" } as NodeJS.ProcessEnv;
|
|
128
|
+
expect(managedBinDir(env)).toBe(join("/custom/root", "transcription", "bin"));
|
|
129
|
+
expect(managedModelDir(env)).toBe(join("/custom/root", "transcription", "models"));
|
|
130
|
+
});
|
|
131
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where the whisper.cpp CLIs live, on a box we didn't install.
|
|
3
|
+
*
|
|
4
|
+
* The binaries arrive by more than one route and we should find them however
|
|
5
|
+
* they got there, because "it's installed but Parachute can't see it" is the
|
|
6
|
+
* worst version of this failure — the operator did the work and got told no.
|
|
7
|
+
*
|
|
8
|
+
* The ladder, first hit wins:
|
|
9
|
+
*
|
|
10
|
+
* 1. **`WHISPER_CPP_BIN_DIR`** — explicit operator override. Always wins;
|
|
11
|
+
* an operator who set it has a reason.
|
|
12
|
+
* 2. **`<PARACHUTE_HOME>/transcription/bin/`** — where `transcription
|
|
13
|
+
* install` puts the Linux release tarball's binaries. Ours, so we check
|
|
14
|
+
* it before anything system-wide.
|
|
15
|
+
* 3. **Homebrew prefix** — `brew install whisper-cpp` is the macOS path and
|
|
16
|
+
* lands in `/opt/homebrew/bin` (Apple Silicon) or `/usr/local/bin`
|
|
17
|
+
* (Intel). Checked explicitly rather than relying on PATH because a
|
|
18
|
+
* launchd-supervised daemon does NOT inherit a login shell's PATH, so
|
|
19
|
+
* `whisper-cli` can be perfectly installed and still invisible to the
|
|
20
|
+
* running vault. That's the single most likely way this breaks on a Mac.
|
|
21
|
+
* 4. **`PATH`** — the ordinary case on Linux, and the escape hatch for any
|
|
22
|
+
* packaging we didn't anticipate.
|
|
23
|
+
*
|
|
24
|
+
* Returns absolute paths, never bare names, so the spawn can't be re-resolved
|
|
25
|
+
* against a different PATH than the one we probed.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import { existsSync } from "fs";
|
|
29
|
+
import { homedir } from "os";
|
|
30
|
+
import { delimiter, join } from "path";
|
|
31
|
+
import type { TranscriptionEngine } from "./models.ts";
|
|
32
|
+
|
|
33
|
+
/** Binary name for an engine. */
|
|
34
|
+
export function binaryNameFor(engine: TranscriptionEngine): string {
|
|
35
|
+
return engine === "whisper" ? "whisper-cli" : "parakeet-cli";
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/** Homebrew prefixes worth probing, most likely first. */
|
|
39
|
+
const BREW_PREFIXES = ["/opt/homebrew", "/usr/local", "/home/linuxbrew/.linuxbrew"] as const;
|
|
40
|
+
|
|
41
|
+
export interface ResolveBinaryDeps {
|
|
42
|
+
env?: NodeJS.ProcessEnv;
|
|
43
|
+
existsImpl?: (p: string) => boolean;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The ecosystem root, mirroring `config.ts`'s resolution. */
|
|
47
|
+
function ecosystemRoot(env: NodeJS.ProcessEnv): string {
|
|
48
|
+
return env.PARACHUTE_HOME ?? join(homedir(), ".parachute");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Where `transcription install` writes downloaded binaries. */
|
|
52
|
+
export function managedBinDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
53
|
+
return join(ecosystemRoot(env), "transcription", "bin");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** Where `transcription install` writes downloaded models. */
|
|
57
|
+
export function managedModelDir(env: NodeJS.ProcessEnv = process.env): string {
|
|
58
|
+
return join(ecosystemRoot(env), "transcription", "models");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Every directory the ladder will probe, in order. Exported so the install
|
|
63
|
+
* verb and the admin UI can SHOW the operator where we looked — a "not found"
|
|
64
|
+
* that lists the searched paths is debuggable; one that doesn't isn't.
|
|
65
|
+
*/
|
|
66
|
+
export function candidateBinDirs(deps: ResolveBinaryDeps = {}): string[] {
|
|
67
|
+
const env = deps.env ?? process.env;
|
|
68
|
+
const dirs: string[] = [];
|
|
69
|
+
|
|
70
|
+
const override = env.WHISPER_CPP_BIN_DIR?.trim();
|
|
71
|
+
if (override) dirs.push(override);
|
|
72
|
+
|
|
73
|
+
dirs.push(managedBinDir(env));
|
|
74
|
+
|
|
75
|
+
for (const prefix of BREW_PREFIXES) dirs.push(join(prefix, "bin"));
|
|
76
|
+
|
|
77
|
+
for (const p of (env.PATH ?? "").split(delimiter)) {
|
|
78
|
+
const t = p.trim();
|
|
79
|
+
if (t.length > 0) dirs.push(t);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Dedupe, preserving order.
|
|
83
|
+
return [...new Set(dirs)];
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Resolve a CLI to an absolute path, or `undefined` when it isn't anywhere we
|
|
88
|
+
* look. See the module header for the ladder and why brew is probed explicitly.
|
|
89
|
+
*/
|
|
90
|
+
export function resolveCliBinary(
|
|
91
|
+
engine: TranscriptionEngine,
|
|
92
|
+
deps: ResolveBinaryDeps = {},
|
|
93
|
+
): string | undefined {
|
|
94
|
+
const exists = deps.existsImpl ?? existsSync;
|
|
95
|
+
const name = binaryNameFor(engine);
|
|
96
|
+
for (const dir of candidateBinDirs(deps)) {
|
|
97
|
+
const candidate = join(dir, name);
|
|
98
|
+
if (exists(candidate)) return candidate;
|
|
99
|
+
}
|
|
100
|
+
return undefined;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Resolve `ffmpeg` the same way. Same launchd-PATH problem, same fix. */
|
|
104
|
+
export function resolveFfmpeg(deps: ResolveBinaryDeps = {}): string | undefined {
|
|
105
|
+
const exists = deps.existsImpl ?? existsSync;
|
|
106
|
+
for (const dir of candidateBinDirs(deps)) {
|
|
107
|
+
const candidate = join(dir, "ffmpeg");
|
|
108
|
+
if (exists(candidate)) return candidate;
|
|
109
|
+
}
|
|
110
|
+
return undefined;
|
|
111
|
+
}
|
|
@@ -29,8 +29,10 @@
|
|
|
29
29
|
import { join } from "path";
|
|
30
30
|
import { homedir } from "os";
|
|
31
31
|
import { existsSync, readFileSync } from "fs";
|
|
32
|
+
import { DEFAULT_MODEL_ID } from "./models.ts";
|
|
32
33
|
|
|
33
34
|
export const TRANSCRIPTION_PROVIDERS = [
|
|
35
|
+
"whisper-cpp",
|
|
34
36
|
"scribe-http",
|
|
35
37
|
"transcribe-cpp",
|
|
36
38
|
"parakeet-mlx",
|
|
@@ -54,9 +56,17 @@ export const DEFAULT_ONNX_ASR_MODEL = "nemo-parakeet-tdt-0.6b-v3";
|
|
|
54
56
|
|
|
55
57
|
/**
|
|
56
58
|
* Resolve the configured provider name. `TRANSCRIPTION_PROVIDER` selects it;
|
|
57
|
-
* unset (or blank) ⇒ `scribe-http
|
|
58
|
-
*
|
|
59
|
-
*
|
|
59
|
+
* unset (or blank) ⇒ `scribe-http`. An unrecognized value warns once and falls
|
|
60
|
+
* back rather than failing boot — a typo shouldn't take transcription offline
|
|
61
|
+
* hard.
|
|
62
|
+
*
|
|
63
|
+
* NOTE on the default: `scribe-http` remains the fallback for now because
|
|
64
|
+
* flipping it is a separate, riskier change — see the `whisper-cpp` entry in
|
|
65
|
+
* `models.ts`. On a box with nothing configured, `scribe-http` with no
|
|
66
|
+
* SCRIBE_URL means transcription doesn't run at all, which vault#643 now
|
|
67
|
+
* reports honestly instead of silently skipping. Making `whisper-cpp` the
|
|
68
|
+
* default is the follow-up, once `transcription install` can guarantee a
|
|
69
|
+
* runnable binary + model.
|
|
60
70
|
*/
|
|
61
71
|
export function resolveTranscriptionProviderName(
|
|
62
72
|
env: NodeJS.ProcessEnv = process.env,
|
|
@@ -395,3 +405,16 @@ export function onnxAsrInstalled(
|
|
|
395
405
|
const bin = resolveOnnxAsrBin(env, deps);
|
|
396
406
|
return !!bin && (deps.existsImpl ?? existsSync)(bin);
|
|
397
407
|
}
|
|
408
|
+
|
|
409
|
+
/**
|
|
410
|
+
* Resolve the configured model id for the `whisper-cpp` provider.
|
|
411
|
+
* `TRANSCRIPTION_MODEL` selects it; unset ⇒ the catalog default.
|
|
412
|
+
*
|
|
413
|
+
* Unlike the provider name, an unknown value is NOT silently coerced — the
|
|
414
|
+
* caller reports it, because a typo'd model id should be visible rather than
|
|
415
|
+
* quietly transcribing with something the operator didn't choose.
|
|
416
|
+
*/
|
|
417
|
+
export function resolveTranscriptionModelId(env: NodeJS.ProcessEnv = process.env): string {
|
|
418
|
+
const raw = env.TRANSCRIPTION_MODEL?.trim();
|
|
419
|
+
return raw && raw.length > 0 ? raw : DEFAULT_MODEL_ID;
|
|
420
|
+
}
|