@openparachute/vault 0.7.3-rc.1 → 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.
Files changed (75) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/bytes-provider.ts +65 -0
  3. package/core/src/attachment/policy.test.ts +66 -0
  4. package/core/src/attachment/policy.ts +131 -0
  5. package/core/src/attachment/tickets.test.ts +45 -0
  6. package/core/src/attachment/tickets.ts +117 -0
  7. package/core/src/attachment-tickets-tool.test.ts +286 -0
  8. package/core/src/conformance.ts +2 -1
  9. package/core/src/content-range.test.ts +127 -0
  10. package/core/src/content-range.ts +100 -0
  11. package/core/src/contract-typed-index.test.ts +4 -3
  12. package/core/src/core.test.ts +521 -4
  13. package/core/src/display-title.test.ts +190 -0
  14. package/core/src/embedding/chunker.test.ts +97 -0
  15. package/core/src/embedding/chunker.ts +180 -0
  16. package/core/src/embedding/provider.ts +108 -0
  17. package/core/src/embedding/staleness.test.ts +87 -0
  18. package/core/src/embedding/staleness.ts +83 -0
  19. package/core/src/embedding/vector-codec.test.ts +99 -0
  20. package/core/src/embedding/vector-codec.ts +60 -0
  21. package/core/src/embedding/vectors.test.ts +163 -0
  22. package/core/src/embedding/vectors.ts +135 -0
  23. package/core/src/expand.ts +11 -3
  24. package/core/src/indexed-fields.test.ts +9 -3
  25. package/core/src/indexed-fields.ts +9 -1
  26. package/core/src/lede.test.ts +96 -0
  27. package/core/src/mcp-semantic-search.test.ts +160 -0
  28. package/core/src/mcp.ts +712 -11
  29. package/core/src/notes.semantic-search.test.ts +131 -0
  30. package/core/src/notes.ts +313 -6
  31. package/core/src/query-warnings.ts +20 -0
  32. package/core/src/schema-defaults.ts +85 -1
  33. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  34. package/core/src/schema.ts +81 -1
  35. package/core/src/search-fts-v25.test.ts +9 -1
  36. package/core/src/search-query.test.ts +42 -0
  37. package/core/src/search-query.ts +27 -0
  38. package/core/src/search-title-boost.test.ts +125 -0
  39. package/core/src/seed-packs.test.ts +117 -1
  40. package/core/src/seed-packs.ts +217 -1
  41. package/core/src/store.semantic-search.test.ts +236 -0
  42. package/core/src/store.ts +162 -5
  43. package/core/src/tag-schemas.ts +27 -12
  44. package/core/src/types.ts +55 -1
  45. package/core/src/vault-projection.ts +55 -0
  46. package/package.json +6 -1
  47. package/src/add-pack.test.ts +32 -0
  48. package/src/attachment-bytes.ts +68 -0
  49. package/src/attachment-tickets.test.ts +475 -0
  50. package/src/attachment-tickets.ts +340 -0
  51. package/src/cli.ts +5 -1
  52. package/src/contract-errors.test.ts +2 -1
  53. package/src/embedding/capability.test.ts +33 -0
  54. package/src/embedding/capability.ts +34 -0
  55. package/src/embedding/external-api.test.ts +154 -0
  56. package/src/embedding/external-api.ts +144 -0
  57. package/src/embedding/onnx-transformers.test.ts +113 -0
  58. package/src/embedding/onnx-transformers.ts +141 -0
  59. package/src/embedding/select.test.ts +99 -0
  60. package/src/embedding/select.ts +92 -0
  61. package/src/embedding-worker.test.ts +300 -0
  62. package/src/embedding-worker.ts +226 -0
  63. package/src/mcp-http.ts +33 -4
  64. package/src/mcp-tools.ts +61 -14
  65. package/src/onboarding-seed.test.ts +64 -0
  66. package/src/read-attachment.test.ts +436 -0
  67. package/src/routes.ts +226 -96
  68. package/src/routing.ts +17 -0
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +28 -2
  71. package/src/storage.test.ts +200 -1
  72. package/src/vault-embeddings-capability.test.ts +82 -0
  73. package/src/vault-store-embedding-wiring.test.ts +69 -0
  74. package/src/vault-store.ts +53 -2
  75. package/src/vault.test.ts +48 -11
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";
@@ -13,6 +13,7 @@ import {
13
13
  truncatedResultsWarning,
14
14
  computeSearchDidYouMean,
15
15
  searchDidYouMeanWarning,
16
+ embeddingsPendingWarning,
16
17
  type QueryWarning,
17
18
  } from "./query-warnings.js";
