@openparachute/vault 0.6.0-rc.1 → 0.6.1

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 (99) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +32 -7
  3. package/core/src/content-range.test.ts +374 -0
  4. package/core/src/content-range.ts +185 -0
  5. package/core/src/core.test.ts +279 -26
  6. package/core/src/expand-visibility.test.ts +102 -0
  7. package/core/src/expand.ts +31 -3
  8. package/core/src/indexed-fields.ts +1 -1
  9. package/core/src/link-count.test.ts +301 -0
  10. package/core/src/links.ts +172 -22
  11. package/core/src/mcp.ts +254 -34
  12. package/core/src/notes.ts +172 -48
  13. package/core/src/obsidian-alignment.test.ts +375 -0
  14. package/core/src/obsidian.ts +234 -14
  15. package/core/src/portable-md.test.ts +40 -0
  16. package/core/src/portable-md.ts +142 -16
  17. package/core/src/query-perf-routing.test.ts +208 -0
  18. package/core/src/schema.ts +87 -11
  19. package/core/src/store.ts +69 -22
  20. package/core/src/tag-expand-axis.test.ts +301 -0
  21. package/core/src/tag-hierarchy.ts +80 -0
  22. package/core/src/tag-schemas.ts +61 -46
  23. package/core/src/triggers-store.test.ts +100 -0
  24. package/core/src/triggers-store.ts +165 -0
  25. package/core/src/types.ts +68 -4
  26. package/core/src/vault-projection.ts +20 -0
  27. package/core/src/wikilinks.ts +2 -2
  28. package/package.json +2 -3
  29. package/src/admin-spa.test.ts +100 -10
  30. package/src/admin-spa.ts +48 -3
  31. package/src/auth-hub-jwt.test.ts +8 -1
  32. package/src/auth-status.ts +2 -2
  33. package/src/auth.test.ts +39 -3
  34. package/src/auth.ts +31 -2
  35. package/src/auto-transcribe.test.ts +51 -0
  36. package/src/auto-transcribe.ts +24 -6
  37. package/src/autostart.test.ts +75 -0
  38. package/src/autostart.ts +84 -0
  39. package/src/cli.ts +434 -140
  40. package/src/config.test.ts +109 -0
  41. package/src/config.ts +157 -10
  42. package/src/content-range-routes.test.ts +178 -0
  43. package/src/export-watch.test.ts +23 -0
  44. package/src/export-watch.ts +14 -0
  45. package/src/git-preflight.test.ts +70 -0
  46. package/src/git-preflight.ts +68 -0
  47. package/src/github-device-flow.test.ts +265 -6
  48. package/src/github-device-flow.ts +297 -45
  49. package/src/hub-jwt.test.ts +75 -2
  50. package/src/hub-jwt.ts +43 -6
  51. package/src/init-summary.test.ts +120 -5
  52. package/src/init-summary.ts +67 -25
  53. package/src/live-match.test.ts +198 -0
  54. package/src/live-match.ts +310 -0
  55. package/src/mcp-install.test.ts +93 -0
  56. package/src/mcp-install.ts +106 -0
  57. package/src/mcp-tools.ts +80 -7
  58. package/src/mirror-config.test.ts +14 -0
  59. package/src/mirror-config.ts +11 -0
  60. package/src/mirror-credentials.test.ts +20 -0
  61. package/src/mirror-credentials.ts +6 -2
  62. package/src/mirror-import.test.ts +110 -0
  63. package/src/mirror-import.ts +71 -13
  64. package/src/mirror-manager.test.ts +51 -0
  65. package/src/mirror-manager.ts +73 -11
  66. package/src/mirror-routes.test.ts +1331 -110
  67. package/src/mirror-routes.ts +787 -30
  68. package/src/oauth-discovery.test.ts +55 -0
  69. package/src/oauth-discovery.ts +24 -5
  70. package/src/routes.ts +763 -122
  71. package/src/routing.test.ts +451 -5
  72. package/src/routing.ts +121 -5
  73. package/src/scopes.ts +1 -1
  74. package/src/server.ts +66 -4
  75. package/src/storage.test.ts +162 -0
  76. package/src/subscribe.test.ts +588 -0
  77. package/src/subscribe.ts +248 -0
  78. package/src/subscriptions.ts +295 -0
  79. package/src/tag-expand-routes.test.ts +45 -0
  80. package/src/tag-scope.ts +68 -1
  81. package/src/token-store.ts +7 -7
  82. package/src/transcription-worker.test.ts +471 -5
  83. package/src/transcription-worker.ts +212 -44
  84. package/src/triggers-api.test.ts +533 -0
  85. package/src/triggers-api.ts +295 -0
  86. package/src/triggers.ts +93 -7
  87. package/src/usage.test.ts +362 -0
  88. package/src/usage.ts +318 -0
  89. package/src/vault-create.test.ts +340 -12
  90. package/src/vault-name.test.ts +61 -3
  91. package/src/vault-name.ts +62 -14
  92. package/src/vault-remove.test.ts +187 -0
  93. package/src/vault-store.ts +10 -3
  94. package/src/vault.test.ts +1353 -62
  95. package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
  96. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  97. package/web/ui/dist/index.html +2 -2
  98. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  99. package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/core/src/mcp.ts CHANGED
