@openparachute/vault 0.7.3-rc.10 → 0.7.3-rc.12
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/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +100 -0
- package/core/src/mcp.ts +331 -1
- package/core/src/vault-projection.ts +17 -10
- package/package.json +1 -1
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +126 -1
- package/src/attachment-tickets.ts +77 -1
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/server.ts +7 -0
- package/src/storage.test.ts +200 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault.test.ts +26 -13
package/core/src/mcp.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { Store, Note, QueryOpts } from "./types.js";
|
|
2
|
+
import type { Store, Note, QueryOpts, Attachment } from "./types.js";
|
|
3
3
|
import { transactionAsync } from "./txn.js";
|
|
4
4
|
import * as noteOps from "./notes.js";
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, validatePath } from "./notes.js";
|
|
@@ -43,10 +43,13 @@ import {
|
|
|
43
43
|
parseContentRange,
|
|
44
44
|
applyContentRange,
|
|
45
45
|
contentRangeRequiresContent,
|
|
46
|
+
parseAttachmentContentRange,
|
|
47
|
+
alignByteWindow,
|
|
46
48
|
MIN_CONTENT_LENGTH,
|
|
47
49
|
} from "./content-range.js";
|
|
48
50
|
import {
|
|
49
51
|
BLOCKED_ATTACHMENT_EXTENSIONS,
|
|
52
|
+
ATTACHMENT_MIME_TYPES,
|
|
50
53
|
sanitizeAttachmentExtension,
|
|
51
54
|
mimeForAttachmentExtension,
|
|
52
55
|
} from "./attachment/policy.js";
|
|
@@ -57,6 +60,23 @@ import {
|
|
|
57
60
|
type AttachmentTicket,
|
|
58
61
|
type AttachmentTicketProvider,
|
|
59
62
|
} from "./attachment/tickets.js";
|
|
63
|
+
import {
|
|
64
|
+
MAX_ATTACHMENT_IMAGE_BYTES,
|
|
65
|
+
type AttachmentBytesProvider,
|
|
66
|
+
} from "./attachment/bytes-provider.js";
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A single MCP tool-result content block. Mirrors the subset of the MCP SDK's
|
|
70
|
+
* `CallToolResult.content` shape this codebase actually emits — a plain text
|
|
71
|
+
* block (the existing, universal shape) or a real image block (`read-attachment`'s
|
|
72
|
+
* image branch, D3). Kept as a local, minimal type rather than importing the
|
|
73
|
+
* SDK's own (broader — audio, resource links, ...) union, since core has no
|
|
74
|
+
* dependency on `@modelcontextprotocol/sdk` today and this is the only shape
|
|
75
|
+
* any tool here produces.
|
|
76
|
+
*/
|
|
77
|
+
export type McpContentBlock =
|
|
78
|
+
| { type: "text"; text: string }
|
|
79
|
+
| { type: "image"; data: string; mimeType: string };
|
|
60
80
|
|
|
61
81
|
export interface McpToolDef {
|
|
62
82
|
name: string;
|
|
@@ -75,6 +95,17 @@ export interface McpToolDef {
|
|
|
75
95
|
* future addition that forgets to stamp this gets the safer treatment.
|
|
76
96
|
*/
|
|
77
97
|
requiredVerb: "read" | "write" | "admin";
|
|
98
|
+
/**
|
|
99
|
+
* OPTIONAL override for how `execute()`'s return value becomes MCP
|
|
100
|
+
* `content` blocks. Every tool WITHOUT this wraps its result as the single
|
|
101
|
+
* `JSON.stringify` text block the HTTP layer has always produced
|
|
102
|
+
* (`src/mcp-http.ts`) — unchanged default behavior. `read-attachment`'s
|
|
103
|
+
* image branch is the only current user: it needs a REAL `{type:"image"}`
|
|
104
|
+
* block alongside the row-JSON text block so the model actually SEES the
|
|
105
|
+
* picture, not just its metadata (D3, attachments-for-agents design "the
|
|
106
|
+
* one wrapper change").
|
|
107
|
+
*/
|
|
108
|
+
resultContent?: (result: unknown) => McpContentBlock[];
|
|
78
109
|
}
|
|
79
110
|
|
|
80
111
|
// ---------------------------------------------------------------------------
|
|
@@ -166,6 +197,172 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
|
166
197
|
return content;
|
|
167
198
|
}
|
|
168
199
|
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// read-attachment helpers (Wave 2 model lane — D2-D5)
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
/** Non-`text/*` mime types `read-attachment` still treats as text (D2's "text/* + TEXT_MIMES allowlist"). `text/csv` and `text/markdown` already match `text/*` via ATTACHMENT_MIME_TYPES, so they need no entry here. */
|
|
205
|
+
const TEXT_MIME_ALLOWLIST = new Set([
|
|
206
|
+
"application/json",
|
|
207
|
+
"application/ndjson",
|
|
208
|
+
"application/x-ndjson",
|
|
209
|
+
"application/yaml",
|
|
210
|
+
"application/x-yaml",
|
|
211
|
+
]);
|
|
212
|
+
|
|
213
|
+
function baseMime(mimeType: string): string {
|
|
214
|
+
return mimeType.split(";")[0]!.trim().toLowerCase();
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function isTextMime(mimeType: string): boolean {
|
|
218
|
+
const base = baseMime(mimeType);
|
|
219
|
+
return base.startsWith("text/") || TEXT_MIME_ALLOWLIST.has(base);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function isImageMime(mimeType: string): boolean {
|
|
223
|
+
return baseMime(mimeType).startsWith("image/");
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function isAudioOrVideoMime(mimeType: string): boolean {
|
|
227
|
+
const base = baseMime(mimeType);
|
|
228
|
+
return base.startsWith("audio/") || base.startsWith("video/");
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Effective mime for `read-attachment` — same discipline the REST byte-serve
|
|
233
|
+
* route uses (`src/routes.ts`'s `GET /storage/<path>`): the stored file's
|
|
234
|
+
* EXTENSION wins over the row's `mime_type` column, since the row's mime is
|
|
235
|
+
* caller-asserted at upload time and never verified against the bytes. Falls
|
|
236
|
+
* back to the row's mime_type, then `application/octet-stream`.
|
|
237
|
+
*/
|
|
238
|
+
function effectiveAttachmentMime(attachment: Attachment): string {
|
|
239
|
+
const ext = sanitizeAttachmentExtension(attachment.path);
|
|
240
|
+
return ATTACHMENT_MIME_TYPES[ext] ?? attachment.mimeType ?? "application/octet-stream";
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/** Stat the attachment's bytes, or throw the `attachment_binary_missing` refusal (D5's "row outlived bytes" case — e.g. an audio-retention eviction). */
|
|
244
|
+
async function statAttachmentOrMissing(
|
|
245
|
+
attachment: Attachment,
|
|
246
|
+
provider: AttachmentBytesProvider,
|
|
247
|
+
): Promise<{ size: number }> {
|
|
248
|
+
const stat = await provider.stat(attachment);
|
|
249
|
+
if (!stat) {
|
|
250
|
+
throw structuredError(`Attachment binary missing: "${attachment.id}"`, {
|
|
251
|
+
error_type: "attachment_binary_missing",
|
|
252
|
+
how_to:
|
|
253
|
+
"the attachment row exists but its bytes are gone (e.g. an audio-retention eviction after transcription) — this content can't be read",
|
|
254
|
+
});
|
|
255
|
+
}
|
|
256
|
+
return stat;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Text branch: byte-windowed read using the exact query-notes pagination
|
|
261
|
+
* contract (`content`/`content_offset`/`content_total_length`/
|
|
262
|
+
* `content_next_offset`). Does a BOUNDED positional read — never the whole
|
|
263
|
+
* file — via `alignByteWindow` (see its doc comment for the exact window
|
|
264
|
+
* the provider is asked to fetch).
|
|
265
|
+
*/
|
|
266
|
+
async function readTextAttachment(
|
|
267
|
+
attachment: Attachment,
|
|
268
|
+
mimeType: string,
|
|
269
|
+
params: Record<string, unknown>,
|
|
270
|
+
provider: AttachmentBytesProvider,
|
|
271
|
+
): Promise<Record<string, unknown>> {
|
|
272
|
+
const range = parseAttachmentContentRange(params.content_offset, params.content_length);
|
|
273
|
+
const stat = await statAttachmentOrMissing(attachment, provider);
|
|
274
|
+
const total = stat.size;
|
|
275
|
+
|
|
276
|
+
if (range.offset >= total) {
|
|
277
|
+
return {
|
|
278
|
+
attachment_id: attachment.id,
|
|
279
|
+
mime_type: mimeType,
|
|
280
|
+
content: "",
|
|
281
|
+
content_offset: total,
|
|
282
|
+
content_total_length: total,
|
|
283
|
+
content_next_offset: null,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
const rawStart = Math.max(0, range.offset - 3);
|
|
288
|
+
// +1: alignByteWindow's end-boundary check reads the byte AT the window's
|
|
289
|
+
// exclusive end — see its doc comment precondition.
|
|
290
|
+
const rawEnd = Math.min(total, range.offset + range.length + 1);
|
|
291
|
+
const raw = await provider.readRange(attachment, rawStart, rawEnd);
|
|
292
|
+
const fields = alignByteWindow(raw, rawStart, range, total);
|
|
293
|
+
|
|
294
|
+
return {
|
|
295
|
+
attachment_id: attachment.id,
|
|
296
|
+
mime_type: mimeType,
|
|
297
|
+
...fields,
|
|
298
|
+
};
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* Image branch: whole-file read, gated by {@link MAX_ATTACHMENT_IMAGE_BYTES}
|
|
303
|
+
* (checked via `stat` BEFORE any bytes are read — an over-cap image never
|
|
304
|
+
* touches `readRange` at all). The base64 payload rides in `_mcpImage`, a
|
|
305
|
+
* field this tool's own `resultContent` consumes to build the real MCP image
|
|
306
|
+
* block and then strips before the text block is serialized (see the tool
|
|
307
|
+
* definition below) — no other caller should read `_mcpImage`.
|
|
308
|
+
*/
|
|
309
|
+
async function readImageAttachment(
|
|
310
|
+
attachment: Attachment,
|
|
311
|
+
mimeType: string,
|
|
312
|
+
provider: AttachmentBytesProvider,
|
|
313
|
+
): Promise<Record<string, unknown>> {
|
|
314
|
+
const stat = await statAttachmentOrMissing(attachment, provider);
|
|
315
|
+
if (stat.size > MAX_ATTACHMENT_IMAGE_BYTES) {
|
|
316
|
+
throw structuredError(
|
|
317
|
+
`Image attachment (${stat.size} bytes) exceeds the ${MAX_ATTACHMENT_IMAGE_BYTES} byte (4 MiB) read cap`,
|
|
318
|
+
{
|
|
319
|
+
error_type: "image_too_large",
|
|
320
|
+
size: stat.size,
|
|
321
|
+
max_bytes: MAX_ATTACHMENT_IMAGE_BYTES,
|
|
322
|
+
how_to: "mint a download ticket with request-attachment-download and process the image locally",
|
|
323
|
+
},
|
|
324
|
+
);
|
|
325
|
+
}
|
|
326
|
+
const raw = await provider.readRange(attachment, 0, stat.size);
|
|
327
|
+
return {
|
|
328
|
+
attachment_id: attachment.id,
|
|
329
|
+
mime_type: mimeType,
|
|
330
|
+
size_bytes: stat.size,
|
|
331
|
+
_mcpImage: { data: Buffer.from(raw).toString("base64"), mimeType },
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Audio/video branch: never bytes. Returns a transcript pointer built
|
|
337
|
+
* entirely from metadata the transcription pipeline already stamps
|
|
338
|
+
* (`attachment.metadata.transcribe_status`) plus the provider's OPTIONAL
|
|
339
|
+
* sibling-note resolution — no bytes are read or even stat'd.
|
|
340
|
+
*/
|
|
341
|
+
async function readAudioPointer(
|
|
342
|
+
attachment: Attachment,
|
|
343
|
+
provider: AttachmentBytesProvider,
|
|
344
|
+
): Promise<Record<string, unknown>> {
|
|
345
|
+
const meta = attachment.metadata as Record<string, unknown> | undefined;
|
|
346
|
+
const transcribeStatus = typeof meta?.transcribe_status === "string" ? meta.transcribe_status : undefined;
|
|
347
|
+
if (!transcribeStatus) {
|
|
348
|
+
throw structuredError(`Audio/video attachment "${attachment.id}" has no transcript`, {
|
|
349
|
+
error_type: "audio_bytes_not_supported",
|
|
350
|
+
how_to:
|
|
351
|
+
"re-attach with transcribe: true to get a transcript, or mint a download ticket with request-attachment-download to process the bytes locally",
|
|
352
|
+
});
|
|
353
|
+
}
|
|
354
|
+
const result: Record<string, unknown> = {
|
|
355
|
+
attachment_id: attachment.id,
|
|
356
|
+
transcribe_status: transcribeStatus,
|
|
357
|
+
note_id: attachment.noteId,
|
|
358
|
+
};
|
|
359
|
+
if (provider.resolveTranscriptNote) {
|
|
360
|
+
const transcriptNote = await provider.resolveTranscriptNote(attachment);
|
|
361
|
+
if (transcriptNote) result.transcript_note = transcriptNote;
|
|
362
|
+
}
|
|
363
|
+
return result;
|
|
364
|
+
}
|
|
365
|
+
|
|
169
366
|
// ---------------------------------------------------------------------------
|
|
170
367
|
// Tool generation
|
|
171
368
|
// ---------------------------------------------------------------------------
|
|
@@ -291,6 +488,25 @@ export interface GenerateMcpToolsOpts {
|
|
|
291
488
|
*/
|
|
292
489
|
noteVisible?: (note: Note) => boolean | Promise<boolean>;
|
|
293
490
|
};
|
|
491
|
+
/**
|
|
492
|
+
* `AttachmentBytesProvider` seam (Wave 2 model lane — D10, same "tools
|
|
493
|
+
* omitted when unwired" posture as `attachmentTickets` above). When
|
|
494
|
+
* provided, `generateMcpTools` appends `read-attachment` to the returned
|
|
495
|
+
* tool list. Omitted (a door that hasn't wired byte access yet) →
|
|
496
|
+
* `read-attachment` is ABSENT from the list entirely.
|
|
497
|
+
*/
|
|
498
|
+
attachmentBytes?: {
|
|
499
|
+
provider: AttachmentBytesProvider;
|
|
500
|
+
/**
|
|
501
|
+
* OPTIONAL per-note visibility predicate — identical contract to
|
|
502
|
+
* `attachmentTickets.noteVisible` above (same tag-scope confidentiality
|
|
503
|
+
* intent; kept as a separate field since a caller could in principle
|
|
504
|
+
* wire one seam without the other, though the server layer always wires
|
|
505
|
+
* both from the same underlying check). Omitted → every attachment is
|
|
506
|
+
* visible.
|
|
507
|
+
*/
|
|
508
|
+
noteVisible?: (note: Note) => boolean | Promise<boolean>;
|
|
509
|
+
};
|
|
294
510
|
}
|
|
295
511
|
|
|
296
512
|
/**
|
|
@@ -3019,6 +3235,120 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
3019
3235
|
);
|
|
3020
3236
|
}
|
|
3021
3237
|
|
|
3238
|
+
// =====================================================================
|
|
3239
|
+
// 14. read-attachment — model-lane byte reads (Wave 2). Present ONLY
|
|
3240
|
+
// when the server layer wires an AttachmentBytesProvider — see
|
|
3241
|
+
// `GenerateMcpToolsOpts.attachmentBytes`'s doc comment (D10). Dispatches
|
|
3242
|
+
// by mime family: text ranges come back as `content`/`content_offset`/
|
|
3243
|
+
// `content_total_length`/`content_next_offset` (the exact query-notes
|
|
3244
|
+
// pagination contract); images come back as a real MCP image block (see
|
|
3245
|
+
// this tool's `resultContent`); audio/video never send bytes — a
|
|
3246
|
+
// transcript pointer instead; PDF/other binary refuse with a
|
|
3247
|
+
// download-ticket pointer. Unlike the ticket tools, bytes (or a base64
|
|
3248
|
+
// encoding of them) DO pass through this tool — that's the whole point
|
|
3249
|
+
// of the model lane.
|
|
3250
|
+
// =====================================================================
|
|
3251
|
+
const bytesSeam = opts?.attachmentBytes;
|
|
3252
|
+
if (bytesSeam) {
|
|
3253
|
+
tools.push({
|
|
3254
|
+
name: "read-attachment",
|
|
3255
|
+
requiredVerb: "read",
|
|
3256
|
+
description:
|
|
3257
|
+
"Read an attachment's content directly into this conversation (the model lane — bytes DO pass through this tool, unlike request-attachment-upload/download). Behavior depends on mime type: text/* (+ json/ndjson/yaml — csv/markdown are already text/*) returns a byte-windowed `content` slice using the exact query-notes content_offset/content_length/content_next_offset pagination contract (default 65536 bytes / 64 KiB, max 262144 / 256 KiB per call — loop, feeding content_next_offset back as content_offset, for more). image/* returns a real image you can see, capped at 4 MiB raw (over-cap refuses with a pointer to request-attachment-download; content_offset/content_length don't apply to images). audio/video never send bytes — you get back a transcript pointer (transcribe_status, note_id, and a transcript_note when one exists) instead of the audio itself. PDF and other binary formats aren't directly readable here — mint a download ticket with request-attachment-download and process the file with your own runtime.",
|
|
3258
|
+
inputSchema: {
|
|
3259
|
+
type: "object",
|
|
3260
|
+
properties: {
|
|
3261
|
+
attachment_id: {
|
|
3262
|
+
type: "string",
|
|
3263
|
+
description: "The attachment's id (from a note's attachment rows, e.g. include_attachments: true on query-notes)",
|
|
3264
|
+
},
|
|
3265
|
+
content_offset: {
|
|
3266
|
+
type: "number",
|
|
3267
|
+
description: "Text attachments only. Byte offset to start reading from (UTF-8 bytes). Defaults to 0.",
|
|
3268
|
+
},
|
|
3269
|
+
content_length: {
|
|
3270
|
+
type: "number",
|
|
3271
|
+
description: "Text attachments only. Byte budget for this call. Defaults to 65536 (64 KiB); max 262144 (256 KiB).",
|
|
3272
|
+
},
|
|
3273
|
+
},
|
|
3274
|
+
required: ["attachment_id"],
|
|
3275
|
+
},
|
|
3276
|
+
execute: async (params) => {
|
|
3277
|
+
const attachmentId = params.attachment_id;
|
|
3278
|
+
if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
|
|
3279
|
+
throw structuredError("`attachment_id` is required", {
|
|
3280
|
+
error_type: "missing_required_field",
|
|
3281
|
+
field: "attachment_id",
|
|
3282
|
+
how_to: "pass the attachment id from a note's attachment rows",
|
|
3283
|
+
});
|
|
3284
|
+
}
|
|
3285
|
+
|
|
3286
|
+
const attachment = await store.getAttachment(attachmentId);
|
|
3287
|
+
if (!attachment) {
|
|
3288
|
+
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
3289
|
+
error_type: "not_found",
|
|
3290
|
+
field: "attachment_id",
|
|
3291
|
+
});
|
|
3292
|
+
}
|
|
3293
|
+
|
|
3294
|
+
if (bytesSeam.noteVisible) {
|
|
3295
|
+
const owningNote = await store.getNote(attachment.noteId);
|
|
3296
|
+
// Same uniform not_found posture as the ticket tools — a
|
|
3297
|
+
// tag-scoped caller learns nothing about an out-of-scope note's
|
|
3298
|
+
// existence via a differential error.
|
|
3299
|
+
if (!owningNote || !(await bytesSeam.noteVisible(owningNote))) {
|
|
3300
|
+
throw structuredError(`Attachment not found: "${attachmentId}"`, {
|
|
3301
|
+
error_type: "not_found",
|
|
3302
|
+
field: "attachment_id",
|
|
3303
|
+
});
|
|
3304
|
+
}
|
|
3305
|
+
}
|
|
3306
|
+
|
|
3307
|
+
const mimeType = effectiveAttachmentMime(attachment);
|
|
3308
|
+
|
|
3309
|
+
if (isImageMime(mimeType)) {
|
|
3310
|
+
if (params.content_offset !== undefined || params.content_length !== undefined) {
|
|
3311
|
+
throw structuredError("content_offset/content_length don't apply to image attachments", {
|
|
3312
|
+
error_type: "invalid_query",
|
|
3313
|
+
field: "content_offset",
|
|
3314
|
+
hint: "omit content_offset/content_length for an image read — the whole image (up to the 4 MiB cap) comes back in one call",
|
|
3315
|
+
});
|
|
3316
|
+
}
|
|
3317
|
+
return await readImageAttachment(attachment, mimeType, bytesSeam.provider);
|
|
3318
|
+
}
|
|
3319
|
+
if (isTextMime(mimeType)) {
|
|
3320
|
+
return await readTextAttachment(attachment, mimeType, params, bytesSeam.provider);
|
|
3321
|
+
}
|
|
3322
|
+
if (isAudioOrVideoMime(mimeType)) {
|
|
3323
|
+
return await readAudioPointer(attachment, bytesSeam.provider);
|
|
3324
|
+
}
|
|
3325
|
+
|
|
3326
|
+
// Other binary (PDF, zip, docx, ...) — refuse honestly rather than
|
|
3327
|
+
// returning garbage or truncated bytes; extraction is a v2 concern
|
|
3328
|
+
// (D5). Still stats first so a row whose bytes are ALSO gone gets
|
|
3329
|
+
// the more accurate attachment_binary_missing instead.
|
|
3330
|
+
const stat = await statAttachmentOrMissing(attachment, bytesSeam.provider);
|
|
3331
|
+
throw structuredError(`Attachment type "${mimeType}" isn't directly readable by this tool`, {
|
|
3332
|
+
error_type: "unsupported_attachment_type",
|
|
3333
|
+
mime_type: mimeType,
|
|
3334
|
+
size: stat.size,
|
|
3335
|
+
how_to: "mint a download ticket with request-attachment-download and process the file locally",
|
|
3336
|
+
});
|
|
3337
|
+
},
|
|
3338
|
+
resultContent: (result) => {
|
|
3339
|
+
const r = result as ({ _mcpImage?: { data: string; mimeType: string } } & Record<string, unknown>) | null;
|
|
3340
|
+
if (r && r._mcpImage) {
|
|
3341
|
+
const { _mcpImage, ...rest } = r;
|
|
3342
|
+
return [
|
|
3343
|
+
{ type: "text", text: JSON.stringify(rest, null, 2) },
|
|
3344
|
+
{ type: "image", data: _mcpImage.data, mimeType: _mcpImage.mimeType },
|
|
3345
|
+
];
|
|
3346
|
+
}
|
|
3347
|
+
return [{ type: "text", text: JSON.stringify(result, null, 2) }];
|
|
3348
|
+
},
|
|
3349
|
+
});
|
|
3350
|
+
}
|
|
3351
|
+
|
|
3022
3352
|
return tools;
|
|
3023
3353
|
}
|
|
3024
3354
|
|
|
@@ -283,27 +283,34 @@ function sqliteToUserType(t: string): string {
|
|
|
283
283
|
* its own context on them (bytes never ride MCP either way).
|
|
284
284
|
*
|
|
285
285
|
* `ticketsEnabled` reflects whether THIS door has wired an
|
|
286
|
-
* `AttachmentTicketProvider` (bun: always, as of
|
|
287
|
-
* — its mirror is a separate PR).
|
|
288
|
-
*
|
|
289
|
-
*
|
|
290
|
-
*
|
|
286
|
+
* `AttachmentTicketProvider` (bun: always, as of the Wave 1 PR; cloud: not
|
|
287
|
+
* yet — its mirror is a separate PR). `readEnabled` reflects whether it's
|
|
288
|
+
* wired an `AttachmentBytesProvider` (bun: always, as of this PR — Wave 2).
|
|
289
|
+
* An unwired seam omits BOTH its tool(s) from `tools/list` (see
|
|
290
|
+
* `generateMcpTools`'s `attachmentTickets` / `attachmentBytes` opts) AND
|
|
291
|
+
* its sentence here — the brief never dangles a pointer at a tool the
|
|
292
|
+
* agent can't actually call.
|
|
291
293
|
*
|
|
292
294
|
* Kept as a single dense paragraph (not its own multi-line list) to stay
|
|
293
295
|
* inside the connect-time brief's token budget — see
|
|
294
296
|
* `projectionToMarkdown`'s doc comment.
|
|
295
297
|
*/
|
|
296
|
-
export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean }): string {
|
|
298
|
+
export function attachmentsInstructionBlock(opts: { ticketsEnabled: boolean; readEnabled?: boolean }): string {
|
|
297
299
|
const sentences: string[] = [
|
|
298
|
-
"Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP).",
|
|
300
|
+
"Notes can carry file attachments (`include_attachments: true` on `query-notes` returns their rows; bytes don't ride MCP tool RESULTS unless you ask for them).",
|
|
299
301
|
];
|
|
300
302
|
if (opts.ticketsEnabled) {
|
|
301
303
|
sentences.push(
|
|
302
|
-
"To move bytes, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
|
|
304
|
+
"To move bytes without spending your own context, call `request-attachment-upload` / `request-attachment-download` — each mints a short-lived, single-use URL (with a ready-to-run `curl_example`) your shell spends directly; no MCP session credential is needed to spend it.",
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
if (opts.readEnabled) {
|
|
308
|
+
sentences.push(
|
|
309
|
+
"To read an attachment directly into this conversation, call `read-attachment` — text comes back as a paginated `content` slice, images as a real image you can see, audio/video as a transcript pointer (never raw bytes), and PDF/other binary formats point you at a download ticket instead.",
|
|
303
310
|
);
|
|
304
311
|
}
|
|
305
312
|
sentences.push(
|
|
306
|
-
"If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer
|
|
313
|
+
"If your runtime holds this vault's own API token, REST works too: upload = `POST {base}/storage/upload` (multipart `file`, ≤100 MB) then `POST {base}/notes/{id}/attachments` `{path, mimeType, transcribe?}`; download = `GET {base}/storage/{path}` with the same `Authorization: Bearer` (honors a `Range: bytes=a-b` header for partial reads). Audio attached with `transcribe: true` is transcribed automatically.",
|
|
307
314
|
);
|
|
308
315
|
return sentences.join(" ");
|
|
309
316
|
}
|
|
@@ -344,7 +351,7 @@ export function projectionToMarkdown(args: {
|
|
|
344
351
|
* that hasn't wired ticket support yet (or a test fixture) never
|
|
345
352
|
* accidentally advertises tools it can't back.
|
|
346
353
|
*/
|
|
347
|
-
attachments?: { ticketsEnabled: boolean };
|
|
354
|
+
attachments?: { ticketsEnabled: boolean; readEnabled?: boolean };
|
|
348
355
|
}): string {
|
|
349
356
|
const { vaultName, description, projection, coordinates } = args;
|
|
350
357
|
const stats = projection.stats;
|
package/package.json
CHANGED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Attachment bytes — bun's filesystem implementation of the model-lane
|
|
3
|
+
* (Wave 2) `AttachmentBytesProvider` seam (`core/src/attachment/bytes-provider.ts`).
|
|
4
|
+
*
|
|
5
|
+
* Stateless by design (unlike the ticket provider, there's no in-memory
|
|
6
|
+
* state to share across requests) — a fresh instance per MCP session is
|
|
7
|
+
* cheap, so `createFsAttachmentBytesProvider` is a plain factory, not a
|
|
8
|
+
* shared singleton.
|
|
9
|
+
*
|
|
10
|
+
* `readRange` uses `Bun.file(path).slice(start, end)` — a bounded,
|
|
11
|
+
* positional read (Bun resolves the slice lazily via `pread` under the
|
|
12
|
+
* hood), never the whole-file `readFileSync` this design explicitly moves
|
|
13
|
+
* away from (see `handleStorage`'s REST `Range` support in `src/routes.ts`
|
|
14
|
+
* for the sibling fix on the byte-serve side).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { existsSync, statSync } from "fs";
|
|
18
|
+
import { join, normalize } from "path";
|
|
19
|
+
import type { Attachment } from "../core/src/types.ts";
|
|
20
|
+
import type { AttachmentBytesProvider } from "../core/src/attachment/bytes-provider.ts";
|
|
21
|
+
import { assetsDir } from "./config.ts";
|
|
22
|
+
import { transcriptPathFor } from "./transcript-note.ts";
|
|
23
|
+
import { getVaultStore } from "./vault-store.ts";
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Resolve + confine an attachment's on-disk path under this vault's assets
|
|
27
|
+
* dir. Same guard as the ticket download spend route (`src/attachment-tickets.ts`)
|
|
28
|
+
* and the REST byte-serve route (`src/routes.ts`): normalize, then require
|
|
29
|
+
* the result to still start with the (normalized) assets root — a stored
|
|
30
|
+
* `path` can never resolve outside it, but a defense-in-depth check costs
|
|
31
|
+
* nothing. Returns `null` on a traversal attempt (treated identically to
|
|
32
|
+
* "file doesn't exist" by every caller here).
|
|
33
|
+
*/
|
|
34
|
+
function resolveConfinedPath(vaultName: string, attachment: Attachment): string | null {
|
|
35
|
+
const assets = assetsDir(vaultName);
|
|
36
|
+
const filePath = normalize(join(assets, attachment.path));
|
|
37
|
+
if (!filePath.startsWith(normalize(assets))) return null;
|
|
38
|
+
return filePath;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
class FsAttachmentBytesProvider implements AttachmentBytesProvider {
|
|
42
|
+
constructor(private readonly vaultName: string) {}
|
|
43
|
+
|
|
44
|
+
async stat(attachment: Attachment): Promise<{ size: number } | null> {
|
|
45
|
+
const filePath = resolveConfinedPath(this.vaultName, attachment);
|
|
46
|
+
if (!filePath || !existsSync(filePath)) return null;
|
|
47
|
+
return { size: statSync(filePath).size };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
async readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array> {
|
|
51
|
+
const filePath = resolveConfinedPath(this.vaultName, attachment);
|
|
52
|
+
if (!filePath) return new Uint8Array(0);
|
|
53
|
+
const slice = Bun.file(filePath).slice(start, end);
|
|
54
|
+
return new Uint8Array(await slice.arrayBuffer());
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async resolveTranscriptNote(attachment: Attachment): Promise<{ id: string; path: string } | null> {
|
|
58
|
+
const store = getVaultStore(this.vaultName);
|
|
59
|
+
const note = await store.getNoteByPath(transcriptPathFor(attachment.path));
|
|
60
|
+
if (!note) return null;
|
|
61
|
+
return { id: note.id, path: note.path ?? transcriptPathFor(attachment.path) };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Build a fresh `AttachmentBytesProvider` scoped to one vault. Cheap — safe to call per-request. */
|
|
66
|
+
export function createFsAttachmentBytesProvider(vaultName: string): AttachmentBytesProvider {
|
|
67
|
+
return new FsAttachmentBytesProvider(vaultName);
|
|
68
|
+
}
|
|
@@ -29,7 +29,15 @@ const { handleScopedMcp } = await import("./mcp-http.ts");
|
|
|
29
29
|
const { getServerInstruction } = await import("./mcp-tools.ts");
|
|
30
30
|
const { writeVaultConfig } = await import("./config.ts");
|
|
31
31
|
const { getVaultStore } = await import("./vault-store.ts");
|
|
32
|
-
const {
|
|
32
|
+
const {
|
|
33
|
+
getSharedAttachmentTicketProvider,
|
|
34
|
+
InProcessAttachmentTicketProvider,
|
|
35
|
+
sweepExpiredAttachmentTickets,
|
|
36
|
+
startAttachmentTicketSweep,
|
|
37
|
+
stopAttachmentTicketSweep,
|
|
38
|
+
} = await import("./attachment-tickets.ts");
|
|
39
|
+
const { generateTicketId } = await import("../core/src/attachment/tickets.ts");
|
|
40
|
+
import type { AttachmentTicket } from "../core/src/attachment/tickets.ts";
|
|
33
41
|
const { attachmentsInstructionBlock } = await import("../core/src/vault-projection.ts");
|
|
34
42
|
|
|
35
43
|
/** `route()` takes the pathname as a separate arg (server.ts derives it from `req.url`) — this wrapper matches that call shape everywhere below. */
|
|
@@ -347,4 +355,121 @@ describe("attachment tickets — discoverability", () => {
|
|
|
347
355
|
expect(md).toContain("## Attachments");
|
|
348
356
|
expect(md).toContain("request-attachment-upload");
|
|
349
357
|
});
|
|
358
|
+
|
|
359
|
+
test("attachmentsInstructionBlock teaches read-attachment when readEnabled, and omits it when not", () => {
|
|
360
|
+
const enabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: true });
|
|
361
|
+
expect(enabled).toContain("read-attachment");
|
|
362
|
+
|
|
363
|
+
const disabled = attachmentsInstructionBlock({ ticketsEnabled: true, readEnabled: false });
|
|
364
|
+
expect(disabled).not.toContain("read-attachment");
|
|
365
|
+
});
|
|
366
|
+
|
|
367
|
+
test("bun's connect-time getServerInstruction teaches read-attachment too", async () => {
|
|
368
|
+
const vaultName = freshVault("tickets-instructions-read");
|
|
369
|
+
const md = await getServerInstruction(vaultName);
|
|
370
|
+
expect(md).toContain("read-attachment");
|
|
371
|
+
});
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
describe("attachment ticket sweep (vault#612)", () => {
|
|
375
|
+
// Isolated instance — no risk of touching the one process-wide provider
|
|
376
|
+
// every OTHER test in this file (and every other test FILE, via
|
|
377
|
+
// getSharedAttachmentTicketProvider) also shares.
|
|
378
|
+
function ticketExpiringAt(id: string, expiresAt: number): AttachmentTicket {
|
|
379
|
+
return {
|
|
380
|
+
id,
|
|
381
|
+
kind: "download",
|
|
382
|
+
vaultName: "sweep-unit-test",
|
|
383
|
+
createdAt: expiresAt - 60_000,
|
|
384
|
+
expiresAt,
|
|
385
|
+
attachmentId: "att-1",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
test("sweepExpired drops only expired-unspent tickets and returns the count dropped", async () => {
|
|
390
|
+
const provider = new InProcessAttachmentTicketProvider();
|
|
391
|
+
const now = Date.now();
|
|
392
|
+
await provider.put(ticketExpiringAt("expired-1", now - 5000));
|
|
393
|
+
await provider.put(ticketExpiringAt("expired-2", now - 1));
|
|
394
|
+
await provider.put(ticketExpiringAt("fresh-1", now + 60_000));
|
|
395
|
+
expect(provider.size()).toBe(3);
|
|
396
|
+
|
|
397
|
+
const dropped = provider.sweepExpired(now);
|
|
398
|
+
expect(dropped).toBe(2);
|
|
399
|
+
expect(provider.size()).toBe(1);
|
|
400
|
+
|
|
401
|
+
// Dropped tickets are gone — take() returns null, same as "never existed".
|
|
402
|
+
expect(await provider.take("expired-1")).toBeNull();
|
|
403
|
+
expect(await provider.take("expired-2")).toBeNull();
|
|
404
|
+
// The unexpired ticket survived the sweep and is still spendable.
|
|
405
|
+
const fresh = await provider.take("fresh-1");
|
|
406
|
+
expect(fresh?.id).toBe("fresh-1");
|
|
407
|
+
});
|
|
408
|
+
|
|
409
|
+
test("a ticket expiring exactly `now` counts as expired (< comparison, not <=)", async () => {
|
|
410
|
+
const provider = new InProcessAttachmentTicketProvider();
|
|
411
|
+
const now = Date.now();
|
|
412
|
+
await provider.put(ticketExpiringAt("boundary", now));
|
|
413
|
+
expect(provider.sweepExpired(now)).toBe(0); // expiresAt === now is NOT yet expired
|
|
414
|
+
expect(provider.sweepExpired(now + 1)).toBe(1); // one ms later, it is
|
|
415
|
+
});
|
|
416
|
+
|
|
417
|
+
test("a no-op sweep (nothing expired) drops nothing", () => {
|
|
418
|
+
const provider = new InProcessAttachmentTicketProvider();
|
|
419
|
+
expect(provider.sweepExpired(Date.now())).toBe(0);
|
|
420
|
+
expect(provider.size()).toBe(0);
|
|
421
|
+
});
|
|
422
|
+
|
|
423
|
+
test("sweepExpiredAttachmentTickets delegates to the shared provider (unique ids — safe alongside concurrent tests)", async () => {
|
|
424
|
+
const provider = getSharedAttachmentTicketProvider();
|
|
425
|
+
const now = Date.now();
|
|
426
|
+
const expiredId = generateTicketId();
|
|
427
|
+
const freshId = generateTicketId();
|
|
428
|
+
await provider.put(ticketExpiringAt(expiredId, now - 1));
|
|
429
|
+
await provider.put(ticketExpiringAt(freshId, now + 60_000));
|
|
430
|
+
|
|
431
|
+
sweepExpiredAttachmentTickets(now);
|
|
432
|
+
|
|
433
|
+
expect(await provider.take(expiredId)).toBeNull();
|
|
434
|
+
const fresh = await provider.take(freshId);
|
|
435
|
+
expect(fresh?.id).toBe(freshId);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test("sweepExpiredAttachmentTickets always returns a number (the `?? 0` fallback path never throws)", () => {
|
|
439
|
+
// By this point in the suite the shared provider already exists (other
|
|
440
|
+
// tests above created it) — this doesn't re-prove the true
|
|
441
|
+
// never-created case in isolation, but pins the return type/no-throw
|
|
442
|
+
// contract the periodic sweep timer depends on every tick.
|
|
443
|
+
expect(typeof sweepExpiredAttachmentTickets()).toBe("number");
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
test("start/stop the periodic sweep: idempotent start, a short-interval real timer actually drops an expired ticket, clean stop", async () => {
|
|
447
|
+
// Poll via `size()`, NOT `take(id)` — `take()` deletes unconditionally
|
|
448
|
+
// on any lookup (expired or not; see its own doc comment), so polling
|
|
449
|
+
// with it would consume the ticket itself on the FIRST poll — before
|
|
450
|
+
// the timer ever fires — and the test would pass for the wrong reason.
|
|
451
|
+
const provider = getSharedAttachmentTicketProvider() as InstanceType<typeof InProcessAttachmentTicketProvider>;
|
|
452
|
+
const now = Date.now();
|
|
453
|
+
const id = generateTicketId();
|
|
454
|
+
await provider.put(ticketExpiringAt(id, now - 1)); // already expired
|
|
455
|
+
const baseline = provider.size();
|
|
456
|
+
|
|
457
|
+
startAttachmentTicketSweep(15); // 15ms — short enough to observe within the test timeout
|
|
458
|
+
startAttachmentTicketSweep(15); // idempotent — no-op, doesn't create a second timer
|
|
459
|
+
|
|
460
|
+
// Poll briefly rather than a single fixed sleep — bounded by the test
|
|
461
|
+
// runner's own timeout.
|
|
462
|
+
let sizeDropped = false;
|
|
463
|
+
for (let i = 0; i < 20 && !sizeDropped; i++) {
|
|
464
|
+
await new Promise((r) => setTimeout(r, 15));
|
|
465
|
+
if (provider.size() < baseline) sizeDropped = true;
|
|
466
|
+
}
|
|
467
|
+
stopAttachmentTicketSweep();
|
|
468
|
+
stopAttachmentTicketSweep(); // idempotent — no-op on an already-stopped sweep
|
|
469
|
+
|
|
470
|
+
expect(sizeDropped).toBe(true);
|
|
471
|
+
// Confirm it was genuinely OUR ticket the sweep dropped, not a
|
|
472
|
+
// coincidental size change from something else.
|
|
473
|
+
expect(await provider.take(id)).toBeNull();
|
|
474
|
+
});
|
|
350
475
|
});
|