@openparachute/vault 0.4.7-rc.1 → 0.4.8-rc.4

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.
Files changed (42) hide show
  1. package/README.md +44 -10
  2. package/core/src/connection-pragmas.test.ts +232 -0
  3. package/core/src/core.test.ts +257 -0
  4. package/core/src/cursor.test.ts +160 -0
  5. package/core/src/cursor.ts +272 -0
  6. package/core/src/mcp.ts +51 -7
  7. package/core/src/notes.ts +164 -2
  8. package/core/src/portable-md.test.ts +247 -0
  9. package/core/src/portable-md.ts +118 -1
  10. package/core/src/schema.ts +98 -2
  11. package/core/src/store.ts +11 -1
  12. package/core/src/types.ts +32 -0
  13. package/package.json +1 -1
  14. package/src/auth-status.ts +4 -0
  15. package/src/auto-transcribe.test.ts +116 -0
  16. package/src/auto-transcribe.ts +48 -0
  17. package/src/cli.ts +151 -50
  18. package/src/config.test.ts +26 -0
  19. package/src/config.ts +53 -1
  20. package/src/db.ts +15 -2
  21. package/src/export-watch.test.ts +99 -0
  22. package/src/mcp-install-interactive.test.ts +23 -2
  23. package/src/mcp-install-interactive.ts +21 -2
  24. package/src/mcp-install.test.ts +40 -0
  25. package/src/mcp-tools.ts +17 -1
  26. package/src/module-config.ts +70 -14
  27. package/src/module-manifest.test.ts +93 -0
  28. package/src/module-manifest.ts +94 -0
  29. package/src/routes.ts +267 -50
  30. package/src/scribe-discovery.test.ts +77 -0
  31. package/src/scribe-discovery.ts +91 -0
  32. package/src/scribe-env.test.ts +66 -1
  33. package/src/scribe-env.ts +42 -1
  34. package/src/self-register.test.ts +380 -0
  35. package/src/self-register.ts +234 -0
  36. package/src/server.ts +46 -11
  37. package/src/transcript-note.test.ts +171 -0
  38. package/src/transcript-note.ts +189 -0
  39. package/src/transcription-registry.ts +22 -0
  40. package/src/transcription-worker.test.ts +250 -0
  41. package/src/transcription-worker.ts +186 -27
  42. package/src/vault.test.ts +347 -0
@@ -867,3 +867,253 @@ describe("transcription worker — hook-driven", () => {
867
867
  }
868
868
  });