18
19
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
@@ -42,8 +43,40 @@ import {
42
43
  parseContentRange,
43
44
  applyContentRange,
44
45
  contentRangeRequiresContent,
46
+ parseAttachmentContentRange,
47
+ alignByteWindow,
45
48
  MIN_CONTENT_LENGTH,
46
49
  } from "./content-range.js";
50
+ import {
51
+ BLOCKED_ATTACHMENT_EXTENSIONS,
52
+ ATTACHMENT_MIME_TYPES,
53
+ sanitizeAttachmentExtension,
54
+ mimeForAttachmentExtension,
55
+ } from "./attachment/policy.js";
56
+ import {
57
+ computeTicketTtlMs,
58
+ generateTicketId,
59
+ MAX_TICKET_UPLOAD_BYTES,
60
+ type AttachmentTicket,
61
+ type AttachmentTicketProvider,
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 };
47
80
 
48
81
  export interface McpToolDef {
49
82
  name: string;
@@ -62,6 +95,17 @@ export interface McpToolDef {
62
95
  * future addition that forgets to stamp this gets the safer treatment.
63
96
  */
64
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[];
65
109
  }
66
110
 
67
111
  // ---------------------------------------------------------------------------
@@ -79,7 +123,7 @@ export interface McpToolDef {
79
123
  */
80
124
  function structuredError(
81
125
  message: string,
82
- fields: { error_type: string; field?: string; hint?: string },
126
+ fields: { error_type: string; field?: string; hint?: string } & Record<string, unknown>,
83
127
  ): Error {
84
128
  return Object.assign(new Error(message), fields);
85
129
  }
@@ -153,6 +197,172 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
153
197
  return content;
154
198
  }
155
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
+
156
366
  // ---------------------------------------------------------------------------
157
367
  // Tool generation
158
368
  // ---------------------------------------------------------------------------
@@ -245,6 +455,58 @@ export interface GenerateMcpToolsOpts {
245
455
  * path with no extra fetch.
246
456
  */
247
457
  aggregateVisibility?: (note: Note) => boolean;
458
+ /**
459
+ * `AttachmentTicketProvider` seam (vault attachment-tickets design,
460
+ * Wave 1 — D10 "tools omitted when unwired"). When provided,
461
+ * `generateMcpTools` appends `request-attachment-upload` /
462
+ * `request-attachment-download` to the returned tool list. Omitted (a
463
+ * door that hasn't wired its ticket seam yet) → the two tools are
464
+ * ABSENT from the list entirely — not merely erroring on call — so an
465
+ * agent is never shown an affordance the runtime can't back.
466
+ */
467
+ attachmentTickets?: {
468
+ provider: AttachmentTicketProvider;
469
+ /** This vault's own name — stamped onto every minted ticket so the spend route can cheaply reject a cross-vault replay. */
470
+ vaultName: string;
471
+ /**
472
+ * Absolute base URL for this vault's ticket spend path, no trailing
473
+ * slash — e.g. `https://host/vault/<name>`. A minted ticket's URL is
474
+ * `${urlBase}/tickets/<id>`. Resolved by the caller (server layer)
475
+ * from the incoming request's `X-Forwarded-Host`/proto — core stays
476
+ * request-unaware.
477
+ */
478
+ urlBase: string;
479
+ /**
480
+ * OPTIONAL per-note visibility predicate (tag-scope confidentiality —
481
+ * same intent as `expandVisibility`/`ifExistsVisible` above, kept
482
+ * separate because it needs to be awaitable). Both ticket tools run
483
+ * fully async execute() paths (unlike `expandVisibility`/
484
+ * `ifExistsVisible`, which feed core's SYNCHRONOUS wikilink/if_exists
485
+ * code and so need the shared pre-resolved-allowlist-holder
486
+ * machinery), so this can just be awaited inline. Omitted (unscoped /
487
+ * internal callers) → every note/attachment is visible.
488
+ */
489
+ noteVisible?: (note: Note) => boolean | Promise<boolean>;
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
+ };
248
510
  }
249
511
 
250
512
  /**
@@ -311,7 +573,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
311
573
  });
312
574
  };
313
575
 
314
- return [
576
+ const tools: McpToolDef[] = [
315
577
 
316
578
  // =====================================================================
317
579
  // 1. query-notes — the universal read tool
@@ -412,6 +674,16 @@ Response shape (vault#550 — three variants, pick by what you passed):
412
674
  description:
413
675
  'How `search` text is turned into an FTS5 query (vault#551). "literal" (DEFAULT): escape + phrase-quote the text so punctuation is literal content, not FTS5 syntax — the fix for `search: "didn\'t"` / "eleven-day" / "18.6" silently returning `[]`. "advanced": pass the text through to FTS5 raw, for callers who want boolean (AND/OR/NOT), manual phrase quoting, or prefix (`*`) syntax — a malformed advanced query throws a structured error (`error_type: "invalid_search_syntax"`) instead of silently returning `[]`. Has no effect without `search` (an `ignored_param` warning fires if you pass it without `search`). Omit for the default ("literal").',
414
676
  },
677
+ near_text: {
678
+ type: "string",
679
+ description:
680
+ 'EXPERIMENTAL (semantic search MVP — may change or be removed while quality is validated). Free text to rank notes by MEANING rather than keyword — "that idea about music remixes as community building" finds the note even if it never uses those exact words. Requires `semantic: true`; mutually exclusive with `search`/`aggregate`/`cursor`. Composes with every other filter (`tag`, `metadata`, date range, ...) exactly like `search` does — those narrow the candidate set FIRST, then ranking runs over just that set. Long notes are chunked internally and ranked by their BEST-matching section, so a match buried in one part of a long note still surfaces the whole note. Results carry a `score` field — cosine similarity in `[-1, 1]` (typically 0.2–0.9), NOT the same scale as `search`\'s bm25 `score`; only meaningful as a relative ranking within one result set.',
681
+ },
682
+ semantic: {
683
+ type: "boolean",
684
+ description:
685
+ 'EXPERIMENTAL. Opt into vector ranking via `near_text` (required when true). No embedding provider configured, or the vault hasn\'t finished indexing, is reported HONESTLY — never a silent fallback to keyword search: a provider-less vault throws a structured `semantic_unavailable` error; a mid-backfill vault returns real (possibly partial) results plus an `embeddings_pending` warning naming how many candidate notes aren\'t embedded yet.',
686
+ },
415
687
  metadata: {
416
688
  type: "object",
417
689
  description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
@@ -499,7 +771,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
499
771
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
500
772
  expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
501
773
  expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
502
- expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines only metadata.summary. Default: 'full'." },
774
+ expand_mode: { type: "string", enum: ["full", "summary"], description: "Expansion rendering: 'full' inlines the linked note's content, 'summary' inlines metadata.summary — falling back to the note's opening paragraph when no metadata.summary exists. Default: 'full'." },
503
775
  },
504
776
  },
505
777
  execute: async (params) => {
@@ -640,6 +912,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
640
912
  "INVALID_QUERY",
641
913
  );
642
914
  }
915
+ if (cursorMode && params.semantic) {
916
+ throw new QueryError(
917
+ `cursor is incompatible with semantic search — ranking is by similarity, not a stable row order to page through.`,
918
+ "INVALID_QUERY",
919
+ );
920
+ }
643
921
  // Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
644
922
  // typo'd value doesn't silently fall back to the default.
645
923
  let expand: TagExpandMode | undefined;
@@ -679,6 +957,13 @@ Response shape (vault#550 — three variants, pick by what you passed):
679
957
  { error_type: "invalid_query", field: "aggregate", hint: "drop `cursor` when using `aggregate`" },
680
958
  );
681
959
  }
960
+ if (params.semantic) {
961
+ throw new QueryError(
962
+ `aggregate is incompatible with semantic search — a rollup returns groups, not ranked notes.`,
963
+ "INVALID_QUERY",
964
+ { error_type: "invalid_query", field: "aggregate", hint: "drop `semantic`/`near_text` when using `aggregate`" },
965
+ );
966
+ }
682
967
  const aggRaw = params.aggregate as Record<string, unknown>;
683
968
  if (typeof aggRaw !== "object" || aggRaw === null || Array.isArray(aggRaw)) {
684
969
  throw new QueryError(
@@ -771,7 +1056,93 @@ Response shape (vault#550 — three variants, pick by what you passed):
771
1056
  // before the result reaches the caller, so an out-of-scope tag name
772
1057
  // never leaks via `did_you_mean`.
773
1058
  let queryWarnings: QueryWarning[] = [];
774
- if (params.search) {
1059
+ // `near_text` only does anything alongside `semantic: true` (mirrors
1060
+ // the `search_mode`-without-`search` ignored_param case above).
1061
+ if (params.near_text !== undefined && !params.semantic) {
1062
+ queryWarnings.push(
1063
+ ignoredParamWarning(
1064
+ "near_text",
1065
+ "`semantic: true` is required to activate near_text — pass both together",
1066
+ ),
1067
+ );
1068
+ }
1069
+ // --- Semantic search (EXPERIMENTAL — semantic search MVP) ---
1070
+ if (params.semantic) {
1071
+ if (params.search) {
1072
+ throw new QueryError(
1073
+ `semantic is incompatible with full-text search — pick one (semantic ranks by meaning via near_text; search ranks by keyword).`,
1074
+ "INVALID_QUERY",
1075
+ {
1076
+ error_type: "invalid_query",
1077
+ field: "semantic",
1078
+ hint: "drop `search` when using `semantic`, or drop `semantic`/`near_text` to use keyword search",
1079
+ },
1080
+ );
1081
+ }
1082
+ if (typeof params.near_text !== "string" || params.near_text.trim() === "") {
1083
+ throw new QueryError(
1084
+ `semantic: true requires \`near_text\` — the free text to rank notes by meaning.`,
1085
+ "INVALID_QUERY",
1086
+ {
1087
+ error_type: "invalid_query",
1088
+ field: "near_text",
1089
+ hint: `pass near_text: "..." alongside semantic: true`,
1090
+ },
1091
+ );
1092
+ }
1093
+ // `search_mode` only shapes `search` text parsing — a stray value
1094
+ // alongside `semantic` is almost certainly a leftover from a
1095
+ // keyword query, so flag it (same policy as the structured-query
1096
+ // branch below).
1097
+ if (searchMode !== undefined) {
1098
+ queryWarnings.push(
1099
+ ignoredParamWarning(
1100
+ "search_mode",
1101
+ "no `search` was provided — search_mode only affects full-text search query parsing",
1102
+ ),
1103
+ );
1104
+ }
1105
+ const tags = normalizeTags(params.tag);
1106
+ const excludeTagsRaw = params.exclude_tags ?? params.excludeTags ?? params.exclude_tag;
1107
+ const excludeTags = normalizeTags(excludeTagsRaw);
1108
+ const semanticOpts: QueryOpts = {
1109
+ tags,
1110
+ tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
1111
+ expand,
1112
+ excludeTags,
1113
+ hasTags: params.has_tags as boolean | undefined,
1114
+ hasLinks: params.has_links as boolean | undefined,
1115
+ hasBrokenLinks: params.has_broken_links as boolean | undefined,
1116
+ path: params.path as string | undefined,
1117
+ pathPrefix: params.path_prefix as string | undefined,
1118
+ extension: params.extension as string | string[] | undefined,
1119
+ // Same `near[]` neighborhood push-down `search`/structured-query
1120
+ // use — a semantic query can be scoped to a graph neighborhood too.
1121
+ ids: nearScope ? [...nearScope] : undefined,
1122
+ metadata: params.metadata as Record<string, unknown> | undefined,
1123
+ createdBy: params.created_by as string | undefined,
1124
+ lastUpdatedBy: params.last_updated_by as string | undefined,
1125
+ createdVia: params.created_via as string | undefined,
1126
+ lastUpdatedVia: params.last_updated_via as string | undefined,
1127
+ dateFrom: params.date_from as string | undefined,
1128
+ dateTo: params.date_to as string | undefined,
1129
+ dateFilter: params.date_filter as
1130
+ | { field?: string; from?: string; to?: string }
1131
+ | undefined,
1132
+ limit: (params.limit as number) ?? 50,
1133
+ };
1134
+ // Uncaught on purpose: `semantic_unavailable` (no/not-ready
1135
+ // provider) propagates to src/mcp-http.ts's QueryError → JSON-RPC
1136
+ // error mapping, same as `invalid_search_syntax` above — never a
1137
+ // silent fallback to keyword search.
1138
+ const semanticResult = await store.semanticSearch(params.near_text, semanticOpts);
1139
+ results = semanticResult.notes;
1140
+ if (semanticResult.pendingCount > 0) {
1141
+ queryWarnings.push(
1142
+ embeddingsPendingWarning(semanticResult.pendingCount, semanticResult.totalCandidates),
1143
+ );
1144
+ }
1145
+ } else if (params.search) {
775
1146
  // `offset` under full-text search (vault contracts-brief V1.2):
776
1147
  // `searchNotes` has no offset parameter at all — FTS5 ranks by
777
1148
  // relevance, not a stable row order, so paging by offset over it
@@ -1245,6 +1616,12 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
1245
1616
  ...updates,
1246
1617
  actor: writeActor,
1247
1618
  via: writeVia,
1619
+ // `tagsForSchemaResolution` (vault#date-field-type review round
1620
+ // 2) — same bug/fix as the update-note handler: `store.tagNote`
1621
+ // below runs AFTER this UPDATE, so without the PROJECTED tag
1622
+ // set here, `date`-field normalization inside store.updateNote
1623
+ // would miss a field newly declared by an incoming tag.
1624
+ tagsForSchemaResolution: [...projectedTags],
1248
1625
  });
1249
1626
  }
1250
1627
 
@@ -1503,7 +1880,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
1503
1880
  - For batch: pass a \`notes\` array, each with an \`id\` field.
1504
1881
  - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
1505
1882
  - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
1506
- - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
1883
+ - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
1507
1884
 
1508
1885
  Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
1509
1886
  inputSchema: {
@@ -1577,7 +1954,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1577
1954
  },
1578
1955
  include_content: {
1579
1956
  type: "boolean",
1580
- description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
1957
+ description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a computed `displayTitle`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
1581
1958
  },
1582
1959
  include_links: {
1583
1960
  type: "boolean",
@@ -1960,10 +2337,18 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1960
2337
  // --- Strict-schema gate (vault#299 Part A) ---
1961
2338
  // Validate the PROSPECTIVE shape (final tags + merged metadata,
1962
2339
  // including a state_transition's `to`) before the write so a
1963
- // rejection leaves the note untouched.
2340
+ // rejection leaves the note untouched. `projectedTags` is hoisted
2341
+ // out of this block (not just used here) — the `store.updateNote`
2342
+ // call below also needs it, as `tagsForSchemaResolution`, so that
2343
+ // `date`-field normalization sees a tag ADDED in this SAME call
2344
+ // (vault#date-field-type review round 2 — the actual `store.tagNote`
2345
+ // mutation happens AFTER the core UPDATE, so without this the
2346
+ // schema resolution inside `store.updateNote` would still be
2347
+ // looking at the note's stale pre-write tag set).
2348
+ let projectedTags: Set<string>;
1964
2349
  {
1965
2350
  const removeSet = new Set<string>((item.tags as any)?.remove ?? []);
1966
- const projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
2351
+ projectedTags = new Set<string>((note.tags ?? []).filter((t) => !removeSet.has(t)));
1967
2352
  for (const t of ((item.tags as any)?.add as string[] | undefined) ?? []) projectedTags.add(t);
1968
2353
  const baseMeta = updates.metadata ?? ((note.metadata as Record<string, unknown>) ?? {});
1969
2354
  const projectedMeta = stItem !== undefined
@@ -2007,6 +2392,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2007
2392
  // updated_at on a no-op).
2008
2393
  updates.actor = writeActor;
2009
2394
  updates.via = writeVia;
2395
+ // `tagsForSchemaResolution` (vault#date-field-type review round
2396
+ // 2) — the PROJECTED final tag set, so `date`-field
2397
+ // normalization inside store.updateNote sees a tag ADDED in
2398
+ // this same call, not just the note's pre-write tags. See the
2399
+ // comment on `projectedTags`'s declaration above.
2400
+ updates.tagsForSchemaResolution = [...projectedTags];
2010
2401
  // store.updateNote routes through noteOps.updateNote, which runs
2011
2402
  // the UPDATE (with optional `AND updated_at IS ?`) atomically and
2012
2403
  // throws ConflictError on mismatch. No mutations have happened
@@ -2270,11 +2661,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2270
2661
  additionalProperties: {
2271
2662
  type: "object",
2272
2663
  properties: {
2273
- type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference — all seven are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`." },
2664
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference, date — all eight are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference/date are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`. `date` stores/validates exactly like `string`, but the value must be an ISO-8601 date (`2026-07-09`) or full timestamp (`2026-07-09T00:00:00.000Z`) — an unparseable value is a type_mismatch (advisory) or a rejected write (`strict: true` / `indexed: true`), same treatment as any other type mismatch. A full timestamp carrying an explicit `±HH:MM` offset is normalized to canonical UTC (`Z`-suffixed) on write — the offset is accepted, but not persisted verbatim — so indexed `date` fields sort/filter correctly under the TEXT comparison `gt`/`gte`/`lt`/`lte`/`date_filter`/`order_by` all use; a bare date (no time component) is left as-is." },
2274
2665
  description: { type: "string" },
2275
2666
  enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
2276
2667
  default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
2277
- indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
2668
+ indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference/date are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
2278
2669
  strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
2279
2670
  required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
2280
2671
  cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
@@ -2649,6 +3040,316 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
2649
3040
  },
2650
3041
 
2651
3042
  ];
3043
+
3044
+ // =====================================================================
3045
+ // 12/13. request-attachment-upload / request-attachment-download —
3046
+ // runtime-lane attachment tickets (Wave 1). Present ONLY when the
3047
+ // server layer wires an AttachmentTicketProvider — see
3048
+ // `GenerateMcpToolsOpts.attachmentTickets`'s doc comment (D10, "tools
3049
+ // omitted when unwired"). Bytes never pass through either tool: the
3050
+ // model gets back a URL + a literal curl_example; a runtime with a
3051
+ // shell spends it directly, outside this MCP session entirely.
3052
+ // =====================================================================
3053
+ const ticketSeam = opts?.attachmentTickets;
3054
+ if (ticketSeam) {
3055
+ tools.push(
3056
+ {
3057
+ name: "request-attachment-upload",
3058
+ requiredVerb: "write",
3059
+ description:
3060
+ "Mint a short-lived, single-use upload URL for a note attachment. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Provide the target `note` (id or path), the `filename`, and its exact `size_bytes` — declared here and enforced at spend (a mismatch, or exceeding the 100 MiB REST upload cap, fails the mint or the upload). `mime_type` is inferred from the filename's extension when omitted. Pass `transcribe: true` for an audio file to enqueue it exactly like the REST attach flow does. The ticket's `expires_at` scales with declared size (10 minutes base + 10s per MiB, capped at 30 minutes) and can be spent exactly once — a failed curl means re-minting, not retrying the same URL.",
3061
+ inputSchema: {
3062
+ type: "object",
3063
+ properties: {
3064
+ note: { type: "string", description: "Target note ID or path" },
3065
+ filename: { type: "string", description: "Original filename — sanitized for a blocked extension (active-content types: .html/.svg/.xml/.js/.css/…) and used to infer the MIME type when `mime_type` is omitted." },
3066
+ size_bytes: {
3067
+ type: "number",
3068
+ description: `Declared upload size in bytes. Must be > 0 and <= ${MAX_TICKET_UPLOAD_BYTES} (100 MiB — the same ceiling REST's own /storage/upload enforces). The spend endpoint rejects (413) any upload that exceeds this declared size.`,
3069
+ },
3070
+ mime_type: { type: "string", description: "MIME type to store on the attachment row. Inferred from `filename`'s extension when omitted (`application/octet-stream` for an uncurated extension)." },
3071
+ transcribe: { type: "boolean", description: "Opt into transcription for an audio attachment — mirrors the REST `POST /notes/:id/attachments` `transcribe` flag." },
3072
+ },
3073
+ required: ["note", "filename", "size_bytes"],
3074
+ },
3075
+ execute: async (params) => {
3076
+ const noteRef = params.note;
3077
+ if (typeof noteRef !== "string" || noteRef.trim() === "") {
3078
+ throw structuredError("`note` is required", {
3079
+ error_type: "missing_required_field",
3080
+ field: "note",
3081
+ how_to: "pass the target note's id or path",
3082
+ });
3083
+ }
3084
+ const filename = params.filename;
3085
+ if (typeof filename !== "string" || filename.trim() === "") {
3086
+ throw structuredError("`filename` is required", {
3087
+ error_type: "missing_required_field",
3088
+ field: "filename",
3089
+ how_to: "pass the original filename you're about to upload",
3090
+ });
3091
+ }
3092
+ const sizeBytes = params.size_bytes;
3093
+ if (typeof sizeBytes !== "number" || !Number.isFinite(sizeBytes) || sizeBytes <= 0) {
3094
+ throw structuredError("`size_bytes` must be a positive number", {
3095
+ error_type: "invalid_query",
3096
+ field: "size_bytes",
3097
+ how_to: "pass the exact byte length of the file you're about to upload",
3098
+ });
3099
+ }
3100
+ if (sizeBytes > MAX_TICKET_UPLOAD_BYTES) {
3101
+ throw structuredError(
3102
+ `size_bytes (${sizeBytes}) exceeds the ${MAX_TICKET_UPLOAD_BYTES} byte (100 MiB) upload cap`,
3103
+ {
3104
+ error_type: "file_too_large",
3105
+ field: "size_bytes",
3106
+ limit: MAX_TICKET_UPLOAD_BYTES,
3107
+ got: sizeBytes,
3108
+ how_to: "REST /storage/upload shares this same 100 MiB ceiling — split the file or upload it out-of-band",
3109
+ },
3110
+ );
3111
+ }
3112
+
3113
+ const note = requireNote(db, noteRef);
3114
+ if (ticketSeam.noteVisible && !(await ticketSeam.noteVisible(note))) {
3115
+ // Uniform not_found — a tag-scoped caller learns nothing about
3116
+ // an out-of-scope note's existence (same posture as every other
3117
+ // scope-gated tool in this file).
3118
+ throw structuredError(`Note not found: "${noteRef}"`, { error_type: "not_found", field: "note" });
3119
+ }
3120
+
3121
+ const ext = sanitizeAttachmentExtension(filename);
3122
+ if (BLOCKED_ATTACHMENT_EXTENSIONS.has(ext)) {
3123
+ throw structuredError(`File type ${ext} not allowed (active/executable content)`, {
3124
+ error_type: "blocked_upload_extension",
3125
+ field: "filename",
3126
+ extension: ext,
3127
+ how_to: "rename with a non-executable extension, or store the content as note text instead",
3128
+ });
3129
+ }
3130
+
3131
+ const mimeType =
3132
+ typeof params.mime_type === "string" && params.mime_type.trim() !== ""
3133
+ ? params.mime_type
3134
+ : mimeForAttachmentExtension(ext);
3135
+
3136
+ const now = Date.now();
3137
+ const expiresAt = now + computeTicketTtlMs(sizeBytes);
3138
+ const id = generateTicketId();
3139
+ const ticket: AttachmentTicket = {
3140
+ id,
3141
+ kind: "upload",
3142
+ vaultName: ticketSeam.vaultName,
3143
+ createdAt: now,
3144
+ expiresAt,
3145
+ noteId: note.id,
3146
+ filename,
3147
+ mimeType,
3148
+ sizeBytes,
3149
+ transcribe: params.transcribe === true,
3150
+ };
3151
+ await ticketSeam.provider.put(ticket);
3152
+
3153
+ const url = `${ticketSeam.urlBase}/tickets/${id}`;
3154
+ return {
3155
+ method: "PUT",
3156
+ url,
3157
+ headers: { "content-type": mimeType },
3158
+ expires_at: new Date(expiresAt).toISOString(),
3159
+ max_bytes: sizeBytes,
3160
+ curl_example: `curl -X PUT -H 'content-type: ${mimeType}' --data-binary @${filename} '${url}'`,
3161
+ };
3162
+ },
3163
+ },
3164
+ {
3165
+ name: "request-attachment-download",
3166
+ requiredVerb: "read",
3167
+ description:
3168
+ "Mint a short-lived, single-use download URL for an existing attachment's bytes. Bytes never pass through this tool — you get back a URL (+ a ready-to-run `curl_example`) your runtime's shell spends directly; no MCP session credential is needed to spend it. Pass the `attachment_id` from a note's `include_attachments: true` rows (query-notes) or `GET .../attachments`. The ticket's `expires_at` follows the same size-scaled window as upload tickets (10 minutes base, up to 30) and can be spent exactly once.",
3169
+ inputSchema: {
3170
+ type: "object",
3171
+ properties: {
3172
+ attachment_id: { type: "string", description: "The attachment's id (from a note's attachment rows)" },
3173
+ },
3174
+ required: ["attachment_id"],
3175
+ },
3176
+ execute: async (params) => {
3177
+ const attachmentId = params.attachment_id;
3178
+ if (typeof attachmentId !== "string" || attachmentId.trim() === "") {
3179
+ throw structuredError("`attachment_id` is required", {
3180
+ error_type: "missing_required_field",
3181
+ field: "attachment_id",
3182
+ how_to: "pass the attachment id from a note's attachment rows",
3183
+ });
3184
+ }
3185
+
3186
+ const attachment = await store.getAttachment(attachmentId);
3187
+ if (!attachment) {
3188
+ throw structuredError(`Attachment not found: "${attachmentId}"`, {
3189
+ error_type: "not_found",
3190
+ field: "attachment_id",
3191
+ });
3192
+ }
3193
+
3194
+ if (ticketSeam.noteVisible) {
3195
+ const owningNote = await store.getNote(attachment.noteId);
3196
+ // A missing owning note (shouldn't happen — ON DELETE CASCADE —
3197
+ // but never trust it) collapses to the same not_found as an
3198
+ // out-of-scope note: no oracle either way.
3199
+ if (!owningNote || !(await ticketSeam.noteVisible(owningNote))) {
3200
+ throw structuredError(`Attachment not found: "${attachmentId}"`, {
3201
+ error_type: "not_found",
3202
+ field: "attachment_id",
3203
+ });
3204
+ }
3205
+ }
3206
+
3207
+ const metaSize = attachment.metadata?.size;
3208
+ const declaredSize = typeof metaSize === "number" ? metaSize : undefined;
3209
+ const now = Date.now();
3210
+ const expiresAt = now + computeTicketTtlMs(declaredSize);
3211
+ const id = generateTicketId();
3212
+ const ticket: AttachmentTicket = {
3213
+ id,
3214
+ kind: "download",
3215
+ vaultName: ticketSeam.vaultName,
3216
+ createdAt: now,
3217
+ expiresAt,
3218
+ attachmentId: attachment.id,
3219
+ mimeType: attachment.mimeType,
3220
+ sizeBytes: declaredSize,
3221
+ };
3222
+ await ticketSeam.provider.put(ticket);
3223
+
3224
+ const url = `${ticketSeam.urlBase}/tickets/${id}`;
3225
+ return {
3226
+ method: "GET",
3227
+ url,
3228
+ mime_type: attachment.mimeType,
3229
+ ...(declaredSize !== undefined ? { size_bytes: declaredSize } : {}),
3230
+ expires_at: new Date(expiresAt).toISOString(),
3231
+ curl_example: `curl -o downloaded${sanitizeAttachmentExtension(attachment.path) || ""} '${url}'`,
3232
+ };
3233
+ },
3234
+ },
3235
+ );
3236
+ }
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
+
3352
+ return tools;
2652
3353
  }
2653
3354
 
2654
3355
  // ---------------------------------------------------------------------------