@openparachute/vault 0.7.3-rc.10 → 0.7.3-rc.11

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.
@@ -0,0 +1,65 @@
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
+ }
@@ -23,8 +23,13 @@ import {
23
23
  parseContentRange,
24
24
  sliceContentRange,
25
25
  applyContentRange,
26
+ parseAttachmentContentRange,
27
+ alignByteWindow,
26
28
  MIN_CONTENT_LENGTH,
29
+ DEFAULT_ATTACHMENT_WINDOW_BYTES,
30
+ MAX_ATTACHMENT_WINDOW_BYTES,
27
31
  } from "./content-range.js";
32
+ import { QueryError } from "./query-operators.js";
28
33
 
29
34
  // ---------------------------------------------------------------------------
30
35
  // 1. parseContentRange
@@ -237,6 +242,128 @@ describe("content range — reassembly property", () => {
237
242
  });
238
243
  });
239
244
 
245
+ // ---------------------------------------------------------------------------
246
+ // 2c. parseAttachmentContentRange + alignByteWindow (read-attachment)
247
+ // ---------------------------------------------------------------------------
248
+
249
+ describe("parseAttachmentContentRange", () => {
250
+ it("defaults offset=0, length=DEFAULT_ATTACHMENT_WINDOW_BYTES when both are omitted", () => {
251
+ expect(parseAttachmentContentRange(undefined, undefined)).toEqual({
252
+ offset: 0,
253
+ length: DEFAULT_ATTACHMENT_WINDOW_BYTES,
254
+ });
255
+ });
256
+
257
+ it("offset only → length still defaults (never 'read to end' — that's the query-notes shape, not this one)", () => {
258
+ expect(parseAttachmentContentRange(1000, undefined)).toEqual({
259
+ offset: 1000,
260
+ length: DEFAULT_ATTACHMENT_WINDOW_BYTES,
261
+ });
262
+ });
263
+
264
+ it("accepts an explicit length up to the max", () => {
265
+ expect(parseAttachmentContentRange(0, MAX_ATTACHMENT_WINDOW_BYTES)).toEqual({
266
+ offset: 0,
267
+ length: MAX_ATTACHMENT_WINDOW_BYTES,
268
+ });
269
+ });
270
+
271
+ it("rejects a length below MIN_CONTENT_LENGTH", () => {
272
+ expect(() => parseAttachmentContentRange(0, 2)).toThrow(QueryError);
273
+ });
274
+
275
+ it("rejects a length above MAX_ATTACHMENT_WINDOW_BYTES", () => {
276
+ expect(() => parseAttachmentContentRange(0, MAX_ATTACHMENT_WINDOW_BYTES + 1)).toThrow(QueryError);
277
+ });
278
+
279
+ it("rejects a negative offset", () => {
280
+ expect(() => parseAttachmentContentRange(-1, undefined)).toThrow(QueryError);
281
+ });
282
+ });
283
+
284
+ describe("alignByteWindow", () => {
285
+ /**
286
+ * Simulates the real caller: a BOUNDED positional read of
287
+ * `[max(0, offset-3), min(total, offset+length))` from `full`, THEN
288
+ * alignment — never handing the whole buffer to `alignByteWindow`, so a
289
+ * pass here proves the "never load the whole file" contract actually
290
+ * holds and isn't just true by accident of the test passing the full
291
+ * buffer.
292
+ */
293
+ function boundedRead(full: Buffer, offset: number, length: number): { raw: Uint8Array; rawStart: number } {
294
+ const total = full.byteLength;
295
+ const rawStart = Math.max(0, offset - 3);
296
+ // +1: alignByteWindow's end-boundary check reads the byte AT the
297
+ // (exclusive) window end to decide whether it's a continuation byte —
298
+ // that's one byte past `offset + length`, so the read must include it
299
+ // (see alignByteWindow's doc comment precondition).
300
+ const rawEnd = Math.min(total, offset + length + 1);
301
+ return { raw: full.subarray(rawStart, Math.max(rawStart, rawEnd)), rawStart };
302
+ }
303
+
304
+ it("offset past end → empty slice, complete (mirrors sliceContentRange)", () => {
305
+ const full = Buffer.from("abc", "utf8");
306
+ const { raw, rawStart } = boundedRead(full, 999, 16);
307
+ const r = alignByteWindow(raw, rawStart, { offset: 999, length: 16 }, full.byteLength);
308
+ expect(r.content).toBe("");
309
+ expect(r.content_offset).toBe(3);
310
+ expect(r.content_total_length).toBe(3);
311
+ expect(r.content_next_offset).toBeNull();
312
+ });
313
+
314
+ it("matches sliceContentRange byte-for-byte on a plain ASCII window", () => {
315
+ const s = "hello world";
316
+ const full = Buffer.from(s, "utf8");
317
+ const { raw, rawStart } = boundedRead(full, 0, 5);
318
+ const bounded = alignByteWindow(raw, rawStart, { offset: 0, length: 5 }, full.byteLength);
319
+ const whole = sliceContentRange(s, { offset: 0, length: 5 });
320
+ expect(bounded).toEqual(whole);
321
+ });
322
+
323
+ it("never splits a codepoint under a bounded read (matches sliceContentRange across a 4-byte emoji)", () => {
324
+ const s = "ab\u{1F600}cd"; // emoji occupies bytes 2..5 of 8
325
+ const full = Buffer.from(s, "utf8");
326
+ for (const [offset, length] of [
327
+ [0, 5], // budget cuts mid-emoji → backs off to byte 2
328
+ [2, 4], // exact emoji window
329
+ [4, 8], // offset lands mid-emoji → aligns down to byte 2
330
+ [6, 4], // final ASCII tail
331
+ ] as const) {
332
+ const { raw, rawStart } = boundedRead(full, offset, length);
333
+ const bounded = alignByteWindow(raw, rawStart, { offset, length }, full.byteLength);
334
+ const whole = sliceContentRange(s, { offset, length });
335
+ expect(bounded).toEqual(whole);
336
+ }
337
+ });
338
+
339
+ it("reassembly property: chaining content_next_offset through BOUNDED reads reproduces the full content", () => {
340
+ const rand = mulberry32(0xba5eba11);
341
+ const POOL = ["a", "Z", "9", " ", "\n", "é", "ψ", "你", "‱", "\u{1F600}", "\u{1D11E}"];
342
+ for (let iter = 0; iter < 40; iter++) {
343
+ const charCount = Math.floor(rand() * 100);
344
+ let content = "";
345
+ for (let i = 0; i < charCount; i++) content += POOL[Math.floor(rand() * POOL.length)]!;
346
+ const full = Buffer.from(content, "utf8");
347
+ const total = full.byteLength;
348
+ const budget = MIN_CONTENT_LENGTH + Math.floor(rand() * 13); // 4..16 bytes
349
+
350
+ let offset = 0;
351
+ let assembled = "";
352
+ for (let step = 0; step <= total + 2; step++) {
353
+ const { raw, rawStart } = boundedRead(full, offset, budget);
354
+ const slice = alignByteWindow(raw, rawStart, { offset, length: budget }, total);
355
+ expect(Buffer.byteLength(slice.content, "utf8")).toBeLessThanOrEqual(budget);
356
+ expect(slice.content_total_length).toBe(total);
357
+ assembled += slice.content;
358
+ if (slice.content_next_offset === null) break;
359
+ expect(slice.content_next_offset).toBeGreaterThan(offset);
360
+ offset = slice.content_next_offset;
361
+ }
362
+ expect(assembled).toBe(content);
363
+ }
364
+ });
365
+ });
366
+
240
367
  // ---------------------------------------------------------------------------
