@openparachute/vault 0.7.3-rc.13 → 0.7.3-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.
Files changed (53) hide show
  1. package/README.md +3 -5
  2. package/core/src/conformance.ts +1 -2
  3. package/core/src/content-range.test.ts +0 -127
  4. package/core/src/content-range.ts +0 -100
  5. package/core/src/contract-typed-index.test.ts +3 -4
  6. package/core/src/core.test.ts +4 -521
  7. package/core/src/expand.ts +3 -11
  8. package/core/src/indexed-fields.test.ts +3 -9
  9. package/core/src/indexed-fields.ts +1 -9
  10. package/core/src/mcp.ts +10 -601
  11. package/core/src/notes.ts +4 -201
  12. package/core/src/schema-defaults.ts +1 -85
  13. package/core/src/search-fts-v25.test.ts +1 -9
  14. package/core/src/search-query.test.ts +0 -42
  15. package/core/src/search-query.ts +0 -27
  16. package/core/src/seed-packs.test.ts +1 -117
  17. package/core/src/seed-packs.ts +1 -217
  18. package/core/src/store.ts +3 -59
  19. package/core/src/tag-schemas.ts +12 -27
  20. package/core/src/types.ts +1 -7
  21. package/core/src/vault-projection.ts +0 -55
  22. package/package.json +1 -1
  23. package/src/add-pack.test.ts +0 -32
  24. package/src/auth-hub-jwt.test.ts +1 -118
  25. package/src/auth.ts +0 -64
  26. package/src/cli.ts +1 -5
  27. package/src/contract-errors.test.ts +1 -2
  28. package/src/mcp-http.ts +4 -33
  29. package/src/mcp-tools.ts +14 -61
  30. package/src/oauth-discovery.ts +0 -31
  31. package/src/onboarding-seed.test.ts +0 -64
  32. package/src/routes.ts +95 -92
  33. package/src/routing.test.ts +4 -229
  34. package/src/routing.ts +23 -152
  35. package/src/scopes.ts +0 -22
  36. package/src/server.ts +0 -7
  37. package/src/storage.test.ts +1 -200
  38. package/src/transcription-worker.test.ts +0 -151
  39. package/src/transcription-worker.ts +52 -113
  40. package/src/vault.test.ts +11 -48
  41. package/core/src/attachment/bytes-provider.ts +0 -65
  42. package/core/src/attachment/policy.test.ts +0 -66
  43. package/core/src/attachment/policy.ts +0 -131
  44. package/core/src/attachment/tickets.test.ts +0 -45
  45. package/core/src/attachment/tickets.ts +0 -117
  46. package/core/src/attachment-tickets-tool.test.ts +0 -286
  47. package/core/src/display-title.test.ts +0 -190
  48. package/core/src/lede.test.ts +0 -96
  49. package/core/src/search-title-boost.test.ts +0 -125
  50. package/src/attachment-bytes.ts +0 -68
  51. package/src/attachment-tickets.test.ts +0 -475
  52. package/src/attachment-tickets.ts +0 -340
  53. package/src/read-attachment.test.ts +0 -436
@@ -1627,154 +1627,3 @@ describe("transcription worker — legacy in-body memo content safety (finding F
1627
1627
  expect(note!.content).toContain("retry text with $& and $0 and $$ literal");
1628
1628
  });
1629
1629
  });
