@openparachute/vault 0.6.0-rc.1 → 0.6.0

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 (91) hide show
  1. package/.parachute/module.json +14 -3
  2. package/README.md +7 -7
  3. package/core/src/core.test.ts +279 -26
  4. package/core/src/expand-visibility.test.ts +102 -0
  5. package/core/src/expand.ts +31 -3
  6. package/core/src/indexed-fields.ts +1 -1
  7. package/core/src/link-count.test.ts +301 -0
  8. package/core/src/links.ts +97 -2
  9. package/core/src/mcp.ts +201 -33
  10. package/core/src/notes.ts +44 -8
  11. package/core/src/obsidian-alignment.test.ts +375 -0
  12. package/core/src/obsidian.ts +234 -14
  13. package/core/src/portable-md.test.ts +40 -0
  14. package/core/src/portable-md.ts +142 -16
  15. package/core/src/schema.ts +58 -11
  16. package/core/src/store.ts +69 -22
  17. package/core/src/tag-expand-axis.test.ts +301 -0
  18. package/core/src/tag-hierarchy.ts +80 -0
  19. package/core/src/tag-schemas.ts +61 -46
  20. package/core/src/triggers-store.test.ts +100 -0
  21. package/core/src/triggers-store.ts +165 -0
  22. package/core/src/types.ts +68 -4
  23. package/core/src/vault-projection.ts +20 -0
  24. package/core/src/wikilinks.ts +2 -2
  25. package/package.json +2 -3
  26. package/src/admin-spa.test.ts +100 -10
  27. package/src/admin-spa.ts +48 -3
  28. package/src/auth-hub-jwt.test.ts +8 -1
  29. package/src/auth-status.ts +2 -2
  30. package/src/auth.test.ts +39 -3
  31. package/src/auth.ts +31 -2
  32. package/src/auto-transcribe.test.ts +51 -0
  33. package/src/auto-transcribe.ts +24 -6
  34. package/src/autostart.test.ts +75 -0
  35. package/src/autostart.ts +84 -0
  36. package/src/cli.ts +434 -140
  37. package/src/config.test.ts +109 -0
  38. package/src/config.ts +157 -10
  39. package/src/export-watch.test.ts +23 -0
  40. package/src/export-watch.ts +14 -0
  41. package/src/git-preflight.test.ts +70 -0
  42. package/src/git-preflight.ts +68 -0
  43. package/src/hub-jwt.test.ts +75 -2
  44. package/src/hub-jwt.ts +43 -6
  45. package/src/init-summary.test.ts +120 -5
  46. package/src/init-summary.ts +67 -25
  47. package/src/live-match.test.ts +198 -0
  48. package/src/live-match.ts +310 -0
  49. package/src/mcp-install.test.ts +93 -0
  50. package/src/mcp-install.ts +106 -0
  51. package/src/mcp-tools.ts +80 -7
  52. package/src/mirror-config.test.ts +14 -0
  53. package/src/mirror-config.ts +11 -0
  54. package/src/mirror-import.test.ts +110 -0
  55. package/src/mirror-import.ts +71 -13
  56. package/src/mirror-manager.test.ts +51 -0
  57. package/src/mirror-manager.ts +73 -11
  58. package/src/mirror-routes.test.ts +463 -1
  59. package/src/mirror-routes.ts +474 -4
  60. package/src/oauth-discovery.test.ts +55 -0
  61. package/src/oauth-discovery.ts +24 -5
  62. package/src/routes.ts +696 -121
  63. package/src/routing.test.ts +451 -5
  64. package/src/routing.ts +113 -5
  65. package/src/scopes.ts +1 -1
  66. package/src/server.ts +66 -4
  67. package/src/storage.test.ts +162 -0
  68. package/src/subscribe.test.ts +588 -0
  69. package/src/subscribe.ts +248 -0
  70. package/src/subscriptions.ts +295 -0
  71. package/src/tag-expand-routes.test.ts +45 -0
  72. package/src/tag-scope.ts +68 -1
  73. package/src/token-store.ts +7 -7
  74. package/src/transcription-worker.test.ts +471 -5
  75. package/src/transcription-worker.ts +212 -44
  76. package/src/triggers-api.test.ts +533 -0
  77. package/src/triggers-api.ts +295 -0
  78. package/src/triggers.ts +93 -7
  79. package/src/usage.test.ts +362 -0
  80. package/src/usage.ts +318 -0
  81. package/src/vault-create.test.ts +340 -12
  82. package/src/vault-name.test.ts +61 -3
  83. package/src/vault-name.ts +62 -14
  84. package/src/vault-remove.test.ts +187 -0
  85. package/src/vault-store.ts +10 -3
  86. package/src/vault.test.ts +1353 -62
  87. package/web/ui/dist/assets/index-CGL256oe.js +60 -0
  88. package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
  89. package/web/ui/dist/index.html +2 -2
  90. package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
  91. 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";
