@openparachute/vault 0.7.3-rc.8 → 0.7.3
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/content-range-constants.ts +19 -0
- package/core/src/content-range.test.ts +127 -0
- package/core/src/content-range.ts +105 -8
- package/core/src/core.test.ts +66 -4
- package/core/src/expand.ts +11 -3
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-manifest.test.ts +200 -0
- package/core/src/mcp-manifest.ts +736 -0
- package/core/src/mcp.ts +357 -607
- package/core/src/notes.ts +69 -10
- 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/auth-hub-jwt.test.ts +118 -1
- package/src/auth.ts +64 -0
- package/src/config.test.ts +16 -0
- package/src/config.ts +17 -0
- package/src/embedding/select.test.ts +58 -30
- package/src/embedding/select.ts +62 -21
- package/src/live-frame-parity.test.ts +21 -0
- package/src/mcp-http.ts +20 -3
- package/src/mcp-tools.ts +15 -3
- package/src/oauth-discovery.ts +31 -0
- package/src/read-attachment.test.ts +436 -0
- package/src/routes.ts +80 -4
- package/src/routing.test.ts +229 -4
- package/src/routing.ts +135 -23
- package/src/scopes.ts +22 -0
- package/src/server.ts +17 -8
- package/src/storage.test.ts +200 -1
- package/src/subscriptions.ts +13 -1
- package/src/transcription-worker.test.ts +151 -0
- package/src/transcription-worker.ts +113 -52
- package/src/vault-embeddings-capability.test.ts +28 -6
- package/src/vault-store-embedding-wiring.test.ts +25 -16
- package/src/vault-store.ts +32 -16
- package/src/vault.test.ts +26 -13
- package/src/ws-server.ts +9 -1
- package/src/ws-subscribe.test.ts +87 -0
- package/src/ws-subscribe.ts +25 -6
package/README.md
CHANGED
|
@@ -30,7 +30,7 @@ For remote access from Claude Desktop or mobile apps, see [Deployment](#deployme
|
|
|
30
30
|
|
|
31
31
|
A server on port 1940 with:
|
|
32
32
|
|
|
33
|
-
- **MCP** —
|
|
33
|
+
- **MCP** — tools for AI agents (notes, tags, graph, vault info, attachments)
|
|
34
34
|
- **REST API** — Full CRUD for notes, tags, links, full-text search
|
|
35
35
|
- **Wikilink auto-linking** — `[[wikilinks]]` in note content automatically create links in the graph
|
|
36
36
|
- **Obsidian import/export** — Bidirectional interop with Obsidian vaults
|
|
@@ -252,12 +252,14 @@ parachute-vault backup --schedule daily # hourly | daily | weekly | manua
|
|
|
252
252
|
parachute-vault backup status # schedule, last run, destinations, next run
|
|
253
253
|
```
|
|
254
254
|
|
|
255
|
-
## MCP tools (
|
|
255
|
+
## MCP tools (13 core, + attachments + admin)
|
|
256
256
|
|
|
257
257
|
**Notes**: `query-notes` (universal read — single by ID/path, filter, search, graph neighborhood), `create-note` (single or batch), `update-note` (single or batch — content, tags, links, metadata), `delete-note`
|
|
258
|
-
**Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`
|
|
258
|
+
**Tags**: `list-tags` (with optional schema detail), `update-tag` (upsert description + schema fields), `delete-tag`, `rename-tag`, `merge-tags`
|
|
259
259
|
**Graph**: `find-path` (BFS between two notes)
|
|
260
260
|
**Vault**: `vault-info` (get/update description + stats)
|
|
261
|
+
**Admin**: `prune-schema`, `doctor` (taxonomy/metadata integrity scan), `manage-token`
|
|
262
|
+
**Attachments** (2, present when the door wires an attachment-ticket provider — always true on this server): `request-attachment-upload`, `request-attachment-download` — mint a short-lived, single-use ticket a runtime's shell spends directly at `/vault/{name}/tickets/{id}`; bytes never pass through the tool call. See [`docs/HTTP_API.md`](./docs/HTTP_API.md#attachment-tickets-vault611--wave-1-bun-only).
|
|
261
263
|
|
|
262
264
|
### Vault descriptions
|
|
263
265
|
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `AttachmentBytesProvider` — the model-lane (Wave 2) byte-access seam.
|
|
3
|
+
* Mirrors `AttachmentTicketProvider` (`./tickets.ts`): a per-door
|
|
4
|
+
* implementation binds this to its own storage (bun: local fs, see
|
|
5
|
+
* `src/attachment-bytes.ts`; a future cloud implementation: ranged R2 GETs
|
|
6
|
+
* through the vault DO). Core stays storage-unaware — the `read-attachment`
|
|
7
|
+
* tool (`core/src/mcp.ts`) calls only through this interface, so bun and a
|
|
8
|
+
* future cloud implementation can never drift on the read contract.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately narrow: stat + a bounded positional read, plus one optional
|
|
11
|
+
* hook for the audio transcript pointer. All POLICY (mime-family dispatch,
|
|
12
|
+
* size caps, range validation, tag-scope) lives in the `read-attachment`
|
|
13
|
+
* tool itself — same division as the ticket seam (`GenerateMcpToolsOpts`'s
|
|
14
|
+
* doc comment in `core/src/mcp.ts`).
|
|
15
|
+
*
|
|
16
|
+
* D10 (attachments-for-agents design): "tools omitted when unwired" — a
|
|
17
|
+
* door that hasn't wired this seam simply never passes `attachmentBytes` to
|
|
18
|
+
* `generateMcpTools`, so `read-attachment` is absent from `tools/list`
|
|
19
|
+
* entirely, not merely erroring on call.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import type { Attachment } from "../types.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 4 MiB raw-bytes cap on the image branch of `read-attachment` (D3): the
|
|
26
|
+
* largest honest budget under Claude's ~5 MB image API limit, with room for
|
|
27
|
+
* base64 blow-up (~5.6 MiB on the wire) and DO-transient headroom on a
|
|
28
|
+
* future cloud implementation. Enforced BEFORE a read is attempted — the
|
|
29
|
+
* tool calls `stat()` first and refuses over-cap without ever calling
|
|
30
|
+
* `readRange()`.
|
|
31
|
+
*/
|
|
32
|
+
export const MAX_ATTACHMENT_IMAGE_BYTES = 4 * 1024 * 1024;
|
|
33
|
+
|
|
34
|
+
export interface AttachmentBytesProvider {
|
|
35
|
+
/**
|
|
36
|
+
* Byte size of the attachment's stored bytes, or `null` when the row
|
|
37
|
+
* exists but its bytes don't — e.g. an `audio_retention` eviction after a
|
|
38
|
+
* successful transcription (`src/transcription-worker.ts` unlinks the
|
|
39
|
+
* file on `until_transcribed` / `never` retention), or any other
|
|
40
|
+
* out-of-band loss. Drives the `attachment_binary_missing` refusal.
|
|
41
|
+
*/
|
|
42
|
+
stat(attachment: Attachment): Promise<{ size: number } | null>;
|
|
43
|
+
/**
|
|
44
|
+
* Read the half-open byte range `[start, end)`. Bounded by construction —
|
|
45
|
+
* callers never ask for the whole file when only a window is needed (the
|
|
46
|
+
* text path); the image path DOES read start-to-end, but only after
|
|
47
|
+
* `stat()` has already confirmed the file is under
|
|
48
|
+
* {@link MAX_ATTACHMENT_IMAGE_BYTES}. `start`/`end` are always within
|
|
49
|
+
* `[0, size]` as reported by a prior `stat()` call on the same attachment
|
|
50
|
+
* — implementations don't need to re-clamp defensively, though doing so
|
|
51
|
+
* costs nothing.
|
|
52
|
+
*/
|
|
53
|
+
readRange(attachment: Attachment, start: number, end: number): Promise<Uint8Array>;
|
|
54
|
+
/**
|
|
55
|
+
* OPTIONAL: resolve the sibling transcript note for a completed
|
|
56
|
+
* audio/video transcription (bun: `<attachment-path>.transcript`, see
|
|
57
|
+
* `transcriptPathFor` in `src/transcript-note.ts`). Omitted by a door
|
|
58
|
+
* whose transcript instead lives in the owning note's body (the design's
|
|
59
|
+
* cloud path, D... — no separate sibling note to point at) — the
|
|
60
|
+
* `read-attachment` audio branch falls back to `note_id` alone (the
|
|
61
|
+
* owning note) in that case, per the design's "on cloud the transcript
|
|
62
|
+
* lives in the owning note's body, so `note_id` is the pointer."
|
|
63
|
+
*/
|
|
64
|
+
resolveTranscriptNote?(attachment: Attachment): Promise<{ id: string; path: string } | null>;
|
|
65
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Content-range constants with ZERO runtime dependencies.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from `content-range.ts` (which imports `QueryError` from
|
|
5
|
+
* `query-operators.ts` → `bun:sqlite`) so the pure-data MCP tool manifest
|
|
6
|
+
* (`mcp-manifest.ts`) can reference `MIN_CONTENT_LENGTH` in a tool
|
|
7
|
+
* `description` without dragging the sqlite driver into its import graph —
|
|
8
|
+
* the front-of-house Wave 0 workerd invariant. `content-range.ts` re-exports
|
|
9
|
+
* `MIN_CONTENT_LENGTH` from here, so every existing importer is unaffected.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Minimum accepted `content_length`. A UTF-8 codepoint is at most 4 bytes,
|
|
14
|
+
* so any budget >= 4 is guaranteed to make progress (the codepoint at the
|
|
15
|
+
* window start always fits). Budgets 1–3 could stall forever on a 4-byte
|
|
16
|
+
* emoji (empty slice, next_offset == offset); rejecting them up front is
|
|
17
|
+
* deterministic and simpler than a runtime "no progress" error.
|
|
18
|
+
*/
|
|
19
|
+
export const MIN_CONTENT_LENGTH = 4;
|
|
@@ -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
|
// ---------------------------------------------------------------------------
|
|
@@ -30,14 +30,11 @@
|
|
|
30
30
|
|
|
31
31
|
import { QueryError } from "./query-operators.js";
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
* deterministic and simpler than a runtime "no progress" error.
|
|
39
|
-
*/
|
|
40
|
-
export const MIN_CONTENT_LENGTH = 4;
|
|
33
|
+
// `MIN_CONTENT_LENGTH` lives in the dependency-free `content-range-constants.ts`
|
|
34
|
+
// so the pure-data MCP tool manifest can import it without pulling `bun:sqlite`
|
|
35
|
+
// (front-of-house Wave 0). Re-exported here so existing importers are unchanged.
|
|
36
|
+
export { MIN_CONTENT_LENGTH } from "./content-range-constants.js";
|
|
37
|
+
import { MIN_CONTENT_LENGTH } from "./content-range-constants.js";
|
|
41
38
|
|
|
42
39
|
export interface ContentRange {
|
|
43
40
|
/** Byte offset (UTF-8) to start reading from. */
|
|
@@ -183,3 +180,103 @@ export function applyContentRange(
|
|
|
183
180
|
result.content_total_length = fields.content_total_length;
|
|
184
181
|
result.content_next_offset = fields.content_next_offset;
|
|
185
182
|
}
|
|
183
|
+
|
|
184
|
+
// ---------------------------------------------------------------------------
|
|
185
|
+
// Attachment byte-window reads (`read-attachment`, Wave 2 model lane)
|
|
186
|
+
// ---------------------------------------------------------------------------
|
|
187
|
+
|
|
188
|
+
/** Default `read-attachment` text window when the caller omits `content_length` — small enough to never nuke a context budget. */
|
|
189
|
+
export const DEFAULT_ATTACHMENT_WINDOW_BYTES = 65_536; // 64 KiB
|
|
190
|
+
|
|
191
|
+
/** Hard per-call ceiling on `read-attachment`'s `content_length` — a deliberate-big-bite max, not a default. */
|
|
192
|
+
export const MAX_ATTACHMENT_WINDOW_BYTES = 262_144; // 256 KiB
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Parse `read-attachment`'s `content_offset` / `content_length` pair.
|
|
196
|
+
* Unlike {@link parseContentRange} (query-notes: omitted params mean "range
|
|
197
|
+
* mode off, return everything"), a `read-attachment` call ALWAYS reads a
|
|
198
|
+
* bounded window — omitting `content_length` defaults it to
|
|
199
|
+
* {@link DEFAULT_ATTACHMENT_WINDOW_BYTES} rather than "the whole file" (an
|
|
200
|
+
* attachment can be arbitrarily large; a note can't cheaply be). Returns a
|
|
201
|
+
* fully-resolved `{offset, length}` (never the query-notes "null = off"
|
|
202
|
+
* shape). Throws `QueryError` (INVALID_QUERY) on a negative/non-integer
|
|
203
|
+
* value, a `content_length` below {@link MIN_CONTENT_LENGTH}, or one above
|
|
204
|
+
* {@link MAX_ATTACHMENT_WINDOW_BYTES}.
|
|
205
|
+
*/
|
|
206
|
+
export function parseAttachmentContentRange(
|
|
207
|
+
offsetRaw: unknown,
|
|
208
|
+
lengthRaw: unknown,
|
|
209
|
+
): { offset: number; length: number } {
|
|
210
|
+
const offset = toNonNegativeInt(offsetRaw, "content_offset") ?? 0;
|
|
211
|
+
const length = toNonNegativeInt(lengthRaw, "content_length");
|
|
212
|
+
if (length !== undefined && length < MIN_CONTENT_LENGTH) {
|
|
213
|
+
throw new QueryError(
|
|
214
|
+
`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).`,
|
|
215
|
+
"INVALID_QUERY",
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
if (length !== undefined && length > MAX_ATTACHMENT_WINDOW_BYTES) {
|
|
219
|
+
throw new QueryError(
|
|
220
|
+
`invalid \`content_length\` value ${JSON.stringify(lengthRaw)} — exceeds the ${MAX_ATTACHMENT_WINDOW_BYTES} byte (256 KiB) per-call max for read-attachment.`,
|
|
221
|
+
"INVALID_QUERY",
|
|
222
|
+
);
|
|
223
|
+
}
|
|
224
|
+
return { offset, length: length ?? DEFAULT_ATTACHMENT_WINDOW_BYTES };
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
/**
|
|
228
|
+
* Byte-level counterpart to {@link sliceContentRange} for `read-attachment`'s
|
|
229
|
+
* text path, where loading the WHOLE file into memory to slice a string
|
|
230
|
+
* (`sliceContentRange`'s approach) is exactly the thing a 500 MB attachment
|
|
231
|
+
* forbids. The caller does a BOUNDED positional read first — `raw` is
|
|
232
|
+
* whatever bytes it actually fetched, starting at file offset `rawStart`
|
|
233
|
+
* — and this function applies the identical alignment rules
|
|
234
|
+
* `sliceContentRange` applies to a full in-memory string, operating only on
|
|
235
|
+
* that window.
|
|
236
|
+
*
|
|
237
|
+
* Precondition (caller's responsibility, not re-validated here): `raw`
|
|
238
|
+
* covers at least `[max(0, range.offset - 3), min(total, range.offset +
|
|
239
|
+
* range.length) + 1)` — i.e. from 3 bytes before the requested offset
|
|
240
|
+
* through ONE byte past the requested end, clamped to `total`. The 3-byte
|
|
241
|
+
* lookback is enough to find the leading byte of any UTF-8 codepoint the
|
|
242
|
+
* requested `offset` might land inside (a codepoint is at most 4 bytes,
|
|
243
|
+
* i.e. at most 3 continuation bytes after its leading byte); the 1-byte
|
|
244
|
+
* lookahead is what the end-alignment check reads to decide whether the
|
|
245
|
+
* budget cut lands mid-codepoint (mirroring `sliceContentRange`, which
|
|
246
|
+
* checks `bytes[end]` — the byte AT the exclusive cut point).
|
|
247
|
+
*/
|
|
248
|
+
export function alignByteWindow(
|
|
249
|
+
raw: Uint8Array,
|
|
250
|
+
rawStart: number,
|
|
251
|
+
range: { offset: number; length: number },
|
|
252
|
+
total: number,
|
|
253
|
+
): ContentRangeFields {
|
|
254
|
+
if (range.offset >= total) {
|
|
255
|
+
return {
|
|
256
|
+
content: "",
|
|
257
|
+
content_offset: total,
|
|
258
|
+
content_total_length: total,
|
|
259
|
+
content_next_offset: null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
const byteAt = (idx: number): number => raw[idx - rawStart]!;
|
|
264
|
+
|
|
265
|
+
// Align the start DOWN to the leading byte of the codepoint containing
|
|
266
|
+
// `offset` — same rule as sliceContentRange, bounded to what's in `raw`.
|
|
267
|
+
let start = range.offset;
|
|
268
|
+
while (start > rawStart && isContinuationByte(byteAt(start))) start--;
|
|
269
|
+
|
|
270
|
+
// Window end: budget capped at total, then clamped to what's actually in
|
|
271
|
+
// `raw` (defense-in-depth — a caller that under-read would otherwise
|
|
272
|
+
// index past the buffer). Aligned DOWN so the slice never ends mid-codepoint.
|
|
273
|
+
let end = Math.min(start + range.length, total, rawStart + raw.length);
|
|
274
|
+
while (end > start && end < total && isContinuationByte(byteAt(end))) end--;
|
|
275
|
+
|
|
276
|
+
return {
|
|
277
|
+
content: Buffer.from(raw.subarray(start - rawStart, end - rawStart)).toString("utf8"),
|
|
278
|
+
content_offset: start,
|
|
279
|
+
content_total_length: total,
|
|
280
|
+
content_next_offset: end >= total ? null : end,
|
|
281
|
+
};
|
|
282
|
+
}
|
package/core/src/core.test.ts
CHANGED
|
@@ -5893,8 +5893,10 @@ describe("query-notes link expansion", async () => {
|
|
|
5893
5893
|
expect(result[0].preview).toBeTruthy();
|
|
5894
5894
|
});
|
|
5895
5895
|
|
|
5896
|
-
it("expand_mode=summary with no metadata.summary renders empty body inline", async () => {
|
|
5897
|
-
|
|
5896
|
+
it("expand_mode=summary with no metadata.summary AND no lede (title-only note) renders empty body inline", async () => {
|
|
5897
|
+
// Single-line content: the whole thing IS the title, so there's no
|
|
5898
|
+
// paragraph after it for computeLede to fall back to.
|
|
5899
|
+
await store.createNote("unsummarized title-only body", { path: "Plain" });
|
|
5898
5900
|
await store.createNote("see [[Plain]]", { path: "Src" });
|
|
5899
5901
|
const tools = generateMcpTools(store);
|
|
5900
5902
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
@@ -5905,8 +5907,68 @@ describe("query-notes link expansion", async () => {
|
|
|
5905
5907
|
expand_mode: "summary",
|
|
5906
5908
|
}) as any;
|
|
5907
5909
|
expect(result.content).toContain('mode="summary"');
|
|
5908
|
-
//
|
|
5909
|
-
expect(result.content).not.toContain("unsummarized body");
|
|
5910
|
+
// No summary, no lede — we still get the block but with nothing between delimiters.
|
|
5911
|
+
expect(result.content).not.toContain("unsummarized title-only body");
|
|
5912
|
+
});
|
|
5913
|
+
|
|
5914
|
+
it("expand_mode=summary with no metadata.summary falls back to the note's lede", async () => {
|
|
5915
|
+
await store.createNote(
|
|
5916
|
+
"# Long canonical statement\n\nUnforced / wu wei, in one paragraph.\n\n(Many paragraphs of detail follow...)",
|
|
5917
|
+
{ path: "Statements/NoSummary" },
|
|
5918
|
+
);
|
|
5919
|
+
await store.createNote("Overview: [[Statements/NoSummary]]", { path: "Index2" });
|
|
5920
|
+
const tools = generateMcpTools(store);
|
|
5921
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
5922
|
+
|
|
5923
|
+
const result = await query.execute({
|
|
5924
|
+
id: "Index2",
|
|
5925
|
+
expand_links: true,
|
|
5926
|
+
expand_mode: "summary",
|
|
5927
|
+
}) as any;
|
|
5928
|
+
|
|
5929
|
+
expect(result.content).toContain('mode="summary"');
|
|
5930
|
+
expect(result.content).toContain("Unforced / wu wei, in one paragraph.");
|
|
5931
|
+
expect(result.content).not.toContain("Many paragraphs of detail");
|
|
5932
|
+
// The heading/title itself isn't repeated as the summary.
|
|
5933
|
+
expect(result.content).not.toContain("Long canonical statement");
|
|
5934
|
+
});
|
|
5935
|
+
|
|
5936
|
+
it("expand_mode=summary: metadata.summary still wins over the lede when both are present", async () => {
|
|
5937
|
+
await store.createNote(
|
|
5938
|
+
"# Title\n\nThis is the lede paragraph, not the summary.",
|
|
5939
|
+
{ path: "Statements/Both", metadata: { summary: "The curated summary." } },
|
|
5940
|
+
);
|
|
5941
|
+
await store.createNote("see [[Statements/Both]]", { path: "Index3" });
|
|
5942
|
+
const tools = generateMcpTools(store);
|
|
5943
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
5944
|
+
|
|
5945
|
+
const result = await query.execute({
|
|
5946
|
+
id: "Index3",
|
|
5947
|
+
expand_links: true,
|
|
5948
|
+
expand_mode: "summary",
|
|
5949
|
+
}) as any;
|
|
5950
|
+
|
|
5951
|
+
expect(result.content).toContain("The curated summary.");
|
|
5952
|
+
expect(result.content).not.toContain("This is the lede paragraph");
|
|
5953
|
+
});
|
|
5954
|
+
|
|
5955
|
+
it("expand_mode=summary lede fallback respects a leading frontmatter block", async () => {
|
|
5956
|
+
await store.createNote(
|
|
5957
|
+
"---\ntitle: X\n---\n# Real Title\n\nThe lede after frontmatter and title.",
|
|
5958
|
+
{ path: "Statements/Frontmatter" },
|
|
5959
|
+
);
|
|
5960
|
+
await store.createNote("see [[Statements/Frontmatter]]", { path: "Index4" });
|
|
5961
|
+
const tools = generateMcpTools(store);
|
|
5962
|
+
const query = tools.find((t) => t.name === "query-notes")!;
|
|
5963
|
+
|
|
5964
|
+
const result = await query.execute({
|
|
5965
|
+
id: "Index4",
|
|
5966
|
+
expand_links: true,
|
|
5967
|
+
expand_mode: "summary",
|
|
5968
|
+
}) as any;
|
|
5969
|
+
|
|
5970
|
+
expect(result.content).toContain("The lede after frontmatter and title.");
|
|
5971
|
+
expect(result.content).not.toContain("Real Title");
|
|
5910
5972
|
});
|
|
5911
5973
|
});
|
|
5912
5974
|
|
package/core/src/expand.ts
CHANGED
|
@@ -3,8 +3,9 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Used by `query-notes` when `expand_links=true`. Replaces wikilink matches
|
|
5
5
|
* with delimited blocks containing the linked note's content (full mode) or
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* a summary (summary mode): `metadata.summary` when present, else the
|
|
7
|
+
* target note's lede (see `summaryText`). Deduplicates across the query and
|
|
8
|
+
* guards against cycles via a shared `expanded` set.
|
|
8
9
|
*/
|
|
9
10
|
|
|
10
11
|
import { Database } from "bun:sqlite";
|
|
@@ -152,11 +153,18 @@ function renderSummary(note: Note): string {
|
|
|
152
153
|
return `<expanded path="${pathAttr}" mode="summary">\n${summary}\n</expanded>`;
|
|
153
154
|
}
|
|
154
155
|
|
|
156
|
+
/**
|
|
157
|
+
* `metadata.summary` wins when present (no behavior change for existing
|
|
158
|
+
* callers). Absent it, falls back to the target note's lede — its opening
|
|
159
|
+
* paragraph, per `computeLede` — so a note that was never given a
|
|
160
|
+
* `metadata.summary` still yields something useful in summary-mode
|
|
161
|
+
* expansion instead of an empty block.
|
|
162
|
+
*/
|
|
155
163
|
function summaryText(note: Note): string {
|
|
156
164
|
const meta = note.metadata as Record<string, unknown> | undefined;
|
|
157
165
|
const s = meta?.summary;
|
|
158
166
|
if (typeof s === "string" && s.trim()) return s.trim();
|
|
159
|
-
return "";
|
|
167
|
+
return noteOps.computeLede(note.content) ?? "";
|
|
160
168
|
}
|
|
161
169
|
|
|
162
170
|
function escapeAttr(s: string): string {
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `computeLede` — the first non-empty paragraph AFTER a note's title line,
|
|
3
|
+
* used by `expand_mode: "summary"` (core/src/expand.ts) as a fallback when
|
|
4
|
+
* `metadata.summary` is absent (summaries-as-content, 2026-07-17 dialogue).
|
|
5
|
+
*
|
|
6
|
+
* Shares its title-line rule (including the leading-frontmatter skip) with
|
|
7
|
+
* `computeDisplayTitle` — see `display-title.test.ts` for that function's
|
|
8
|
+
* own coverage. This file covers only the lede-specific behavior: where the
|
|
9
|
+
* paragraph starts, how it's collapsed, the length cap, and the
|
|
10
|
+
* title-only/no-content null cases.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect } from "bun:test";
|
|
13
|
+
import { computeLede, LEDE_MAX_LEN } from "./notes.js";
|
|
14
|
+
|
|
15
|
+
describe("computeLede", () => {
|
|
16
|
+
it("returns the first paragraph after a heading title, blank-line separated", () => {
|
|
17
|
+
expect(
|
|
18
|
+
computeLede("# Title\n\nThis is the lede paragraph.\n\nSecond paragraph — not the lede."),
|
|
19
|
+
).toBe("This is the lede paragraph.");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("joins a multi-line paragraph into one collapsed line", () => {
|
|
23
|
+
expect(
|
|
24
|
+
computeLede("# Title\n\nThis lede paragraph\nspans two lines.\n\nSecond paragraph."),
|
|
25
|
+
).toBe("This lede paragraph spans two lines.");
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("treats text immediately following the title (no blank line) as the lede", () => {
|
|
29
|
+
expect(computeLede("Grocery List\nmilk, eggs\ncheese")).toBe("milk, eggs cheese");
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("collapses internal whitespace runs to single spaces", () => {
|
|
33
|
+
expect(computeLede("Title\n\nLine one with extra spaces.")).toBe(
|
|
34
|
+
"Line one with extra spaces.",
|
|
35
|
+
);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it("returns null for a title-only note (does not repeat the title)", () => {
|
|
39
|
+
expect(computeLede("Just a title")).toBeNull();
|
|
40
|
+
expect(computeLede("# Just a title")).toBeNull();
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("returns null when only blank lines follow the title", () => {
|
|
44
|
+
expect(computeLede("# Title\n\n\n \n")).toBeNull();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it("returns null for empty, null, or undefined content", () => {
|
|
48
|
+
expect(computeLede("")).toBeNull();
|
|
49
|
+
expect(computeLede(null)).toBeNull();
|
|
50
|
+
expect(computeLede(undefined)).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("returns null when content is whitespace-only (no title at all)", () => {
|
|
54
|
+
expect(computeLede(" \n\t\n ")).toBeNull();
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
describe("leading frontmatter block", () => {
|
|
58
|
+
it("finds the lede after a closed frontmatter block AND its title line", () => {
|
|
59
|
+
expect(
|
|
60
|
+
computeLede("---\ntitle: X\n---\n# Real Title\n\nThe lede text."),
|
|
61
|
+
).toBe("The lede text.");
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it("finds the lede when the title immediately follows frontmatter with no blank line", () => {
|
|
65
|
+
expect(
|
|
66
|
+
computeLede("---\ntitle: X\n---\nReal Title\nlede text right after."),
|
|
67
|
+
).toBe("lede text right after.");
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("returns null for a frontmatter-only note with no body after the title", () => {
|
|
71
|
+
expect(computeLede("---\ntitle: X\n---\nReal Title")).toBeNull();
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
describe("length cap", () => {
|
|
76
|
+
it("truncates to LEDE_MAX_LEN code points", () => {
|
|
77
|
+
const longParagraph = "a".repeat(1000);
|
|
78
|
+
const result = computeLede(`# Title\n\n${longParagraph}`)!;
|
|
79
|
+
expect(result.length).toBe(LEDE_MAX_LEN);
|
|
80
|
+
expect(result).toBe("a".repeat(LEDE_MAX_LEN));
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("does not truncate a lede at or under the cap", () => {
|
|
84
|
+
const exact = "a".repeat(LEDE_MAX_LEN);
|
|
85
|
+
expect(computeLede(`# Title\n\n${exact}`)).toBe(exact);
|
|
86
|
+
const short = "short lede";
|
|
87
|
+
expect(computeLede(`# Title\n\n${short}`)).toBe(short);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it("truncates by Unicode code point, not UTF-16 code unit (no split surrogate pairs)", () => {
|
|
91
|
+
const emojiParagraph = "😀".repeat(500);
|
|
92
|
+
const result = computeLede(`# Title\n\n${emojiParagraph}`)!;
|
|
93
|
+
expect(Array.from(result).length).toBe(LEDE_MAX_LEN);
|
|
94
|
+
});
|
|
95
|
+
});
|
|
96
|
+
});
|