@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.5",
3
+ "version": "0.7.6-rc.2",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -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
- attMeta.transcribe_error = NO_PROVIDER_ERROR;
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
+ });
@@ -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
- | { kind: "unavailable" };
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()) return { kind: "unavailable" };
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. Deliberately actionable the operator needs to know which of the
119
- * two things to do, not just that something went wrong.
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
- /** `parachute-vault transcription status` — provider + per-provider install state. */
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
- console.log(`Configured provider: ${active}`);
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
- // transcribe-cpp prebuilt libs + GGUF model + built CLI. "runnable" is an
4207
- // EXECUTED verdict, not a stat: `--help` must actually exit 0 (vault#534 —
4208
- // an existsSync-only check said "yes" on Linux while the CLI exited 1 on
4209
- // every real transcription because its dlopen'd CPU backends never loaded).
4210
- const paths = resolveTranscribeCppPaths();
4211
- const manifest = readManifest(paths.manifestPath);
4212
- const cliPresent = existsSync(paths.binPath);
4213
- const filesPresent = transcribeCppInstalled(paths); // CLI + model both on disk
4214
- let installed = false;
4215
- let notRunnableReason: string | undefined;
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(` (not installed — run: parachute-vault transcription install --provider transcribe-cpp)`);
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
- // parakeet-mlx / onnx-asr Python venv providers (scribe-fold Phase 2b).
4240
- const pyManifest = readPythonManifest(pythonManifestPath());
4241
- const pkBin = resolveParakeetMlxBin();
4242
- const pkRunnable = parakeetMlxInstalled();
4243
- console.log(`\nparakeet-mlx runnable: ${pkRunnable ? "yes" : "no"}`);
4244
- if (pkRunnable) {
4245
- console.log(` bin: ${pkBin}`);
4246
- console.log(` model: ${resolveParakeetMlxModel()} (HF cache; downloads on first use)`);
4247
- } else {
4248
- console.log(
4249
- process.platform === "darwin" && process.arch === "arm64"
4250
- ? ` (not installed — run: parachute-vault transcription install${tier.provider === "parakeet-mlx" ? "" : " --provider parakeet-mlx"})`
4251
- : " (macOS Apple Silicon only)",
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
- const oxBin = resolveOnnxAsrBin();
4256
- const oxRunnable = onnxAsrInstalled();
4257
- console.log(`onnx-asr runnable: ${oxRunnable ? "yes" : "no"}`);
4258
- if (oxRunnable) {
4259
- console.log(` bin: ${oxBin}`);
4260
- console.log(` model: ${resolveOnnxAsrModel()} (HF cache)`);
4261
- } else {
4262
- console.log(
4263
- ` (not installed — run: parachute-vault transcription install${tier.provider === "onnx-asr" ? "" : " --provider onnx-asr"})`,
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 (pyManifest) {
4267
- console.log(
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
- // Offline warning when the ACTIVE provider isn't runnable.
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
- `\nAvailable transcribe-cpp models (override with --model): ${Object.values(MODELS).map((m: ModelChoice) => m.name).join(", ")}`,
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, ${MANAGE_TOKEN_MAX_TTL_SECONDS}] are rejected.`,
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`. Zero, negative, NaN, and
1111
- // beyond-max all reject the cap is the safety backstop if revoke fails,
1112
- // so it must be strict.
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 > MANAGE_TOKEN_MAX_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, ${MANAGE_TOKEN_MAX_TTL_SECONDS}]; got ${params.ttl_seconds}.`,
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;