1630
-
1631
- // ---------------------------------------------------------------------------
1632
- // voice W2 — segmented recordings. The app slices a long recording into
1633
- // ~10-min segments, each linked as its own attachment on ONE note carrying a
1634
- // client-set `segment_index` (0-based). The legacy in-body path targets that
1635
- // part's markers — `_Transcript pending (part N)._` / `_Transcription
1636
- // unavailable (part N)._`, N = segment_index + 1 — so each transcript lands in
1637
- // its own pre-allocated slot regardless of completion order. Marker strings
1638
- // are a BYTE-EXACT cross-door + cross-repo contract (the cloud worker ships
1639
- // the identical text), so these assertions pin the exact bytes.
1640
- // ---------------------------------------------------------------------------
1641
-
1642
- describe("transcription worker — segmented recordings (voice W2)", () => {
1643
- test("three parts completing OUT OF ORDER (2 ok, 0 fails, 1 ok) each land in their own slot", async () => {
1644
- // One note, three pre-allocated part slots + the shared stub opt-in.
1645
- const body =
1646
- "# 🎙️ Voice memo\n\n" +
1647
- "_Transcript pending (part 1)._\n\n" +
1648
- "_Transcript pending (part 2)._\n\n" +
1649
- "_Transcript pending (part 3)._\n";
1650
- await store.createNote(body, { id: "seg-note", metadata: { transcribe_stub: true } });
1651
-
1652
- seedAudio("memos/seg0.webm");
1653
- seedAudio("memos/seg1.webm");
1654
- seedAudio("memos/seg2.webm");
1655
- const seg0 = await store.addAttachment("seg-note", "memos/seg0.webm", "audio/webm", {
1656
- transcribe_status: "pending",
1657
- segment_index: 0,
1658
- });
1659
- const seg1 = await store.addAttachment("seg-note", "memos/seg1.webm", "audio/webm", {
1660
- transcribe_status: "pending",
1661
- segment_index: 1,
1662
- });
1663
- const seg2 = await store.addAttachment("seg-note", "memos/seg2.webm", "audio/webm", {
1664
- transcribe_status: "pending",
1665
- segment_index: 2,
1666
- });
1667
-
1668
- // Complete part 3 (segment_index 2) FIRST — success.
1669
- const w2 = makeWorker({ fetchImpl: mkFetchMock([{ text: "part three text" }]) });
1670
- try { await w2.kick("default", seg2); } finally { await w2.stop(); }
1671
-
1672
- // Then part 1 (segment_index 0) — terminal failure (maxAttempts=1).
1673
- const w0 = makeWorker({
1674
- fetchImpl: mkFetchMock([{ error: "scribe down", status: 500 }]),
1675
- maxAttempts: 1,
1676
- });
1677
- try { await w0.kick("default", seg0); } finally { await w0.stop(); }
1678
-
1679
- // Finally part 2 (segment_index 1) — success.
1680
- const w1 = makeWorker({ fetchImpl: mkFetchMock([{ text: "part two text" }]) });
1681
- try { await w1.kick("default", seg1); } finally { await w1.stop(); }
1682
-
1683
- const note = await store.getNote("seg-note");
1684
- // Each part landed in its own slot: part 1 → failure marker, part 2/3 →
1685
- // their transcripts, despite completing 3 → 1 → 2.
1686
- expect(note!.content).toBe(
1687
- "# 🎙️ Voice memo\n\n" +
1688
- "_Transcription unavailable (part 1)._\n\n" +
1689
- "part two text\n\n" +
1690
- "part three text\n",
1691
- );
1692
- // Shared stub SURVIVES — sibling parts each needed the gate open; a
1693
- // per-part clear (the un-segmented behavior) would have blocked parts 2 & 3.
1694
- expect((note!.metadata as any)?.transcribe_stub).toBe(true);
1695
-
1696
- // Attachment rows reflect their individual outcomes.
1697
- const atts = await store.getAttachments("seg-note");
1698
- const byIndex = Object.fromEntries(atts.map((a) => [a.metadata?.segment_index, a]));
1699
- expect(byIndex[0]!.metadata?.transcribe_status).toBe("failed");
1700
- expect(byIndex[1]!.metadata?.transcribe_status).toBe("done");
1701
- expect(byIndex[1]!.metadata?.transcript).toBe("part two text");
1702
- expect(byIndex[2]!.metadata?.transcribe_status).toBe("done");
1703
- expect(byIndex[2]!.metadata?.transcript).toBe("part three text");
1704
- });
1705
-
1706
- test("un-segmented attachment → BARE markers, one-shot stub cleared (regression pin)", async () => {
1707
- // No `segment_index` → byte-for-byte the pre-W2 behavior: replace the bare
1708
- // placeholder, clear the stub. A stray `(part N)` marker in the body is
1709
- // left untouched (the bare path never targets part markers).
1710
- await store.createNote(
1711
- "# 🎙️ Voice memo\n\n_Transcript pending._\n\n_Transcript pending (part 2)._\n",
1712
- { id: "unseg-note", metadata: { transcribe_stub: true } },
1713
- );
1714
- seedAudio("memos/unseg.webm");
1715
- const att = await store.addAttachment("unseg-note", "memos/unseg.webm", "audio/webm", {
1716
- transcribe_status: "pending",
1717
- });
1718
-
1719
- const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "bare transcript" }]) });
1720
- try { await worker.kick("default", att); } finally { await worker.stop(); }
1721
-
1722
- const note = await store.getNote("unseg-note");
1723
- // Bare marker replaced; the part-2 marker is NOT touched by the bare path.
1724
- expect(note!.content).toBe(
1725
- "# 🎙️ Voice memo\n\nbare transcript\n\n_Transcript pending (part 2)._\n",
1726
- );
1727
- // One-shot stub cleared, exactly as today.
1728
- expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
1729
- });
1730
-
1731
- test("malformed segment_index falls back to bare markers (contract: integer ≥ 0)", async () => {
1732
- // A non-integer / negative `segment_index` must NOT fabricate a `(part N)`
1733
- // marker — it degrades to the bare path (fully backward compatible).
1734
- await store.createNote(
1735
- "# 🎙️ Voice memo\n\n_Transcript pending._\n",
1736
- { id: "seg-bad", metadata: { transcribe_stub: true } },
1737
- );
1738
- seedAudio("memos/segbad.webm");
1739
- const att = await store.addAttachment("seg-bad", "memos/segbad.webm", "audio/webm", {
1740
- transcribe_status: "pending",
1741
- segment_index: -1, // invalid → bare path
1742
- });
1743
-
1744
- const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "fallback transcript" }]) });
1745
- try { await worker.kick("default", att); } finally { await worker.stop(); }
1746
-
1747
- const note = await store.getNote("seg-bad");
1748
- expect(note!.content).toBe("# 🎙️ Voice memo\n\nfallback transcript\n");
1749
- expect((note!.metadata as any)?.transcribe_stub).toBeUndefined();
1750
- });
1751
-
1752
- test("segmented part whose marker was edited away → transcript appended (graceful fallback)", async () => {
1753
- // The user rewrote part 2's slot by hand, removing its pending marker.
1754
- // Mirror the un-segmented replace-or-append policy scoped to the part:
1755
- // with neither of part 2's markers present, append the transcript
1756
- // (no `(part N)` prefix) rather than destroying the user's edit.
1757
- const editedBody =
1758
- "# 🎙️ Voice memo\n\n" +
1759
- "_Transcript pending (part 1)._\n\n" +
1760
- "User rewrote part two by hand.\n";
1761
- await store.createNote(editedBody, { id: "seg-edit", metadata: { transcribe_stub: true } });
1762
- seedAudio("memos/segedit.webm");
1763
- const seg1 = await store.addAttachment("seg-edit", "memos/segedit.webm", "audio/webm", {
1764
- transcribe_status: "pending",
1765
- segment_index: 1, // part 2
1766
- });
1767
-
1768
- const worker = makeWorker({ fetchImpl: mkFetchMock([{ text: "the real part two" }]) });
1769
- try { await worker.kick("default", seg1); } finally { await worker.stop(); }
1770
-
1771
- const note = await store.getNote("seg-edit");
1772
- // Part 2's transcript appended; the user's edit + part 1's pending slot
1773
- // both survive untouched.
1774
- expect(note!.content).toBe(`${editedBody}\n\nthe real part two`);
1775
- expect(note!.content).toContain("User rewrote part two by hand.");
1776
- expect(note!.content).toContain("_Transcript pending (part 1)._");
1777
- // Segmented → shared stub preserved (part 1 still needs the gate open).
1778
- expect((note!.metadata as any)?.transcribe_stub).toBe(true);
1779
- });
1780
- });
@@ -64,57 +64,41 @@ import {
64
64
  } from "../core/src/transcription/provider.ts";