@@ -3,6 +3,7 @@ import type { Store, Note } from "./types.js";
3
3
  import * as noteOps from "./notes.js";
4
4
  import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
5
5
  import { QueryError } from "./query-operators.js";
6
+ import { TAG_EXPAND_MODES, type TagExpandMode } from "./tag-hierarchy.js";
6
7
  import * as linkOps from "./links.js";
7
8
  import * as tagSchemaOps from "./tag-schemas.js";
8
9
  import type { TagFieldSchema } from "./tag-schemas.js";
@@ -14,6 +15,12 @@ import {
14
15
  type ExpandContext,
15
16
  type ExpandMode,
16
17
  } from "./expand.js";
18
+ import {
19
+ parseContentRange,
20
+ applyContentRange,
21
+ contentRangeRequiresContent,
22
+ MIN_CONTENT_LENGTH,
23
+ } from "./content-range.js";
17
24
 
18
25
  export interface McpToolDef {
19
26
  name: string;
@@ -99,13 +106,41 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
99
106
  // Tool generation
100
107
  // ---------------------------------------------------------------------------
101
108
 
109
+ /**
110
+ * Options for {@link generateMcpTools}.
111
+ *
112
+ * `expandVisibility` (vault security review) is an OPTIONAL per-note
113
+ * visibility predicate threaded into the wikilink-expansion context for
114
+ * `query-notes`. When provided, `expand_links` inlining leaves any wikilink
115
+ * whose target fails the predicate UNRESOLVED — so a tag-scoped MCP session
116
+ * can't inline out-of-scope note content during expansion (the filtering
117
+ * happens DURING expansion, not after). Core stays scope-unaware: it
118
+ * receives a plain `(note) => boolean` closure and never imports the
119
+ * server's tag-scope module. Omitted (every internal / unscoped caller) →
120
+ * expansion behaves exactly as before.
121
+ */
122
+ export interface GenerateMcpToolsOpts {
123
+ expandVisibility?: (note: Note) => boolean;
124
+ /**
125
+ * `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
126
+ * into the `near[]` graph BFS. When provided, the traversal refuses to walk
127
+ * THROUGH any note that fails the predicate — making a tag-scoped `near[]`
128
+ * query symmetric with `find-path` (scope is a wall, not a sieve). Core
129
+ * stays scope-unaware: it receives a plain `(noteId) => boolean` closure.
130
+ * Omitted (unscoped / internal callers) → the full graph is walked.
131
+ */
132
+ nearTraversable?: (noteId: string) => boolean;
133
+ }
134
+
102
135
  /**
103
136
  * Generate the consolidated MCP tools for a vault. Surface (10):
104
137
  * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
105
138
  * delete-tag, find-path, vault-info, prune-schema (admin).
106
139
  */
107
- export function generateMcpTools(store: Store): McpToolDef[] {
108
- const db: Database = (store as any).db;
140
+ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
141
+ const db: Database = store.db;
142
+ const expandVisibility = opts?.expandVisibility;
143
+ const nearTraversable = opts?.nearTraversable;
109
144
 
110
145
  return [
111
146
 
@@ -124,6 +159,8 @@ export function generateMcpTools(store: Store): McpToolDef[] {
124
159
 
125
160
  Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
126
161
 
162
+ Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
163
+
127
164
  Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.`,
128
165
  inputSchema: {
129
166
  type: "object",
@@ -137,6 +174,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
137
174
  description: "Filter by tag(s)",
138
175
  },
139
176
  tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
177
+ expand: {
178
+ type: "string",
179
+ enum: ["subtypes", "namespace", "both", "exact"],
180
+ description: "How each `tag` expands. 'subtypes' (DEFAULT): the tag plus its declared parent_names descendants — the semantic is-a axis (e.g. tag:entity also matches person/work). 'namespace': the tag plus everything filed under it by NAME (tag:entity also matches entity/archived) — the lexical filing axis. 'both': union of the two. 'exact': only the literal tag, no expansion. Omit for 'subtypes' (current behavior).",
181
+ },
140
182
  exclude_tags: {
141
183
  oneOf: [
142
184
  { type: "string" },
@@ -178,7 +220,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
178
220
  type: "object",
179
221
  description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
180
222
  },
181
- order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
223
+ order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
182
224
  date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
183
225
  date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
184
226
  date_filter: {
@@ -209,6 +251,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
209
251
  "Opaque cursor for 'since last checked' agent loops (vault#313). First call: omit. The response will include `next_cursor` — pass it on the subsequent call to receive only notes created or updated since the prior page. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`. The response shape switches to `{notes, next_cursor}` when this parameter is present.",
210
252
  },
211
253
  include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
254
+ content_offset: {
255
+ type: "number",
256
+ description:
257
+ "Byte offset (UTF-8) into note content to start reading from (default 0). For reading a note too large for one response: pass the previous response's `content_next_offset` here to continue. An offset landing mid-codepoint is aligned DOWN to the codepoint's leading byte (chained `content_next_offset` values are always aligned); the effective start is echoed back as `content_offset` on the response. Requires content in the response — errors when combined with include_content=false (or a list query without include_content=true).",
258
+ },
259
+ content_length: {
260
+ type: "number",
261
+ description:
262
+ `Maximum bytes (UTF-8) of note content to return (minimum ${MIN_CONTENT_LENGTH}). When this or content_offset is set, the returned \`content\` is the byte slice and the response gains \`content_offset\` (effective start), \`content_total_length\` (full content size in bytes), and \`content_next_offset\` (pass back as content_offset to continue; null when the slice reaches the end). Slices end on a UTF-8 codepoint boundary, so a slice may be up to 3 bytes under the budget — never over. Concatenating the slices from offset 0 through content_next_offset=null reconstructs the content byte-for-byte. On list queries the same window applies to each note's content independently. When expand_links=true the range applies to the returned (expanded) content.`,
263
+ },
212
264
  include_metadata: {
213
265
  oneOf: [
214
266
  { type: "boolean" },
@@ -217,6 +269,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
217
269
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
218
270
  },
219
271
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
272
+ include_link_count: {
273
+ type: "boolean",
274
+ description:
275
+ "Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, `linkCount` is the raw degree and MAY include edges to notes the token can't see — only the number leaks, not the neighbor.",
276
+ },
277
+ link_count_direction: {
278
+ type: "string",
279
+ enum: ["both", "outbound", "inbound"],
280
+ description:
281
+ "Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses the both-directions degree.",
282
+ },
220
283
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
221
284
  expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
222
285
  expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
@@ -235,20 +298,43 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
235
298
  ),
236
299
  );
237
300
  const expandCtx: ExpandContext | null = expandLinks
238
- ? { db, mode: expandMode, expanded: new Set() }
301
+ ? {
302
+ db,
303
+ mode: expandMode,
304
+ expanded: new Set(),
305
+ // Tag-scope confidentiality (security review): when a visibility
306
+ // predicate was injected, wikilinks to out-of-scope notes are
307
+ // left unresolved DURING inlining — never embedded. Unscoped
308
+ // callers pass no predicate and inlining is unchanged.
309
+ ...(expandVisibility ? { isVisible: expandVisibility } : {}),
310
+ }
239
311
  : null;
240
312
 
313
+ // --- Content range (bounded reads for large notes) ---
314
+ // Validates loudly: bad values throw QueryError here, before any
315
+ // query work. Null when neither param is present — response shape
316
+ // stays byte-identical to the no-pagination behavior.
317
+ const contentRange = parseContentRange(params.content_offset, params.content_length);
318
+
241
319
  // --- Single note by ID/path ---
242
320
  if (params.id) {
243
321
  const note = resolveNote(db, params.id as string);
244
322
  if (!note) return { error: "Note not found", id: params.id };
245
323
  const includeContent = params.include_content !== false; // default true for single
324
+ // Range params are meaningless on a content-less shape — error
325
+ // rather than silently ignore (same loud-validation policy as
326
+ // `expand`).
327
+ if (contentRange && !includeContent) throw contentRangeRequiresContent();
246
328
  let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
247
329
  if (expandCtx && includeContent && typeof result.content === "string") {
248
330
  // Mark the top-level note as already expanded so it can't recursively inline itself.
249
331
  expandCtx.expanded.add(note.id);
250
332
  result.content = expandContent(result.content, expandCtx, expandDepth);
251
333
  }
334
+ // Range applies to the FINAL returned content — after wikilink
335
+ // expansion — so the window the client pages through is the same
336
+ // document it would have received unpaged.
337
+ if (contentRange && includeContent) applyContentRange(result, contentRange);
252
338
  result = filterMetadata(result, params.include_metadata as boolean | string[] | undefined);
253
339
  if (params.include_links) {
254
340
  result.links = linkOps.getLinksHydrated(db, note.id);
@@ -256,10 +342,27 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
256
342
  if (params.include_attachments) {
257
343
  result.attachments = await store.getAttachments(note.id);
258
344
  }
345
+ // linkCount injected after filterMetadata on purpose — same as
346
+ // links/attachments above; filterMetadata only touches `metadata`.
347
+ if (params.include_link_count) {
348
+ const dir = normalizeLinkCountDirection(params.link_count_direction);
349
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
350
+ }
259
351
  return result;
260
352
  }
261
353
 
262
354
  // --- Build near-scope (graph-filtered set of allowed IDs) ---
355
+ //
356
+ // Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
357
+ // find-path): when the session is tag-scoped the server injects a
358
+ // `nearTraversable` predicate (mcp-tools.ts), and the BFS refuses to
359
+ // walk THROUGH out-of-scope notes — scope is a wall, not a sieve. So a
360
+ // token scoped to ["work"] can't reach an in-scope note at depth 2 via
361
+ // a #personal intermediary at depth 1. Core stays scope-unaware: it
362
+ // only invokes the injected closure. Unscoped sessions pass no
363
+ // predicate → the FULL graph is walked exactly as before. The
364
+ // `applyTagScopeWrappers` result-filter still runs afterward (defense
365
+ // in depth), but the wall makes it redundant for `near[]`.
263
366
  let nearScope: Set<string> | null = null;
264
367
  if (params.near) {
265
368
  const near = params.near as { note_id: string; depth?: number; relationship?: string };
@@ -269,6 +372,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
269
372
  const traversed = linkOps.traverseLinks(db, anchor.id, {
270
373
  max_depth: depth,
271
374
  relationship: near.relationship,
375
+ isTraversable: nearTraversable,
272
376
  });
273
377
  nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
274
378
  }
@@ -295,6 +399,18 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
295
399
  "INVALID_QUERY",
296
400
  );
297
401
  }
402
+ // Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
403
+ // typo'd value doesn't silently fall back to the default.
404
+ let expand: TagExpandMode | undefined;
405
+ if (params.expand !== undefined && params.expand !== null) {
406
+ if (typeof params.expand !== "string" || !(TAG_EXPAND_MODES as readonly string[]).includes(params.expand)) {
407
+ throw new QueryError(
408
+ `invalid \`expand\` value ${JSON.stringify(params.expand)} — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes").`,
409
+ "INVALID_QUERY",
410
+ );
411
+ }
412
+ expand = params.expand as TagExpandMode;
413
+ }
298
414
 
299
415
  // --- Full-text search ---
300
416
  let results: Note[];
@@ -310,6 +426,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
310
426
  results = await store.searchNotes(params.search as string, {
311
427
  tags,
312
428
  limit: (params.limit as number) ?? 50,
429
+ expand,
313
430
  });
314
431
  } else {
315
432
  // --- Structured query ---
@@ -329,6 +446,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
329
446
  const queryOpts = {
330
447
  tags,
331
448
  tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
449
+ expand,
332
450
  excludeTags,
333
451
  hasTags: params.has_tags as boolean | undefined,
334
452
  hasLinks: params.has_links as boolean | undefined,
@@ -371,6 +489,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
371
489
 
372
490
  // --- Format output ---
373
491
  const includeContent = params.include_content === true; // default false for list
492
+ // Range params require content in the response — on lists that
493
+ // means an explicit include_content=true (the lean default carries
494
+ // no content to slice). Error rather than silently ignore.
495
+ if (contentRange && !includeContent) throw contentRangeRequiresContent();
374
496
  const includeMetadata = params.include_metadata as boolean | string[] | undefined;
375
497
  let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(noteOps.toNoteIndex);
376
498
 
@@ -385,17 +507,46 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
385
507
  }
386
508
  }
