@openparachute/vault 0.7.5 → 0.7.6-rc.2

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.
@@ -91,6 +91,8 @@ import { redactToken } from "./export-watch.ts";
91
91
  import {
92
92
  findConflictingVault,
93
93
  remoteConflictMessage,
94
+ findUnrelatedRemoteHistory,
95
+ unrelatedHistoryMessage,
94
96
  } from "./mirror-remote-guard.ts";
95
97
  import { GitNotInstalledError, ensureGitAvailable } from "./git-preflight.ts";
96
98
  import { getVaultStore } from "./vault-store.ts";
@@ -1000,7 +1002,7 @@ export async function handleAuthPat(
1000
1002
  async function probeGitLsRemote(
1001
1003
  url: string,
1002
1004
  timeoutMs: number,
1003
- ): Promise<{ ok: boolean; error?: string }> {
1005
+ ): Promise<{ ok: boolean; error?: string; heads?: string[] }> {
1004
1006
  // GIT_TERMINAL_PROMPT=0 ensures bad credentials FAIL FAST instead of
1005
1007
  // sitting at "Username:" indefinitely (which the timeout would then
1006
1008
  // catch, but failing fast on the auth wall is the better UX).
@@ -1025,7 +1027,18 @@ async function probeGitLsRemote(
1025
1027
  }, timeoutMs);
1026
1028
  const exitCode = await proc.exited;
1027
1029
  clearTimeout(timer);
1028
- if (exitCode === 0) return { ok: true };
1030
+ if (exitCode === 0) {
1031
+ // vault#823: the ref list answers "is this remote empty?", which is what
1032
+ // the unrelated-history guard needs. We were already piping stdout and
1033
+ // dropping it on the floor — the reachability answer and the emptiness
1034
+ // answer come out of the same spawn.
1035
+ const stdout = new TextDecoder().decode(await new Response(proc.stdout).arrayBuffer());
1036
+ const heads = stdout
1037
+ .split("\n")
1038
+ .map((line) => line.split("\t")[0]?.trim() ?? "")
1039
+ .filter((sha) => /^[0-9a-f]{40}$/.test(sha));
1040
+ return { ok: true, heads };
1041
+ }
1029
1042
  const stderr = new TextDecoder()
1030
1043
  .decode(await new Response(proc.stderr).arrayBuffer())
1031
1044
  .trim();
@@ -1736,6 +1749,13 @@ export async function handleMirrorImport(
1736
1749
  spawnOverride?: GitSpawn,
1737
1750
  whichOverride?: (cmd: string) => string | null,
1738
1751
  managerOverride?: MirrorManager,
1752
+ // vault#823 test seam — the unrelated-history probe reaches the network
1753
+ // (`git ls-remote`) on the sync-arm path. Inject one to keep import tests
1754
+ // hermetic; production passes nothing and the real probe runs.
1755
+ probeOverride?: (
1756
+ url: string,
1757
+ timeoutMs: number,
1758
+ ) => Promise<{ ok: boolean; error?: string; heads?: string[] }>,
1739
1759
  ): Promise<Response> {
1740
1760
  let body: {
1741
1761
  remote_url?: unknown;
@@ -1939,6 +1959,7 @@ export async function handleMirrorImport(
1939
1959
  auth,
1940
1960
  manager,
1941
1961
  override,
1962
+ probeOverride,
1942
1963
  });
1943
1964
  result.sync_enabled = outcome.sync_enabled;
1944
1965
  if (outcome.warning) result.sync_warning = outcome.warning;
@@ -2127,6 +2148,41 @@ function importErrorTitle(errorType: ImportJobError["error_type"]): string {
2127
2148
  * stored credential's remote host/path against the import remote.
2128
2149
  * - **A mirror already targets the SAME remote** — no-op success.
2129
2150
  */
2151
+ /**
2152
+ * Head shas on `remoteUrl`, for the unrelated-history guard (vault#823).
2153
+ *
2154
+ * Reuses `probeGitLsRemote` — same spawn, same 10s bound, same no-prompt
2155
+ * posture — and resolves the same authed URL shape the import clone used, so
2156
+ * a private repo answers here too.
2157
+ *
2158
+ * Fails OPEN: any unreachable/ambiguous result returns `[]`, which the guard
2159
+ * reads as "empty remote" and lets the bind proceed. A network blip must not
2160
+ * block a legitimate setup; the failure it exists to catch is deterministic
2161
+ * and will be caught on the next attempt.
2162
+ */
2163
+ async function headShasOfRemote(
2164
+ remoteUrl: string,
2165
+ auth: ImportAuth,
2166
+ probeOverride?: (
2167
+ url: string,
2168
+ timeoutMs: number,
2169
+ ) => Promise<{ ok: boolean; error?: string; heads?: string[] }>,
2170
+ ): Promise<{ heads: string[]; probed: boolean }> {
2171
+ let url = remoteUrl;
2172
+ if (auth.kind === "pat") {
2173
+ url = embedTokenInRemoteUrl(remoteUrl, auth.token) ?? remoteUrl;
2174
+ }
2175
+ try {
2176
+ const probe = await (probeOverride ?? probeGitLsRemote)(url, 10_000);
2177
+ // `ok` with no `heads` key means an older probe shape, not an empty remote —
2178
+ // treat it as "didn't ask" so the caller can say so.
2179
+ if (probe.ok && probe.heads !== undefined) return { heads: probe.heads, probed: true };
2180
+ return { heads: [], probed: false };
2181
+ } catch {
2182
+ return { heads: [], probed: false };
2183
+ }
2184
+ }
2185
+
2130
2186
  export async function enableSyncToImportedRepo(opts: {
2131
2187
  vaultName: string;
2132
2188
  remoteUrl: string;
@@ -2143,8 +2199,40 @@ export async function enableSyncToImportedRepo(opts: {
2143
2199
  * backs up to this repo.
2144
2200
  */
2145
2201
  override?: boolean;
2202
+ /**
2203
+ * Test seam for the vault#823 unrelated-history probe (default
2204
+ * `probeGitLsRemote`, which spawns real git at the supplied remote). Inject
2205
+ * one returning `{ ok: true, heads: [] }` to exercise the arm path without
2206
+ * touching the network — matching `handleAuthPat`'s `probeOverride`.
2207
+ */
2208
+ probeOverride?: (
2209
+ url: string,
2210
+ timeoutMs: number,
2211
+ ) => Promise<{ ok: boolean; error?: string; heads?: string[] }>;
2146
2212
  }): Promise<{ sync_enabled: boolean; warning?: string }> {
2147
- const { vaultName, remoteUrl, auth, manager, override = false } = opts;
2213
+ const { vaultName, remoteUrl, auth, manager, override = false, probeOverride } = opts;
2214
+ // Set when the unrelated-history probe couldn't reach the remote. Rides along
2215
+ // on a SUCCESSFUL arm — the operator gets Sync and the caveat, not neither.
2216
+ let historyUnverified = false;
2217
+ const withUnverifiedNote = (
2218
+ result: { sync_enabled: boolean; warning?: string },
2219
+ ): { sync_enabled: boolean; warning?: string } => {
2220
+ if (!historyUnverified || !result.sync_enabled) return result;
2221
+ // Points at the vault's OWN Git remote section, which renders
2222
+ // `status.last_push_error` today (mirror-manager.ts:121-140, "Cut 5: push
2223
+ // observability"). Deliberately NOT the hub account page: that tile only
2224
+ // reports push outcome once hub#820 lands, and an advisory naming a
2225
+ // diagnostic that doesn't work yet is the same defect this whole set has
2226
+ // been clearing — a claim asserting something that isn't true. Uni caught
2227
+ // that this sentence had made #820 a precondition for user-facing text.
2228
+ const note =
2229
+ "Sync is on, but we couldn't reach the repo to check its history lines up with this vault's backup. " +
2230
+ "If pushes start failing, the Git remote section will show the error.";
2231
+ return {
2232
+ ...result,
2233
+ warning: result.warning ? `${result.warning} ${note}` : note,
2234
+ };
2235
+ };
2148
2236
 
2149
2237
  if (!manager) {
2150
2238
  return {
@@ -2167,6 +2255,38 @@ export async function enableSyncToImportedRepo(opts: {
2167
2255
  warning: `Import succeeded, but Sync was not enabled — ${remoteConflictMessage(conflict)}`,
2168
2256
  };
2169
2257
  }
2258
+
2259
+ // vault#823: arming Sync against a remote whose history this vault's
2260
+ // mirror can't reach produces a backup that is refused on EVERY push and
2261
+ // never self-corrects. Import is the path that creates it — it brings the
2262
+ // notes across but not the mirror's git history, so the mirror is a fresh
2263
+ // `git init` and the remote we just cloned from is, to it, unrelated.
2264
+ //
2265
+ // Decline the optional sync-arm and say why. The import itself already
2266
+ // succeeded and is not touched: the thing that would be wrong is quietly
2267
+ // pointing a backup at a repo it can never write to.
2268
+ const probe = await headShasOfRemote(remoteUrl, auth, probeOverride);
2269
+ if (probe.probed) {
2270
+ const unrelated = await findUnrelatedRemoteHistory({
2271
+ mirrorPath: manager.getStatus().mirror_path,
2272
+ remoteUrl,
2273
+ remoteHeads: probe.heads,
2274
+ });
2275
+ if (unrelated) {
2276
+ return {
2277
+ sync_enabled: false,
2278
+ warning: `Import succeeded, but Sync was not enabled — ${unrelatedHistoryMessage(unrelated)}`,
2279
+ };
2280
+ }
2281
+ } else {
2282
+ // Fail OPEN — a network blip must not block a legitimate setup. But say
2283
+ // so: this guard runs at BIND time and there may be no next bind, so a
2284
+ // silently-skipped check is the same five-day silence it exists to
2285
+ // prevent, reached by a different route. Arming with an unverified
2286
+ // remote is the right trade; arming without saying it was unverified is
2287
+ // not.
2288
+ historyUnverified = true;
2289
+ }
2170
2290
  }
2171
2291
 
2172
2292
  // --- Resolve the push credential we'll persist for this remote. ----------
@@ -2237,7 +2357,7 @@ export async function enableSyncToImportedRepo(opts: {
2237
2357
  };
2238
2358
  }
2239
2359
  }
2240
- return await applyEnabledAutoPush(manager);
2360
+ return withUnverifiedNote(await applyEnabledAutoPush(manager));
2241
2361
  }
2242
2362
  // Different remote — don't clobber the operator's existing backup target.
2243
2363
  return {
@@ -2325,7 +2445,7 @@ export async function enableSyncToImportedRepo(opts: {
2325
2445
  }
2326
2446
  }
2327
2447
 
2328
- return await applyEnabledAutoPush(manager);
2448
+ return withUnverifiedNote(await applyEnabledAutoPush(manager));
2329
2449
  }
2330
2450
 
2331
2451
  /**
package/src/routes.ts CHANGED
@@ -147,6 +147,7 @@ import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
147
147
  import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
148
148
  import {
149
149
  NO_PROVIDER_ERROR,
150
+ noProviderErrorFor,
150
151
  classifyAutoTranscribe,
151
152
  shouldAutoTranscribe,
152
153
  warnNoTranscriptionProvider,
@@ -2367,6 +2368,22 @@ async function handleNotesInner(
2367
2368
  // Explicit `transcribe: true` wins — if the caller asked, we honor that
2368
2369
  // regardless of the auto-transcribe toggle (back-compat).
2369
2370
  const explicitOptIn = body.transcribe === true;
2371
+ // Explicit opt-OUT. `transcribe: false` is a caller saying no, and it is
2372
+ // NOT the same as saying nothing — which is the only reading this route
2373
+ // had until now (`body.transcribe` was read once, as `=== true`, so
2374
+ // `false` and absent were indistinguishable).
2375
+ //
2376
+ // That collapse is what put the auto path in front of a decision the user
2377
+ // had already made: the app's `voice-capture-plan.ts` sets this from the
2378
+ // capture's transcribe toggle, its docstring states the invariant
2379
+ // ("every link sends `transcribe: false`"), and the sync queue dropped
2380
+ // the `false` in transit. With both halves in place a "no" arrives as a
2381
+ // "no" and auto-transcribe is never consulted — it exists to guess when
2382
+ // nobody expressed a preference, which is no longer this case.
2383
+ //
2384
+ // Absent still means absent, so old app builds and any caller with
2385
+ // genuinely no opinion are byte-unchanged; this is additive.
2386
+ const explicitOptOut = body.transcribe === false;
2370
2387
  // Per-vault auto-transcribe: read THIS vault's `auto_transcribe.enabled`
2371
2388
  // (vault.yaml) and pass it as the precedence-winning toggle. A vault that
2372
2389
  // set its own value uses it; one that left it unset falls through to the
@@ -2378,9 +2395,18 @@ async function handleNotesInner(
2378
2395
  // turned it off" and "nothing is configured to do it" both used to read
2379
2396
  // as `false`, so a misconfigured box silently accepted audio and
2380
2397
  // transcribed nothing — no marker, no status, no log.
2398
+ // An explicit opt-out resolves to `disabled`, which already carries
2399
+ // exactly the right meaning here — "silence is correct: they asked for
2400
+ // nothing to happen" (see `AutoTranscribeDecision`). Reusing it rather
2401
+ // than adding a kind means the downstream branches need no changes: not
2402
+ // `transcribe`, so nothing is enqueued; not `unavailable`, so no `failed`
2403
+ // marker and no missing-provider warning. The attachment links as a plain
2404
+ // audio file, which is what the caller asked for.
2381
2405
  const autoDecision = explicitOptIn
2382
2406
  ? ({ kind: "transcribe" } as const)
2383
- : classifyAutoTranscribe(body.mimeType, { perVaultEnabled });
2407
+ : explicitOptOut
2408
+ ? ({ kind: "disabled" } as const)
2409
+ : classifyAutoTranscribe(body.mimeType, { perVaultEnabled });
2384
2410
  const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
2385
2411
  // Enabled, audio, but no reachable provider. Record it on the attachment
2386
2412
  // so the state is visible in the API and the admin SPA instead of the
@@ -2404,7 +2430,12 @@ async function handleNotesInner(
2404
2430
  : transcribeUnavailable
2405
2431
  ? {
2406
2432
  transcribe_status: "failed" as const,
2407
- transcribe_error: NO_PROVIDER_ERROR,
2433
+ // Name the real situation — see noProviderErrorFor. On a box
2434
+ // with a local provider configured, the flat NO_PROVIDER_ERROR
2435
+ // told the operator to do what they had already done.
2436
+ transcribe_error: noProviderErrorFor(
2437
+ autoDecision.kind === "unavailable" ? autoDecision.localProvider : null,
2438
+ ),
2408
2439
  transcribe_requested_at: new Date().toISOString(),
2409
2440
  transcribe_origin: "auto" as const,
2410
2441
  ...(validSegment ? { segment_index: segIdx } : {}),
@@ -1,4 +1,7 @@
1
1
  import { describe, test, expect, afterEach } from "bun:test";
2
+ import { randomUUID } from "crypto";
3
+ import { tmpdir } from "os";
4
+ import { join } from "path";
2
5
  import { resolveTranscriptionCapability, defaultTranscriptionProvider } from "./capability.ts";
3
6
  import { ScribeHttpProvider } from "./providers/scribe-http.ts";
4
7
  import { TranscribeCppProvider } from "./providers/transcribe-cpp.ts";
@@ -81,11 +84,55 @@ describe("defaultTranscriptionProvider — provider selection", () => {
81
84
  else process.env.TRANSCRIPTION_PROVIDER = saved;
82
85
  });
83
86
 
84
- test("default (unset) the scribe-http provider", () => {
87
+ const savedScribe = process.env.SCRIBE_URL;
88
+ const savedHome = process.env.PARACHUTE_HOME;
89
+ afterEach(() => {
90
+ if (savedScribe === undefined) delete process.env.SCRIBE_URL;
91
+ else process.env.SCRIBE_URL = savedScribe;
92
+ if (savedHome === undefined) delete process.env.PARACHUTE_HOME;
93
+ else process.env.PARACHUTE_HOME = savedHome;
94
+ });
95
+
96
+ test("default (unset) with a reachable scribe → the scribe-http provider", () => {
85
97
  delete process.env.TRANSCRIPTION_PROVIDER;
98
+ process.env.SCRIBE_URL = "http://scribe.test";
86
99
  expect(defaultTranscriptionProvider().name).toBe("scribe-http");
87
100
  });
88
101
 
102
+ // REGRESSION (Aaron's box, 2026-08-07): `select.ts` defaults an un-scribed
103
+ // box to `whisper-cpp`, but this factory had no `whisper-cpp` branch and fell
104
+ // through to ScribeHttpProvider — which reports unavailable, so the landing
105
+ // said `transcription: {enabled:false}` and the app hid the mic while the
106
+ // WORKER (server.ts) was happily running whisper-cpp. The capability flag and
107
+ // the thing that actually transcribes must name the same provider.
108
+ test("default (unset) with no scribe → the whisper-cpp provider, not scribe-http", () => {
109
+ delete process.env.TRANSCRIPTION_PROVIDER;
110
+ delete process.env.SCRIBE_URL;
111
+ // Unsetting SCRIBE_URL is not enough to mean "no scribe": resolution falls
112
+ // back to the real `~/.parachute/services.json`, so on a box that still has
113
+ // a `parachute-scribe` entry this would resolve scribe-http and fail on
114
+ // correct code. Point PARACHUTE_HOME at an empty dir so "no scribe" is
115
+ // actually true here.
116
+ process.env.PARACHUTE_HOME = join(tmpdir(), `pv-cap-${randomUUID()}`);
117
+ expect(defaultTranscriptionProvider().name).toBe("whisper-cpp");
118
+ });
119
+
120
+ test("TRANSCRIPTION_PROVIDER=whisper-cpp → the whisper-cpp provider", () => {
121
+ process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
122
+ expect(defaultTranscriptionProvider().name).toBe("whisper-cpp");
123
+ });
124
+
125
+ test("whisper-cpp with no installed model resolves to disabled (no throw)", async () => {
126
+ // Point PARACHUTE_HOME at a dir with no managed model. `available()` gates
127
+ // on the model file existing, so this stays hermetic even on a dev box
128
+ // that has `brew install whisper-cpp` on PATH.
129
+ process.env.TRANSCRIPTION_PROVIDER = "whisper-cpp";
130
+ process.env.PARACHUTE_HOME = join(tmpdir(), `pv-cap-${randomUUID()}`);
131
+ const cap = await resolveTranscriptionCapability(defaultTranscriptionProvider());
132
+ expect(cap.enabled).toBe(false);
133
+ expect(cap.provider).toBeUndefined();
134
+ });
135
+
89
136
  test("TRANSCRIPTION_PROVIDER=transcribe-cpp → the transcribe-cpp provider", () => {
90
137
  process.env.TRANSCRIPTION_PROVIDER = "transcribe-cpp";
91
138
  expect(defaultTranscriptionProvider().name).toBe("transcribe-cpp");
@@ -19,10 +19,15 @@ import { ScribeHttpProvider } from "./providers/scribe-http.ts";
19
19
  import { TranscribeCppProvider } from "./providers/transcribe-cpp.ts";
20
20
  import { ParakeetMlxProvider } from "./providers/parakeet-mlx.ts";
21
21
  import { OnnxAsrProvider } from "./providers/onnx-asr.ts";
22
+ import { WhisperCppProvider } from "./providers/whisper-cpp.ts";
22
23
  import { getCachedScribeUrl } from "../scribe-discovery.ts";
23
24
  import { resolveScribeAuthToken } from "../scribe-env.ts";
25
+ import { findModel } from "./models.ts";
26
+ import { managedModelDir, resolveCliBinary, resolveFfmpeg } from "./resolve-binary.ts";
27
+ import { join } from "path";
24
28
  import {
25
29
  resolveTranscriptionProviderName,
30
+ resolveTranscriptionModelId,
26
31
  resolveTranscribeCppPaths,
27
32
  resolveParakeetMlxBin,
28
33
  resolveParakeetMlxModel,
@@ -42,6 +47,10 @@ export interface TranscriptionCapability {
42
47
  * `TRANSCRIPTION_PROVIDER` (scribe-fold Phase 2a) so the capability flag
43
48
  * reflects whichever provider is actually configured:
44
49
  *
50
+ * - `whisper-cpp` → the local whisper.cpp CLIs and the DEFAULT provider on a
51
+ * box with no reachable scribe. The model id decides which CLI
52
+ * (`parakeet-cli` / `whisper-cli`); `available()` is `false` until
53
+ * `transcription install` has put both a binary and the model in place.
45
54
  * - `transcribe-cpp` → the local provider, resolving the installed binary +
46
55
  * GGUF model paths; `available()` is `false` until `transcription install`
47
56
  * has run.
@@ -55,6 +64,20 @@ export interface TranscriptionCapability {
55
64
  */
56
65
  export function defaultTranscriptionProvider(): TranscriptionProvider {
57
66
  const name = resolveTranscriptionProviderName();
67
+ if (name === "whisper-cpp") {
68
+ // Mirrors the worker's construction in `server.ts` so the capability flag
69
+ // and the thing that actually transcribes agree. An unknown model id, a
70
+ // missing binary or a missing model file all resolve to `undefined` here,
71
+ // and `WhisperCppProvider.available()` reports not-ok for each — the flag
72
+ // goes false without throwing, which is the landing's contract.
73
+ const model = findModel(resolveTranscriptionModelId());
74
+ return new WhisperCppProvider({
75
+ binPath: model ? resolveCliBinary(model.engine) : undefined,
76
+ engine: model?.engine ?? "whisper",
77
+ modelPath: model ? join(managedModelDir(), model.filename) : undefined,
78
+ ffmpegPath: resolveFfmpeg(),
79
+ });
80
+ }
58
81
  if (name === "transcribe-cpp") {
59
82
  const paths = resolveTranscribeCppPaths();
60
83
  return new TranscribeCppProvider({ binPath: paths.binPath, modelPath: paths.modelPath });
@@ -26,7 +26,14 @@ import type { SpawnRunner } from "./transcribe-cpp.ts";
26
26
 
27
27
  const AUDIO = new Uint8Array([1, 2, 3, 4]);
28
28
 
29
- /** A provider wired to a scripted spawn; both paths "exist" by default. */
29
+ /**
30
+ * A provider wired to a scripted spawn; both paths "exist" by default.
31
+ *
32
+ * `whichImpl` is stubbed because `available()` now also requires ffmpeg, and
33
+ * the default `ffmpegPath` is the bare name `"ffmpeg"` resolved on PATH. Left
34
+ * un-stubbed these tests would pass on a dev box with ffmpeg installed and fail
35
+ * in a CI container without it — the exact green-locally/red-in-CI trap.
36
+ */
30
37
  function makeProvider(
31
38
  spawn: SpawnRunner,
32
39
  over: Partial<WhisperCppProviderOpts> = {},
@@ -37,6 +44,7 @@ function makeProvider(
37
44
  modelPath: "/models/m.bin",
38
45
  spawn,
39
46
  existsImpl: () => true,
47
+ whichImpl: () => "/usr/bin/ffmpeg",
40
48
  tmpDir: tmpdir(),
41
49
  ...over,
42
50
  });
@@ -107,6 +115,41 @@ describe("availability", () => {
107
115
  test("both present → ok", async () => {
108
116
  expect((await makeProvider(scripted({}, {})).available()).ok).toBe(true);
109
117
  });
118
+
119
+ // ffmpeg is a hard requirement — `transcribe()` always transcodes to 16 kHz
120
+ // mono WAV first, so a box without it can never transcribe anything. Leaving
121
+ // it out of `available()` made the vault landing advertise a working mic that
122
+ // failed on every recording.
123
+ test("missing ffmpeg is reported as its own problem, distinct from binary/model", async () => {
124
+ const p = makeProvider(scripted({}, {}), { whichImpl: () => null });
125
+ const a = await p.available();
126
+ expect(a.ok).toBe(false);
127
+ expect(a.reason).toMatch(/ffmpeg/);
128
+ expect(a.reason).not.toMatch(/parakeet-cli binary/);
129
+ expect(a.reason).not.toMatch(/model file/);
130
+ });
131
+
132
+ test("an ABSOLUTE ffmpegPath is stat'd, not resolved on PATH", async () => {
133
+ const p = makeProvider(scripted({}, {}), {
134
+ ffmpegPath: "/opt/custom/ffmpeg",
135
+ existsImpl: (x) => x !== "/opt/custom/ffmpeg",
136
+ whichImpl: () => "/usr/bin/ffmpeg", // PATH has one; the configured path does not exist
137
+ });
138
+ const a = await p.available();
139
+ expect(a.ok).toBe(false);
140
+ expect(a.reason).toMatch(/ffmpeg/);
141
+ });
142
+
143
+ // The regression this probe could easily have introduced: the constructor
144
+ // defaults ffmpegPath to the BARE name "ffmpeg". Stat'ing that would always
145
+ // miss and report every default-constructed provider unavailable.
146
+ test("the bare-name default resolves on PATH rather than being stat'd", async () => {
147
+ const p = makeProvider(scripted({}, {}), {
148
+ existsImpl: (x) => x === "/bin/parakeet-cli" || x === "/models/m.bin",
149
+ whichImpl: (cmd) => (cmd === "ffmpeg" ? "/usr/bin/ffmpeg" : null),
150
+ });
151
+ expect((await p.available()).ok).toBe(true);
152
+ });
110
153
  });
111
154
 
112
155
  describe("transcribe — error taxonomy", () => {
@@ -77,6 +77,8 @@ export interface WhisperCppProviderOpts {
77
77
  spawn?: SpawnRunner;
78
78
  /** Existence probe (tests inject). */
79
79
  existsImpl?: (p: string) => boolean;
80
+ /** PATH lookup for a bare command name, e.g. the default `"ffmpeg"` (tests inject). */
81
+ whichImpl?: (cmd: string) => string | null;
80
82
  /** Scratch dir for temp audio. Default `os.tmpdir()`. */
81
83
  tmpDir?: string;
82
84
  }
@@ -131,6 +133,7 @@ export class WhisperCppProvider implements TranscriptionProvider {
131
133
  private readonly timeoutMs: number;
132
134
  private readonly spawn: SpawnRunner;
133
135
  private readonly existsImpl: (p: string) => boolean;
136
+ private readonly whichImpl: (cmd: string) => string | null;
134
137
  private readonly tmpDir: string;
135
138
  /** Once available, stays available for this process — avoids re-statting. */
136
139
  private cachedAvailable = false;
@@ -143,13 +146,38 @@ export class WhisperCppProvider implements TranscriptionProvider {
143
146
  this.timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
144
147
  this.spawn = opts.spawn ?? defaultSpawnRunner;
145
148
  this.existsImpl = opts.existsImpl ?? existsSync;
149
+ this.whichImpl = opts.whichImpl ?? ((cmd) => Bun.which(cmd));
146
150
  this.tmpDir = opts.tmpDir ?? tmpdir();
147
151
  }
148
152
 
149
153
  /**
150
- * Ready iff both the CLI and the model are on disk. Reports WHICH is missing
151
- * — "not installed" is not an actionable message when there are two things
152
- * it could mean and different fixes for each.
154
+ * Is ffmpeg actually invokable?
155
+ *
156
+ * `ffmpegPath` arrives in two shapes and they need different probes. A
157
+ * resolved absolute path (what `resolveFfmpeg()` returns) can be stat'd. The
158
+ * constructor's `"ffmpeg"` default is a BARE COMMAND NAME to be resolved on
159
+ * PATH — stat'ing that would always miss, and reporting every default-
160
+ * constructed provider unavailable would be a far worse bug than the one this
161
+ * check exists to catch.
162
+ */
163
+ private ffmpegReady(): boolean {
164
+ const p = this.ffmpegPath;
165
+ if (p.includes("/")) return this.existsImpl(p);
166
+ return this.whichImpl(p) != null;
167
+ }
168
+
169
+ /**
170
+ * Ready iff the CLI, the model AND ffmpeg are all present. Reports WHICH is
171
+ * missing — "not installed" is not an actionable message when there are
172
+ * several things it could mean and different fixes for each.
173
+ *
174
+ * ffmpeg is a hard requirement, not a nicety: `transcribe()` ALWAYS transcodes
175
+ * to 16 kHz mono WAV before invoking the CLI (browser capture is webm/opus,
176
+ * which neither CLI reads), so without ffmpeg every transcription fails with
177
+ * `ffmpeg_missing`. Omitting it here made the vault landing advertise
178
+ * `transcription: {enabled:true}` on a box that could never transcribe a
179
+ * single file — a mic that appears and then fails is worse than one that
180
+ * honestly isn't offered.
153
181
  */
154
182
  async available(): Promise<ProviderAvailability> {
155
183
  if (this.cachedAvailable) return { ok: true };
@@ -163,6 +191,9 @@ export class WhisperCppProvider implements TranscriptionProvider {
163
191
  if (!this.modelPath || !this.existsImpl(this.modelPath)) {
164
192
  missing.push("the model file (`parachute-vault transcription install` downloads it)");
165
193
  }
194
+ if (!this.ffmpegReady()) {
195
+ missing.push("ffmpeg (`brew install ffmpeg` on macOS, `apt install ffmpeg` on Linux)");
196
+ }
166
197
  if (missing.length > 0) {
167
198
  return { ok: false, reason: `whisper-cpp is not ready — missing ${missing.join(" and ")}` };
168
199
  }
@@ -30,38 +30,101 @@ import {
30
30
  const silent = { warn: () => {} };
31
31
 
32
32
  describe("resolveTranscriptionProviderName", () => {
33
- test("unset scribe-http (behavior-preserving default)", () => {
34
- expect(resolveTranscriptionProviderName({}, silent)).toBe("scribe-http");
33
+ // Scribe presence is injected in every case. Left to production resolution it
34
+ // would read the developer's real ~/.parachute/services.json, so "unset" would
35
+ // resolve differently on a box that happens to have scribe installed — the
36
+ // test would pass for the wrong reason on one machine and fail on another.
37
+ const noScribe = { scribeConfiguredImpl: () => false };
38
+ const withScribe = { scribeConfiguredImpl: () => true };
39
+
40
+ test("unset + no scribe → whisper-cpp (the local default)", () => {
41
+ // The default a fresh box gets. `scribe-http` used to win here and was
42
+ // unreachable, so audio was accepted and never transcribed.
43
+ expect(resolveTranscriptionProviderName({}, silent, noScribe)).toBe("whisper-cpp");
35
44
  });
36
- test("blank → scribe-http", () => {
37
- expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: " " }, silent)).toBe("scribe-http");
45
+
46
+ test("unset + a reachable scribe → scribe-http (a working box keeps working)", () => {
47
+ // The safety property of the flip. These operators configured scribe by
48
+ // NOT configuring anything, so "unset" can't be read as "wants local" —
49
+ // flipping unconditionally would take transcription away from them.
50
+ expect(resolveTranscriptionProviderName({}, silent, withScribe)).toBe("scribe-http");
38
51
  });
39
- test("explicit transcribe-cpp", () => {
40
- expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "transcribe-cpp" }, silent)).toBe(
41
- "transcribe-cpp",
52
+
53
+ test("blank same default resolution as unset", () => {
54
+ expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: " " }, silent, noScribe)).toBe(
55
+ "whisper-cpp",
42
56
  );
57
+ expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: " " }, silent, withScribe)).toBe(
58
+ "scribe-http",
59
+ );
60
+ });
61
+
62
+ test("an EXPLICIT scribe-http is honored even with no scribe reachable", () => {
63
+ // Explicit config always wins over the probe: the operator may be about to
64
+ // start scribe, or point SCRIBE_URL somewhere that's briefly down. Silently
65
+ // overriding a stated choice is its own bug.
66
+ expect(
67
+ resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "scribe-http" }, silent, noScribe),
68
+ ).toBe("scribe-http");
69
+ });
70
+
71
+ test("an EXPLICIT whisper-cpp is honored even when scribe IS reachable", () => {
72
+ expect(
73
+ resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "whisper-cpp" }, silent, withScribe),
74
+ ).toBe("whisper-cpp");
43
75
  });
76
+
77
+ test("explicit transcribe-cpp", () => {
78
+ expect(
79
+ resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "transcribe-cpp" }, silent, noScribe),
80
+ ).toBe("transcribe-cpp");
81
+ });
82
+
44
83
  test("explicit parakeet-mlx / onnx-asr (scribe-fold Phase 2b)", () => {
45
- expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "parakeet-mlx" }, silent)).toBe(
46
- "parakeet-mlx",
47
- );
48
- expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "onnx-asr" }, silent)).toBe(
49
- "onnx-asr",
50
- );
84
+ expect(
85
+ resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "parakeet-mlx" }, silent, noScribe),
86
+ ).toBe("parakeet-mlx");
87
+ expect(
88
+ resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "onnx-asr" }, silent, noScribe),
89
+ ).toBe("onnx-asr");
51
90
  });