@@ -99,13 +100,41 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
99
100
  // Tool generation
100
101
  // ---------------------------------------------------------------------------
101
102
 
103
+ /**
104
+ * Options for {@link generateMcpTools}.
105
+ *
106
+ * `expandVisibility` (vault security review) is an OPTIONAL per-note
107
+ * visibility predicate threaded into the wikilink-expansion context for
108
+ * `query-notes`. When provided, `expand_links` inlining leaves any wikilink
109
+ * whose target fails the predicate UNRESOLVED — so a tag-scoped MCP session
110
+ * can't inline out-of-scope note content during expansion (the filtering
111
+ * happens DURING expansion, not after). Core stays scope-unaware: it
112
+ * receives a plain `(note) => boolean` closure and never imports the
113
+ * server's tag-scope module. Omitted (every internal / unscoped caller) →
114
+ * expansion behaves exactly as before.
115
+ */
116
+ export interface GenerateMcpToolsOpts {
117
+ expandVisibility?: (note: Note) => boolean;
118
+ /**
119
+ * `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
120
+ * into the `near[]` graph BFS. When provided, the traversal refuses to walk
121
+ * THROUGH any note that fails the predicate — making a tag-scoped `near[]`
122
+ * query symmetric with `find-path` (scope is a wall, not a sieve). Core
123
+ * stays scope-unaware: it receives a plain `(noteId) => boolean` closure.
124
+ * Omitted (unscoped / internal callers) → the full graph is walked.
125
+ */
126
+ nearTraversable?: (noteId: string) => boolean;
127
+ }
128
+
102
129
  /**
103
130
  * Generate the consolidated MCP tools for a vault. Surface (10):
104
131
  * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
105
132
  * delete-tag, find-path, vault-info, prune-schema (admin).
106
133
  */
107
- export function generateMcpTools(store: Store): McpToolDef[] {
108
- const db: Database = (store as any).db;
134
+ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
135
+ const db: Database = store.db;
136
+ const expandVisibility = opts?.expandVisibility;
137
+ const nearTraversable = opts?.nearTraversable;
109
138
 
110
139
  return [
111
140
 
@@ -137,6 +166,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
137
166
  description: "Filter by tag(s)",
138
167
  },
139
168
  tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
169
+ expand: {
170
+ type: "string",
171
+ enum: ["subtypes", "namespace", "both", "exact"],
172
+ 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).",
173
+ },
140
174
  exclude_tags: {
141
175
  oneOf: [
142
176
  { type: "string" },
@@ -178,7 +212,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
178
212
  type: "object",
179
213
  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
214
  },
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." },
215
+ 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
216
  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
217
  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
218
  date_filter: {
@@ -217,6 +251,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
217
251
  description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
218
252
  },
219
253
  include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
254
+ include_link_count: {
255
+ type: "boolean",
256
+ description:
257
+ "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.",
258
+ },
259
+ link_count_direction: {
260
+ type: "string",
261
+ enum: ["both", "outbound", "inbound"],
262
+ description:
263
+ "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.",
264
+ },
220
265
  include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
221
266
  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
267
  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,7 +280,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
235
280
  ),
236
281
  );
237
282
  const expandCtx: ExpandContext | null = expandLinks
238
- ? { db, mode: expandMode, expanded: new Set() }
283
+ ? {
284
+ db,
285
+ mode: expandMode,
286
+ expanded: new Set(),
287
+ // Tag-scope confidentiality (security review): when a visibility
288
+ // predicate was injected, wikilinks to out-of-scope notes are
289
+ // left unresolved DURING inlining — never embedded. Unscoped
290
+ // callers pass no predicate and inlining is unchanged.
291
+ ...(expandVisibility ? { isVisible: expandVisibility } : {}),
292
+ }
239
293
  : null;
