@openparachute/vault 0.7.3-rc.9 → 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.
Files changed (43) hide show
  1. package/core/src/attachment/bytes-provider.ts +65 -0
  2. package/core/src/content-range-constants.ts +19 -0
  3. package/core/src/content-range.test.ts +127 -0
  4. package/core/src/content-range.ts +105 -8
  5. package/core/src/core.test.ts +66 -4
  6. package/core/src/expand.ts +11 -3
  7. package/core/src/lede.test.ts +96 -0
  8. package/core/src/mcp-manifest.test.ts +200 -0
  9. package/core/src/mcp-manifest.ts +736 -0
  10. package/core/src/mcp.ts +357 -607
  11. package/core/src/notes.ts +69 -10
  12. package/core/src/vault-projection.ts +17 -10
  13. package/package.json +1 -1
  14. package/src/attachment-bytes.ts +68 -0
  15. package/src/attachment-tickets.test.ts +126 -1
  16. package/src/attachment-tickets.ts +77 -1
  17. package/src/auth-hub-jwt.test.ts +118 -1
  18. package/src/auth.ts +64 -0
  19. package/src/config.test.ts +16 -0
  20. package/src/config.ts +17 -0
  21. package/src/embedding/select.test.ts +58 -30
  22. package/src/embedding/select.ts +62 -21
  23. package/src/live-frame-parity.test.ts +21 -0
  24. package/src/mcp-http.ts +20 -3
  25. package/src/mcp-tools.ts +15 -3
  26. package/src/oauth-discovery.ts +31 -0
  27. package/src/read-attachment.test.ts +436 -0
  28. package/src/routes.ts +80 -4
  29. package/src/routing.test.ts +229 -4
  30. package/src/routing.ts +135 -23
  31. package/src/scopes.ts +22 -0
  32. package/src/server.ts +17 -8
  33. package/src/storage.test.ts +200 -1
  34. package/src/subscriptions.ts +13 -1
  35. package/src/transcription-worker.test.ts +151 -0
  36. package/src/transcription-worker.ts +113 -52
  37. package/src/vault-embeddings-capability.test.ts +28 -6
  38. package/src/vault-store-embedding-wiring.test.ts +25 -16
  39. package/src/vault-store.ts +32 -16
  40. package/src/vault.test.ts +26 -13
  41. package/src/ws-server.ts +9 -1
  42. package/src/ws-subscribe.test.ts +87 -0
  43. package/src/ws-subscribe.ts +25 -6
@@ -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
- * Minimum accepted `content_length`. A UTF-8 codepoint is at most 4 bytes,
35
- * so any budget >= 4 is guaranteed to make progress (the codepoint at the
36
- * window start always fits). Budgets 1–3 could stall forever on a 4-byte
37
- * emoji (empty slice, next_offset == offset); rejecting them up front is
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
+ }
@@ -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
- await store.createNote("unsummarized body", { path: "Plain" });
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
- // Summary is empty — we still get the block but with nothing between delimiters.
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
 
@@ -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
- * metadata summary (summary mode). Deduplicates across the query and guards
7
- * against cycles via a shared `expanded` set.
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
+ });