65
65
  import { ScribeHttpProvider } from "./transcription/providers/scribe-http.ts";
66
66
 
67
+ /** Placeholder pattern written by the voice-memo capture stub. */
68
+ const TRANSCRIPT_PLACEHOLDER = /_Transcript pending\._/;
69
+
67
70
  /**
68
- * The in-body transcription markers.
69
- *
70
- * The BARE markers are the un-segmented default; voice W2 (segmented
71
- * recordings) targets per-part variants built by `markersFor`. Both are a
72
- * BYTE-EXACT cross-door + cross-repo contract — the cloud Workers-AI
73
- * transcription path ships the identical strings, and the notes-ui status
74
- * chip (parachute-surface TranscriptionStatus.tsx) keys off the failure
75
- * marker's exact copy. Don't change any of this text without a coordinated
76
- * change in both places. A friendlier "retry available" copy + chip
77
- * affordance is a tracked parachute-surface follow-up.
71
+ * Body written when transcription reaches a terminal failure (maxAttempts
72
+ * exhausted, or the audio file is missing). This used to be written by
73
+ * Lens's now-removed scribe client; owning it here means a failed upload
74
+ * stops reading "Transcript pending" forever regardless of which client
75
+ * uploaded the audio.
78
76
  *
79
- * Owning the failure marker here (it used to be written by Lens's now-removed
80
- * scribe client) means a failed upload stops reading "Transcript pending"
81
- * forever regardless of which client uploaded the audio.
77
+ * NOTE: the notes-ui status chip (parachute-surface TranscriptionStatus.tsx)
78
+ * keys off this exact string, so don't change the copy without a coordinated
79
+ * change there. A friendlier "retry available" copy + chip affordance is a
80
+ * tracked parachute-surface follow-up.
82
81
  */