240
294
 
241
295
  // --- Single note by ID/path ---
@@ -256,10 +310,27 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
256
310
  if (params.include_attachments) {
257
311
  result.attachments = await store.getAttachments(note.id);
258
312
  }
313
+ // linkCount injected after filterMetadata on purpose — same as
314
+ // links/attachments above; filterMetadata only touches `metadata`.
315
+ if (params.include_link_count) {
316
+ const dir = normalizeLinkCountDirection(params.link_count_direction);
317
+ result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
318
+ }
259
319
  return result;
260
320
  }
261
321
 
262
322
  // --- Build near-scope (graph-filtered set of allowed IDs) ---
323
+ //
324
+ // Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
325
+ // find-path): when the session is tag-scoped the server injects a
326
+ // `nearTraversable` predicate (mcp-tools.ts), and the BFS refuses to
327
+ // walk THROUGH out-of-scope notes — scope is a wall, not a sieve. So a
328
+ // token scoped to ["work"] can't reach an in-scope note at depth 2 via
329
+ // a #personal intermediary at depth 1. Core stays scope-unaware: it
330
+ // only invokes the injected closure. Unscoped sessions pass no
331
+ // predicate → the FULL graph is walked exactly as before. The
332
+ // `applyTagScopeWrappers` result-filter still runs afterward (defense
333
+ // in depth), but the wall makes it redundant for `near[]`.
263
334
  let nearScope: Set<string> | null = null;
264
335
  if (params.near) {
265
336
  const near = params.near as { note_id: string; depth?: number; relationship?: string };
@@ -269,6 +340,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
269
340
  const traversed = linkOps.traverseLinks(db, anchor.id, {
270
341
  max_depth: depth,
271
342
  relationship: near.relationship,
343
+ isTraversable: nearTraversable,
272
344
  });
273
345
  nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
274
346
  }
@@ -295,6 +367,18 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
295
367
  "INVALID_QUERY",
296
368
  );
297
369
  }
370
+ // Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
371
+ // typo'd value doesn't silently fall back to the default.
372
+ let expand: TagExpandMode | undefined;
373
+ if (params.expand !== undefined && params.expand !== null) {
374
+ if (typeof params.expand !== "string" || !(TAG_EXPAND_MODES as readonly string[]).includes(params.expand)) {
375
+ throw new QueryError(
376
+ `invalid \`expand\` value ${JSON.stringify(params.expand)} — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes").`,
377
+ "INVALID_QUERY",
378
+ );
379
+ }
380
+ expand = params.expand as TagExpandMode;
381
+ }
298
382
 
299
383
  // --- Full-text search ---
300
384
  let results: Note[];
@@ -310,6 +394,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
310
394
  results = await store.searchNotes(params.search as string, {
311
395
  tags,
312
396
  limit: (params.limit as number) ?? 50,
397
+ expand,
313
398
  });