869
869
  });
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // vault#353 — auto-origin path: materialize <attachment-path>.transcript.md
873
+ // notes on success AND on terminal failure (so the retry endpoint has a
874
+ // surface to act on). The legacy `transcribe_stub`-patching path remains
875
+ // unchanged; these tests pin the new behavior for attachments stamped with
876
+ // `transcribe_origin: "auto"`.
877
+ // ---------------------------------------------------------------------------
878
+
879
+ describe("transcription worker — auto-origin (vault#353)", () => {
880
+ test("success: materializes <path>.transcript with frontmatter + body", async () => {
881
+ const owner = await store.createNote("# Voice memo\n", { id: "auto-1" });
882
+ seedAudio("memos/auto-1.webm");
883
+ await store.addAttachment(owner.id, "memos/auto-1.webm", "audio/webm", {
884
+ transcribe_status: "pending",
885
+ transcribe_origin: "auto",
886
+ });
887
+
888
+ const worker = makeWorker({
889
+ fetchImpl: mkFetchMock([{ text: "this is the spoken text" }]),
890
+ });
891
+ try {
892
+ const processed = await worker.tick();
893
+ expect(processed).toBe(1);
894
+ } finally {
895
+ await worker.stop();
896
+ }
897
+
898
+ // The transcript note exists, with the expected shape.
899
+ const transcript = await store.getNoteByPath("memos/auto-1.webm.transcript");
900
+ expect(transcript).not.toBeNull();
901
+ expect(transcript!.content).toBe("this is the spoken text");
902
+ expect(transcript!.tags).toContain("transcript");
903
+ expect((transcript!.metadata as any)?.transcript_status).toBe("complete");
904
+ expect((transcript!.metadata as any)?.transcript_of).toBe("memos/auto-1.webm");
905
+ expect(typeof (transcript!.metadata as any)?.transcript_duration_ms).toBe("number");
906
+
907
+ // Source note is NOT touched (no stub patching on auto-origin).
908
+ const sourceNote = await store.getNote("auto-1");
909
+ expect(sourceNote!.content).toBe("# Voice memo\n");
910
+
911
+ // Attachment row also reflects success — sweep + retry hit the same row.
912
+ const [att] = await store.getAttachments(owner.id);
913
+ expect(att.metadata?.transcribe_status).toBe("done");
914
+ });
915
+
916
+ test("missing_provider 400: terminal failure on first try → failed transcript note", async () => {
917
+ const owner = await store.createNote("# Voice memo\n", { id: "auto-mp" });
918
+ seedAudio("memos/auto-mp.webm");
919
+ await store.addAttachment(owner.id, "memos/auto-mp.webm", "audio/webm", {
920
+ transcribe_status: "pending",
921
+ transcribe_origin: "auto",
922
+ });
923
+
924
+ // Custom fetchImpl that returns the structured 400 missing_provider
925
+ // payload (scribe#47 shape).
926
+ const fetchImpl: typeof fetch = (async () => {
927
+ return new Response(
928
+ JSON.stringify({
929
+ error: "no transcription provider configured",
930
+ error_code: "missing_provider",
931
+ }),
932
+ { status: 400, headers: { "content-type": "application/json" } },
933
+ );
934
+ }) as typeof fetch;
935
+
936
+ const worker = makeWorker({ fetchImpl, maxAttempts: 5 });
937
+ try {
938
+ await worker.tick();
939
+ } finally {
940
+ await worker.stop();
941
+ }
942
+
943
+ const transcript = await store.getNoteByPath("memos/auto-mp.webm.transcript");
944
+ expect(transcript).not.toBeNull();
945
+ expect(transcript!.content).toBe("");
946
+ expect((transcript!.metadata as any)?.transcript_status).toBe("failed");
947
+ expect((transcript!.metadata as any)?.transcript_error).toContain("missing_provider");
948
+
949
+ // 4xx is terminal — attempts tracked but status went straight to failed,
950
+ // not parked in pending with backoff.
951
+ const [att] = await store.getAttachments(owner.id);
952
+ expect(att.metadata?.transcribe_status).toBe("failed");
953
+ expect((att.metadata as any)?.transcribe_error_code).toBe("missing_provider");
954
+ });
955
+
956
+ test("5xx timeout: retries with backoff (NOT terminal on first failure)", async () => {
957
+ const owner = await store.createNote("# Voice memo\n", { id: "auto-503" });
958
+ seedAudio("memos/auto-503.webm");
959
+ await store.addAttachment(owner.id, "memos/auto-503.webm", "audio/webm", {
960
+ transcribe_status: "pending",
961
+ transcribe_origin: "auto",
962
+ });
963
+
964
+ const worker = makeWorker({
965
+ fetchImpl: mkFetchMock([{ error: "upstream timeout", status: 503 }]),
966
+ maxAttempts: 3,
967
+ });
968
+ try {
969
+ await worker.tick();
970
+ } finally {
971
+ await worker.stop();
972
+ }
973
+
974
+ // 5xx is retriable — attachment goes back to pending with backoff.
975
+ // The transcript note is NOT materialized yet (the failure isn't terminal
976
+ // and we don't want to surface intermediate failures to the user).
977
+ const [att] = await store.getAttachments(owner.id);
978
+ expect(att.metadata?.transcribe_status).toBe("pending");
979
+ expect(att.metadata?.transcribe_backoff_until).toBeTruthy();
980
+
981
+ const transcript = await store.getNoteByPath("memos/auto-503.webm.transcript");
982
+ expect(transcript).toBeNull();
983
+ });
984
+
985
+ test("audio gone: terminal failure → failed transcript note materialized", async () => {
986
+ const owner = await store.createNote("# Voice memo\n", { id: "auto-gone" });
987
+ // No seedAudio call — the file is missing.
988
+ await store.addAttachment(owner.id, "memos/auto-gone.webm", "audio/webm", {
989
+ transcribe_status: "pending",
990
+ transcribe_origin: "auto",
991
+ });
992
+
993
+ const worker = makeWorker({
994
+ fetchImpl: mkFetchMock([{ text: "should never run" }]),
995
+ });
996
+ try {
997
+ await worker.tick();
998
+ } finally {
999
+ await worker.stop();
1000
+ }
1001
+
1002
+ const transcript = await store.getNoteByPath("memos/auto-gone.webm.transcript");
1003
+ expect(transcript).not.toBeNull();
1004
+ expect((transcript!.metadata as any)?.transcript_status).toBe("failed");
1005
+ expect((transcript!.metadata as any)?.transcript_error).toBe("audio file not found");
1006
+ });
1007
+
1008
+ test("legacy stub flow unchanged: no transcript note materialized for transcribe_stub-only", async () => {
1009
+ await store.createNote(
1010
+ "# Voice\n\n_Transcript pending._\n",
1011
+ { id: "legacy-1", metadata: { transcribe_stub: true } },
1012
+ );
1013
+ seedAudio("memos/legacy-1.webm");
1014
+ await store.addAttachment("legacy-1", "memos/legacy-1.webm", "audio/webm", {
1015
+ transcribe_status: "pending",
1016
+ // Legacy path: NO transcribe_origin: "auto".
1017
+ });
1018
+
1019
+ const worker = makeWorker({
1020
+ fetchImpl: mkFetchMock([{ text: "stub-patched" }]),
1021
+ });
1022
+ try {
1023
+ await worker.tick();
1024
+ } finally {
1025
+ await worker.stop();
1026
+ }
1027
+
1028
+ // The note body was patched in place (legacy behavior).
1029
+ const note = await store.getNote("legacy-1");
1030
+ expect(note!.content).toBe("# Voice\n\nstub-patched\n");
1031
+
1032
+ // No transcript note was created.
1033
+ const transcript = await store.getNoteByPath("memos/legacy-1.webm.transcript");
1034
+ expect(transcript).toBeNull();
1035
+ });
1036
+
1037
+ test("retry path: failed transcript is overwritten in place on success", async () => {
1038
+ const owner = await store.createNote("# Voice memo\n", { id: "auto-retry" });
1039
+ seedAudio("memos/auto-retry.webm");
1040
+ await store.addAttachment(owner.id, "memos/auto-retry.webm", "audio/webm", {
1041
+ transcribe_status: "pending",
1042
+ transcribe_origin: "auto",
1043
+ });
1044
+
1045
+ // Pass 1: missing_provider failure → failed transcript note materialized.
1046
+ const fetch400: typeof fetch = (async () => {
1047
+ return new Response(
1048
+ JSON.stringify({ error: "missing", error_code: "missing_provider" }),
1049
+ { status: 400, headers: { "content-type": "application/json" } },
1050
+ );
1051
+ }) as typeof fetch;
1052
+ const worker1 = makeWorker({ fetchImpl: fetch400 });
1053
+ try { await worker1.tick(); } finally { await worker1.stop(); }
1054
+
1055
+ const failed = await store.getNoteByPath("memos/auto-retry.webm.transcript");
1056
+ expect(failed).not.toBeNull();
1057
+ const failedId = failed!.id;
1058
+ expect((failed!.metadata as any)?.transcript_status).toBe("failed");
1059
+
1060
+ // Pass 2: re-enqueue by flipping the attachment back to pending (this is
1061
+ // what the retry endpoint does) + give scribe a successful response.
1062
+ const [att] = await store.getAttachments(owner.id);
1063
+ await store.setAttachmentMetadata(att.id, {
1064
+ ...(att.metadata ?? {}),
1065
+ transcribe_status: "pending",
1066
+ transcribe_origin: "auto",
1067
+ });
1068
+ const worker2 = makeWorker({
1069
+ fetchImpl: mkFetchMock([{ text: "second time's the charm" }]),
1070
+ });
1071
+ try { await worker2.tick(); } finally { await worker2.stop(); }
1072
+
1073
+ const success = await store.getNoteByPath("memos/auto-retry.webm.transcript");
1074
+ expect(success).not.toBeNull();
1075
+ // Same note id — updated in place, not a fresh row.
1076
+ expect(success!.id).toBe(failedId);
1077
+ expect(success!.content).toBe("second time's the charm");
1078
+ expect((success!.metadata as any)?.transcript_status).toBe("complete");
1079
+ expect((success!.metadata as any)?.transcript_error).toBeUndefined();
1080
+ });
1081
+
1082
+ test("concurrent uploads: 5 audio files yield 5 transcript notes (no path collision)", async () => {
1083
+ const owners: string[] = [];
1084
+ for (let i = 0; i < 5; i++) {
1085
+ const note = await store.createNote(`# Voice ${i}\n`, { id: `concurrent-${i}` });
1086
+ owners.push(note.id);
1087
+ seedAudio(`memos/concurrent-${i}.webm`);
1088
+ await store.addAttachment(note.id, `memos/concurrent-${i}.webm`, "audio/webm", {
1089
+ transcribe_status: "pending",
1090
+ transcribe_origin: "auto",
1091
+ });
1092
+ }
1093
+
1094
+ // Same transcript body to each. The worker drains FIFO.
1095
+ const worker = makeWorker({
1096
+ fetchImpl: mkFetchMock([
1097
+ { text: "transcript-0" },
1098
+ { text: "transcript-1" },
1099
+ { text: "transcript-2" },
1100
+ { text: "transcript-3" },
1101
+ { text: "transcript-4" },
1102
+ ]),
1103
+ });
1104
+ try {
1105
+ const processed = await worker.tick();
1106
+ expect(processed).toBe(5);
1107
+ } finally {
1108
+ await worker.stop();
1109
+ }
1110
+
1111
+ // 5 transcript notes — one per audio file. The bodies map to FIFO order;
1112
+ // we just assert each note exists with `complete` status.
1113
+ for (let i = 0; i < 5; i++) {
1114
+ const t = await store.getNoteByPath(`memos/concurrent-${i}.webm.transcript`);
1115
+ expect(t).not.toBeNull();
1116
+ expect((t!.metadata as any)?.transcript_status).toBe("complete");
1117
+ }
1118
+ });
1119
+ });
@@ -54,6 +54,7 @@ import type { Store, Attachment } from "../core/src/types.ts";
54
54
  import type { HookRegistry } from "../core/src/hooks.ts";