83
- const BARE_PENDING = "_Transcript pending._";
84
- const BARE_UNAVAILABLE = "_Transcription unavailable._";
85
-
86
- /** Escape a literal string for safe embedding in a `RegExp`. */
87
- function escapeRegExp(literal: string): string {
88
- return literal.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
89
- }
82
+ const TRANSCRIPT_UNAVAILABLE = "_Transcription unavailable._";
90
83
 
91
84
  /**
92
- * The pending + terminal-failure markers for an attachment, honoring
93
- * `segment_index` (voice W2 segmented recordings). `undefined` yields the
94
- * bare markers (fully backward compatible — un-segmented flows are byte-
95
- * unchanged). An integer 0 yields this part's markers, with the human-
96
- * facing part number N = segment_index + 1 (1-based, decimal):
97
- * `_Transcript pending (part N)._` / `_Transcription unavailable (part N)._`
98
- */
99
- function markersFor(segmentIndex: number | undefined): { pending: string; unavailable: string } {
100
- if (segmentIndex === undefined) return { pending: BARE_PENDING, unavailable: BARE_UNAVAILABLE };
101
- const n = segmentIndex + 1;
102
- return {
103
- pending: `_Transcript pending (part ${n})._`,
104
- unavailable: `_Transcription unavailable (part ${n})._`,
105
- };
106
- }
107
-
108
- /**
109
- * A valid segment index (integer ≥ 0) off attachment metadata, else
110
- * `undefined` — the un-segmented path. Client-set at link time; anything that
111
- * isn't a non-negative integer falls back to the bare markers rather than
112
- * fabricating a `(part N)`.
85
+ * On a successful (re)transcription of a legacy in-body memo, the transcript
86
+ * replaces whichever marker is currently in the body the original
87
+ * `_Transcript pending._` on a first-try success, OR `_Transcription
88
+ * unavailable._` if a prior attempt failed and we're now retrying. Matching
89
+ * both means a retried success lands in the same spot a first-try success
90
+ * would, preserving the surrounding capture body (the `![[memo]]` embed,
91
+ * the `_Recorded …_` line, the header).
92
+ *
93
+ * Deliberately NO `/g` flag `.replace` swaps only the FIRST match. A
94
+ * canonical capture body holds exactly one marker, so first-match is the
95
+ * correct target. `applyFailureMarker`'s includes-guard (no-op when the
96
+ * marker is already present) prevents markers accumulating across repeated
97
+ * terminal failures, so the body never carries two of the same marker. A
98
+ * hand-edited body that somehow contains both markers patches only the
99
+ * first — accepted (degenerate, operator-induced).
113
100
  */
114
- function segmentIndexOf(meta: { segment_index?: unknown }): number | undefined {
115
- const raw = meta.segment_index;
116
- return typeof raw === "number" && Number.isInteger(raw) && raw >= 0 ? raw : undefined;
117
- }
101
+ const TRANSCRIPT_SUCCESS_TARGET = /_Transcript pending\._|_Transcription unavailable\._/;
118
102
 
119
103
  /**
120
104
  * Default sweep cadence (ms). The sweep is the safety net for backoff-
@@ -211,14 +195,6 @@ interface PendingMeta {
211
195
  * worker preserves the original stub-patching behavior (Lens flow).
212
196
  */
213
197
  transcribe_origin?: "auto" | "legacy";
214
- /**
215
- * Voice W2 (segmented recordings): a client-set 0-based index marking this
216
- * attachment as one segment of a longer recording sliced into ~10-min parts,
217
- * all linked on ONE note. When present, the legacy in-body path targets this
218
- * part's markers (`… (part N)._`, N = segment_index + 1) rather than the bare
219
- * ones — making per-part ordering structurally guaranteed. See `markersFor`.
220
- */
221
- segment_index?: number;
222
198
  [k: string]: unknown;
223
199
  }
224
200
 
@@ -381,28 +357,17 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
381
357
  * attachment failure we're trying to record.
382
358
  *
383
359
  * Body policy (finding F — never destroy content):