387
509
 
510
+ // --- Content range (per-note, post-expansion) ---
511
+ // The same byte window applies to EACH note's content independently
512
+ // — the primary use is a single large note, but list mode keeps the
513
+ // simple per-note semantic (every note reports its own
514
+ // content_total_length / content_next_offset).
515
+ if (contentRange && includeContent) {
516
+ for (const n of output) applyContentRange(n, contentRange);
517
+ }
518
+
388
519
  // --- Apply metadata filtering ---
389
520
  if (includeMetadata !== undefined && includeMetadata !== true) {
390
521
  output = output.map((n: any) => filterMetadata(n, includeMetadata));
391
522
  }
392
523
 
524
+ // --- Opt-in link degree (vault feedback #4) ---
525
+ // ONE batch count over all result ids (NOT per-note), so the field
526
+ // stays O(2 index scans) per request regardless of page size.
527
+ // Injected on the same objects the enrichment loop copies below.
528
+ // Ordering: runs AFTER the filterMetadata pass above on purpose —
529
+ // filterMetadata only touches the `metadata` key, so linkCount
530
+ // survives. Don't casually swap the order.
531
+ if (params.include_link_count) {
532
+ const dir = normalizeLinkCountDirection(params.link_count_direction);
533
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
534
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
535
+ }
536
+
393
537
  // --- Hydrate links/attachments per note if requested ---