55
55
  import { appendContextPart, fetchContextEntries, type ContextPayload } from "./context.ts";
56
56
  import type { TriggerIncludeContext } from "./config.ts";
57
+ import { upsertTranscriptNote } from "./transcript-note.ts";
57
58
 
58
59
  /** Placeholder pattern written by Lens's voice-memo stub. */
59
60
  const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
@@ -139,9 +140,38 @@ interface PendingMeta {
139
140
  transcribe_error?: string;
140
141
  transcript?: string;
141
142
  transcribe_done_at?: string;
143
+ /**
144
+ * Marker stamped by the attachment-write code path (vault#353) when the
145
+ * audio attachment was queued via the auto-transcribe pipeline (mime-type
146
+ * matched `audio/*` AND `autoTranscribe.enabled === true`). When set to
147
+ * `"auto"`, the worker materializes a `<attachment-path>.transcript.md`
148
+ * note on terminal states (success OR failure) so the transcript surface
149
+ * is uniform regardless of outcome. Absent or set to `"legacy"`, the
150
+ * worker preserves the original stub-patching behavior (Lens flow).
151
+ */
152
+ transcribe_origin?: "auto" | "legacy";
142
153
  [k: string]: unknown;
143
154
  }
144
155
 
156
+ /**
157
+ * Structured error thrown when scribe returns a 4xx with a recognized
158
+ * `error_code` — we surface the code on the transcript note's frontmatter
159
+ * so callers can branch on stable strings instead of regex-matching message
160
+ * text. Today the canonical code is `missing_provider` (scribe#47).
161
+ */
162
+ class ScribeApiError extends Error {
163
+ readonly errorCode?: string;
164
+ readonly httpStatus: number;
165
+ readonly retriable: boolean;
166
+ constructor(message: string, opts: { errorCode?: string; httpStatus: number; retriable: boolean }) {
167
+ super(message);
168
+ this.name = "ScribeApiError";
169
+ this.errorCode = opts.errorCode;
170
+ this.httpStatus = opts.httpStatus;
171
+ this.retriable = opts.retriable;
172
+ }
173
+ }
174
+
145
175
  /**
146
176
  * Start the worker loop. Returns a handle with `stop()` + `tick()`.
147
177
  * Tests should build the worker and call `tick()` directly; production
@@ -216,6 +246,38 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
216
246
  }
217
247
  }
218
248
 
249
+ /**
250
+ * On a terminal failure for an attachment with `transcribe_origin: "auto"`,
251
+ * write (or update) a `<attachment-path>.transcript.md` note with
252
+ * `transcript_status: failed` so the user has a queryable record of the
253
+ * failure and a target for the retry endpoint. Best-effort: any error
254
+ * materializing the transcript note is logged, never propagated — the
255
+ * attachment metadata write is the durable record of failure.
256
+ */
257
+ async function writeFailureTranscriptNote(
258
+ store: Store,
259
+ attachment: Attachment,
260
+ errMsg: string,
261
+ errorCode: string | undefined,
262
+ durationMs: number | undefined,
263
+ ): Promise<void> {
264
+ try {
265
+ await upsertTranscriptNote(store, {
266
+ attachmentPath: attachment.path,
267
+ attachmentId: attachment.id,
268
+ attachmentNoteId: attachment.noteId,
269
+ status: "failed",
270
+ error: errorCode ? `${errorCode}: ${errMsg}` : errMsg,
271
+ durationMs,
272
+ });
273
+ } catch (err) {
274
+ logger.error(
275
+ `[transcribe] failed to write failure transcript note for attachment ${attachment.id}:`,
276
+ err,
277
+ );
278
+ }
279
+ }
280
+
219
281
  async function processOneLocked(vault: string, attachment: Attachment): Promise<void> {
220
282
  const store = opts.getStore(vault);
221
283
  // Re-read metadata — the in-memory `attachment` may be stale (the hook
@@ -226,6 +288,10 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
226
288
  if (meta.transcribe_status !== "pending") return;
227
289
 
228
290
  const attempts = (meta.transcribe_attempts as number | undefined) ?? 0;
291
+ // Whether to materialize a transcript note (vault#353 auto-transcribe path)
292
+ // vs. the legacy stub-patching path (Lens flow). Auto-write notes also
293
+ // surface failures so the user can retry from the transcript note.
294
+ const isAutoOrigin = meta.transcribe_origin === "auto";
229
295
 
230
296
  // Honor backoff — we re-check here in case another tick queued this
231
297
  // attachment between the listing and now.
@@ -243,7 +309,11 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
243
309
  transcribe_status: "failed",
244
310
  transcribe_error: "audio file not found",
245
311
  });