384
- * - Pending marker PRESENT → surgical replace of the pending marker with
385
- * the failure marker. The `![[memo]]` embed + any surrounding text
386
- * survive. For a segmented attachment (`segment_index` set) this is the
387
- * per-part `_Transcript pending (part N)._`; otherwise the bare marker.
388
- * - Failure marker ALREADY PRESENT no-op (idempotent; a double-terminal-
389
- * failure must not stack markers).
390
- * - Otherwise (pending marker absent the user edited the note while it
391
- * was pending) APPEND `\n\n` + failure marker to the existing content.
392
- * The old code full-replaced the body here, destroying the embed AND the
393
- * user's edits. We append instead so nothing is lost. If the content is
394
- * empty, the marker alone becomes the body (avoids a leading blank line).
360
+ * - Placeholder PRESENT → surgical replace of `_Transcript pending._`
361
+ * with the marker. The `![[memo]]` embed + any surrounding text survive.
362
+ * - Marker ALREADY PRESENT no-op (idempotent; a double-terminal-failure
363
+ * must not stack markers).
364
+ * - Otherwise (placeholder absent the user edited the note while it was
365
+ * pending) APPEND `\n\n` + marker to the existing content. The old
366
+ * code full-replaced the body here, destroying the embed AND the user's
367
+ * edits. We append instead so nothing is lost. If the content is empty,
368
+ * the marker alone becomes the body (avoids a leading blank line).
395
369
  */