52
- test("explicit scribe-http", () => {
53
- expect(resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "scribe-http" }, silent)).toBe(
54
- "scribe-http",
91
+
92
+ test("unknown value → warns + falls back to the DEFAULT, not to scribe-http", () => {
93
+ // The fallback follows the same rule as unset, so a typo on a box with no
94
+ // scribe lands on the provider that can actually run rather than a dead one.
95
+ let warned = "";
96
+ const name = resolveTranscriptionProviderName(
97
+ { TRANSCRIPTION_PROVIDER: "whisper-magic" },
98
+ { warn: (...a: unknown[]) => (warned = a.join(" ")) },
99
+ noScribe,
55
100
  );
101
+ expect(name).toBe("whisper-cpp");
102
+ // The warning must name what it fell back TO, or the operator can't tell
103
+ // which provider is actually running.
104
+ expect(warned).toContain("whisper-magic");
105
+ expect(warned).toContain("whisper-cpp");
56
106
  });
57
- test("unknown value → warns + falls back to scribe-http", () => {
58
- let warned = false;
107
+
108
+ test("unknown value on a scribe box falls back to scribe-http", () => {
109
+ let warned = "";
59
110
  const name = resolveTranscriptionProviderName(
60
111
  { TRANSCRIPTION_PROVIDER: "whisper-magic" },
61
- { warn: () => (warned = true) },
112
+ { warn: (...a: unknown[]) => (warned = a.join(" ")) },
113
+ withScribe,
62
114
  );
63
115
  expect(name).toBe("scribe-http");
64
- expect(warned).toBe(true);
116
+ expect(warned).toContain("scribe-http");
117
+ });
118
+
119
+ test("the scribe probe is consulted ONLY when there's no explicit provider", () => {
120
+ // Cheap guard against a regression that makes every capability check stat
121
+ // services.json — this runs on the per-upload path.
122
+ let probes = 0;
123
+ const counting = { scribeConfiguredImpl: () => (probes++, false) };
124
+ resolveTranscriptionProviderName({ TRANSCRIPTION_PROVIDER: "whisper-cpp" }, silent, counting);
125
+ expect(probes).toBe(0);
126
+ resolveTranscriptionProviderName({}, silent, counting);
127
+ expect(probes).toBe(1);
65
128
  });
66
129
  });
67
130