314
399
  } else {
315
400
  // --- Structured query ---
@@ -329,6 +414,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
329
414
  const queryOpts = {
330
415
  tags,
331
416
  tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
417
+ expand,
332
418
  excludeTags,
333
419
  hasTags: params.has_tags as boolean | undefined,
334
420
  hasLinks: params.has_links as boolean | undefined,
@@ -390,6 +476,19 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
390
476
  output = output.map((n: any) => filterMetadata(n, includeMetadata));
391
477
  }
392
478
 
479
+ // --- Opt-in link degree (vault feedback #4) ---
480
+ // ONE batch count over all result ids (NOT per-note), so the field
481
+ // stays O(2 index scans) per request regardless of page size.
482
+ // Injected on the same objects the enrichment loop copies below.
483
+ // Ordering: runs AFTER the filterMetadata pass above on purpose —
484
+ // filterMetadata only touches the `metadata` key, so linkCount
485
+ // survives. Don't casually swap the order.
486
+ if (params.include_link_count) {
487
+ const dir = normalizeLinkCountDirection(params.link_count_direction);
488
+ const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
489
+ for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
490
+ }
491
+
393
492
  // --- Hydrate links/attachments per note if requested ---
394
493
  if (params.include_links || params.include_attachments) {
395
494
  const enrichedOut: any[] = [];
@@ -512,17 +611,35 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
512
611
  throw e;
513
612
  }
514
613
 
515
- // Apply tag schema effects
614
+ // Apply tag schema effects, then re-read the notes whose metadata was
615
+ // actually default-filled so the response reflects the final on-disk
616
+ // state (the `created` entries were read before `applySchemaDefaults`
617
+ // ran, so default-filled metadata isn't on them yet). This mirrors the
618
+ // update-note path, which already re-reads post-defaults. The re-read
619
+ // is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
620
+ // entirely when no defaults were applied, so the common no-defaults
621
+ // path adds zero extra reads.
622
+ const mutatedIds = new Set<string>();
516
623
  for (const note of created) {
517
624
  if (note.tags && note.tags.length > 0) {
518
- await applySchemaDefaults(store, db, [note.id], note.tags);
625
+ for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
626
+ mutatedIds.add(id);
627
+ }
519
628
  }
520
629
  }
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));
630
+ const refreshed =
631
+ mutatedIds.size === 0
632
+ ? created
633
+ : (() => {
634
+ const byId = new Map(
635
+ noteOps.getNotes(db, [...mutatedIds]).map((n) => [n.id, n]),
636
+ );
637
+ return created.map((n) => byId.get(n.id) ?? n);
638
+ })();
639
+
640
+ // Attach `validation_status` from any tag's `fields` declaration that
641
+ // applies to this note, against the post-defaults state.
642
+ const final = refreshed.map((n) => attachValidationStatus(store, db, n));
526
643
  return batch ? final : final[0];
527
644
  },
528
645
  },
@@ -543,7 +660,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
543
660
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
544
661
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
545
662
  - 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).
663
+ - **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
664
  - **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