394
538
  if (params.include_links || params.include_attachments) {
539
+ // Links hydrate for the WHOLE page in a constant number of
540
+ // queries (see getLinksHydratedForNotes) — the per-note variant
541
+ // cost (1 link query + 1 summary query + N tag queries) × page
542
+ // size. 2026-06-10 perf measurements.
543
+ const linksByNote = params.include_links
544
+ ? linkOps.getLinksHydratedForNotes(db, (output as any[]).map((n: any) => n.id))
545
+ : null;
395
546
  const enrichedOut: any[] = [];
396
547
  for (const n of output as any[]) {
397
548
  const enriched: any = { ...n };
398
- if (params.include_links) enriched.links = linkOps.getLinksHydrated(db, n.id);
549
+ if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
399
550
  if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
400
551
  enrichedOut.push(enriched);
401
552
  }
@@ -512,17 +663,35 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
512
663
  throw e;
513
664
  }
514
665
 
515
- // Apply tag schema effects
666
+ // Apply tag schema effects, then re-read the notes whose metadata was
667
+ // actually default-filled so the response reflects the final on-disk
668
+ // state (the `created` entries were read before `applySchemaDefaults`
669
+ // ran, so default-filled metadata isn't on them yet). This mirrors the
670
+ // update-note path, which already re-reads post-defaults. The re-read
671
+ // is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
672
+ // entirely when no defaults were applied, so the common no-defaults
673
+ // path adds zero extra reads.
674
+ const mutatedIds = new Set<string>();
516
675
  for (const note of created) {
517
676
  if (note.tags && note.tags.length > 0) {
518
- await applySchemaDefaults(store, db, [note.id], note.tags);
677
+ for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
678
+ mutatedIds.add(id);
679
+ }
519
680
  }
