@openparachute/vault 0.7.3-rc.13 → 0.7.3-rc.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 +3 -5
- package/core/src/content-range.test.ts +0 -127
- package/core/src/content-range.ts +0 -100
- package/core/src/core.test.ts +4 -66
- package/core/src/expand.ts +3 -11
- package/core/src/mcp.ts +6 -577
- package/core/src/notes.ts +4 -201
- package/core/src/search-fts-v25.test.ts +1 -9
- package/core/src/search-query.test.ts +0 -42
- package/core/src/search-query.ts +0 -27
- package/core/src/seed-packs.test.ts +1 -117
- package/core/src/seed-packs.ts +1 -217
- package/core/src/types.ts +0 -6
- package/core/src/vault-projection.ts +0 -55
- package/package.json +1 -1
- package/src/add-pack.test.ts +0 -32
- package/src/auth-hub-jwt.test.ts +1 -118
- package/src/auth.ts +0 -64
- package/src/cli.ts +1 -5
- package/src/mcp-http.ts +4 -33
- package/src/mcp-tools.ts +14 -61
- package/src/oauth-discovery.ts +0 -31
- package/src/onboarding-seed.test.ts +0 -64
- package/src/routes.ts +95 -92
- package/src/routing.test.ts +4 -229
- package/src/routing.ts +23 -152
- package/src/scopes.ts +0 -22
- package/src/server.ts +0 -7
- package/src/storage.test.ts +1 -200
- package/src/transcription-worker.test.ts +0 -151
- package/src/transcription-worker.ts +52 -113
- package/src/vault.test.ts +11 -48
- package/core/src/attachment/bytes-provider.ts +0 -65
- package/core/src/attachment/policy.test.ts +0 -66
- package/core/src/attachment/policy.ts +0 -131
- package/core/src/attachment/tickets.test.ts +0 -45
- package/core/src/attachment/tickets.ts +0 -117
- package/core/src/attachment-tickets-tool.test.ts +0 -286
- package/core/src/display-title.test.ts +0 -190
- package/core/src/lede.test.ts +0 -96
- package/core/src/search-title-boost.test.ts +0 -125
- package/src/attachment-bytes.ts +0 -68
- package/src/attachment-tickets.test.ts +0 -475
- package/src/attachment-tickets.ts +0 -340
- package/src/read-attachment.test.ts +0 -436
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** — tools for AI agents (notes, tags, graph, vault info
|
|
33
|
+
- **MCP** — 9 tools for AI agents (notes, tags, graph, vault info)
|
|
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,14 +252,12 @@ 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 (9)
|
|
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`
|
|
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).
|
|
263
261
|
|
|
264
262
|
### Vault descriptions
|
|
265
263
|
|
|
@@ -23,13 +23,8 @@ import {
|
|
|
23
23
|
parseContentRange,
|
|
24
24
|
sliceContentRange,
|
|
25
25
|
applyContentRange,
|
|
26
|
-
parseAttachmentContentRange,
|
|
27
|
-
alignByteWindow,
|
|
28
26
|
MIN_CONTENT_LENGTH,
|
|
29
|
-
DEFAULT_ATTACHMENT_WINDOW_BYTES,
|
|
30
|
-
MAX_ATTACHMENT_WINDOW_BYTES,
|
|
31
27
|
} from "./content-range.js";
|
|
32
|
-
import { QueryError } from "./query-operators.js";
|
|
33
28
|
|
|
34
29
|
// ---------------------------------------------------------------------------
|
|
35
30
|
// 1. parseContentRange
|
|
@@ -242,128 +237,6 @@ describe("content range — reassembly property", () => {
|
|
|
242
237
|
});
|
|
243
238
|
});
|
|
244
239
|
|
|
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
|
-
|
|
367
240
|
// ---------------------------------------------------------------------------
|
|
368
241
|
// 3. MCP face — query-notes
|
|
369
242
|
// ---------------------------------------------------------------------------
|
|
@@ -183,103 +183,3 @@ 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
|
-
}
|
package/core/src/core.test.ts
CHANGED
|
@@ -5893,10 +5893,8 @@ 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
|
|
5897
|
-
|
|
5898
|
-
// paragraph after it for computeLede to fall back to.
|
|
5899
|
-
await store.createNote("unsummarized title-only body", { path: "Plain" });
|
|
5896
|
+
it("expand_mode=summary with no metadata.summary renders empty body inline", async () => {
|
|
5897
|
+
await store.createNote("unsummarized body", { path: "Plain" });
|
|
5900
5898
|
await store.createNote("see [[Plain]]", { path: "Src" });
|
|
5901
5899
|
const tools = generateMcpTools(store);
|
|
5902
5900
|
const query = tools.find((t) => t.name === "query-notes")!;
|
|
@@ -5907,68 +5905,8 @@ describe("query-notes link expansion", async () => {
|
|
|
5907
5905
|
expand_mode: "summary",
|
|
5908
5906
|
}) as any;
|
|
5909
5907
|
expect(result.content).toContain('mode="summary"');
|
|
5910
|
-
//
|
|
5911
|
-
expect(result.content).not.toContain("unsummarized
|
|
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");
|
|
5908
|
+
// Summary is empty — we still get the block but with nothing between delimiters.
|
|
5909
|
+
expect(result.content).not.toContain("unsummarized body");
|
|
5972
5910
|
});
|
|
5973
5911
|
});
|
|
5974
5912
|
|
package/core/src/expand.ts
CHANGED
|
@@ -3,9 +3,8 @@
|
|
|
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
|
-
*
|
|
8
|
-
* guards against cycles via a shared `expanded` set.
|
|
6
|
+
* metadata summary (summary mode). Deduplicates across the query and guards
|
|
7
|
+
* against cycles via a shared `expanded` set.
|
|
9
8
|
*/
|
|
10
9
|
|
|
11
10
|
import { Database } from "bun:sqlite";
|
|
@@ -153,18 +152,11 @@ function renderSummary(note: Note): string {
|
|
|
153
152
|
return `<expanded path="${pathAttr}" mode="summary">\n${summary}\n</expanded>`;
|
|
154
153
|
}
|
|
155
154
|
|
|
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
|
-
*/
|
|
163
155
|
function summaryText(note: Note): string {
|
|
164
156
|
const meta = note.metadata as Record<string, unknown> | undefined;
|
|
165
157
|
const s = meta?.summary;
|
|
166
158
|
if (typeof s === "string" && s.trim()) return s.trim();
|
|
167
|
-
return
|
|
159
|
+
return "";
|
|
168
160
|
}
|
|
169
161
|
|
|
170
162
|
function escapeAttr(s: string): string {
|