396
- async function applyFailureMarker(
397
- store: Store,
398
- noteId: string,
399
- segmentIndex: number | undefined,
400
- ): Promise<void> {
401
- // Bare markers by default; this segment's `(part N)` markers when the
402
- // attachment carries a `segment_index` (voice W2). String-search replace
403
- // targets the FIRST occurrence (a canonical body holds exactly one), and
404
- // the includes-guard below keeps a repeated terminal failure from stacking.
405
- const { pending, unavailable } = markersFor(segmentIndex);
370
+ async function applyFailureMarker(store: Store, noteId: string): Promise<void> {
406
371
  // OC-guarded (vault#435): the read-transform-write below is re-run against
407
372
  // fresh content on a conflict so a concurrent user edit isn't clobbered.
408
373
  // The transform is pure w.r.t. the note it's handed; the stub-set and
@@ -416,24 +381,17 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
416
381
  if (noteMeta.transcribe_stub !== true) return null;
417
382
 
418
383
  let body: string;
419
- if (note.content.includes(pending)) {
420
- // Function replacer so the search string is treated literally and the
421
- // (fixed) failure marker is inserted verbatim.
422
- body = note.content.replace(pending, () => unavailable);
423
- } else if (note.content.includes(unavailable)) {
384
+ if (TRANSCRIPT_PLACEHOLDER.test(note.content)) {
385
+ body = note.content.replace(TRANSCRIPT_PLACEHOLDER, TRANSCRIPT_UNAVAILABLE);
386
+ } else if (note.content.includes(TRANSCRIPT_UNAVAILABLE)) {
424
387
  // Marker already present — nothing to do. Clear the stub and
425
388
  // return without rewriting the body so we don't stack markers.
426
389
  body = note.content;
427
390
  } else {
428
391
  body = note.content.length > 0
429
- ? `${note.content}\n\n${unavailable}`
430
- : unavailable;
392
+ ? `${note.content}\n\n${TRANSCRIPT_UNAVAILABLE}`
393
+ : TRANSCRIPT_UNAVAILABLE;
431
394
  }
432
- // Segmented: the stub is SHARED across this note's parts — keep it set
433
- // so sibling parts still resolve their own slots. Return content only
434
- // (leave note metadata untouched). Un-segmented: clear the one-shot
435
- // stub as before (byte-unchanged).
436
- if (segmentIndex !== undefined) return { content: body };
437
395
  const { transcribe_stub: _drop, ...restMeta } = noteMeta;
438
396
  return { content: body, metadata: restMeta };
439
397
  },
@@ -491,12 +449,6 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
491
449
  // vs. the legacy stub-patching path (Lens flow). Auto-write notes also
492
450
  // surface failures so the user can retry from the transcript note.
493
451
  const isAutoOrigin = meta.transcribe_origin === "auto";
494
- // Voice W2: when this attachment is one segment of a longer recording
495
- // (client-set `segment_index`), the legacy in-body path targets this
496
- // part's markers instead of the bare ones. Undefined for un-segmented
497
- // attachments — byte-unchanged behavior. Only the legacy path consults it;
498
- // the auto/transcript-note path is untouched (segments are a memo concern).
499
- const segmentIndex = segmentIndexOf(meta);
500
452
 
501
453
  // Honor backoff — we re-check here in case another tick queued this
502
454
  // attachment between the listing and now.
@@ -517,7 +469,7 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
517
469
  if (isAutoOrigin) {
518
470
  await writeFailureTranscriptNote(store, attachment, "audio file not found", undefined, undefined);
519
471
  } else {
520
- await applyFailureMarker(store, attachment.noteId, segmentIndex);
472
+ await applyFailureMarker(store, attachment.noteId);
521
473
  }
522
474
  return;
523
475
  }
@@ -575,7 +527,7 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
575
527
  if (isAutoOrigin) {
576
528
  await writeFailureTranscriptNote(store, attachment, errMsg, apiErr?.code, undefined);
577
529
  } else {
578
- await applyFailureMarker(store, attachment.noteId, segmentIndex);
530
+ await applyFailureMarker(store, attachment.noteId);
579
531
  }
580
532
  // retention=never drops the audio on any terminal state, including
581
533
  // failure. The user opted in to "I don't want the audio kept around
@@ -626,16 +578,6 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
626
578
  // before the transcript arrives opts out of the overwrite. OC-guarded
627
579
  // (vault#435): re-applied against fresh content on a conflict so a
628
580
  // concurrent user edit isn't clobbered.
629
- //
630
- // Success replaces whichever of THIS part's markers is present (bare, or
631
- // `(part N)` when segmented). Built with no `/g` flag so `.replace`
632
- // swaps only the FIRST match — a canonical capture body holds exactly
633
- // one marker per part; alternation preserves positional-first semantics
634
- // (a retried success replaces the failure marker where a first-try
635
- // success replaced the pending one) so byte-for-byte matching today's
636
- // un-segmented behavior.
637
- const { pending, unavailable } = markersFor(segmentIndex);
638
- const successTarget = new RegExp(`${escapeRegExp(pending)}|${escapeRegExp(unavailable)}`);
639
581
  await applyNoteTransformWithOC(
640
582
  store,
641
583
  attachment.noteId,
@@ -644,31 +586,28 @@ export function startTranscriptionWorker(opts: TranscriptionWorkerOpts): Transcr
644
586
  const noteMeta = (note.metadata as Record<string, unknown> | undefined) ?? {};
645
587
  if (noteMeta.transcribe_stub !== true) return null;
646
588
  // Body policy (finding F — never destroy content):
647
- // - pending OR failure marker present → surgical replace in place.
648
- // The embed + surrounding capture body survive.
589
+ // - placeholder OR failure-marker present → surgical replace in
590
+ // place (a retried success replaces the `_Transcription
591
+ // unavailable._` marker, landing exactly where a first-try
592
+ // success would). The embed + surrounding capture body survive.
649
593
  // - neither present (user edited the note while pending) → APPEND
650
594
  // the transcript instead of full-replacing the body, so the
651
595
  // user's edits + the `![[memo]]` embed are preserved. The old
652
596
  // code full-replaced here, which destroyed both.
653
597
  let body: string;
654
- if (successTarget.test(note.content)) {
598
+ if (TRANSCRIPT_SUCCESS_TARGET.test(note.content)) {
655
599
  // Function replacer, NOT a string — speech-to-text is arbitrary
656
600
  // user content, and String.replace treats `$&`, `$\``, `$'`,
657
601
  // `$1`-`$9` as special patterns in a string replacement. A
658
602
  // transcript containing `$&` would otherwise inject the matched
659
603
  // marker text into the body. `() => transcript` returns the text
660
604
  // verbatim.
661
- body = note.content.replace(successTarget, () => transcript);
605
+ body = note.content.replace(TRANSCRIPT_SUCCESS_TARGET, () => transcript);
662
606
  } else {
663
607
  body = note.content.length > 0
664
608
  ? `${note.content}\n\n${transcript}`
665
609
  : transcript;
666
610
  }
667
- // Segmented: the stub is SHARED across this note's parts — keep it
668
- // set so sibling parts still resolve their own slots. Return content
669
- // only (leave note metadata untouched). Un-segmented: clear the
670
- // one-shot stub as before (byte-unchanged).
671
- if (segmentIndex !== undefined) return { content: body };
672
611
  const { transcribe_stub: _drop, ...restMeta } = noteMeta;
673
612
  return { content: body, metadata: restMeta };
674
613
  },
package/src/vault.test.ts CHANGED
@@ -2139,22 +2139,6 @@ describe("HTTP /notes", async () => {
2139
2139
  expect(body[0]).not.toHaveProperty("content");
2140
2140
  expect(body[0]).toHaveProperty("byteSize");
2141
2141
  expect(body[0]).toHaveProperty("preview");
2142
- expect(body[0]).toHaveProperty("displayTitle");
2143
- });
2144
-
2145
- // Title axis (ratified 2026-07-17) — displayTitle on the REST lean shape.
2146
- test("GET /notes lean shape carries the computed displayTitle", async () => {
2147
- await store.createNote("# Meeting Notes\nagenda: budget", { path: "meeting" });
2148
- const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2149
- const body = await res.json() as any[];
2150
- expect(body[0].displayTitle).toBe("Meeting Notes");
2151
- });
2152
-
2153
- test("GET /notes lean shape reports null displayTitle for an empty note", async () => {
2154
- await store.createNote("", { path: "empty" });
2155
- const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2156
- const body = await res.json() as any[];
2157
- expect(body[0].displayTitle).toBeNull();
2158
2142
  });
2159
2143
 
2160
2144
  test("GET /notes?include_content=true returns full notes", async () => {
@@ -6625,15 +6609,8 @@ describe("stateless MCP transport", async () => {
6625
6609
  expect(toolNames).not.toContain("merge-tags");
6626
6610
  // Admin tools (vault#376) are hidden too
6627
6611
  expect(toolNames).not.toContain("manage-token");
6628
- // request-attachment-download and read-attachment are read-tier
6629
- // (upload is write-tier).
6630
- expect(toolNames).toContain("request-attachment-download");
6631
- expect(toolNames).not.toContain("request-attachment-upload");
6632
- expect(toolNames).toContain("read-attachment");
6633
- // Read tier is exactly 7 tools (doctor added by the re-tier;
6634
- // request-attachment-download by the attachment-tickets design;
6635
- // read-attachment by Wave 2).
6636
- expect(toolNames.length).toBe(7);
6612
+ // Read tier is exactly 5 tools (doctor added by the re-tier).
6613
+ expect(toolNames.length).toBe(5);
6637
6614
 
6638
6615
  closeAllStores();
6639
6616
  });
@@ -6914,23 +6891,15 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6914
6891
  return names;
6915
6892
  }
6916
6893
 
6917
- test("vault:read sees exactly the 7 read tools (doctor moved admin → read; read-attachment and request-attachment-download are read-tier)", async () => {
6894
+ test("vault:read sees exactly the 5 read tools (doctor moved admin → read)", async () => {
6918
6895
  const names = await listToolNames(["vault:read"]);
6919
6896
  expect(new Set(names)).toEqual(
6920
- new Set([
6921
- "query-notes",
6922
- "list-tags",
6923
- "find-path",
6924
- "vault-info",
6925
- "doctor",
6926
- "request-attachment-download",
6927
- "read-attachment",
6928
- ]),
6897
+ new Set(["query-notes", "list-tags", "find-path", "vault-info", "doctor"]),
6929
6898
  );
6930
- expect(names.length).toBe(7);
6899
+ expect(names.length).toBe(5);
6931
6900
  });
6932
6901
 
6933
- test("vault:read + vault:write sees the 11 read+write tools (tag-schema tools moved write → admin; request-attachment-upload is write-tier)", async () => {
6902
+ test("vault:read + vault:write sees the 8 read+write tools (tag-schema tools moved write → admin)", async () => {
6934
6903
  const names = await listToolNames(["vault:read", "vault:write"]);
6935
6904
  expect(new Set(names)).toEqual(
6936
6905
  new Set([
@@ -6942,12 +6911,9 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6942
6911
  "create-note",
6943
6912
  "update-note",
6944
6913
  "delete-note",
6945
- "request-attachment-upload",
6946
- "request-attachment-download",
6947
- "read-attachment",
6948
6914
  ]),
6949
6915
  );
6950
- expect(names.length).toBe(11);
6916
+ expect(names.length).toBe(8);
6951
6917
  expect(names).not.toContain("manage-token");
6952
6918
  // Re-tier (this PR): update-tag/delete-tag/rename-tag/merge-tags are now
6953
6919
  // admin-tier — structure/taxonomy curation, not content authorship.
@@ -6959,7 +6925,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6959
6925
  expect(names).toContain("delete-note");
6960
6926
  });
6961
6927
 
6962
- test("vault:admin sees all 17 tools including manage-token + prune-schema + the tag-schema tools + all three attachment tools", async () => {
6928
+ test("vault:admin sees all 14 tools including manage-token + prune-schema + the tag-schema tools", async () => {
6963
6929
  const names = await listToolNames(["vault:read", "vault:write", "vault:admin"]);
6964
6930
  expect(names).toContain("manage-token");
6965
6931
  expect(names).toContain("prune-schema");
@@ -6968,13 +6934,10 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
6968
6934
  expect(names).toContain("delete-tag");
6969
6935
  expect(names).toContain("rename-tag");
6970
6936
  expect(names).toContain("merge-tags");
6971
- expect(names).toContain("request-attachment-upload");
6972
- expect(names).toContain("request-attachment-download");
6973
- expect(names).toContain("read-attachment");
6974
- expect(names.length).toBe(17);
6937
+ expect(names.length).toBe(14);
6975
6938
  });
6976
6939
 
6977
- test("legacy-derived full token sees all 17 tools (back-compat)", async () => {
6940
+ test("legacy-derived full token sees all 14 tools (back-compat)", async () => {
6978
6941
  const { handleScopedMcp } = await import("./mcp-http.ts");
6979
6942
  const { writeVaultConfig } = await import("./config.ts");
6980
6943
  const { closeAllStores } = await import("./vault-store.ts");
@@ -7007,7 +6970,7 @@ describe("MCP tools/list scope tiers (vault#376)", () => {
7007
6970
  } as any);
7008
6971
  const body = await res.json() as any;
7009
6972
  const names: string[] = body.result.tools.map((t: any) => t.name);
7010
- expect(names.length).toBe(17);
6973
+ expect(names.length).toBe(14);
7011
6974
  expect(names).toContain("manage-token");
7012
6975
  expect(names).toContain("prune-schema");
7013
6976
  expect(names).toContain("doctor");
@@ -1,65 +0,0 @@
1
- /**
2
- * `AttachmentBytesProvider` — the model-lane (Wave 2) byte-access seam.
3
- * Mirrors `AttachmentTicketProvider` (`./tickets.ts`): a per-door
4
- * implementation binds this to its own storage (bun: local fs, see
5
- * `src/attachment-bytes.ts`; a future cloud implementation: ranged R2 GETs
6
- * through the vault DO). Core stays storage-unaware — the `read-attachment`
7
- * tool (`core/src/mcp.ts`) calls only through this interface, so bun and a
8
- * future cloud implementation can never drift on the read contract.
9
- *
10
- * Deliberately narrow: stat + a bounded positional read, plus one optional
11
- * hook for the audio transcript pointer. All POLICY (mime-family dispatch,
12
- * size caps, range validation, tag-scope) lives in the `read-attachment`
13
- * tool itself — same division as the ticket seam (`GenerateMcpToolsOpts`'s
14
- * doc comment in `core/src/mcp.ts`).
15
- *
16
- * D10 (attachments-for-agents design): "tools omitted when unwired" — a
17
- * door that hasn't wired this seam simply never passes `attachmentBytes` to
18
- * `generateMcpTools`, so `read-attachment` is absent from `tools/list`
19
- * entirely, not merely erroring on call.
20
- */
21
-
22
- import type { Attachment } from "../types.js";
23
-
24
- /**
25
- * 4 MiB raw-bytes cap on the image branch of `read-attachment` (D3): the
26
- * largest honest budget under Claude's ~5 MB image API limit, with room for
27
- * base64 blow-up (~5.6 MiB on the wire) and DO-transient headroom on a
28
- * future cloud implementation. Enforced BEFORE a read is attempted — the
29
- * tool calls `stat()` first and refuses over-cap without ever calling
30
- * `readRange()`.
31
- */
32
- export const MAX_ATTACHMENT_IMAGE_BYTES = 4 * 1024 * 1024;
33
-
34
- export interface AttachmentBytesProvider {
35
- /**
36
- * Byte size of the attachment's stored bytes, or `null` when the row
37
- * exists but its bytes don't — e.g. an `audio_retention` eviction after a
38
- * successful transcription (`src/transcription-worker.ts` unlinks the
39
- * file on `until_transcribed` / `never` retention), or any other
40
- * out-of-band loss. Drives the `attachment_binary_missing` refusal.
41
- */
42
- stat(attachment: Attachment): Promise<{ size: number } | null>;
43
- /**
44
- * Read the half-open byte range `[start, end)`. Bounded by construction —
45
- * callers never ask for the whole file when only a window is needed (the
46
- * text path); the image path DOES read start-to-end, but only after
47
- * `stat()` has already confirmed the file is under
48
- * {@link MAX_ATTACHMENT_IMAGE_BYTES}. `start`/`end` are always within
49
- * `[0, size]` as reported by a prior `stat()` call on the same attachment
50
- * — implementations don't need to re-clamp defensively, though doing so
51
- * costs nothing.
52
- */
53
- readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array>;
54
- /**
55
- * OPTIONAL: resolve the sibling transcript note for a completed
56
- * audio/video transcription (bun: `<attachment-path>.transcript`, see
57
- * `transcriptPathFor` in `src/transcript-note.ts`). Omitted by a door
58
- * whose transcript instead lives in the owning note's body (the design's
59
- * cloud path, D... — no separate sibling note to point at) — the
60
- * `read-attachment` audio branch falls back to `note_id` alone (the
61
- * owning note) in that case, per the design's "on cloud the transcript
62
- * lives in the owning note's body, so `note_id` is the pointer."
63
- */
64
- resolveTranscriptNote?(attachment: Attachment): Promise<{ id: string; path: string } | null>;
65
- }