@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.
- package/README.md +5 -3
- package/core/src/attachment/bytes-provider.ts +65 -0
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/conformance.ts +2 -1
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +100 -0
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +521 -4
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +712 -11
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +313 -6
- package/core/src/query-warnings.ts +20 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +55 -1
- package/core/src/vault-projection.ts +55 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-bytes.ts +68 -0
- package/src/attachment-tickets.test.ts +475 -0
- package/src/attachment-tickets.ts +340 -0
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +33 -4
- package/src/mcp-tools.ts +61 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +226 -96
- package/src/routing.ts +17 -0
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +28 -2
- package/src/storage.test.ts +200 -1
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +48 -11
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `request-attachment-upload` / `request-attachment-download` — the two
|
|
3
|
+
* MCP mint tools generated by `generateMcpTools` (see mcp.ts's
|
|
4
|
+
* `GenerateMcpToolsOpts.attachmentTickets`). Exercises the "tools omitted
|
|
5
|
+
* when unwired" contract (D10) and the mint-time policy (note/attachment
|
|
6
|
+
* resolution, tag-scope, size cap, extension blocklist) against a fake
|
|
7
|
+
* in-memory `AttachmentTicketProvider` — the spend side (actually writing
|
|
8
|
+
* bytes) is bun-only and covered in src/attachment-tickets.test.ts.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, test, expect, beforeEach } from "bun:test";
|
|
12
|
+
import { Database } from "bun:sqlite";
|
|
13
|
+
import { BunSqliteStore } from "./store.ts";
|
|
14
|
+
import { generateMcpTools, type McpToolDef } from "./mcp.ts";
|
|
15
|
+
import type { AttachmentTicket, AttachmentTicketProvider } from "./attachment/tickets.ts";
|
|
16
|
+
import { MAX_TICKET_UPLOAD_BYTES } from "./attachment/tickets.ts";
|
|
17
|
+
import type { Note } from "./types.ts";
|
|
18
|
+
|
|
19
|
+
class FakeTicketProvider implements AttachmentTicketProvider {
|
|
20
|
+
tickets = new Map<string, AttachmentTicket>();
|
|
21
|
+
async put(t: AttachmentTicket): Promise<void> {
|
|
22
|
+
this.tickets.set(t.id, t);
|
|
23
|
+
}
|
|
24
|
+
async take(id: string): Promise<AttachmentTicket | null> {
|
|
25
|
+
const t = this.tickets.get(id);
|
|
26
|
+
if (!t) return null;
|
|
27
|
+
this.tickets.delete(id);
|
|
28
|
+
return t;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
let store: BunSqliteStore;
|
|
33
|
+
|
|
34
|
+
beforeEach(() => {
|
|
35
|
+
store = new BunSqliteStore(new Database(":memory:"));
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
function findTool(tools: McpToolDef[], name: string): McpToolDef {
|
|
39
|
+
const t = tools.find((x) => x.name === name);
|
|
40
|
+
if (!t) throw new Error(`tool "${name}" not found`);
|
|
41
|
+
return t;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async function callTool(tool: McpToolDef, params: Record<string, unknown>): Promise<any> {
|
|
45
|
+
return await tool.execute(params);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function expectToolError(tool: McpToolDef, params: Record<string, unknown>): Promise<any> {
|
|
49
|
+
try {
|
|
50
|
+
await tool.execute(params);
|
|
51
|
+
throw new Error("expected the tool to throw");
|
|
52
|
+
} catch (err) {
|
|
53
|
+
return err as any;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
describe("D10 — tools omitted when unwired", () => {
|
|
58
|
+
test("no attachmentTickets opt → neither ticket tool is generated", () => {
|
|
59
|
+
const tools = generateMcpTools(store);
|
|
60
|
+
expect(tools.find((t) => t.name === "request-attachment-upload")).toBeUndefined();
|
|
61
|
+
expect(tools.find((t) => t.name === "request-attachment-download")).toBeUndefined();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
test("attachmentTickets opt provided → both ticket tools are generated with the right verbs", () => {
|
|
65
|
+
const provider = new FakeTicketProvider();
|
|
66
|
+
const tools = generateMcpTools(store, {
|
|
67
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
68
|
+
});
|
|
69
|
+
const upload = findTool(tools, "request-attachment-upload");
|
|
70
|
+
const download = findTool(tools, "request-attachment-download");
|
|
71
|
+
expect(upload.requiredVerb).toBe("write");
|
|
72
|
+
expect(download.requiredVerb).toBe("read");
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe("request-attachment-upload — mint", () => {
|
|
77
|
+
test("happy path: returns the ticket envelope and stores a matching provider record", async () => {
|
|
78
|
+
const note = await store.createNote("# Target\n", { path: "target" });
|
|
79
|
+
const provider = new FakeTicketProvider();
|
|
80
|
+
const tools = generateMcpTools(store, {
|
|
81
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
82
|
+
});
|
|
83
|
+
const tool = findTool(tools, "request-attachment-upload");
|
|
84
|
+
|
|
85
|
+
const before = Date.now();
|
|
86
|
+
const result = await callTool(tool, { note: note.id, filename: "photo.png", size_bytes: 1024 });
|
|
87
|
+
expect(result.method).toBe("PUT");
|
|
88
|
+
expect(result.url).toMatch(/^https:\/\/host\/vault\/v1\/tickets\/[0-9a-f]{64}$/);
|
|
89
|
+
expect(result.headers["content-type"]).toBe("image/png");
|
|
90
|
+
expect(result.max_bytes).toBe(1024);
|
|
91
|
+
expect(result.curl_example).toContain("curl -X PUT");
|
|
92
|
+
expect(result.curl_example).toContain(result.url);
|
|
93
|
+
|
|
94
|
+
const ticketId = result.url.split("/tickets/")[1] as string;
|
|
95
|
+
const stored = provider.tickets.get(ticketId)!;
|
|
96
|
+
expect(stored.kind).toBe("upload");
|
|
97
|
+
expect(stored.noteId).toBe(note.id);
|
|
98
|
+
expect(stored.filename).toBe("photo.png");
|
|
99
|
+
expect(stored.mimeType).toBe("image/png");
|
|
100
|
+
expect(stored.sizeBytes).toBe(1024);
|
|
101
|
+
expect(stored.vaultName).toBe("v1");
|
|
102
|
+
// TTL for a 1024-byte (<1 MiB) declared size is the 10-minute base.
|
|
103
|
+
expect(stored.expiresAt - before).toBeGreaterThanOrEqual(10 * 60 * 1000 - 1000);
|
|
104
|
+
expect(stored.expiresAt - before).toBeLessThanOrEqual(10 * 60 * 1000 + 5000);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("mime_type is inferred from the filename extension when omitted", async () => {
|
|
108
|
+
const note = await store.createNote("# T\n", { path: "t2" });
|
|
109
|
+
const provider = new FakeTicketProvider();
|
|
110
|
+
const tools = generateMcpTools(store, {
|
|
111
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
112
|
+
});
|
|
113
|
+
const result = await callTool(findTool(tools, "request-attachment-upload"), {
|
|
114
|
+
note: note.id,
|
|
115
|
+
filename: "notes.csv",
|
|
116
|
+
size_bytes: 10,
|
|
117
|
+
});
|
|
118
|
+
expect(result.headers["content-type"]).toBe("text/csv; charset=utf-8");
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
test("transcribe: true rides through to the stored ticket", async () => {
|
|
122
|
+
const note = await store.createNote("# T\n", { path: "t3" });
|
|
123
|
+
const provider = new FakeTicketProvider();
|
|
124
|
+
const tools = generateMcpTools(store, {
|
|
125
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
126
|
+
});
|
|
127
|
+
const result = await callTool(findTool(tools, "request-attachment-upload"), {
|
|
128
|
+
note: note.id,
|
|
129
|
+
filename: "memo.wav",
|
|
130
|
+
size_bytes: 10,
|
|
131
|
+
transcribe: true,
|
|
132
|
+
});
|
|
133
|
+
const ticketId = result.url.split("/tickets/")[1] as string;
|
|
134
|
+
expect(provider.tickets.get(ticketId)!.transcribe).toBe(true);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test("missing note / filename / size_bytes → missing_required_field with how_to", async () => {
|
|
138
|
+
const provider = new FakeTicketProvider();
|
|
139
|
+
const tools = generateMcpTools(store, {
|
|
140
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
141
|
+
});
|
|
142
|
+
const tool = findTool(tools, "request-attachment-upload");
|
|
143
|
+
|
|
144
|
+
const e1 = await expectToolError(tool, { filename: "a.png", size_bytes: 1 });
|
|
145
|
+
expect(e1.error_type).toBe("missing_required_field");
|
|
146
|
+
expect(e1.field).toBe("note");
|
|
147
|
+
expect(typeof e1.how_to).toBe("string");
|
|
148
|
+
|
|
149
|
+
const e2 = await expectToolError(tool, { note: "x", size_bytes: 1 });
|
|
150
|
+
expect(e2.error_type).toBe("missing_required_field");
|
|
151
|
+
expect(e2.field).toBe("filename");
|
|
152
|
+
|
|
153
|
+
const e3 = await expectToolError(tool, { note: "x", filename: "a.png" });
|
|
154
|
+
expect(e3.error_type).toBe("invalid_query");
|
|
155
|
+
expect(e3.field).toBe("size_bytes");
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
test("size_bytes over the 100 MiB cap → file_too_large with limit/got/how_to", async () => {
|
|
159
|
+
const note = await store.createNote("# T\n", { path: "t4" });
|
|
160
|
+
const provider = new FakeTicketProvider();
|
|
161
|
+
const tools = generateMcpTools(store, {
|
|
162
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
163
|
+
});
|
|
164
|
+
const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
|
|
165
|
+
note: note.id,
|
|
166
|
+
filename: "huge.zip",
|
|
167
|
+
size_bytes: MAX_TICKET_UPLOAD_BYTES + 1,
|
|
168
|
+
});
|
|
169
|
+
expect(err.error_type).toBe("file_too_large");
|
|
170
|
+
expect(err.limit).toBe(MAX_TICKET_UPLOAD_BYTES);
|
|
171
|
+
expect(err.got).toBe(MAX_TICKET_UPLOAD_BYTES + 1);
|
|
172
|
+
expect(typeof err.how_to).toBe("string");
|
|
173
|
+
expect(provider.tickets.size).toBe(0);
|
|
174
|
+
});
|
|
175
|
+
|
|
176
|
+
test("blocked (active-content) extension → blocked_upload_extension, no ticket minted", async () => {
|
|
177
|
+
const note = await store.createNote("# T\n", { path: "t5" });
|
|
178
|
+
const provider = new FakeTicketProvider();
|
|
179
|
+
const tools = generateMcpTools(store, {
|
|
180
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
181
|
+
});
|
|
182
|
+
const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
|
|
183
|
+
note: note.id,
|
|
184
|
+
filename: "evil.html",
|
|
185
|
+
size_bytes: 10,
|
|
186
|
+
});
|
|
187
|
+
expect(err.error_type).toBe("blocked_upload_extension");
|
|
188
|
+
expect(err.extension).toBe(".html");
|
|
189
|
+
expect(provider.tickets.size).toBe(0);
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
test("unknown note → not_found", async () => {
|
|
193
|
+
const provider = new FakeTicketProvider();
|
|
194
|
+
const tools = generateMcpTools(store, {
|
|
195
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
196
|
+
});
|
|
197
|
+
const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
|
|
198
|
+
note: "does-not-exist",
|
|
199
|
+
filename: "a.png",
|
|
200
|
+
size_bytes: 10,
|
|
201
|
+
});
|
|
202
|
+
expect(err.error_type).toBe("not_found");
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
test("tag-scoped noteVisible: false → uniform not_found (no existence oracle)", async () => {
|
|
206
|
+
const note = await store.createNote("# T\n", { path: "t6" });
|
|
207
|
+
const provider = new FakeTicketProvider();
|
|
208
|
+
const tools = generateMcpTools(store, {
|
|
209
|
+
attachmentTickets: {
|
|
210
|
+
provider,
|
|
211
|
+
vaultName: "v1",
|
|
212
|
+
urlBase: "https://host/vault/v1",
|
|
213
|
+
noteVisible: async (_n: Note) => false,
|
|
214
|
+
},
|
|
215
|
+
});
|
|
216
|
+
const err = await expectToolError(findTool(tools, "request-attachment-upload"), {
|
|
217
|
+
note: note.id,
|
|
218
|
+
filename: "a.png",
|
|
219
|
+
size_bytes: 10,
|
|
220
|
+
});
|
|
221
|
+
expect(err.error_type).toBe("not_found");
|
|
222
|
+
expect(provider.tickets.size).toBe(0);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
describe("request-attachment-download — mint", () => {
|
|
227
|
+
test("happy path: returns the ticket envelope with mime_type + known size, stores a matching provider record", async () => {
|
|
228
|
+
const note = await store.createNote("# T\n", { path: "t7" });
|
|
229
|
+
const attachment = await store.addAttachment(note.id, "2026-07-17/x.png", "image/png", { size: 42 });
|
|
230
|
+
const provider = new FakeTicketProvider();
|
|
231
|
+
const tools = generateMcpTools(store, {
|
|
232
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
233
|
+
});
|
|
234
|
+
const result = await callTool(findTool(tools, "request-attachment-download"), {
|
|
235
|
+
attachment_id: attachment.id,
|
|
236
|
+
});
|
|
237
|
+
expect(result.method).toBe("GET");
|
|
238
|
+
expect(result.mime_type).toBe("image/png");
|
|
239
|
+
expect(result.size_bytes).toBe(42);
|
|
240
|
+
expect(result.curl_example).toContain(result.url);
|
|
241
|
+
|
|
242
|
+
const ticketId = result.url.split("/tickets/")[1] as string;
|
|
243
|
+
const stored = provider.tickets.get(ticketId)!;
|
|
244
|
+
expect(stored.kind).toBe("download");
|
|
245
|
+
expect(stored.attachmentId).toBe(attachment.id);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("missing attachment_id → missing_required_field", async () => {
|
|
249
|
+
const provider = new FakeTicketProvider();
|
|
250
|
+
const tools = generateMcpTools(store, {
|
|
251
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
252
|
+
});
|
|
253
|
+
const err = await expectToolError(findTool(tools, "request-attachment-download"), {});
|
|
254
|
+
expect(err.error_type).toBe("missing_required_field");
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
test("unknown attachment → not_found", async () => {
|
|
258
|
+
const provider = new FakeTicketProvider();
|
|
259
|
+
const tools = generateMcpTools(store, {
|
|
260
|
+
attachmentTickets: { provider, vaultName: "v1", urlBase: "https://host/vault/v1" },
|
|
261
|
+
});
|
|
262
|
+
const err = await expectToolError(findTool(tools, "request-attachment-download"), {
|
|
263
|
+
attachment_id: "does-not-exist",
|
|
264
|
+
});
|
|
265
|
+
expect(err.error_type).toBe("not_found");
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test("tag-scoped noteVisible: false on the owning note → not_found, same shape as unknown attachment", async () => {
|
|
269
|
+
const note = await store.createNote("# T\n", { path: "t8" });
|
|
270
|
+
const attachment = await store.addAttachment(note.id, "2026-07-17/y.png", "image/png");
|
|
271
|
+
const provider = new FakeTicketProvider();
|
|
272
|
+
const tools = generateMcpTools(store, {
|
|
273
|
+
attachmentTickets: {
|
|
274
|
+
provider,
|
|
275
|
+
vaultName: "v1",
|
|
276
|
+
urlBase: "https://host/vault/v1",
|
|
277
|
+
noteVisible: async (_n: Note) => false,
|
|
278
|
+
},
|
|
279
|
+
});
|
|
280
|
+
const err = await expectToolError(findTool(tools, "request-attachment-download"), {
|
|
281
|
+
attachment_id: attachment.id,
|
|
282
|
+
});
|
|
283
|
+
expect(err.error_type).toBe("not_found");
|
|
284
|
+
expect(provider.tickets.size).toBe(0);
|
|
285
|
+
});
|
|
286
|
+
});
|
package/core/src/conformance.ts
CHANGED
|
@@ -78,7 +78,8 @@ function toStrictSchemaField(spec: TagFieldSchema): SchemaField {
|
|
|
78
78
|
spec.type === "integer" ||
|
|
79
79
|
spec.type === "boolean" ||
|
|
80
80
|
spec.type === "array" ||
|
|
81
|
-
spec.type === "object"
|
|
81
|
+
spec.type === "object" ||
|
|
82
|
+
spec.type === "date"
|
|
82
83
|
) {
|
|
83
84
|
out.type = spec.type;
|
|
84
85
|
}
|
|
@@ -23,8 +23,13 @@ import {
|
|
|
23
23
|
parseContentRange,
|
|
24
24
|
sliceContentRange,
|
|
25
25
|
applyContentRange,
|
|
26
|
+
parseAttachmentContentRange,
|
|
27
|
+
alignByteWindow,
|
|
26
28
|
MIN_CONTENT_LENGTH,
|
|
29
|
+
DEFAULT_ATTACHMENT_WINDOW_BYTES,
|
|
30
|
+
MAX_ATTACHMENT_WINDOW_BYTES,
|
|
27
31
|
} from "./content-range.js";
|
|
32
|
+
import { QueryError } from "./query-operators.js";
|
|
28
33
|
|
|
29
34
|
// ---------------------------------------------------------------------------
|
|
30
35
|
// 1. parseContentRange
|
|
@@ -237,6 +242,128 @@ describe("content range — reassembly property", () => {
|
|
|
237
242
|
});
|
|
238
243
|
});
|
|
239
244
|
|
|
245
|
+
// ---------------------------------------------------------------------------
|
|
246
|
+
// 2c. parseAttachmentContentRange + alignByteWindow (read-attachment)
|
|
247
|
+
// ---------------------------------------------------------------------------
|
|
248
|
+
|
|
249
|
+
describe("parseAttachmentContentRange", () => {
|
|
250
|
+
it("defaults offset=0, length=DEFAULT_ATTACHMENT_WINDOW_BYTES when both are omitted", () => {
|
|
251
|
+
expect(parseAttachmentContentRange(undefined, undefined)).toEqual({
|
|
252
|
+
offset: 0,
|
|
253
|
+
length: DEFAULT_ATTACHMENT_WINDOW_BYTES,
|
|
254
|
+
});
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
it("offset only → length still defaults (never 'read to end' — that's the query-notes shape, not this one)", () => {
|
|
258
|
+
expect(parseAttachmentContentRange(1000, undefined)).toEqual({
|
|
259
|
+
offset: 1000,
|
|
260
|
+
length: DEFAULT_ATTACHMENT_WINDOW_BYTES,
|
|
261
|
+
});
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
it("accepts an explicit length up to the max", () => {
|
|
265
|
+
expect(parseAttachmentContentRange(0, MAX_ATTACHMENT_WINDOW_BYTES)).toEqual({
|
|
266
|
+
offset: 0,
|
|
267
|
+
length: MAX_ATTACHMENT_WINDOW_BYTES,
|
|
268
|
+
});
|
|
269
|
+
});
|
|
270
|
+
|
|
271
|
+
it("rejects a length below MIN_CONTENT_LENGTH", () => {
|
|
272
|
+
expect(() => parseAttachmentContentRange(0, 2)).toThrow(QueryError);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
it("rejects a length above MAX_ATTACHMENT_WINDOW_BYTES", () => {
|
|
276
|
+
expect(() => parseAttachmentContentRange(0, MAX_ATTACHMENT_WINDOW_BYTES + 1)).toThrow(QueryError);
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
it("rejects a negative offset", () => {
|
|
280
|
+
expect(() => parseAttachmentContentRange(-1, undefined)).toThrow(QueryError);
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe("alignByteWindow", () => {
|
|
285
|
+
/**
|
|
286
|
+
* Simulates the real caller: a BOUNDED positional read of
|
|
287
|
+
* `[max(0, offset-3), min(total, offset+length))` from `full`, THEN
|
|
288
|
+
* alignment — never handing the whole buffer to `alignByteWindow`, so a
|
|
289
|
+
* pass here proves the "never load the whole file" contract actually
|
|
290
|
+
* holds and isn't just true by accident of the test passing the full
|
|
291
|
+
* buffer.
|
|
292
|
+
*/
|
|
293
|
+
function boundedRead(full: Buffer, offset: number, length: number): { raw: Uint8Array; rawStart: number } {
|
|
294
|
+
const total = full.byteLength;
|
|
295
|
+
const rawStart = Math.max(0, offset - 3);
|
|
296
|
+
// +1: alignByteWindow's end-boundary check reads the byte AT the
|
|
297
|
+
// (exclusive) window end to decide whether it's a continuation byte —
|
|
298
|
+
// that's one byte past `offset + length`, so the read must include it
|
|
299
|
+
// (see alignByteWindow's doc comment precondition).
|
|
300
|
+
const rawEnd = Math.min(total, offset + length + 1);
|
|
301
|
+
return { raw: full.subarray(rawStart, Math.max(rawStart, rawEnd)), rawStart };
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
it("offset past end → empty slice, complete (mirrors sliceContentRange)", () => {
|
|
305
|
+
const full = Buffer.from("abc", "utf8");
|
|
306
|
+
const { raw, rawStart } = boundedRead(full, 999, 16);
|
|
307
|
+
const r = alignByteWindow(raw, rawStart, { offset: 999, length: 16 }, full.byteLength);
|
|
308
|
+
expect(r.content).toBe("");
|
|
309
|
+
expect(r.content_offset).toBe(3);
|
|
310
|
+
expect(r.content_total_length).toBe(3);
|
|
311
|
+
expect(r.content_next_offset).toBeNull();
|
|
312
|
+
});
|
|
313
|
+
|
|
314
|
+
it("matches sliceContentRange byte-for-byte on a plain ASCII window", () => {
|
|
315
|
+
const s = "hello world";
|
|
316
|
+
const full = Buffer.from(s, "utf8");
|
|
317
|
+
const { raw, rawStart } = boundedRead(full, 0, 5);
|
|
318
|
+
const bounded = alignByteWindow(raw, rawStart, { offset: 0, length: 5 }, full.byteLength);
|
|
319
|
+
const whole = sliceContentRange(s, { offset: 0, length: 5 });
|
|
320
|
+
expect(bounded).toEqual(whole);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
it("never splits a codepoint under a bounded read (matches sliceContentRange across a 4-byte emoji)", () => {
|
|
324
|
+
const s = "ab\u{1F600}cd"; // emoji occupies bytes 2..5 of 8
|
|
325
|
+
const full = Buffer.from(s, "utf8");
|
|
326
|
+
for (const [offset, length] of [
|
|
327
|
+
[0, 5], // budget cuts mid-emoji → backs off to byte 2
|
|
328
|
+
[2, 4], // exact emoji window
|
|
329
|
+
[4, 8], // offset lands mid-emoji → aligns down to byte 2
|
|
330
|
+
[6, 4], // final ASCII tail
|
|
331
|
+
] as const) {
|
|
332
|
+
const { raw, rawStart } = boundedRead(full, offset, length);
|
|
333
|
+
const bounded = alignByteWindow(raw, rawStart, { offset, length }, full.byteLength);
|
|
334
|
+
const whole = sliceContentRange(s, { offset, length });
|
|
335
|
+
expect(bounded).toEqual(whole);
|
|
336
|
+
}
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it("reassembly property: chaining content_next_offset through BOUNDED reads reproduces the full content", () => {
|
|
340
|
+
const rand = mulberry32(0xba5eba11);
|
|
341
|
+
const POOL = ["a", "Z", "9", " ", "\n", "é", "ψ", "你", "‱", "\u{1F600}", "\u{1D11E}"];
|
|
342
|
+
for (let iter = 0; iter < 40; iter++) {
|
|
343
|
+
const charCount = Math.floor(rand() * 100);
|
|
344
|
+
let content = "";
|
|
345
|
+
for (let i = 0; i < charCount; i++) content += POOL[Math.floor(rand() * POOL.length)]!;
|
|
346
|
+
const full = Buffer.from(content, "utf8");
|
|
347
|
+
const total = full.byteLength;
|
|
348
|
+
const budget = MIN_CONTENT_LENGTH + Math.floor(rand() * 13); // 4..16 bytes
|
|
349
|
+
|
|
350
|
+
let offset = 0;
|
|
351
|
+
let assembled = "";
|
|
352
|
+
for (let step = 0; step <= total + 2; step++) {
|
|
353
|
+
const { raw, rawStart } = boundedRead(full, offset, budget);
|
|
354
|
+
const slice = alignByteWindow(raw, rawStart, { offset, length: budget }, total);
|
|
355
|
+
expect(Buffer.byteLength(slice.content, "utf8")).toBeLessThanOrEqual(budget);
|
|
356
|
+
expect(slice.content_total_length).toBe(total);
|
|
357
|
+
assembled += slice.content;
|
|
358
|
+
if (slice.content_next_offset === null) break;
|
|
359
|
+
expect(slice.content_next_offset).toBeGreaterThan(offset);
|
|
360
|
+
offset = slice.content_next_offset;
|
|
361
|
+
}
|
|
362
|
+
expect(assembled).toBe(content);
|
|
363
|
+
}
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
|
|
240
367
|
// ---------------------------------------------------------------------------
|
|
241
368
|
// 3. MCP face — query-notes
|
|
242
369
|
// ---------------------------------------------------------------------------
|
|
@@ -183,3 +183,103 @@ export function applyContentRange(
|
|
|
183
183
|
result.content_total_length = fields.content_total_length;
|
|
184
184
|
result.content_next_offset = fields.content_next_offset;
|
|
185
185
|
}
|
|
186
|
+
|
|
187
|
+
// ---------------------------------------------------------------------------
|
|
188
|
+
// Attachment byte-window reads (`read-attachment`, Wave 2 model lane)
|
|
189
|
+
// ---------------------------------------------------------------------------
|
|
190
|
+
|
|
191
|
+
/** Default `read-attachment` text window when the caller omits `content_length` — small enough to never nuke a context budget. */
|
|
192
|
+
export const DEFAULT_ATTACHMENT_WINDOW_BYTES = 65_536; // 64 KiB
|
|
193
|
+
|
|
194
|
+
/** Hard per-call ceiling on `read-attachment`'s `content_length` — a deliberate-big-bite max, not a default. */
|
|
195
|
+
export const MAX_ATTACHMENT_WINDOW_BYTES = 262_144; // 256 KiB
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Parse `read-attachment`'s `content_offset` / `content_length` pair.
|
|
199
|
+
* Unlike {@link parseContentRange} (query-notes: omitted params mean "range
|
|
200
|
+
* mode off, return everything"), a `read-attachment` call ALWAYS reads a
|
|
201
|
+
* bounded window — omitting `content_length` defaults it to
|
|
202
|
+
* {@link DEFAULT_ATTACHMENT_WINDOW_BYTES} rather than "the whole file" (an
|
|
203
|
+
* attachment can be arbitrarily large; a note can't cheaply be). Returns a
|
|
204
|
+
* fully-resolved `{offset, length}` (never the query-notes "null = off"
|
|
205
|
+
* shape). Throws `QueryError` (INVALID_QUERY) on a negative/non-integer
|
|
206
|
+
* value, a `content_length` below {@link MIN_CONTENT_LENGTH}, or one above
|
|
207
|
+
* {@link MAX_ATTACHMENT_WINDOW_BYTES}.
|
|
208
|
+
*/
|
|
209
|
+
export function parseAttachmentContentRange(
|
|
210
|
+
offsetRaw: unknown,
|
|
211
|
+
lengthRaw: unknown,
|
|
212
|
+
): { offset: number; length: number } {
|
|
213
|
+
const offset = toNonNegativeInt(offsetRaw, "content_offset") ?? 0;
|
|
214
|
+
const length = toNonNegativeInt(lengthRaw, "content_length");
|
|
215
|
+
if (length !== undefined && length < MIN_CONTENT_LENGTH) {
|
|
216
|
+
throw new QueryError(
|
|
217
|
+
`invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — must be at least ${MIN_CONTENT_LENGTH} bytes (the size of the largest UTF-8 codepoint, so every window makes progress).`,
|
|
218
|
+
"INVALID_QUERY",
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
if (length !== undefined && length > MAX_ATTACHMENT_WINDOW_BYTES) {
|
|
222
|
+
throw new QueryError(
|
|
223
|
+
`invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — exceeds the ${MAX_ATTACHMENT_WINDOW_BYTES} byte (256 KiB) per-call max for read-attachment.`,
|
|
224
|
+
"INVALID_QUERY",
|
|
225
|
+
);
|
|
226
|
+
}
|
|
227
|
+
return { offset, length: length ?? DEFAULT_ATTACHMENT_WINDOW_BYTES };
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
/**
|
|
231
|
+
* Byte-level counterpart to {@link sliceContentRange} for `read-attachment`'s
|
|
232
|
+
* text path, where loading the WHOLE file into memory to slice a string
|
|
233
|
+
* (`sliceContentRange`'s approach) is exactly the thing a 500 MB attachment
|
|
234
|
+
* forbids. The caller does a BOUNDED positional read first — `raw` is
|
|
235
|
+
* whatever bytes it actually fetched, starting at file offset `rawStart`
|
|
236
|
+
* — and this function applies the identical alignment rules
|
|
237
|
+
* `sliceContentRange` applies to a full in-memory string, operating only on
|
|
238
|
+
* that window.
|
|
239
|
+
*
|
|
240
|
+
* Precondition (caller's responsibility, not re-validated here): `raw`
|
|
241
|
+
* covers at least `[max(0, range.offset - 3), min(total, range.offset +
|
|
242
|
+
* range.length) + 1)` — i.e. from 3 bytes before the requested offset
|
|
243
|
+
* through ONE byte past the requested end, clamped to `total`. The 3-byte
|
|
244
|
+
* lookback is enough to find the leading byte of any UTF-8 codepoint the
|
|
245
|
+
* requested `offset` might land inside (a codepoint is at most 4 bytes,
|
|
246
|
+
* i.e. at most 3 continuation bytes after its leading byte); the 1-byte
|
|
247
|
+
* lookahead is what the end-alignment check reads to decide whether the
|
|
248
|
+
* budget cut lands mid-codepoint (mirroring `sliceContentRange`, which
|
|
249
|
+
* checks `bytes[end]` — the byte AT the exclusive cut point).
|
|
250
|
+
*/
|
|
251
|
+
export function alignByteWindow(
|
|
252
|
+
raw: Uint8Array,
|
|
253
|
+
rawStart: number,
|
|
254
|
+
range: { offset: number; length: number },
|
|
255
|
+
total: number,
|
|
256
|
+
): ContentRangeFields {
|
|
257
|
+
if (range.offset >= total) {
|
|
258
|
+
return {
|
|
259
|
+
content: "",
|
|
260
|
+
content_offset: total,
|
|
261
|
+
content_total_length: total,
|
|
262
|
+
content_next_offset: null,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
const byteAt = (idx: number): number => raw[idx - rawStart]!;
|
|
267
|
+
|
|
268
|
+
// Align the start DOWN to the leading byte of the codepoint containing
|
|
269
|
+
// `offset` — same rule as sliceContentRange, bounded to what's in `raw`.
|
|
270
|
+
let start = range.offset;
|
|
271
|
+
while (start > rawStart && isContinuationByte(byteAt(start))) start--;
|
|
272
|
+
|
|
273
|
+
// Window end: budget capped at total, then clamped to what's actually in
|
|
274
|
+
// `raw` (defense-in-depth — a caller that under-read would otherwise
|
|
275
|
+
// index past the buffer). Aligned DOWN so the slice never ends mid-codepoint.
|
|
276
|
+
let end = Math.min(start + range.length, total, rawStart + raw.length);
|
|
277
|
+
while (end > start && end < total && isContinuationByte(byteAt(end))) end--;
|
|
278
|
+
|
|
279
|
+
return {
|
|
280
|
+
content: Buffer.from(raw.subarray(start - rawStart, end - rawStart)).toString("utf8"),
|
|
281
|
+
content_offset: start,
|
|
282
|
+
content_total_length: total,
|
|
283
|
+
content_next_offset: end >= total ? null : end,
|
|
284
|
+
};
|
|
285
|
+
}
|
|
@@ -220,14 +220,15 @@ describe("contract: typed indexes — Decision B: explicit-default-only enum bac
|
|
|
220
220
|
});
|
|
221
221
|
|
|
222
222
|
describe("contract: typed indexes — Decision C: honest type list (#553, flipped from todo)", () => {
|
|
223
|
-
it("the update-tag field-type description clarifies only string/integer/boolean/reference are indexable", () => {
|
|
223
|
+
it("the update-tag field-type description clarifies only string/integer/boolean/reference/date are indexable", () => {
|
|
224
224
|
const updateTag = generateMcpTools(store).find((t) => t.name === "update-tag")!;
|
|
225
225
|
const typeDesc = (updateTag.inputSchema as any).properties.fields.additionalProperties.properties.type.description as string;
|
|
226
226
|
// Honest about the full storage/advisory vocabulary AND the indexable subset.
|
|
227
227
|
expect(typeDesc).toContain("number");
|
|
228
228
|
// vault#typed-reference-field: `reference` joined the indexable subset
|
|
229
|
-
// alongside string/integer/boolean.
|
|
230
|
-
|
|
229
|
+
// alongside string/integer/boolean. vault#date-field-type: `date` joined
|
|
230
|
+
// the same subset — it stores TEXT (ISO-8601), same as string/reference.
|
|
231
|
+
expect(typeDesc).toContain("Only string/integer/boolean/reference/date are INDEXABLE");
|
|
231
232
|
});
|
|
232
233
|
|
|
233
234
|
it("declaring indexed:true with an unindexable type (number) is rejected", async () => {
|