@openparachute/vault 0.7.5-rc.5 → 0.7.5-rc.7

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-rc.5",
3
+ "version": "0.7.5-rc.7",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
@@ -270,6 +270,72 @@ describe("attachment tickets — upload lifecycle", () => {
270
270
  expect((updatedNote!.metadata as any)?.transcribe_stub).toBe(true);
271
271
  });
272
272
 
273
+ // vault#643 — the fresh-install case. Auto-transcribe is ON by default and
274
+ // no provider is reachable (SCRIBE_URL unset, no scribe row), which is
275
+ // exactly what a new box looks like. The upload must not come back looking
276
+ // like an ordinary file: it carries a `failed` status and an actionable
277
+ // reason, so the state is visible in the API and the admin SPA.
278
+ test("audio + auto-transcribe on + NO provider → attachment records the failure, not silence", async () => {
279
+ const vaultName = freshVault("tickets-no-provider");
280
+ const store = getVaultStore(vaultName);
281
+ const note = await store.createNote("# Voice memo\n", { path: "memo-noprov" });
282
+
283
+ const mint = await callTool(vaultName, "request-attachment-upload", {
284
+ note: note.id,
285
+ filename: "memo.webm",
286
+ size_bytes: 4,
287
+ // NO `transcribe: true` — this is the AUTO path, the one that used to
288
+ // silently do nothing.
289
+ });
290
+ const res = await routeReq(
291
+ new Request(mint.url, {
292
+ method: "PUT",
293
+ headers: { "content-type": "audio/webm" },
294
+ body: new Uint8Array([1, 2, 3, 4]),
295
+ }),
296
+ );
297
+ expect(res.status).toBe(201);
298
+ const attachment = (await res.json()) as any;
299
+ expect(attachment.metadata.transcribe_status).toBe("failed");
300
+ expect(attachment.metadata.transcribe_error).toMatch(/no transcription provider configured/i);
301
+ // The reason has to be actionable — naming both routes out of it.
302
+ expect(attachment.metadata.transcribe_error).toMatch(/TRANSCRIPTION_PROVIDER/);
303
+ expect(attachment.metadata.transcribe_error).toMatch(/SCRIBE_URL/);
304
+ expect(attachment.metadata.transcribe_origin).toBe("auto");
305
+ });
306
+
307
+ // The counter-case: turning auto-transcribe OFF must stay silent. The
308
+ // operator asked for nothing to happen, so nothing happening is correct and
309
+ // must not be dressed up as a failure.
310
+ test("audio + auto-transcribe explicitly OFF → no transcribe metadata at all", async () => {
311
+ const vaultName = freshVault("tickets-transcribe-off");
312
+ const store = getVaultStore(vaultName);
313
+ writeVaultConfig({
314
+ name: vaultName,
315
+ api_keys: [],
316
+ created_at: new Date().toISOString(),
317
+ auto_transcribe: { enabled: false },
318
+ } as never);
319
+ const note = await store.createNote("# Voice memo\n", { path: "memo-off" });
320
+
321
+ const mint = await callTool(vaultName, "request-attachment-upload", {
322
+ note: note.id,
323
+ filename: "memo.webm",
324
+ size_bytes: 4,
325
+ });
326
+ const res = await routeReq(
327
+ new Request(mint.url, {
328
+ method: "PUT",
329
+ headers: { "content-type": "audio/webm" },
330
+ body: new Uint8Array([1, 2, 3, 4]),
331
+ }),
332
+ );
333
+ expect(res.status).toBe(201);
334
+ const attachment = (await res.json()) as any;
335
+ expect(attachment.metadata.transcribe_status).toBeUndefined();
336
+ expect(attachment.metadata.transcribe_error).toBeUndefined();
337
+ });
338
+
273
339
  test("segment_index (voice W2): a valid integer >= 0 rides ticket mint through to the attachment row", async () => {
274
340
  const vaultName = freshVault("tickets-segment");
275
341
  const store = getVaultStore(vaultName);
@@ -22,7 +22,11 @@ import type { Store } from "../core/src/types.ts";
22
22
  import type { AttachmentTicket, AttachmentTicketProvider } from "../core/src/attachment/tickets.ts";
23
23
  import { sanitizeAttachmentExtension } from "../core/src/attachment/policy.ts";
24
24
  import { assetsDir, readVaultConfig } from "./config.ts";
25
- import { shouldAutoTranscribe } from "./auto-transcribe.ts";
25
+ import {
26
+ NO_PROVIDER_ERROR,
27
+ classifyAutoTranscribe,
28
+ warnNoTranscriptionProvider,
29
+ } from "./auto-transcribe.ts";
26
30
  import { invalidateUsageCache } from "./usage.ts";
27
31
 
28
32
  function json(data: unknown, status = 200): Response {
@@ -265,7 +269,13 @@ async function handleUploadSpend(
265
269
  // auto-transcribe toggle.
266
270
  const explicitOptIn = ticket.transcribe === true;
267
271
  const perVaultEnabled = readVaultConfig(vaultName)?.auto_transcribe?.enabled;
268
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(ticket.mimeType, { perVaultEnabled });
272
+ const autoDecision = explicitOptIn
273
+ ? ({ kind: "transcribe" } as const)
274
+ : classifyAutoTranscribe(ticket.mimeType, { perVaultEnabled });
275
+ const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
276
+ // vault#643 — same honesty as the REST path: enabled-but-unconfigured is a
277
+ // misconfiguration, not a silent no-op.
278
+ const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
269
279
  const attMeta: Record<string, unknown> = {
270
280
  original_name: ticket.filename,
271
281
  size: buffer.length,
@@ -280,6 +290,15 @@ async function handleUploadSpend(
280
290
  if (ticket.segmentIndex !== undefined) {
281
291
  attMeta.segment_index = ticket.segmentIndex;
282
292
  }
293
+ } else if (transcribeUnavailable) {
294
+ attMeta.transcribe_status = "failed";
295
+ attMeta.transcribe_error = NO_PROVIDER_ERROR;
296
+ attMeta.transcribe_requested_at = new Date().toISOString();
297
+ attMeta.transcribe_origin = "auto";
298
+ if (ticket.segmentIndex !== undefined) {
299
+ attMeta.segment_index = ticket.segmentIndex;
300
+ }
301
+ warnNoTranscriptionProvider();
283
302
  }
284
303
 
285
304
  const attachment = await store.addAttachment(ticket.noteId, relativePath, ticket.mimeType, attMeta);
@@ -6,7 +6,12 @@
6
6
  */
7
7
 
8
8
  import { describe, test, expect } from "bun:test";
9
- import { shouldAutoTranscribe } from "./auto-transcribe.ts";
9
+ import {
10
+ _resetNoProviderWarnForTest,
11
+ classifyAutoTranscribe,
12
+ shouldAutoTranscribe,
13
+ warnNoTranscriptionProvider,
14
+ } from "./auto-transcribe.ts";
10
15
 
11
16
  function readGlobalConfig(enabled: boolean | undefined) {
12
17
  return () => ({
@@ -170,3 +175,109 @@ describe("shouldAutoTranscribe", () => {
170
175
  });
171
176
  });
172
177
  });
178
+
179
+ // ---------------------------------------------------------------------------
180
+ // vault#643 — "disabled" and "unavailable" are not the same answer.
181
+ //
182
+ // `shouldAutoTranscribe` collapsed both into `false`, so a box with
183
+ // auto-transcribe ON and no reachable provider accepted audio, transcribed
184
+ // nothing, wrote no marker, and logged nothing. The attachment was
185
+ // indistinguishable from a plain upload.
186
+ //
187
+ // That is the DEFAULT state of a fresh install: the provider resolves to
188
+ // `scribe-http`, nothing sets SCRIBE_URL, and voice memos silently never
189
+ // transcribe. The hosted door already marks a terminal state rather than
190
+ // skipping quietly (workers/vault/src/vault-do.ts — "voice not enabled for
191
+ // this plan" / "monthly voice limit reached"); this is the self-host twin.
192
+ // ---------------------------------------------------------------------------
193
+ describe("classifyAutoTranscribe (vault#643)", () => {
194
+ const audio = "audio/webm";
195
+ const withProvider = () => "http://127.0.0.1:1943";
196
+ const noProvider = () => undefined;
197
+
198
+ test("non-audio → not-audio, whatever else is true", () => {
199
+ expect(
200
+ classifyAutoTranscribe("image/png", { getCachedScribeUrlImpl: withProvider }).kind,
201
+ ).toBe("not-audio");
202
+ });
203
+
204
+ test("operator turned it off → disabled (silence is CORRECT here)", () => {
205
+ expect(
206
+ classifyAutoTranscribe(audio, {
207
+ perVaultEnabled: false,
208
+ getCachedScribeUrlImpl: withProvider,
209
+ }).kind,
210
+ ).toBe("disabled");
211
+ });
212
+
213
+ test("enabled + no provider → unavailable, NOT disabled — the whole point", () => {
214
+ expect(
215
+ classifyAutoTranscribe(audio, {
216
+ perVaultEnabled: true,
217
+ getCachedScribeUrlImpl: noProvider,
218
+ }).kind,
219
+ ).toBe("unavailable");
220
+ });
221
+
222
+ test("a blank provider URL is unavailable, not a provider", () => {
223
+ expect(
224
+ classifyAutoTranscribe(audio, {
225
+ perVaultEnabled: true,
226
+ getCachedScribeUrlImpl: () => " ",
227
+ }).kind,
228
+ ).toBe("unavailable");
229
+ });
230
+
231
+ test("the default (no toggle set) with no provider is unavailable — the fresh-install case", () => {
232
+ expect(
233
+ classifyAutoTranscribe(audio, {
234
+ readGlobalConfigImpl: (() => ({})) as never,
235
+ getCachedScribeUrlImpl: noProvider,
236
+ }).kind,
237
+ ).toBe("unavailable");
238
+ });
239
+
240
+ test("enabled + provider → transcribe", () => {
241
+ expect(
242
+ classifyAutoTranscribe(audio, {
243
+ perVaultEnabled: true,
244
+ getCachedScribeUrlImpl: withProvider,
245
+ }).kind,
246
+ ).toBe("transcribe");
247
+ });
248
+
249
+ test("shouldAutoTranscribe stays a faithful boolean view of the classifier", () => {
250
+ for (const opts of [
251
+ { perVaultEnabled: false, getCachedScribeUrlImpl: withProvider },
252
+ { perVaultEnabled: true, getCachedScribeUrlImpl: noProvider },
253
+ { perVaultEnabled: true, getCachedScribeUrlImpl: withProvider },
254
+ ]) {
255
+ expect(shouldAutoTranscribe(audio, opts)).toBe(
256
+ classifyAutoTranscribe(audio, opts).kind === "transcribe",
257
+ );
258
+ }
259
+ });
260
+ });
261
+
262
+ describe("warnNoTranscriptionProvider throttle (vault#643)", () => {
263
+ test("warns once per window — a bulk import gets one line, not a hundred", () => {
264
+ _resetNoProviderWarnForTest();
265
+ const seen: string[] = [];
266
+ const orig = console.warn;
267
+ console.warn = (...args: unknown[]) => seen.push(String(args[0]));
268
+ try {
269
+ let now = 1_000_000;
270
+ const clock = () => now;
271
+ for (let i = 0; i < 50; i++) warnNoTranscriptionProvider(clock);
272
+ expect(seen.length).toBe(1);
273
+ expect(seen[0]).toMatch(/no transcription provider configured/i);
274
+ // Past the window → one more.
275
+ now += 61_000;
276
+ warnNoTranscriptionProvider(clock);
277
+ expect(seen.length).toBe(2);
278
+ } finally {
279
+ console.warn = orig;
280
+ _resetNoProviderWarnForTest();
281
+ }
282
+ });
283
+ });
@@ -59,12 +59,90 @@ export function shouldAutoTranscribe(
59
59
  if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
60
60
  return false;
61
61
  }
62
+ return classifyAutoTranscribe(mimeType, opts).kind === "transcribe";
63
+ }
64
+
65
+ /**
66
+ * Why an audio attachment is (or isn't) being transcribed. The three outcomes
67
+ * are NOT interchangeable, which is the whole point of this type:
68
+ *
69
+ * - `transcribe` — enqueue it.
70
+ * - `disabled` — the operator turned auto-transcribe off. Silence is
71
+ * correct: they asked for nothing to happen.
72
+ * - `unavailable` — auto-transcribe is ON, but no provider is reachable.
73
+ * This is a MISCONFIGURATION, and silence is wrong.
74
+ *
75
+ * vault#643: `shouldAutoTranscribe` collapsed the last two into `false`, so a
76
+ * box with transcription enabled and no reachable provider accepted audio,
77
+ * transcribed nothing, wrote no marker, logged nothing, and left an attachment
78
+ * indistinguishable from a plain upload. Observed on a fresh install: the
79
+ * provider resolves to `scribe-http` by default, nothing sets `SCRIBE_URL`, and
80
+ * voice memos silently never transcribe.
81
+ *
82
+ * The hosted door already gets this right — `workers/vault/src/vault-do.ts`
83
+ * marks a terminal state ("voice not enabled for this plan", "monthly voice
84
+ * limit reached") rather than skipping quietly, precisely so the operator never
85
+ * faces an eternal spinner. This brings self-host to the same posture.
86
+ */
87
+ export type AutoTranscribeDecision =
88
+ | { kind: "transcribe" }
89
+ | { kind: "not-audio" }
90
+ | { kind: "disabled" }
91
+ | { kind: "unavailable" };
92
+
93
+ /** The full decision behind `shouldAutoTranscribe`. Same inputs, more answer. */
94
+ export function classifyAutoTranscribe(
95
+ mimeType: string,
96
+ opts: {
97
+ readGlobalConfigImpl?: typeof readGlobalConfig;
98
+ getCachedScribeUrlImpl?: () => string | undefined;
99
+ perVaultEnabled?: boolean;
100
+ enabledOverride?: boolean;
101
+ } = {},
102
+ ): AutoTranscribeDecision {
103
+ if (typeof mimeType !== "string" || !mimeType.toLowerCase().startsWith("audio/")) {
104
+ return { kind: "not-audio" };
105
+ }
62
106
  const enabled = opts.enabledOverride
63
107
  ?? opts.perVaultEnabled
64
108
  ?? (opts.readGlobalConfigImpl ?? readGlobalConfig)().auto_transcribe?.enabled
65
109
  ?? true;
66
- if (!enabled) return false;
110
+ if (!enabled) return { kind: "disabled" };
67
111
  const url = (opts.getCachedScribeUrlImpl ?? getCachedScribeUrl)();
68
- if (!url || !url.trim()) return false;
69
- return true;
112
+ if (!url || !url.trim()) return { kind: "unavailable" };
113
+ return { kind: "transcribe" };
114
+ }
115
+
116
+ /**
117
+ * 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.
120
+ */
121
+ export const NO_PROVIDER_ERROR =
122
+ "no transcription provider configured — set TRANSCRIPTION_PROVIDER to a local " +
123
+ "provider (see `parachute-vault transcription install`), or point SCRIBE_URL at " +
124
+ "a transcription service";
125
+
126
+ /** Throttle for {@link warnNoTranscriptionProvider}. */
127
+ const NO_PROVIDER_WARN_INTERVAL_MS = 60_000;
128
+ let lastNoProviderWarnAt = 0;
129
+
130
+ /**
131
+ * Warn (at most once a minute) that audio is arriving with nowhere to send it.
132
+ *
133
+ * Throttled because it fires per-upload: a bulk import of a hundred voice
134
+ * memos should produce one actionable line, not a hundred. Silence was the old
135
+ * behaviour and it is what made this invisible — a box can accept audio for
136
+ * months and never say that transcription isn't wired up.
137
+ */
138
+ export function warnNoTranscriptionProvider(now: () => number = Date.now): void {
139
+ const t = now();
140
+ if (t - lastNoProviderWarnAt < NO_PROVIDER_WARN_INTERVAL_MS) return;
141
+ lastNoProviderWarnAt = t;
142
+ console.warn(`[transcribe] audio attachment accepted but ${NO_PROVIDER_ERROR}.`);
143
+ }
144
+
145
+ /** Test seam: forget the throttle window. */
146
+ export function _resetNoProviderWarnForTest(): void {
147
+ lastNoProviderWarnAt = 0;
70
148
  }
package/src/routes.ts CHANGED
@@ -145,7 +145,12 @@ import {
145
145
  import { join, extname, normalize } from "path";
146
146
  import { existsSync, mkdirSync, statSync, unlinkSync, writeFileSync } from "fs";
147
147
  import { assetsDir, readGlobalConfig, readVaultConfig } from "./config.ts";
148
- import { shouldAutoTranscribe } from "./auto-transcribe.ts";
148
+ import {
149
+ NO_PROVIDER_ERROR,
150
+ classifyAutoTranscribe,
151
+ shouldAutoTranscribe,
152
+ warnNoTranscriptionProvider,
153
+ } from "./auto-transcribe.ts";
149
154
  // usage.ts imports `assetsDir` from config.ts (neutral ground), so this import
150
155
  // of invalidateUsageCache does NOT form a cycle — routes.ts → usage.ts only.
151
156
  import { invalidateUsageCache } from "./usage.ts";
@@ -2369,7 +2374,18 @@ async function handleNotesInner(
2369
2374
  const perVaultEnabled = vault
2370
2375
  ? readVaultConfig(vault)?.auto_transcribe?.enabled
2371
2376
  : undefined;
2372
- const autoOptIn = !explicitOptIn && shouldAutoTranscribe(body.mimeType, { perVaultEnabled });
2377
+ // vault#643: classify rather than collapse to a boolean. "Operator
2378
+ // turned it off" and "nothing is configured to do it" both used to read
2379
+ // as `false`, so a misconfigured box silently accepted audio and
2380
+ // transcribed nothing — no marker, no status, no log.
2381
+ const autoDecision = explicitOptIn
2382
+ ? ({ kind: "transcribe" } as const)
2383
+ : classifyAutoTranscribe(body.mimeType, { perVaultEnabled });
2384
+ const autoOptIn = !explicitOptIn && autoDecision.kind === "transcribe";
2385
+ // Enabled, audio, but no reachable provider. Record it on the attachment
2386
+ // so the state is visible in the API and the admin SPA instead of the
2387
+ // upload looking like an ordinary file.
2388
+ const transcribeUnavailable = !explicitOptIn && autoDecision.kind === "unavailable";
2373
2389
  // Per-segment slots (voice W2, cloud twin: workers/vault/src/rest/notes.ts
2374
2390
  // ~791-800): an optional `segment_index` (integer >= 0) lets one recording
2375
2391
  // split across several attachments on ONE note, each resolving into its
@@ -2385,7 +2401,16 @@ async function handleNotesInner(
2385
2401
  transcribe_origin: (explicitOptIn ? "legacy" : "auto") as "legacy" | "auto",
2386
2402
  ...(validSegment ? { segment_index: segIdx } : {}),
2387
2403
  }
2388
- : undefined;
2404
+ : transcribeUnavailable
2405
+ ? {
2406
+ transcribe_status: "failed" as const,
2407
+ transcribe_error: NO_PROVIDER_ERROR,
2408
+ transcribe_requested_at: new Date().toISOString(),
2409
+ transcribe_origin: "auto" as const,
2410
+ ...(validSegment ? { segment_index: segIdx } : {}),
2411
+ }
2412
+ : undefined;
2413
+ if (transcribeUnavailable) warnNoTranscriptionProvider();
2389
2414
 
2390
2415
  const attachment = await store.addAttachment(note.id, body.path, body.mimeType, attMeta);
2391
2416
 
package/src/server.ts CHANGED
@@ -279,7 +279,20 @@ if (providerName === "whisper-cpp") {
279
279
  wireTranscriptionWorker(transcriptionWorker);
280
280
  console.log(`[transcribe] worker started → ${scribeUrl}`);
281
281
  } else {
282
- console.log("[transcribe] worker disabled (no scribe in services.json and SCRIBE_URL unset)");
282
+ // vault#643: was a console.log reading "worker disabled", which states a
283
+ // fact about the worker and says nothing about the CONSEQUENCE. On a fresh
284
+ // box the provider resolves to `scribe-http` by default and nothing sets
285
+ // SCRIBE_URL, so this is the DEFAULT state — and audio silently never
286
+ // transcribes. Name the consequence, and warn rather than log, because the
287
+ // box is misconfigured for a feature that is on by default.
288
+ console.warn(
289
+ "[transcribe] NO transcription provider is reachable — audio attachments " +
290
+ "will be accepted but never transcribed. " +
291
+ `Provider resolved to "${providerName}". Fix with either: ` +
292
+ "`parachute-vault transcription install` + TRANSCRIPTION_PROVIDER=<local provider>, " +
293
+ "or point SCRIBE_URL at a transcription service. " +
294
+ "Set `auto_transcribe.enabled: false` to silence this if you don't want transcription.",
295
+ );
283
296
  }
284
297
  }
285
298
 
package/src/vault.test.ts CHANGED
@@ -2877,7 +2877,12 @@ describe("HTTP /notes", async () => {
2877
2877
  expect((note!.metadata as any)?.transcribe_stub).toBe(true);
2878
2878
  });
2879
2879
 
2880
- test("transcribe: false (default) leaves metadata empty and note untouched", async () => {
2880
+ // vault#643: audio with no explicit `transcribe` flag takes the AUTO path.
2881
+ // In this suite no provider is reachable, so the attachment is no longer
2882
+ // left looking like an ordinary upload — it records WHY nothing happened.
2883
+ // It is still not `pending` (nothing was enqueued) and the note is still
2884
+ // untouched (no stub, since the caller never asked for one).
2885
+ test("audio with no flag + no provider records the failure; note untouched", async () => {
2881
2886
  await store.createNote("note body", { id: "v2" });
2882
2887
  const res = await handleNotes(
2883
2888
  mkReq("POST", "/notes/v2/attachments", {
@@ -2889,12 +2894,29 @@ describe("HTTP /notes", async () => {
2889
2894
  );
2890
2895
  expect(res.status).toBe(201);
2891
2896
  const att = await res.json() as any;
2892
- expect(att.metadata?.transcribe_status).toBeUndefined();
2897
+ expect(att.metadata?.transcribe_status).toBe("failed");
2898
+ expect(att.metadata?.transcribe_error).toMatch(/no transcription provider configured/i);
2893
2899
 
2894
2900
  const note = await store.getNote("v2");
2895
2901
  expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
2896
2902
  });
2897
2903
 
2904
+ test("NON-audio with no flag still leaves metadata completely empty", async () => {
2905
+ await store.createNote("note body", { id: "v2b" });
2906
+ const res = await handleNotes(
2907
+ mkReq("POST", "/notes/v2b/attachments", {
2908
+ path: "docs/spec.pdf",
2909
+ mimeType: "application/pdf",
2910
+ }),
2911
+ store,
2912
+ "/v2b/attachments",
2913
+ );
2914
+ expect(res.status).toBe(201);
2915
+ const att = await res.json() as any;
2916
+ expect(att.metadata?.transcribe_status).toBeUndefined();
2917
+ expect(att.metadata?.transcribe_error).toBeUndefined();
2918
+ });
2919
+
2898
2920
  test("transcribe: true preserves other note metadata", async () => {
2899
2921
  await store.createNote("body", { id: "v3", metadata: { summary: "keep me" } });
2900
2922
  await handleNotes(