520
681
  }
521
-
522
- // Re-read after schema-default population so the response reflects the
523
- // final on-disk state, then attach `validation_status` from any
524
- // tag's `fields` declaration that applies to this note.
525
- const final = created.map((n) => attachValidationStatus(store, db, n));
682
+ const refreshed =
683
+ mutatedIds.size === 0
684
+ ? created
685
+ : (() => {
686
+ const byId = new Map(
687
+ noteOps.getNotes(db, [...mutatedIds]).map((n) => [n.id, n]),
688
+ );
689
+ return created.map((n) => byId.get(n.id) ?? n);
690
+ })();
691
+
692
+ // Attach `validation_status` from any tag's `fields` declaration that
693
+ // applies to this note, against the post-defaults state.
694
+ const final = refreshed.map((n) => attachValidationStatus(store, db, n));
526
695
  return batch ? final : final[0];
527
696
  },
528
697
  },
@@ -543,7 +712,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
543
712
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
544
713
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
545
714
  - For batch: pass a \`notes\` array, each with an \`id\` field.
546
- - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
715
+ - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
547
716
  - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
548
717
  - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
549
718
  inputSchema: {
@@ -567,7 +736,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
567
736
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
568
737
  created_at: { type: "string", description: "New created_at timestamp" },
569
738
  if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
570
- force: { type: "boolean", description: "Override the required `if_updated_at` check and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe." },
739
+ force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
571
740
  if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
572
741
  tags: {
573
742
  type: "object",
@@ -609,6 +778,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
609
778
  type: "boolean",
610
779
  description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
611
780
  },
781
+ include_links: {
782
+ type: "boolean",
783
+ description: "Echo the note's hydrated inbound + outbound links on the response (vault feedback #8). Links are *also* echoed automatically whenever the update itself mutated links (`links.add`/`links.remove`), so you rarely need to set this — its purpose is to fetch the current link set on an update that didn't touch links. Default: `false` (and absent from the response unless mutated or requested). Mirrors `query-notes`'s `include_links`. This top-level flag applies to the single-note form only; for a batch, set `include_links` on each note object in `notes` (a top-level `include_links` is ignored when `notes` is present).",
784
+ },
612
785
  // Batch
613
786
  notes: {
614
787
  type: "array",
@@ -632,10 +805,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
632
805
  metadata: { type: "object" },
633
806
  created_at: { type: "string" },
634
807
  if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
635
- force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
808
+ force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` for this item. Does not override an `if_updated_at` you actually pass — a supplied precondition still applies and a mismatch conflicts." },
636
809
  if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
