@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/package.json
CHANGED
|
@@ -24,6 +24,7 @@ import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
|
|
|
24
24
|
import { assetsDir, readVaultConfig } from "./config.ts";
|
|
25
25
|
import {
|
|
26
26
|
NO_PROVIDER_ERROR,
|
|
27
|
+
noProviderErrorFor,
|
|
27
28
|
classifyAutoTranscribe,
|
|
28
29
|
warnNoTranscriptionProvider,
|
|
29
30
|
} from "./auto-transcribe.ts";
|
|
@@ -268,6 +269,17 @@ async function handleUploadSpend(
|
|
|
268
269
|
// mint) wins; otherwise infer from mime-type + the owning vault's
|
|
269
270
|
// auto-transcribe toggle.
|
|
270
271
|
const explicitOptIn = ticket.transcribe === true;
|
|
272
|
+
// NO explicit-opt-out branch here, deliberately — unlike the REST attach
|
|
273
|
+
// route this path CANNOT express one today. `generateMcpTools` mints the
|
|
274
|
+
// ticket with `transcribe: params.transcribe === true` (core/src/mcp.ts),
|
|
275
|
+
// so a caller who says nothing and a caller who says `false` both arrive
|
|
276
|
+
// as `false`. Reading `false` as an opt-out here would therefore disable
|
|
277
|
+
// auto-transcribe for every ticket that didn't explicitly opt in — the
|
|
278
|
+
// collapse this work exists to undo, in the opposite direction.
|
|
279
|
+
//
|
|
280
|
+
// Making the ticket path symmetric means preserving the tri-state at mint
|
|
281
|
+
// first, which is a change to the MCP tool contract and belongs with that
|
|
282
|
+
// argument rather than smuggled in here.
|
|
271
283
|
const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
|
|
272
284
|
const autoDecision = explicitOptIn
|
|
273
285
|
? ({ kind: "transcribe" } as const)
|
|
@@ -292,7 +304,11 @@ async function handleUploadSpend(
|
|
|
292
304
|
}
|
|
293
305
|
} else if (transcribeUnavailable) {
|
|
294
306
|
attMeta.transcribe_status = "failed";
|
|
295
|
-
|
|
307
|
+
// Name the real situation. On a box with a local provider configured,
|
|
308
|
+
// the flat NO_PROVIDER_ERROR told the operator to do what they'd done.
|
|
309
|
+
attMeta.transcribe_error = noProviderErrorFor(
|
|
310
|
+
autoDecision.kind === "unavailable" ? autoDecision.localProvider : null,
|
|
311
|
+
);
|
|
296
312
|
attMeta.transcribe_requested_at = new Date().toISOString();
|
|
297
313
|
attMeta.transcribe_origin = "auto";
|
|
298
314
|
if (ticket.segmentIndex !== undefined) {
|
|
@@ -9,6 +9,8 @@ import { describe, test, expect } from "bun:test";
|
|
|
9
9
|
import {
|
|
10
10
|
_resetNoProviderWarnForTest,
|
|
11
11
|
classifyAutoTranscribe,
|
|
12
|
+
NO_PROVIDER_ERROR,
|
|
13
|
+
noProviderErrorFor,
|
|
12
14
|
shouldAutoTranscribe,
|
|
13
15
|
warnNoTranscriptionProvider,
|
|
14
16
|
} from "./auto-transcribe.ts";
|
|
@@ -281,3 +283,106 @@ describe("warnNoTranscriptionProvider throttle (vault#643)", () => {
|
|
|
281
283
|
}
|
|
282
284
|
});
|
|
283
285
|
});
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* The `unavailable` error must be TRUE, not merely actionable.
|
|
289
|
+
*
|
|
290
|
+
* Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli`
|
|
291
|
+
* on PATH, the model downloaded, and `[transcribe] worker started →
|
|
292
|
+
* whisper-cpp` in the same boot log was writing "no transcription provider
|
|
293
|
+
* configured — set TRANSCRIPTION_PROVIDER to a local provider" onto its
|
|
294
|
+
* attachments. Every clause of that is false there, and the fix it prescribes
|
|
295
|
+
* is a 400 MB re-download that changes nothing.
|
|
296
|
+
*
|
|
297
|
+
* The decision is deliberately unchanged — `unavailable` stays `unavailable`,
|
|
298
|
+
* because this path resolves a scribe URL and nothing else. Only the sentence
|
|
299
|
+
* moves.
|
|
300
|
+
*/
|
|
301
|
+
describe("the unavailable message tells the truth about the local install", () => {
|
|
302
|
+
const audio = "audio/webm";
|
|
303
|
+
const noScribe = () => undefined;
|
|
304
|
+
|
|
305
|
+
test("nothing runnable locally → the original message, unchanged", () => {
|
|
306
|
+
// The fresh-install case the old string was written for, and it is still
|
|
307
|
+
// exactly right there. Byte-identical so a fresh install sees no churn.
|
|
308
|
+
const d = classifyAutoTranscribe(audio, {
|
|
309
|
+
perVaultEnabled: true,
|
|
310
|
+
getCachedScribeUrlImpl: noScribe,
|
|
311
|
+
localProviderImpl: () => null,
|
|
312
|
+
});
|
|
313
|
+
expect(d.kind).toBe("unavailable");
|
|
314
|
+
expect(noProviderErrorFor(d.kind === "unavailable" ? d.localProvider : null)).toBe(
|
|
315
|
+
NO_PROVIDER_ERROR,
|
|
316
|
+
);
|
|
317
|
+
});
|
|
318
|
+
|
|
319
|
+
test("a runnable local provider → a message that does not contradict the box", () => {
|
|
320
|
+
const d = classifyAutoTranscribe(audio, {
|
|
321
|
+
perVaultEnabled: true,
|
|
322
|
+
getCachedScribeUrlImpl: noScribe,
|
|
323
|
+
localProviderImpl: () => "whisper-cpp",
|
|
324
|
+
});
|
|
325
|
+
expect(d.kind).toBe("unavailable");
|
|
326
|
+
const msg = noProviderErrorFor(d.kind === "unavailable" ? d.localProvider : null);
|
|
327
|
+
|
|
328
|
+
// The three false claims the old string made on such a box.
|
|
329
|
+
expect(msg).not.toContain("no transcription provider configured");
|
|
330
|
+
expect(msg).not.toContain("set TRANSCRIPTION_PROVIDER to a local provider");
|
|
331
|
+
expect(msg).not.toContain("transcription install");
|
|
332
|
+
// And what it must say instead: the local install is fine, this path just
|
|
333
|
+
// doesn't read it, and don't go reinstalling anything.
|
|
334
|
+
expect(msg).toContain("whisper-cpp");
|
|
335
|
+
expect(msg).toContain("SCRIBE_URL");
|
|
336
|
+
expect(msg).toMatch(/does not consult it/);
|
|
337
|
+
expect(msg).toMatch(/Reinstalling whisper-cpp will not change this/);
|
|
338
|
+
});
|
|
339
|
+
|
|
340
|
+
test("the decision itself is untouched — this diff moves no behaviour", () => {
|
|
341
|
+
// Same inputs, both local-provider states: still `unavailable` either way.
|
|
342
|
+
// If this ever diverges, the change stopped being diagnostic-only and
|
|
343
|
+
// started deciding what gets transcribed.
|
|
344
|
+
for (const local of [null, "whisper-cpp"]) {
|
|
345
|
+
expect(
|
|
346
|
+
classifyAutoTranscribe(audio, {
|
|
347
|
+
perVaultEnabled: true,
|
|
348
|
+
getCachedScribeUrlImpl: noScribe,
|
|
349
|
+
localProviderImpl: () => local,
|
|
350
|
+
}).kind,
|
|
351
|
+
).toBe("unavailable");
|
|
352
|
+
}
|
|
353
|
+
// And a reachable scribe still wins outright, local install or not.
|
|
354
|
+
expect(
|
|
355
|
+
classifyAutoTranscribe(audio, {
|
|
356
|
+
perVaultEnabled: true,
|
|
357
|
+
getCachedScribeUrlImpl: () => "http://127.0.0.1:1943",
|
|
358
|
+
localProviderImpl: () => "whisper-cpp",
|
|
359
|
+
}).kind,
|
|
360
|
+
).toBe("transcribe");
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
test("an installed provider with ffmpeg missing STILL gets the honest message", () => {
|
|
364
|
+
// The trap that cost a rewrite: `snap.ready` also demands ffmpeg, so it
|
|
365
|
+
// varies box to box. A machine with the provider installed and ffmpeg
|
|
366
|
+
// missing is still one where "no transcription provider configured" is
|
|
367
|
+
// false, so the discriminator is INSTALLED, not READY — it answers the
|
|
368
|
+
// question the sentence asks, identically everywhere.
|
|
369
|
+
const msg = noProviderErrorFor("whisper-cpp");
|
|
370
|
+
expect(msg).not.toBe(NO_PROVIDER_ERROR);
|
|
371
|
+
// And it must not over-claim in the other direction: with ffmpeg missing
|
|
372
|
+
// the install is present but not necessarily working, so the message says
|
|
373
|
+
// requests are ROUTED to it and points at status for the real state.
|
|
374
|
+
expect(msg).toMatch(/routed to it/);
|
|
375
|
+
expect(msg).toContain("transcription status");
|
|
376
|
+
expect(msg).not.toMatch(/is fine/);
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
test("the provider NAME is not the discriminator — the fresh-install trap", () => {
|
|
380
|
+
// `resolveTranscriptionProviderName` returns "whisper-cpp" on a box where
|
|
381
|
+
// nothing is set up at all, because that is the default when no scribe URL
|
|
382
|
+
// resolves. Keying off the name would tell a fresh-install operator their
|
|
383
|
+
// whisper-cpp install is fine when they have never installed one. The
|
|
384
|
+
// discriminator is RUNNABILITY, and null must produce the old message.
|
|
385
|
+
expect(noProviderErrorFor(null)).toBe(NO_PROVIDER_ERROR);
|
|
386
|
+
expect(noProviderErrorFor("whisper-cpp")).not.toBe(NO_PROVIDER_ERROR);
|
|
387
|
+
});
|
|
388
|
+
});
|
package/src/auto-transcribe.ts
CHANGED
|
@@ -10,6 +10,35 @@
|
|
|
10
10
|
|
|
11
11
|
import { readGlobalConfig } from "./config.ts";
|
|
12
12
|
import { getCachedScribeUrl } from "./scribe-discovery.ts";
|
|
13
|
+
import { buildTranscriptionSnapshot } from "./transcription-routes.ts";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The name of a local provider that is INSTALLED on this box, or null.
|
|
17
|
+
*
|
|
18
|
+
* Delegates to the same snapshot `transcription status` and the admin SPA
|
|
19
|
+
* answer from, so these surfaces share one implementation and cannot drift.
|
|
20
|
+
*
|
|
21
|
+
* Keyed on `binary.path`, deliberately, NOT on `snap.ready`. Readiness also
|
|
22
|
+
* demands ffmpeg, so it varies box to box in a way this message shouldn't: a
|
|
23
|
+
* machine with the provider installed but ffmpeg missing is still a machine
|
|
24
|
+
* where "no transcription provider configured — set TRANSCRIPTION_PROVIDER to
|
|
25
|
+
* a local provider" is false. `installed` answers the question the sentence
|
|
26
|
+
* actually asks, and answers it the same way everywhere.
|
|
27
|
+
* (Caught by running the real probe rather than the injected one — the unit
|
|
28
|
+
* tests inject `localProviderImpl` and would never have shown the difference.)
|
|
29
|
+
*
|
|
30
|
+
* Errors swallow to null: a broken probe must degrade to the old, flat message,
|
|
31
|
+
* never take down an attachment upload.
|
|
32
|
+
*/
|
|
33
|
+
function installedLocalProvider(): string | null {
|
|
34
|
+
try {
|
|
35
|
+
const snap = buildTranscriptionSnapshot();
|
|
36
|
+
if (snap.provider === "scribe-http") return null;
|
|
37
|
+
return snap.binary.path ? snap.provider : null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
}
|
|
13
42
|
|
|
14
43
|
/**
|
|
15
44
|
* Pre-vault#353 callers passed `transcribe: true` explicitly on the
|
|
@@ -88,7 +117,22 @@ export type AutoTranscribeDecision =
|
|
|
88
117
|
| { kind: "transcribe" }
|
|
89
118
|
| { kind: "not-audio" }
|
|
90
119
|
| { kind: "disabled" }
|
|
91
|
-
|
|
120
|
+
/**
|
|
121
|
+
* `localProvider` names a local provider INSTALLED on this box (its binary
|
|
122
|
+
* resolves), or null when there is nothing local here.
|
|
123
|
+
*
|
|
124
|
+
* Installation, deliberately, and not the configured provider NAME. The name
|
|
125
|
+
* is a trap: `resolveTranscriptionProviderName` returns `whisper-cpp` on a
|
|
126
|
+
* box where nothing at all is set up, because that is the default when no
|
|
127
|
+
* scribe URL resolves. Keying the message off the name would tell a
|
|
128
|
+
* fresh-install operator their whisper-cpp install is present when they have
|
|
129
|
+
* never installed one — trading one false sentence for another.
|
|
130
|
+
*
|
|
131
|
+
* It does NOT change the decision: `unavailable` is `unavailable` either way,
|
|
132
|
+
* because this path resolves a scribe URL and nothing else. It exists purely
|
|
133
|
+
* so the error can be true. See `noProviderErrorFor`.
|
|
134
|
+
*/
|
|
135
|
+
| { kind: "unavailable"; localProvider: string | null };
|
|
92
136
|
|
|
93
137
|
/** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
|
|
94
138
|
export function classifyAutoTranscribe(
|
|
@@ -98,6 +142,12 @@ export function classifyAutoTranscribe(
|
|
|
98
142
|
getCachedScribeUrlImpl?: () => string | undefined;
|
|
99
143
|
perVaultEnabled?: boolean;
|
|
100
144
|
enabledOverride?: boolean;
|
|
145
|
+
/**
|
|
146
|
+
* Injectable, same shape as the two impls above. Returns the name of a
|
|
147
|
+
* local provider installed here, or null. Defaults to the shared
|
|
148
|
+
* transcription snapshot.
|
|
149
|
+
*/
|
|
150
|
+
localProviderImpl?: () => string | null;
|
|
101
151
|
} = {},
|
|
102
152
|
): AutoTranscribeDecision {
|
|
103
153
|
if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
|
|
@@ -109,20 +159,69 @@ export function classifyAutoTranscribe(
|
|
|
109
159
|
?? true;
|
|
110
160
|
if (!enabled) return { kind: "disabled" };
|
|
111
161
|
const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
|
|
112
|
-
if (!url || !url.trim())
|
|
162
|
+
if (!url || !url.trim()) {
|
|
163
|
+
// Still unavailable — this path reads a scribe URL and nothing else, and
|
|
164
|
+
// that is deliberately unchanged here. We only ask whether something local
|
|
165
|
+
// would run, so the message can name the real situation instead of telling
|
|
166
|
+
// an operator to do what they have already done.
|
|
167
|
+
return {
|
|
168
|
+
kind: "unavailable",
|
|
169
|
+
localProvider: (opts.localProviderImpl ?? installedLocalProvider)(),
|
|
170
|
+
};
|
|
171
|
+
}
|
|
113
172
|
return { kind: "transcribe" };
|
|
114
173
|
}
|
|
115
174
|
|
|
116
175
|
/**
|
|
117
176
|
* The `transcribe_error` written when auto-transcribe is on but no provider is
|
|
118
|
-
* reachable
|
|
119
|
-
* two things to do, not just that something
|
|
177
|
+
* reachable AND nothing local is configured. Deliberately actionable — the
|
|
178
|
+
* operator needs to know which of the two things to do, not just that something
|
|
179
|
+
* went wrong.
|
|
180
|
+
*
|
|
181
|
+
* Correct only when the resolved provider is `scribe-http`. On a box that HAS a
|
|
182
|
+
* local provider this sentence is false in the most expensive way — see
|
|
183
|
+
* `noProviderErrorFor`.
|
|
120
184
|
*/
|
|
121
185
|
export const NO_PROVIDER_ERROR =
|
|
122
186
|
"no transcription provider configured — set TRANSCRIPTION_PROVIDER to a local " +
|
|
123
187
|
"provider (see `parachute-vault transcription install`), or point SCRIBE_URL at " +
|
|
124
188
|
"a transcription service";
|
|
125
189
|
|
|
190
|
+
/**
|
|
191
|
+
* The `transcribe_error` for an `unavailable` decision, told truthfully.
|
|
192
|
+
*
|
|
193
|
+
* Found live: a box with `TRANSCRIPTION_PROVIDER=whisper-cpp`, `parakeet-cli` on
|
|
194
|
+
* PATH, the model downloaded, and `[transcribe] worker started → whisper-cpp` in
|
|
195
|
+
* the same boot was writing `NO_PROVIDER_ERROR` onto its attachments — telling
|
|
196
|
+
* the operator to set the variable they had set and install the provider that
|
|
197
|
+
* was already running. Same failure mode as the `transcription status` bug
|
|
198
|
+
* (#643): a diagnostic that lies during the exact task it exists for, and this
|
|
199
|
+
* one sends someone to re-download a 400 MB model to fix nothing.
|
|
200
|
+
*
|
|
201
|
+
* The asymmetry is real and worth naming in the message rather than hiding:
|
|
202
|
+
* `server.ts` starts a local worker with no scribe URL anywhere in the branch,
|
|
203
|
+
* but this path resolves ONLY a scribe URL. So a local provider genuinely does
|
|
204
|
+
* serve explicit `transcribe: true` requests while genuinely being invisible
|
|
205
|
+
* here. An operator who is not told that will reasonably conclude their install
|
|
206
|
+
* is broken.
|
|
207
|
+
*
|
|
208
|
+
* Deliberately NOT a fix for the asymmetry itself — teaching this path to see
|
|
209
|
+
* local providers changes what gets transcribed, which is a behaviour question
|
|
210
|
+
* and not this diff's to answer.
|
|
211
|
+
*/
|
|
212
|
+
export function noProviderErrorFor(localProvider: string | null): string {
|
|
213
|
+
if (!localProvider) return NO_PROVIDER_ERROR;
|
|
214
|
+
return (
|
|
215
|
+
`auto-transcribe found no reachable provider: it resolves only a scribe URL ` +
|
|
216
|
+
`(SCRIBE_URL, or a \`parachute-scribe\` entry in services.json), and neither is set. ` +
|
|
217
|
+
`A local ${localProvider} install IS present here and explicit transcription requests ` +
|
|
218
|
+
`are routed to it — this path does not consult it. Reinstalling ${localProvider} will ` +
|
|
219
|
+
`not change this. To auto-transcribe uploads, point SCRIBE_URL at a transcription ` +
|
|
220
|
+
`service; otherwise request transcription explicitly on the attachment. ` +
|
|
221
|
+
`\`parachute-vault transcription status\` reports the local install's own state.`
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
|
|
126
225
|
/** Throttle for {@link warnNoTranscriptionProvider}. */
|
|
127
226
|
const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
|
|
128
227
|
let lastNoProviderWarnAt = 0;
|
package/src/cli.ts
CHANGED
|
@@ -167,6 +167,8 @@ import {
|
|
|
167
167
|
type TranscribeCppManifest,
|
|
168
168
|
type PythonInstallManifest,
|
|
169
169
|
} from "./transcription/select.ts";
|
|
170
|
+
import { buildTranscriptionSnapshot } from "./transcription-routes.ts";
|
|
171
|
+
import { TRANSCRIPTION_MODELS } from "./transcription/models.ts";
|
|
170
172
|
import {
|
|
171
173
|
buildTranscribeCli,
|
|
172
174
|
cliSourceUrl,
|
|
@@ -3589,6 +3591,32 @@ function printHistoryUsage(): void {
|
|
|
3589
3591
|
* per-provider install state.
|
|
3590
3592
|
*/
|
|
3591
3593
|
async function cmdTranscription(args: string[]) {
|
|
3594
|
+
// Read the config this command REPORTS ON. Every transcription setting lives
|
|
3595
|
+
// in `~/.parachute/vault/.env` (CLAUDE.md, "Conventions"), and the resolvers
|
|
3596
|
+
// underneath — `resolveTranscriptionProviderName`, `resolveScribeUrl`,
|
|
3597
|
+
// `resolveTranscribeCppPaths`, the model lookup — all read `process.env`.
|
|
3598
|
+
// The daemon gets that env because `server.ts` calls `loadEnvFile()` at boot;
|
|
3599
|
+
// a one-shot CLI process does not, so before this line `transcription status`
|
|
3600
|
+
// answered from an EMPTY env and printed the fallback default as though it
|
|
3601
|
+
// were the operator's configuration.
|
|
3602
|
+
//
|
|
3603
|
+
// Three ways that lied on a box whose `.env` set the values (all reproduced
|
|
3604
|
+
// at 7652af4):
|
|
3605
|
+
// - `TRANSCRIPTION_PROVIDER=scribe-http` in the file → reported whisper-cpp
|
|
3606
|
+
// - `TRANSCRIPTION_MODEL=whisper-tiny.en` in the file → reported Parakeet
|
|
3607
|
+
// - `WHISPER_CPP_BIN_DIR=<dir>` in the file → "parakeet-cli not found"
|
|
3608
|
+
// while the binary sat in that very directory
|
|
3609
|
+
//
|
|
3610
|
+
// The failure mode is the nasty one: with nothing configured the fallback
|
|
3611
|
+
// HAPPENS to equal the file, so the command looks correct right up until an
|
|
3612
|
+
// operator changes something — i.e. it lies precisely during the task it
|
|
3613
|
+
// exists for. Loading here (not just in `status`) covers `install` too, whose
|
|
3614
|
+
// binary probing reads the same overrides.
|
|
3615
|
+
//
|
|
3616
|
+
// `loadEnvFile` only fills keys that are `undefined`, so a var passed
|
|
3617
|
+
// explicitly on the command line still wins — same precedence the daemon has.
|
|
3618
|
+
loadEnvFile();
|
|
3619
|
+
|
|
3592
3620
|
const sub = args[0];
|
|
3593
3621
|
if (sub === "install") {
|
|
3594
3622
|
await cmdTranscriptionInstall(args.slice(1));
|
|
@@ -4188,100 +4216,87 @@ function noRunnableCliGuidance(
|
|
|
4188
4216
|
return lines.join("\n");
|
|
4189
4217
|
}
|
|
4190
4218
|
|
|
4191
|
-
/**
|
|
4219
|
+
/**
|
|
4220
|
+
* `parachute-vault transcription status` — is transcription working, and if
|
|
4221
|
+
* not, what do I run.
|
|
4222
|
+
*
|
|
4223
|
+
* Rewritten to answer that in the first line. It used to open with a "tier
|
|
4224
|
+
* default for this host" recommending `parakeet-mlx`, then list three
|
|
4225
|
+
* superseded providers, and only then (after vault#641 taught it the word)
|
|
4226
|
+
* mention whisper-cpp — which is the actual default and the only one
|
|
4227
|
+
* `transcription install` sets up. An operator read a recommendation for
|
|
4228
|
+
* something we no longer ship, above a wall of "not installed" for things they
|
|
4229
|
+
* should never install.
|
|
4230
|
+
*
|
|
4231
|
+
* The legacy providers still WORK if an operator configured one, so they are
|
|
4232
|
+
* not removed — they are demoted. They print only when actually active or
|
|
4233
|
+
* actually installed, which on a normal box means they never print at all.
|
|
4234
|
+
*/
|
|
4192
4235
|
async function cmdTranscriptionStatus(): Promise<void> {
|
|
4193
4236
|
const active = resolveTranscriptionProviderName();
|
|
4194
|
-
|
|
4195
|
-
|
|
4196
|
-
// What the ratified tier table would pick for this host (install default).
|
|
4197
|
-
const tier = selectDefaultProvider({
|
|
4198
|
-
platform: process.platform,
|
|
4199
|
-
arch: process.arch,
|
|
4200
|
-
totalRamBytes: detectTotalRamBytes(),
|
|
4201
|
-
});
|
|
4202
|
-
console.log(
|
|
4203
|
-
`Tier default for this host (${tier.platform}/${tier.arch}, ${tier.totalRamGb}GB): ${tier.provider}${tier.model ? ` (${tier.model})` : ""}`,
|
|
4204
|
-
);
|
|
4237
|
+
const snap = buildTranscriptionSnapshot();
|
|
4205
4238
|
|
|
4206
|
-
//
|
|
4207
|
-
//
|
|
4208
|
-
//
|
|
4209
|
-
//
|
|
4210
|
-
|
|
4211
|
-
|
|
4212
|
-
|
|
4213
|
-
|
|
4214
|
-
|
|
4215
|
-
|
|
4216
|
-
if (!filesPresent) {
|
|
4217
|
-
notRunnableReason = !cliPresent ? "no transcribe-cli binary" : "no GGUF model on disk";
|
|
4218
|
-
} else {
|
|
4219
|
-
const probe = await probeTranscribeCliRunnable(paths.binPath);
|
|
4220
|
-
installed = probe.ok;
|
|
4221
|
-
notRunnableReason = probe.reason;
|
|
4222
|
-
}
|
|
4223
|
-
console.log(`\ntranscribe-cpp runnable: ${installed ? "yes" : `no (${notRunnableReason})`}`);
|
|
4224
|
-
if (manifest) {
|
|
4225
|
-
console.log(` model: ${manifest.model} (${manifest.modelFile})`);
|
|
4226
|
-
console.log(
|
|
4227
|
-
` libs: ${paths.libsDir}${manifest.libFiles?.length ? ` (${manifest.libFiles.length} file(s))` : ""}`,
|
|
4228
|
-
);
|
|
4229
|
-
console.log(
|
|
4230
|
-
cliPresent
|
|
4231
|
-
? ` cli: ${paths.binPath}${manifest.binBuiltFrom ? ` (built from source @ ${manifest.binBuiltFrom.slice(0, 12)})` : ""}`
|
|
4232
|
-
: ` cli: NOT FOUND — re-run \`transcription install\` to build one (needs a C++ compiler), or set TRANSCRIBE_CPP_BIN`,
|
|
4233
|
-
);
|
|
4234
|
-
console.log(` ram: ${manifest.ram_gb}GB at install (${manifest.os}/${manifest.arch})`);
|
|
4239
|
+
// The headline: one line, answering the question that brought them here.
|
|
4240
|
+
//
|
|
4241
|
+
// Deliberately reports READINESS only, never `snap.active`. `active` reads
|
|
4242
|
+
// the in-process transcription worker registry, which a one-shot CLI process
|
|
4243
|
+
// never has — so keying on it would print "ready, but the worker isn't
|
|
4244
|
+
// running yet" on every healthy box. Liveness is a question only the running
|
|
4245
|
+
// daemon can answer, which is why the admin UI (served from that process)
|
|
4246
|
+
// shows it and this command doesn't pretend to.
|
|
4247
|
+
if (snap.ready) {
|
|
4248
|
+
console.log(`Transcription: ready (${active})`);
|
|
4235
4249
|
} else {
|
|
4236
|
-
console.log(`
|
|
4250
|
+
console.log(`Transcription: NOT running (${active})`);
|
|
4251
|
+
if (snap.reason) console.log(` ${snap.reason}`);
|
|
4252
|
+
if (snap.fix_command) console.log(` fix: ${snap.fix_command}`);
|
|
4237
4253
|
}
|
|
4238
4254
|
|
|
4239
|
-
//
|
|
4240
|
-
|
|
4241
|
-
|
|
4242
|
-
|
|
4243
|
-
|
|
4244
|
-
|
|
4245
|
-
|
|
4246
|
-
|
|
4247
|
-
|
|
4248
|
-
|
|
4249
|
-
|
|
4250
|
-
|
|
4251
|
-
|
|
4252
|
-
|
|
4255
|
+
// What's actually on this machine, for the active local provider.
|
|
4256
|
+
if (snap.provider === "whisper-cpp") {
|
|
4257
|
+
console.log("");
|
|
4258
|
+
console.log(` ${snap.binary.name.padEnd(14)} ${snap.binary.path ?? "not found"}`);
|
|
4259
|
+
console.log(` ${"ffmpeg".padEnd(14)} ${snap.ffmpeg.path ?? "not found"}`);
|
|
4260
|
+
if (snap.model) {
|
|
4261
|
+
console.log(
|
|
4262
|
+
` ${"model".padEnd(14)} ${snap.model.label} (${snap.model.size_mb} MB, ${snap.model.installed ? "downloaded" : "not downloaded"})`,
|
|
4263
|
+
);
|
|
4264
|
+
} else {
|
|
4265
|
+
console.log(` ${"model".padEnd(14)} ${snap.model_id} — not in the catalog`);
|
|
4266
|
+
}
|
|
4267
|
+
if (!snap.binary.path) {
|
|
4268
|
+
// The macOS launchd trap: installed but invisible to a supervised vault.
|
|
4269
|
+
console.log(` searched: ${snap.binary.searched.join(", ")}`);
|
|
4270
|
+
console.log(" (installed elsewhere? set WHISPER_CPP_BIN_DIR to that directory)");
|
|
4271
|
+
}
|
|
4253
4272
|
}
|
|
4254
4273
|
|
|
4255
|
-
|
|
4256
|
-
|
|
4257
|
-
|
|
4258
|
-
|
|
4259
|
-
|
|
4260
|
-
|
|
4261
|
-
|
|
4262
|
-
|
|
4263
|
-
|
|
4264
|
-
);
|
|
4274
|
+
// Legacy providers — demoted, and silent unless one is genuinely in play.
|
|
4275
|
+
// Printing "not installed" for three superseded providers on every run
|
|
4276
|
+
// trained operators to skim past the part that mattered.
|
|
4277
|
+
const legacy: string[] = [];
|
|
4278
|
+
const cppPaths = resolveTranscribeCppPaths();
|
|
4279
|
+
if (active === "transcribe-cpp" || transcribeCppInstalled(cppPaths)) {
|
|
4280
|
+
const probe = transcribeCppInstalled(cppPaths)
|
|
4281
|
+
? await probeTranscribeCliRunnable(cppPaths.binPath)
|
|
4282
|
+
: { ok: false, reason: "not installed" };
|
|
4283
|
+
legacy.push(` transcribe-cpp ${probe.ok ? "runnable" : `not runnable (${probe.reason})`}`);
|
|
4265
4284
|
}
|
|
4266
|
-
if (
|
|
4267
|
-
|
|
4268
|
-
` python install: ${pyManifest.provider} (${pyManifest.pipTarget}) @ ${pyManifest.installedAt}${pyManifest.venv ? ` — venv ${pyManifest.venv}` : ""}`,
|
|
4269
|
-
);
|
|
4285
|
+
if (active === "parakeet-mlx" || parakeetMlxInstalled()) {
|
|
4286
|
+
legacy.push(` parakeet-mlx ${parakeetMlxInstalled() ? `runnable (${resolveParakeetMlxBin()})` : "not installed"}`);
|
|
4270
4287
|
}
|
|
4271
|
-
|
|
4272
|
-
|
|
4273
|
-
const activeRunnable =
|
|
4274
|
-
active === "scribe-http" ||
|
|
4275
|
-
(active === "transcribe-cpp" && installed) ||
|
|
4276
|
-
(active === "parakeet-mlx" && pkRunnable) ||
|
|
4277
|
-
(active === "onnx-asr" && oxRunnable);
|
|
4278
|
-
if (!activeRunnable) {
|
|
4279
|
-
console.log(
|
|
4280
|
-
`\n⚠ provider is ${active} but no runnable ${active} install was found — transcription is offline until one is available (\`parachute-vault transcription install\`).`,
|
|
4281
|
-
);
|
|
4288
|
+
if (active === "onnx-asr" || onnxAsrInstalled()) {
|
|
4289
|
+
legacy.push(` onnx-asr ${onnxAsrInstalled() ? `runnable (${resolveOnnxAsrBin()})` : "not installed"}`);
|
|
4282
4290
|
}
|
|
4291
|
+
if (legacy.length > 0) {
|
|
4292
|
+
console.log("\nLegacy providers (superseded by whisper-cpp):");
|
|
4293
|
+
for (const line of legacy) console.log(line);
|
|
4294
|
+
}
|
|
4295
|
+
|
|
4296
|
+
// The model catalog is what `--model` accepts, so it belongs with the
|
|
4297
|
+
// provider that actually uses it.
|
|
4283
4298
|
console.log(
|
|
4284
|
-
`\
|
|
4299
|
+
`\nModels for whisper-cpp (--model): ${TRANSCRIPTION_MODELS.map((m) => m.id).join(", ")}`,
|
|
4285
4300
|
);
|
|
4286
4301
|
}
|
|
4287
4302
|
|
package/src/mcp-tools.ts
CHANGED
|
@@ -883,9 +883,13 @@ function overrideVaultInfo(
|
|
|
883
883
|
* model error), the cap is the backstop. Operators wanting long-lived
|
|
884
884
|
* tokens mint a hub-issued JWT via the hub mint-token flow (the REST
|
|
885
885
|
* /vault/<name>/tokens endpoint was removed with the pvt_* drop, vault#282).
|
|
886
|
+
* The `long_lived: true` opt-in deliberately relaxes this to a 90-day
|
|
887
|
+
* ceiling for standing credentials; the caller's admin JWT could already
|
|
888
|
+
* mint longer directly from the hub, so the flag adds no new authority.
|
|
886
889
|
*/
|
|
887
890
|
const MANAGE_TOKEN_DEFAULT_TTL_SECONDS = 900; // 15 minutes
|
|
888
891
|
const MANAGE_TOKEN_MAX_TTL_SECONDS = 3600; // 1 hour
|
|
892
|
+
const MANAGE_TOKEN_LONG_TTL_MAX_SECONDS = 7776000; // 90 days; opt-in ceiling for `long_lived: true`, matching hub's API_MINT_TOKEN_DEFAULT_TTL_SECONDS
|
|
889
893
|
|
|
890
894
|
/**
|
|
891
895
|
* Resolve the bare hub origin for the mint/revoke proxy calls. Reuses
|
|
@@ -952,8 +956,10 @@ function buildManageTokenTool(
|
|
|
952
956
|
"escalate. Minting requires a hub-JWT session holding 'vault:" + vaultName +
|
|
953
957
|
":admin'. List + revoke are scoped to tokens this session minted; " +
|
|
954
958
|
"CLI/REST-minted tokens are not surfaced here.\n\n" +
|
|
959
|
+
"`long_lived: true` raises the mint TTL cap to 90 days; a 90-day token is a " +
|
|
960
|
+
"standing credential and meaningfully increases blast radius if leaked/misused.\n\n" +
|
|
955
961
|
"Actions (discriminator: `action`):\n" +
|
|
956
|
-
"- `mint` — { scope: string|string[], ttl_seconds?: number, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
|
|
962
|
+
"- `mint` — { scope: string|string[], ttl_seconds?: number, long_lived?: boolean, description?: string } → { action: \"mint\", token, jti, expires_at, scopes, scoped_tags, vault_name } (vault#555: scopes/scoped_tags/vault_name were previously undocumented here)\n" +
|
|
957
963
|
"- `revoke` — { jti: string } → { action: \"revoke\", ok: boolean, already_revoked?: boolean } — idempotent; a jti not in this session's ledger, or already revoked, still returns ok:true. A genuine failure additionally carries error/message (and, for a hub-side rejection, hub_status).\n" +
|
|
958
964
|
"- `list` — (no inputs) → { action: \"list\", tokens: [...] }",
|
|
959
965
|
inputSchema: {
|
|
@@ -974,7 +980,11 @@ function buildManageTokenTool(
|
|
|
974
980
|
},
|
|
975
981
|
ttl_seconds: {
|
|
976
982
|
type: "number",
|
|
977
|
-
description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour). Values outside (0,
|
|
983
|
+
description: `(action=mint) Token lifetime in seconds. Default ${MANAGE_TOKEN_DEFAULT_TTL_SECONDS} (15 min), max ${MANAGE_TOKEN_MAX_TTL_SECONDS} (1 hour); with long_lived=true, max ${MANAGE_TOKEN_LONG_TTL_MAX_SECONDS} (90 days). Values outside the applicable (0, max] range are rejected.`,
|
|
984
|
+
},
|
|
985
|
+
long_lived: {
|
|
986
|
+
type: "boolean",
|
|
987
|
+
description: "(action=mint, optional) Set true to raise the TTL cap from 1 hour to 90 days. A 90-day token is a standing credential: long-lived and meaningfully increases blast radius if leaked/misused.",
|
|
978
988
|
},
|
|
979
989
|
description: {
|
|
980
990
|
type: "string",
|
|
@@ -1107,9 +1117,20 @@ async function mintAction(
|
|
|
1107
1117
|
}
|
|
1108
1118
|
|
|
1109
1119
|
// TTL bounds. Default 900 (15 min); explicit values must satisfy
|
|
1110
|
-
// `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS
|
|
1111
|
-
//
|
|
1112
|
-
//
|
|
1120
|
+
// `0 < ttl <= MANAGE_TOKEN_MAX_TTL_SECONDS`, or
|
|
1121
|
+
// `0 < ttl <= MANAGE_TOKEN_LONG_TTL_MAX_SECONDS` with `long_lived: true`.
|
|
1122
|
+
// Zero, negative, NaN, and beyond-max all reject — the cap is the safety
|
|
1123
|
+
// backstop if revoke fails, so it must be strict.
|
|
1124
|
+
if (params.long_lived !== undefined && typeof params.long_lived !== "boolean") {
|
|
1125
|
+
return {
|
|
1126
|
+
action: "mint",
|
|
1127
|
+
error: "invalid_request",
|
|
1128
|
+
message: "manage-token mint: long_lived must be a boolean.",
|
|
1129
|
+
};
|
|
1130
|
+
}
|
|
1131
|
+
const maxTtl = params.long_lived === true
|
|
1132
|
+
? MANAGE_TOKEN_LONG_TTL_MAX_SECONDS
|
|
1133
|
+
: MANAGE_TOKEN_MAX_TTL_SECONDS;
|
|
1113
1134
|
let ttl = MANAGE_TOKEN_DEFAULT_TTL_SECONDS;
|
|
1114
1135
|
if (params.ttl_seconds !== undefined && params.ttl_seconds !== null) {
|
|
1115
1136
|
if (typeof params.ttl_seconds !== "number" || !Number.isFinite(params.ttl_seconds)) {
|
|
@@ -1119,11 +1140,11 @@ async function mintAction(
|
|
|
1119
1140
|
message: "manage-token mint: ttl_seconds must be a finite number.",
|
|
1120
1141
|
};
|
|
1121
1142
|
}
|
|
1122
|
-
if (params.ttl_seconds <= 0 || params.ttl_seconds >
|
|
1143
|
+
if (params.ttl_seconds <= 0 || params.ttl_seconds > maxTtl) {
|
|
1123
1144
|
return {
|
|
1124
1145
|
action: "mint",
|
|
1125
1146
|
error: "invalid_request",
|
|
1126
|
-
message: `manage-token mint: ttl_seconds must be in (0, ${
|
|
1147
|
+
message: `manage-token mint: ttl_seconds must be in (0, ${maxTtl}]; got ${params.ttl_seconds}.`,
|
|
1127
1148
|
};
|
|
1128
1149
|
}
|
|
1129
1150
|
ttl = params.ttl_seconds;
|