246
- await applyFailureMarker(store, attachment.noteId);
312
+ if (isAutoOrigin) {
313
+ await writeFailureTranscriptNote(store, attachment, "audio file not found", undefined, undefined);
314
+ } else {
315
+ await applyFailureMarker(store, attachment.noteId);
316
+ }
247
317
  return;
248
318
  }
249
319
 
@@ -256,9 +326,9 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
256
326
  context = await fetchContextEntries(store, predicates, logger);
257
327
  }
258
328
 
259
- let transcript: string;
329
+ let scribeResult: { text: string; durationMs: number };
260
330
  try {
261
- transcript = await callScribe({
331
+ scribeResult = await callScribe({
262
332
  url: opts.scribeUrl,
263
333
  token: opts.scribeToken,
264
334
  filePath,
@@ -269,17 +339,34 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
269
339
  fetchImpl,
270
340
  });
271
341
  } catch (err) {
272
- const nextAttempts = attempts + 1;
273
342
  const errMsg = err instanceof Error ? err.message : String(err);
274
- if (nextAttempts >= maxAttempts) {
275
- logger.error(`[transcribe] giving up on attachment ${attachment.id} after ${nextAttempts} attempts:`, errMsg);
343
+ const apiErr = err instanceof ScribeApiError ? err : null;
344
+ // 4xx with structured error code terminal immediately. Re-POSTing the
345
+ // same audio at a scribe with no provider configured (or that rejects
346
+ // our bearer) will keep failing — the operator has to act, retries don't
347
+ // help. This is the "graceful first-boot path" from design Q5.
348
+ const nonRetriable = apiErr !== null && !apiErr.retriable;
349
+ const nextAttempts = attempts + 1;
350
+ const terminal = nonRetriable || nextAttempts >= maxAttempts;
351
+
352
+ if (terminal) {
353
+ if (nonRetriable) {
354
+ logger.error(`[transcribe] non-retriable scribe error on attachment ${attachment.id} (status ${apiErr!.httpStatus}${apiErr!.errorCode ? `, ${apiErr!.errorCode}` : ""}):`, errMsg);
355
+ } else {
356
+ logger.error(`[transcribe] giving up on attachment ${attachment.id} after ${nextAttempts} attempts:`, errMsg);
357
+ }
276
358
  await store.setAttachmentMetadata(attachment.id, {
277
359
  ...meta,
278
360
  transcribe_status: "failed",
279
361
  transcribe_attempts: nextAttempts,
280
362
  transcribe_error: errMsg,
363
+ ...(apiErr?.errorCode ? { transcribe_error_code: apiErr.errorCode } : {}),
281
364
  });
282
- await applyFailureMarker(store, attachment.noteId);
365
+ if (isAutoOrigin) {
366
+ await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.errorCode, undefined);
367
+ } else {
368
+ await applyFailureMarker(store, attachment.noteId);
369
+ }
283
370
  // retention=never drops the audio on any terminal state, including
284
371
  // failure. The user opted in to "I don't want the audio kept around
285
372
  // regardless of outcome" — honor it.
@@ -302,23 +389,46 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
302
389
  return;
303
390
  }
304
391
 
305
- // Success. Apply to note if the caller still wants us to.
306
- const note = await store.getNote(attachment.noteId);
307
- if (note) {
308
- const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
309
- if (noteMeta.transcribe_stub === true) {
310
- const body = TRANSCRIPT_PLACEHOLDER.test(note.content)
311
- ? note.content.replace(TRANSCRIPT_PLACEHOLDER, transcript)
312
- : transcript;
313
- const { transcribe_stub: _drop, ...restMeta } = noteMeta;
314
- try {
315
- await store.updateNote(note.id, {
316
- content: body,
317
- metadata: restMeta,
318
- skipUpdatedAt: true,
319
- });
320
- } catch (err) {
321
- logger.error(`[transcribe] failed to apply transcript to note ${note.id}:`, err);
392
+ const { text: transcript, durationMs } = scribeResult;
393
+
394
+ // Auto-origin success: materialize the transcript note (vault#353). The
395
+ // note's path is `<attachment-path>.transcript.md`, its frontmatter links
396
+ // back to the audio attachment via `transcript_of`.
397
+ if (isAutoOrigin) {
398
+ try {
399
+ await upsertTranscriptNote(store, {
400
+ attachmentPath: attachment.path,
401
+ attachmentId: attachment.id,
402
+ attachmentNoteId: attachment.noteId,
403
+ status: "complete",
404
+ text: transcript,
405
+ durationMs,
406
+ });
407
+ } catch (err) {
408
+ // Note write failure doesn't invalidate the transcript it's still
409
+ // stored on the attachment row below. Log + continue so retention
410
+ // still applies and the attachment row reflects success.
411
+ logger.error(`[transcribe] failed to write transcript note for attachment ${attachment.id}:`, err);
412
+ }
413
+ } else {
414
+ // Legacy stub-patching path (Lens voice memo flow).
415
+ const note = await store.getNote(attachment.noteId);
416
+ if (note) {
417
+ const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
418
+ if (noteMeta.transcribe_stub === true) {
419
+ const body = TRANSCRIPT_PLACEHOLDER.test(note.content)
420
+ ? note.content.replace(TRANSCRIPT_PLACEHOLDER, transcript)
421
+ : transcript;
422
+ const { transcribe_stub: _drop, ...restMeta } = noteMeta;
423
+ try {
424
+ await store.updateNote(note.id, {
425
+ content: body,
426
+ metadata: restMeta,
427
+ skipUpdatedAt: true,
428
+ });
429
+ } catch (err) {
430
+ logger.error(`[transcribe] failed to apply transcript to note ${note.id}:`, err);
431
+ }
322
432
  }
323
433
  }
324
434
  }
@@ -330,10 +440,12 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
330
440
  transcribe_status: "done",
331
441
  transcribe_attempts: attempts + 1,
332
442
  transcribe_done_at: new Date().toISOString(),
443
+ transcribe_duration_ms: durationMs,
333
444
  transcript,
334
445
  };
335
446
  delete doneMeta.transcribe_backoff_until;
336
447
  delete doneMeta.transcribe_error;
448
+ delete doneMeta.transcribe_error_code;
337
449
  await store.setAttachmentMetadata(attachment.id, doneMeta);
338
450
 
339
451
  // Retention: drop the file but keep the row so the transcript stays
@@ -458,6 +570,24 @@ export function registerTranscriptionHook(
458
570
  });
459
571
  }
460
572
 
573
+ /**
574
+ * Call scribe's `POST /v1/audio/transcriptions` with the audio file + optional
575
+ * context part. Returns the transcript text plus the wall-clock duration of
576
+ * the request, so the worker can surface `transcript_duration_ms` on the
577
+ * transcript note.
578
+ *
579
+ * Failure modes (encoded as throws):
580
+ * - 4xx with a JSON body carrying `error_code`: throws `ScribeApiError`
581
+ * with the code (`missing_provider` etc.). Treated as a non-retriable
582
+ * terminal failure — re-POSTing the same audio at the same broken scribe
583
+ * would just fail the same way; the operator has to act.
584
+ * - 4xx without `error_code` (auth, malformed multipart): throws
585
+ * `ScribeApiError` with the body text. Non-retriable.
586
+ * - 5xx, network error, or timeout: throws a plain `Error`. Retriable —
587
+ * the worker's backoff path picks it up.
588
+ * - 200 with missing/invalid `text` field: throws a plain `Error`.
589
+ * Retriable (could be a transient provider-output glitch).
590
+ */
461
591
  async function callScribe(args: {
462
592
  url: string;
463
593
  token?: string;
@@ -467,9 +597,10 @@ async function callScribe(args: {
467
597
  context: ContextPayload | null;
468
598
  timeoutMs: number;
469
599
  fetchImpl: typeof fetch;
470
- }): Promise<string> {
600
+ }): Promise<{ text: string; durationMs: number }> {
471
601
  const controller = new AbortController();
472
602
  const timer = setTimeout(() => controller.abort(), args.timeoutMs);
603
+ const startedAt = Date.now();
473
604
  try {
474
605
  const fileBuffer = readFileSync(args.filePath);
475
606
  const file = new File([fileBuffer], args.filename, { type: args.mimeType });
@@ -488,14 +619,42 @@ async function callScribe(args: {
488
619
  signal: controller.signal,
489
620
  });
490
621
  if (!resp.ok) {
491
- throw new Error(`scribe returned ${resp.status}: ${await resp.text().catch(() => "")}`);
622
+ const body = await resp.text().catch(() => "");
623
+ // Try to extract structured error_code from JSON body (scribe#47).
624
+ let errorCode: string | undefined;
625
+ let errorMessage: string | undefined;
626
+ try {
627
+ const parsed = JSON.parse(body) as { error?: string; error_code?: string; message?: string };
628
+ if (typeof parsed.error_code === "string") errorCode = parsed.error_code;
629
+ if (typeof parsed.error === "string") errorMessage = parsed.error;
630
+ else if (typeof parsed.message === "string") errorMessage = parsed.message;
631
+ } catch {
632
+ // Not JSON — leave errorCode undefined; the raw body becomes the message.
633
+ }
634
+ // 4xx is terminal (re-POSTing the same audio at the same broken scribe
635
+ // will just fail again). 5xx is retriable — provider hiccup, will likely
636
+ // succeed on backoff.
637
+ const retriable = resp.status >= 500;
638
+ const message = errorMessage
639
+ ?? (errorCode ? `scribe ${errorCode}` : `scribe returned ${resp.status}: ${body}`);
640
+ throw new ScribeApiError(message, {
641
+ errorCode,
642
+ httpStatus: resp.status,
643
+ retriable,
644
+ });
492
645
  }
493
646
  const result = await resp.json() as { text?: string };
494
647
  if (typeof result.text !== "string") {
495
648
  throw new Error("scribe response missing text field");
496
649
  }
497
- return result.text;
650
+ return { text: result.text, durationMs: Date.now() - startedAt };
498
651
  } finally {
499
652
  clearTimeout(timer);
500
653
  }
501
654
  }
655
+
656
+ /**
657
+ * Re-export the structured error type so tests + callers can `instanceof`-check
658
+ * for terminal-failure semantics.
659
+ */
660
+ export { ScribeApiError };