637
810
  tags: { type: "object" },
638
811
  links: { type: "object" },
812
+ include_links: { type: "boolean", description: "Per-item: echo hydrated links on this item's response (vault feedback #8). Also implied when this item mutates links." },
639
813
  },
640
814
  required: ["id"],
641
815
  },
@@ -657,6 +831,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
657
831
  // sync-loop caller (Gitcoin Brain et al) reads this to know which
658
832
  // path fired without doing a separate query. vault#309.
659
833
  const createdIds = new Set<string>();
834
+ // Track which note IDs should echo hydrated links on the response.
835
+ // A note qualifies when this request mutated its links
836
+ // (`links.add`/`links.remove`) OR the caller set `include_links`.
837
+ // vault feedback #8 — previously the update response omitted links
838
+ // entirely, forcing a re-query just to confirm a link the caller had
839
+ // just added/removed. Per-item on batch. Note IDs (not item indices)
840
+ // key this so the create-on-missing branch, which assigns the id
841
+ // late, can register correctly.
842
+ const echoLinkIds = new Set<string>();
660
843
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
661
844
  // failure (precondition error, content_edit miss, ConflictError, …)
662
845
  // rolls back every prior mutation in the batch — see #236.
@@ -745,6 +928,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
745
928
  const fresh = noteOps.getNote(db, created.id) ?? created;
