@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.
@@ -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` (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.
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
+ }