665
  - \`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
666
  inputSchema: {
@@ -567,7 +684,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
567
684
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
568
685
  created_at: { type: "string", description: "New created_at timestamp" },
569
686
  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." },
687
+ 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
688
  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
689
  tags: {
573
690
  type: "object",
@@ -609,6 +726,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
609
726
  type: "boolean",
610
727
  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
728
  },
729
+ include_links: {
730
+ type: "boolean",
731
+ 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).",
732
+ },
612
733
  // Batch
613
734
  notes: {
614
735
  type: "array",
@@ -632,10 +753,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
632
753
  metadata: { type: "object" },
633
754
  created_at: { type: "string" },
634
755
  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." },
756
+ 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
757
  if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
637
758
  tags: { type: "object" },
638
759
  links: { type: "object" },
760
+ 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
761
  },
640
762
  required: ["id"],
641
763
  },
@@ -657,6 +779,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
657
779
  // sync-loop caller (Gitcoin Brain et al) reads this to know which
658
780
  // path fired without doing a separate query. vault#309.
659
781
  const createdIds = new Set<string>();
782
+ // Track which note IDs should echo hydrated links on the response.
783
+ // A note qualifies when this request mutated its links
784
+ // (`links.add`/`links.remove`) OR the caller set `include_links`.
785
+ // vault feedback #8 — previously the update response omitted links
786
+ // entirely, forcing a re-query just to confirm a link the caller had
787
+ // just added/removed. Per-item on batch. Note IDs (not item indices)
788
+ // key this so the create-on-missing branch, which assigns the id
789
+ // late, can register correctly.
790
+ const echoLinkIds = new Set<string>();
660
791
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
661
792
  // failure (precondition error, content_edit miss, ConflictError, …)
662
793
  // rolls back every prior mutation in the batch — see #236.
@@ -745,6 +876,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
745
876
  const fresh = noteOps.getNote(db, created.id) ?? created;
746
877
  updated.push(fresh);
747
878
  createdIds.add(fresh.id);
879
+ // Echo links if this create-on-missing declared `links.add`
880
+ // (the only link op honored on create) or asked explicitly.
881
+ if (linksAdd !== undefined || item.include_links === true) {
882
+ echoLinkIds.add(fresh.id);
883
+ }
748
884
  continue;
749
885
  }
750
886
  // Fallthrough: not-found + no if_missing → existing error
@@ -907,6 +1043,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
907
1043
  }
908
1044
  }
909
1045
 
1046
+ // Echo links if this update mutated them (`links.add`/`links.remove`)
1047
+ // or the caller asked explicitly. vault feedback #8.
1048
+ const linkMutated = (item.links as any)?.add !== undefined || (item.links as any)?.remove !== undefined;
1049
+ if (linkMutated || item.include_links === true) {
1050
+ echoLinkIds.add(note.id);
1051
+ }
1052
+
910
1053
  // Re-read for final state
911
1054
  updated.push(noteOps.getNote(db, note.id) ?? result);
912
1055
  }
@@ -929,11 +1072,23 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
929
1072
  const final = updated.map((n) => {
930
1073
  const validated = attachValidationStatus(store, db, n);
931
1074
  const created = createdIds.has(n.id);
932
- if (includeContent) return { ...validated, created } as Note & { created: boolean };
1075
+ // Echo hydrated links when this note was flagged for it (mutated
1076
+ // its links or `include_links` was set). Additive key, present only
1077
+ // when triggered — mirrors the GET / query-notes shape exactly via
1078
+ // the shared `linkOps.getLinksHydrated` call. vault feedback #8.
1079
+ const echoLinks = echoLinkIds.has(n.id);
1080
+ if (includeContent) {
1081
+ const full: any = { ...validated, created };
1082
+ if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
1083
+ return full as Note & { created: boolean };
1084
+ }
933
1085
  const lean: any = noteOps.toNoteIndex(validated);
934
1086
  const vs = (validated as any).validation_status;
935
1087
  if (vs !== undefined) lean.validation_status = vs;
936
1088
  lean.created = created;
1089
+ // Carry the link echo across the lean conversion — `toNoteIndex`
1090
+ // drops unknown fields.
1091
+ if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
937
1092
  return lean;
938
1093
  });
939
1094
  return batch ? final : final[0];
@@ -1029,7 +1184,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1029
1184
  {
1030
1185
  name: "update-tag",
1031
1186
  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.",
1187
+ 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
1188
  inputSchema: {
1034
1189
  type: "object",
1035
1190
  properties: {
@@ -1051,16 +1206,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1051
1206
  },
1052
1207
  relationships: {
1053
1208
  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
- },
1209
+ 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" } }.',
1210
+ additionalProperties: true,
1064
1211
  },
1065
1212
  parent_names: {
1066
1213
  type: "array",
@@ -1124,10 +1271,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1124
1271
  }
1125
1272
  }
1126
1273
 
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;
1274
+ // ---- relationships: replace wholesale when provided. `relationships`
1275
+ // is an opaque vocabulary map (relationship-name arbitrary JSON the
1276
+ // app interprets). Validate only that it's a JSON object (a map), then
1277
+ // persist verbatim no inner-shape enforcement.
1278
+ let relationshipsPatch: tagSchemaOps.TagRelationshipMap | null | undefined;
1131
1279
  if (params.relationships === null) {
1132
1280
  relationshipsPatch = null;
1133
1281
  } else if (params.relationships !== undefined) {
@@ -1296,9 +1444,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
1296
1444
  // Tag schema effects — auto-populate defaults when tags are applied
1297
1445
  // ---------------------------------------------------------------------------
1298
1446
 
1299
- async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<void> {
1447
+ /**
1448
+ * Fill schema-declared default values into the metadata of the given notes
1449
+ * for any field they omitted. Returns the IDs of the notes whose metadata was
1450
+ * actually written — callers use this to re-read ONLY the mutated notes (and
1451
+ * to skip the re-read entirely when nothing changed). The common no-schema /
1452
+ * no-defaults path returns an empty array.
1453
+ */
1454
+ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
1300
1455
  const schemas = tagSchemaOps.getTagSchemaMap(db);
1301
- if (Object.keys(schemas).length === 0) return;
1456
+ if (Object.keys(schemas).length === 0) return [];
1302
1457
 
1303
1458
  const defaults: Record<string, unknown> = {};
1304
1459
  for (const tag of tags) {
@@ -1310,8 +1465,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1310
1465
  }
1311
1466
  }
1312
1467
  }
1313
- if (Object.keys(defaults).length === 0) return;
1468
+ if (Object.keys(defaults).length === 0) return [];
1314
1469
 
1470
+ const mutated: string[] = [];
1315
1471
  for (const noteId of noteIds) {
1316
1472
  const note = noteOps.getNote(db, noteId);
1317
1473
  if (!note) continue;
@@ -1327,7 +1483,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
1327
1483
  metadata: { ...existing, ...missing },
1328
1484
  skipUpdatedAt: true,
1329
1485
  });
1486
+ mutated.push(noteId);
1330
1487
  }
1488
+ return mutated;
1331
1489
  }
1332
1490
 
1333
1491
  function defaultForField(field: { type: string; enum?: string[] }): unknown {
@@ -1382,6 +1540,16 @@ function normalizeTags(tag: unknown): string[] | undefined {
1382
1540
  return [tag as string];
1383
1541
  }
1384
1542
 
1543
+ /**
1544
+ * Coerce the `link_count_direction` MCP param to a known value, defaulting
1545
+ * to "both" (matches the REST `parseLinkCountDirection` fallback). A typo
1546
+ * silently degrades to the documented default rather than erroring.
1547
+ */
1548
+ function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound" {
1549
+ if (v === "outbound" || v === "inbound") return v;
1550
+ return "both";
1551
+ }
1552
+
1385
1553
  // Re-exported for backward compat; defined in notes.ts alongside the
1386
1554
  // conditional-UPDATE implementation that raises it. AmbiguousPathError
1387
1555
  // joins the set (vault#331 N2) so external callers can `instanceof`
package/core/src/notes.ts CHANGED
@@ -83,7 +83,7 @@ export function createNote(
83
83
  }
84
84
 
85
85
  export function getNote(db: Database, id: string): Note | null {
86
- const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | undefined;
86
+ const row = db.prepare("SELECT * FROM notes WHERE id = ?").get(id) as NoteRow | null;
87
87
  if (!row) return null;
88
88
 
89
89
  const note = rowToNote(row);
@@ -111,7 +111,7 @@ export function getNoteByPath(db: Database, path: string, extension?: string): N
111
111
  if (extension !== undefined) {
112
112
  const row = db.prepare(
113
113
  "SELECT * FROM notes WHERE path = ? COLLATE NOCASE AND LOWER(extension) = ?",
114
- ).get(path, extension.toLowerCase()) as NoteRow | undefined;
114
+ ).get(path, extension.toLowerCase()) as NoteRow | null;
115
115
  if (!row) return null;
116
116
  const note = rowToNote(row);
117
117
  note.tags = getNoteTags(db, note.id);
@@ -459,7 +459,7 @@ export function updateNote(
459
459
  if (isPathUniqueError(err)) {
460
460
  const conflictPath = updates.path !== undefined
461
461
  ? (normalizePath(updates.path) ?? updates.path)
462
- : ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | undefined)?.path ?? "<unknown>");
462
+ : ((db.prepare("SELECT path FROM notes WHERE id = ?").get(id) as { path: string | null } | null)?.path ?? "<unknown>");
463
463
  throw new PathConflictError(conflictPath);
464
464
  }
465
465
  throw err;
@@ -475,7 +475,7 @@ export function updateNote(
475
475
  function throwConflictOrMissing(db: Database, id: string, expected: string): never {
476
476
  const row = db.prepare("SELECT updated_at, path FROM notes WHERE id = ?").get(id) as
477
477
  | { updated_at: string | null; path: string | null }
478
- | undefined;
478
+ | null;
479
479
  if (!row) {
480
480
  throw new Error(`Note not found: "${id}"`);
481
481
  }
@@ -736,6 +736,32 @@ export function queryNotes(db: Database, opts: QueryOpts): Note[] {
736
736
  // be at the mercy of SQLite's row order and the next page could
737
737
  // miss or duplicate one.
738
738
  orderBy = "n.updated_at ASC, n.id ASC";
739
+ } else if (opts.orderBy === "link_count") {
740
+ // `link_count` is a pseudo-field — like `created_at`/`updated_at` in the
741
+ // dateFilter block above, it bypasses `requireIndexedField` (it's not a
742
+ // metadata column). Sort by link DEGREE using the SAME directional-sum
743
+ // definition as the `linkCount` response field (see `getLinkCounts` in
744
+ // links.ts): two correlated COUNT subqueries summed. This MUST stay a
745
+ // sum of two directional counts — a single
746
+ // `COUNT(*) ... WHERE source_id=n.id OR target_id=n.id` would count a
747
+ // self-loop ONCE (degree 1) and DIVERGE from the field's degree-2. Both
748
+ // subqueries ride the existing `idx_links_source` / `idx_links_target`
749
+ // B-trees. `created_at` stays the stable tiebreaker.
750
+ //
751
+ // Always the both-directions degree — inbound-only ordering is a future
752
+ // extension and is not built here.
753
+ //
754
+ // Perf caveat: these are correlated subqueries, evaluated once per
755
+ // candidate row. At small-to-moderate vault sizes (tens of thousands of
756
+ // notes) that's fine — each subquery is an O(log n) index probe. At very
757
+ // large vault sizes the per-row scan cost grows; the upgrade path is a
758
+ // maintained `link_count` counter column on `notes`, incremented in
759
+ // `createLink` and decremented in `deleteLink`, then ordered directly.
760
+ // NOT built now — flagged so a future contributor sees the lever.
761
+ orderBy =
762
+ `((SELECT COUNT(*) FROM links WHERE source_id = n.id) ` +
763
+ `+ (SELECT COUNT(*) FROM links WHERE target_id = n.id)) ${direction}, ` +
764
+ `n.created_at ${direction}`;
739
765
  } else if (opts.orderBy) {
740
766
  requireIndexedField(db, opts.orderBy);
741
767
  // `orderBy` came from indexed_fields (validated on declaration), so
@@ -946,7 +972,7 @@ export function listTags(db: Database): { name: string; count: number }[] {
946
972
  export function deleteTag(db: Database, name: string): { deleted: boolean; notes_untagged: number } {
947
973
  const row = db.prepare("SELECT fields FROM tags WHERE name = ?").get(name) as
948
974
  | { fields: string | null }
949
- | undefined;
975
+ | null;
950
976
  if (!row) return { deleted: false, notes_untagged: 0 };
951
977
 
952
978
  const countRow = db.prepare("SELECT COUNT(*) as c FROM note_tags WHERE tag_name = ?").get(name) as { c: number };
@@ -1107,7 +1133,7 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1107
1133
  for (const { from, to } of renames) {
1108
1134
  const old = readStmt.get(from) as
1109
1135
  | { description: string | null; fields: string | null; relationships: string | null; parent_names: string | null; created_at: string | null }
1110
- | undefined;
1136
+ | null;
1111
1137
  insertStmt.run(
1112
1138
  to,
1113
1139
  old?.description ?? null,
@@ -1522,11 +1548,11 @@ export function getVaultStats(
1522
1548
 
1523
1549
  const earliestRow = db.prepare(
1524
1550
  "SELECT id, created_at FROM notes ORDER BY created_at ASC, id ASC LIMIT 1",
1525
- ).get() as { id: string; created_at: string } | undefined;
1551
+ ).get() as { id: string; created_at: string } | null;
1526
1552
 
1527
1553
  const latestRow = db.prepare(
1528
1554
  "SELECT id, created_at FROM notes ORDER BY created_at DESC, id DESC LIMIT 1",
1529
- ).get() as { id: string; created_at: string } | undefined;
1555
+ ).get() as { id: string; created_at: string } | null;
1530
1556
 
1531
1557
  const monthRows = db.prepare(`