746
929
  updated.push(fresh);
747
930
  createdIds.add(fresh.id);
931
+ // Echo links if this create-on-missing declared `links.add`
932
+ // (the only link op honored on create) or asked explicitly.
933
+ if (linksAdd !== undefined || item.include_links === true) {
934
+ echoLinkIds.add(fresh.id);
935
+ }
748
936
  continue;
749
937
  }
750
938
  // Fallthrough: not-found + no if_missing → existing error
@@ -907,6 +1095,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
907
1095
  }
908
1096
  }
909
1097
 
1098
+ // Echo links if this update mutated them (`links.add`/`links.remove`)
1099
+ // or the caller asked explicitly. vault feedback #8.
1100
+ const linkMutated = (item.links as any)?.add !== undefined || (item.links as any)?.remove !== undefined;
1101
+ if (linkMutated || item.include_links === true) {
1102
+ echoLinkIds.add(note.id);
1103
+ }
1104
+
910
1105
  // Re-read for final state
911
1106
  updated.push(noteOps.getNote(db, note.id) ?? result);
912
1107
  }
@@ -929,11 +1124,23 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
929
1124
  const final = updated.map((n) => {
930
1125
  const validated = attachValidationStatus(store, db, n);
931
1126
  const created = createdIds.has(n.id);
932
- if (includeContent) return { ...validated, created } as Note & { created: boolean };
1127
+ // Echo hydrated links when this note was flagged for it (mutated
1128
+ // its links or `include_links` was set). Additive key, present only
1129
+ // when triggered — mirrors the GET / query-notes shape exactly via
1130
+ // the shared `linkOps.getLinksHydrated` call. vault feedback #8.
1131
+ const echoLinks = echoLinkIds.has(n.id);
1132
+ if (includeContent) {
1133
+ const full: any = { ...validated, created };
1134
+ if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
1135
+ return full as Note & { created: boolean };
1136
+ }
933
1137
  const lean: any = noteOps.toNoteIndex(validated);
934
1138
  const vs = (validated as any).validation_status;
935
1139
  if (vs !== undefined) lean.validation_status = vs;
936
1140
  lean.created = created;
1141
+ // Carry the link echo across the lean conversion — `toNoteIndex`
1142
+ // drops unknown fields.
1143
+ if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
937
1144
  return lean;
938
1145
  });
939
1146
  return batch ? final : final[0];