241
368
  // 3. MCP face — query-notes
242
369
  // ---------------------------------------------------------------------------
@@ -183,3 +183,103 @@ export function applyContentRange(
183
183
  result.content_total_length = fields.content_total_length;
184
184
  result.content_next_offset = fields.content_next_offset;
185
185
  }
186
+
187
+ // ---------------------------------------------------------------------------
188
+ // Attachment byte-window reads (`read-attachment`, Wave 2 model lane)
189
+ // ---------------------------------------------------------------------------
190
+
191
+ /** Default `read-attachment` text window when the caller omits `content_length` — small enough to never nuke a context budget. */
192
+ export const DEFAULT_ATTACHMENT_WINDOW_BYTES = 65_536; // 64 KiB
193
+
194
+ /** Hard per-call ceiling on `read-attachment`'s `content_length` — a deliberate-big-bite max, not a default. */
195
+ export const MAX_ATTACHMENT_WINDOW_BYTES = 262_144; // 256 KiB
196
+
197
+ /**
198
+ * Parse `read-attachment`'s `content_offset` / `content_length` pair.
199
+ * Unlike {@link parseContentRange} (query-notes: omitted params mean "range
200
+ * mode off, return everything"), a `read-attachment` call ALWAYS reads a
201
+ * bounded window — omitting `content_length` defaults it to
202
+ * {@link DEFAULT_ATTACHMENT_WINDOW_BYTES} rather than "the whole file" (an
203
+ * attachment can be arbitrarily large; a note can't cheaply be). Returns a
204
+ * fully-resolved `{offset, length}` (never the query-notes "null = off"
205
+ * shape). Throws `QueryError` (INVALID_QUERY) on a negative/non-integer
206
+ * value, a `content_length` below {@link MIN_CONTENT_LENGTH}, or one above
207
+ * {@link MAX_ATTACHMENT_WINDOW_BYTES}.
208
+ */
209
+ export function parseAttachmentContentRange(
210
+ offsetRaw: unknown,
211
+ lengthRaw: unknown,
212
+ ): { offset: number; length: number } {
213
+ const offset = toNonNegativeInt(offsetRaw, "content_offset") ?? 0;
214
+ const length = toNonNegativeInt(lengthRaw, "content_length");
215
+ if (length !== undefined && length < MIN_CONTENT_LENGTH) {
216
+ throw new QueryError(
217
+ `invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — must be at least ${MIN_CONTENT_LENGTH} bytes (the size of the largest UTF-8 codepoint, so every window makes progress).`,
218
+ "INVALID_QUERY",
219
+ );
220
+ }
221
+ if (length !== undefined && length > MAX_ATTACHMENT_WINDOW_BYTES) {
222
+ throw new QueryError(
223
+ `invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — exceeds the ${MAX_ATTACHMENT_WINDOW_BYTES} byte (256 KiB) per-call max for read-attachment.`,
224
+ "INVALID_QUERY",
225
+ );
226
+ }
227
+ return { offset, length: length ?? DEFAULT_ATTACHMENT_WINDOW_BYTES };
228
+ }
229
+
230
+ /**
231
+ * Byte-level counterpart to {@link sliceContentRange} for `read-attachment`'s
232
+ * text path, where loading the WHOLE file into memory to slice a string
233
+ * (`sliceContentRange`'s approach) is exactly the thing a 500 MB attachment
234
+ * forbids. The caller does a BOUNDED positional read first — `raw` is
235
+ * whatever bytes it actually fetched, starting at file offset `rawStart`
236
+ * — and this function applies the identical alignment rules
237
+ * `sliceContentRange` applies to a full in-memory string, operating only on
238
+ * that window.
239
+ *
240
+ * Precondition (caller's responsibility, not re-validated here): `raw`
241
+ * covers at least `[max(0, range.offset - 3), min(total, range.offset +
242
+ * range.length) + 1)` — i.e. from 3 bytes before the requested offset
243
+ * through ONE byte past the requested end, clamped to `total`. The 3-byte
244
+ * lookback is enough to find the leading byte of any UTF-8 codepoint the
245
+ * requested `offset` might land inside (a codepoint is at most 4 bytes,
246
+ * i.e. at most 3 continuation bytes after its leading byte); the 1-byte
247
+ * lookahead is what the end-alignment check reads to decide whether the
248
+ * budget cut lands mid-codepoint (mirroring `sliceContentRange`, which
249
+ * checks `bytes[end]` — the byte AT the exclusive cut point).
250
+ */
251
+ export function alignByteWindow(
252
+ raw: Uint8Array,
253
+ rawStart: number,
254
+ range: { offset: number; length: number },
255
+ total: number,
256
+ ): ContentRangeFields {
257
+ if (range.offset >= total) {
258
+ return {
259
+ content: "",
260
+ content_offset: total,
261
+ content_total_length: total,
262
+ content_next_offset: null,
263
+ };
264
+ }
265
+
266
+ const byteAt = (idx: number): number => raw[idx - rawStart]!;
267
+
268
+ // Align the start DOWN to the leading byte of the codepoint containing
269
+ // `offset` — same rule as sliceContentRange, bounded to what's in `raw`.
270
+ let start = range.offset;
271
+ while (start > rawStart && isContinuationByte(byteAt(start))) start--;
272
+
273
+ // Window end: budget capped at total, then clamped to what's actually in
274
+ // `raw` (defense-in-depth — a caller that under-read would otherwise
275
+ // index past the buffer). Aligned DOWN so the slice never ends mid-codepoint.
276
+ let end = Math.min(start + range.length, total, rawStart + raw.length);
277
+ while (end > start && end < total && isContinuationByte(byteAt(end))) end--;
278
+
279
+ return {
280
+ content: Buffer.from(raw.subarray(start - rawStart, end - rawStart)).toString("utf8"),
281
+ content_offset: start,
282
+ content_total_length: total,
283
+ content_next_offset: end >= total ? null : end,
284
+ };
285
+ }