1532
1558
  SELECT strftime('%Y-%m', created_at) AS month, COUNT(*) AS count
@@ -1553,6 +1579,15 @@ export function getVaultStats(
1553
1579
  const linkCountRow = db.prepare("SELECT COUNT(*) as c FROM links").get() as { c: number };
1554
1580
  const linkCount = linkCountRow.c;
1555
1581
 
1582
+ // Total content bytes. CAST(content AS BLOB) forces SQLite's LENGTH() to
1583
+ // count UTF-8 BYTES rather than characters (bare LENGTH on TEXT returns a
1584
+ // char count, which undercounts multibyte content). COALESCE because SUM
1585
+ // over zero rows is NULL. See VaultStats.contentBytes for the rationale.
1586
+ const contentBytesRow = db
1587
+ .prepare("SELECT COALESCE(SUM(LENGTH(CAST(content AS BLOB))), 0) as b FROM notes")
1588
+ .get() as { b: number };
1589
+ const contentBytes = contentBytesRow.b;
1590
+
1556
1591
  return {
1557
1592
  totalNotes,
1558
1593
  earliestNote: earliestRow
@@ -1566,6 +1601,7 @@ export function getVaultStats(
1566
1601
  tagCount,
1567
1602
  attachmentCount,
1568
1603
  linkCount,
1604
+ contentBytes,
1569
1605
  };
1570
1606
  }
1571
1607