@@ -1029,7 +1236,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1029
1236
  {
1030
1237
  name: "update-tag",
1031
1238
  requiredVerb: "write",
1032
- description: "Create or update a tag's identity row: description, indexed-field schemas, typed-link relationships, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
1239
+ description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-patterns/patterns/tag-data-model.md.",
1033
1240
  inputSchema: {
1034
1241
  type: "object",
1035
1242
  properties: {
@@ -1051,16 +1258,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1051
1258
  },
1052
1259
  relationships: {
1053
1260
  type: "object",
1054
- description: 'Typed-link declarations. Each value declares { target_tag, cardinality, description? }. Cardinality is one of: one | optional | many | many-required. Phase 1: informational, not enforced at write time. E.g., { "lives_in": { "target_tag": "place", "cardinality": "one" } }',
1055
- additionalProperties: {
1056
- type: "object",
1057
- properties: {
1058
- target_tag: { type: "string", description: "Tag the relationship points at" },
1059
- cardinality: { type: "string", enum: ["one", "optional", "many", "many-required"], description: "How many targets this relationship may have" },
1060
- description: { type: "string", description: "Why this relationship exists; surfaced to AI clients" },
1061
- },
1062
- required: ["target_tag", "cardinality"],
1063
- },
1261
+ description: 'Opaque relationship-vocabulary map: keys are relationship names, values are arbitrary JSON the declaring app interprets. Vault stores and returns the values verbatim and does NOT enforce any inner shape — only that this is a JSON object (a map), not an array or primitive. Replaces any prior map wholesale when provided; pass null to clear. The historical typed shape { "lives_in": { "target_tag": "place", "cardinality": "one" } } is still a valid value, as is any app-defined shape e.g. { "works-on": { "from": "person", "to": "project" } }.',
1262
+ additionalProperties: true,
1064
1263
  },
1065
1264
  parent_names: {
1066
1265
  type: "array",
@@ -1124,10 +1323,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1124
1323
  }
1125
1324
  }
1126
1325
 
1127
- // ---- relationships: replace wholesale when provided. Validate
1128
- // shape + cardinality vocabulary before persisting so a malformed
1129
- // payload can't leave the row in an inconsistent state.
1130
- let relationshipsPatch: Record<string, tagSchemaOps.TagRelationship> | null | undefined;
1326
+ // ---- relationships: replace wholesale when provided. `relationships`
1327
+ // is an opaque vocabulary map (relationship-name arbitrary JSON the
1328
+ // app interprets). Validate only that it's a JSON object (a map), then
1329
+ // persist verbatim no inner-shape enforcement.
1330
+ let relationshipsPatch: tagSchemaOps.TagRelationshipMap | null | undefined;
1131
1331
  if (params.relationships === null) {
1132
1332
  relationshipsPatch = null;
1133
1333
  } else if (params.relationships !== undefined) {
@@ -1296,9 +1496,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1296
1496
  // Tag schema effects — auto-populate defaults when tags are applied
1297
1497
  // ---------------------------------------------------------------------------
1298
1498
 
1299
- async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<void> {
1499
+ /**
1500
+ * Fill schema-declared default values into the metadata of the given notes
1501
+ * for any field they omitted. Returns the IDs of the notes whose metadata was
1502
+ * actually written — callers use this to re-read ONLY the mutated notes (and
1503
+ * to skip the re-read entirely when nothing changed). The common no-schema /
1504
+ * no-defaults path returns an empty array.
1505
+ */
1506
+ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
1300
1507
  const schemas = tagSchemaOps.getTagSchemaMap(db);
1301
- if (Object.keys(schemas).length === 0) return;
1508
+ if (Object.keys(schemas).length === 0) return [];
1302
1509
 
1303
1510
  const defaults: Record<string, unknown> = {};
1304
1511
  for (const tag of tags) {
@@ -1310,8 +1517,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1310
1517
  }
1311
1518
  }
1312
1519
  }
1313
- if (Object.keys(defaults).length === 0) return;
1520
+ if (Object.keys(defaults).length === 0) return [];
1314
1521
 
1522
+ const mutated: string[] = [];
1315
1523
  for (const noteId of noteIds) {
1316
1524
  const note = noteOps.getNote(db, noteId);
1317
1525
  if (!note) continue;
@@ -1327,7 +1535,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1327
1535
  metadata: { ...existing, ...missing },
1328
1536
  skipUpdatedAt: true,
1329
1537
  });
1538
+ mutated.push(noteId);
1330
1539
  }
1540
+ return mutated;
1331
1541
  }
1332
1542
 
1333
1543
  function defaultForField(field: { type: string; enum?: string[] }): unknown {
@@ -1382,6 +1592,16 @@ function normalizeTags(tag: unknown): string[] | undefined {
1382
1592
  return [tag as string];
1383
1593
  }
1384
1594
 
1595
+ /**
1596
+ * Coerce the `link_count_direction` MCP param to a known value, defaulting
1597
+ * to "both" (matches the REST `parseLinkCountDirection` fallback). A typo
1598
+ * silently degrades to the documented default rather than erroring.
1599
+ */
1600
+ function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound" {
1601
+ if (v === "outbound" || v === "inbound") return v;
1602
+ return "both";
1603
+ }
1604
+
1385
1605
  // Re-exported for backward compat; defined in notes.ts alongside the
1386
1606
  // conditional-UPDATE implementation that raises it. AmbiguousPathError
1387
1607
  // joins the set (vault#331 N2) so external callers can `instanceof`