@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.
- package/package.json +1 -1
- package/src/attachment-tickets.ts +17 -1
- package/src/auto-transcribe.test.ts +105 -0
- package/src/auto-transcribe.ts +103 -4
- package/src/cli.ts +97 -82
- package/src/mcp-tools.ts +28 -7
- package/src/mirror-remote-guard.test.ts +159 -0
- package/src/mirror-remote-guard.ts +124 -0
- package/src/mirror-routes.test.ts +147 -0
- package/src/mirror-routes.ts +125 -5
- package/src/routes.ts +33 -2
- package/src/transcription/capability.test.ts +48 -1
- package/src/transcription/capability.ts +23 -0
- package/src/transcription/providers/whisper-cpp.test.ts +44 -1
- package/src/transcription/providers/whisper-cpp.ts +34 -3
- package/src/transcription/select.test.ts +83 -20
- package/src/transcription/select.ts +48 -16
- package/src/transcription-routes.test.ts +29 -6
- package/src/transcription-status-cli.test.ts +221 -0
- package/src/vault.test.ts +120 -0
package/src/mirror-routes.ts
CHANGED
|
@@ -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)
|
|
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
|
-
:
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
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
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
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
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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(
|
|
46
|
-
"parakeet-mlx",
|
|
47
|
-
);
|
|
48
|
-
expect(
|
|
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
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
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 =
|
|
112
|
+
{ warn: (...a: unknown[]) => (warned = a.join(" ")) },
|
|
113
|
+
withScribe,
|
|
62
114
|
);
|
|
63
115
|
expect(name).toBe("scribe-http");
|
|
64
|
-
expect(warned).
|
|
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
|
|