@openparachute/vault 0.3.3 → 0.4.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 (79) hide show
  1. package/.parachute/module.json +15 -0
  2. package/core/src/core.test.ts +2252 -7
  3. package/core/src/links.ts +1 -1
  4. package/core/src/mcp.ts +801 -67
  5. package/core/src/note-schemas.ts +232 -0
  6. package/core/src/notes.ts +313 -35
  7. package/core/src/obsidian.ts +3 -3
  8. package/core/src/paths.ts +1 -1
  9. package/core/src/query-operators.ts +23 -7
  10. package/core/src/schema-defaults.ts +287 -0
  11. package/core/src/schema.ts +393 -9
  12. package/core/src/store.ts +248 -6
  13. package/core/src/tag-hierarchy.ts +137 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +100 -6
  16. package/core/src/wikilinks.ts +3 -3
  17. package/package.json +13 -3
  18. package/src/admin-spa.test.ts +161 -0
  19. package/src/admin-spa.ts +161 -0
  20. package/src/auth-hub-jwt.test.ts +231 -0
  21. package/src/auth-status.ts +84 -0
  22. package/src/auth.test.ts +135 -23
  23. package/src/auth.ts +144 -15
  24. package/src/backup.ts +4 -7
  25. package/src/cli.ts +322 -57
  26. package/src/config.test.ts +44 -0
  27. package/src/config.ts +68 -40
  28. package/src/hub-jwt.test.ts +296 -0
  29. package/src/hub-jwt.ts +79 -0
  30. package/src/init.test.ts +216 -0
  31. package/src/mcp-http.ts +30 -28
  32. package/src/mcp-install.ts +1 -1
  33. package/src/mcp-tools.ts +294 -6
  34. package/src/module-config.ts +1 -1
  35. package/src/oauth.test.ts +345 -0
  36. package/src/oauth.ts +85 -14
  37. package/src/owner-auth.ts +57 -1
  38. package/src/prompt.ts +6 -5
  39. package/src/routes.ts +686 -58
  40. package/src/routing.test.ts +466 -1
  41. package/src/routing.ts +108 -24
  42. package/src/scopes.test.ts +66 -8
  43. package/src/scopes.ts +163 -37
  44. package/src/server.ts +24 -2
  45. package/src/services-manifest.test.ts +20 -0
  46. package/src/services-manifest.ts +9 -2
  47. package/src/stop-signal.test.ts +85 -0
  48. package/src/storage.test.ts +92 -0
  49. package/src/tag-scope.ts +118 -0
  50. package/src/token-store.test.ts +47 -0
  51. package/src/token-store.ts +128 -13
  52. package/src/tokens-routes.test.ts +720 -0
  53. package/src/tokens-routes.ts +392 -0
  54. package/src/transcription-worker.test.ts +5 -0
  55. package/src/triggers.ts +1 -1
  56. package/src/two-factor.ts +2 -2
  57. package/src/vault-create.test.ts +193 -0
  58. package/src/vault-name.test.ts +123 -0
  59. package/src/vault-name.ts +80 -0
  60. package/src/vault.test.ts +868 -3
  61. package/tsconfig.json +8 -1
  62. package/.claude/settings.local.json +0 -8
  63. package/.dockerignore +0 -8
  64. package/.env.example +0 -9
  65. package/CHANGELOG.md +0 -175
  66. package/CLAUDE.md +0 -125
  67. package/Caddyfile +0 -3
  68. package/Dockerfile +0 -22
  69. package/bun.lock +0 -219
  70. package/bunfig.toml +0 -2
  71. package/deploy/parachute-vault.service +0 -20
  72. package/docker-compose.yml +0 -50
  73. package/docs/HTTP_API.md +0 -434
  74. package/docs/auth-model.md +0 -340
  75. package/fly.toml +0 -24
  76. package/package/package.json +0 -32
  77. package/railway.json +0 -14
  78. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  79. package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/mcp.ts CHANGED
@@ -1,11 +1,12 @@
1
1
  import { Database } from "bun:sqlite";
2
2
  import type { Store, Note } from "./types.js";
3
3
  import * as noteOps from "./notes.js";
4
- import { filterMetadata } from "./notes.js";
4
+ import { filterMetadata, MAX_BATCH_SIZE } from "./notes.js";
5
5
  import * as linkOps from "./links.js";
6
6
  import * as tagSchemaOps from "./tag-schemas.js";
7
7
  import type { TagFieldSchema } from "./tag-schemas.js";
8
8
  import * as indexedFieldOps from "./indexed-fields.js";
9
+ import { MAPPING_KINDS, type SchemaMappingKind, type NoteSchemaField } from "./note-schemas.js";
9
10
  import {
10
11
  expandContent,
11
12
  DEFAULT_EXPAND_DEPTH,
@@ -68,7 +69,7 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
68
69
  // ---------------------------------------------------------------------------
69
70
 
70
71
  /**
71
- * Generate the 9 consolidated MCP tools for a vault.
72
+ * Generate the 10 consolidated MCP tools for a vault.
72
73
  */
73
74
  export function generateMcpTools(store: Store): McpToolDef[] {
74
75
  const db: Database = (store as any).db;
@@ -102,7 +103,31 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
102
103
  description: "Filter by tag(s)",
103
104
  },
104
105
  tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
105
- exclude_tags: { type: "array", items: { type: "string" }, description: "Exclude notes with these tags" },
106
+ exclude_tags: {
107
+ oneOf: [
108
+ { type: "string" },
109
+ { type: "array", items: { type: "string" } },
110
+ ],
111
+ description: "Exclude notes with these tag(s). Accepts a single tag or an array. Aliases `excludeTags` and `exclude_tag` are also accepted. If multiple alias forms are provided, `exclude_tags` takes precedence (then `excludeTags`, then `exclude_tag`).",
112
+ },
113
+ // The runtime alias-fallback chain accepts these too. Declared
114
+ // here so schema-introspecting clients (Claude, MCP clients
115
+ // that surface tool schemas) see them as valid inputs rather
116
+ // than thinking the canonical is the only option.
117
+ excludeTags: {
118
+ oneOf: [
119
+ { type: "string" },
120
+ { type: "array", items: { type: "string" } },
121
+ ],
122
+ description: "Alias for `exclude_tags` (camelCase). Same shape and semantics — pick whichever is more natural for your client.",
123
+ },
124
+ exclude_tag: {
125
+ oneOf: [
126
+ { type: "string" },
127
+ { type: "array", items: { type: "string" } },
128
+ ],
129
+ description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
130
+ },
106
131
  has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
107
132
  has_links: { type: "boolean", description: "Presence filter: true = only notes with at least one inbound or outbound link; false = only orphaned notes (no links in either direction)." },
108
133
  path: { type: "string", description: "Exact path match (case-insensitive)" },
@@ -113,8 +138,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
113
138
  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.",
114
139
  },
115
140
  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." },
116
- date_from: { type: "string", description: "Start date (ISO, inclusive)" },
117
- date_to: { type: "string", description: "End date (ISO, exclusive)" },
141
+ date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
142
+ date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
143
+ date_filter: {
144
+ type: "object",
145
+ properties: {
146
+ field: { type: "string", description: "Field to filter on. Defaults to `created_at` (vault ingestion time). Any other field must be declared `indexed: true` in a tag schema — same contract as metadata operator queries and `order_by`." },
147
+ from: { type: "string", description: "Inclusive lower bound (ISO date)." },
148
+ to: { type: "string", description: "Exclusive upper bound (ISO date)." },
149
+ },
150
+ description: "Generalized date-range filter. Use this when the date that matters is the *content* date (e.g. an email's received date, a meeting's scheduled date), not the vault ingestion time — set `field` to the indexed metadata field that holds it. Mutually exclusive with the top-level `date_from` / `date_to` shorthand.",
151
+ },
118
152
  near: {
119
153
  type: "object",
120
154
  properties: {
@@ -198,24 +232,49 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
198
232
  if (params.search) {
199
233
  // Normalize tag param
200
234
  const tags = normalizeTags(params.tag);
201
- results = noteOps.searchNotes(db, params.search as string, {
235
+ // Route through `store.searchNotes` (not `noteOps.searchNotes`) so
236
+ // tag-hierarchy expansion fires for MCP callers the same as for
237
+ // HTTP REST callers — `tag: "manual"` matches descendants declared
238
+ // via `_tags/*` config notes. Mirrors the structured-query fix
239
+ // from #214; same class of bypass bug (tracked as #227).
240
+ results = await store.searchNotes(params.search as string, {
202
241
  tags,
203
242
  limit: (params.limit as number) ?? 50,
204
243
  });
205
244
  } else {
206
245
  // --- Structured query ---
207
246
  const tags = normalizeTags(params.tag);
208
- results = noteOps.queryNotes(db, {
247
+ // Accept canonical `exclude_tags` plus camelCase / singular aliases.
248
+ // LLM callers frequently pick the wrong name (training-data drift
249
+ // toward camelCase across MCP tools) and the JSON-RPC layer drops
250
+ // unknown keys silently; aliasing here closes the silent-no-op gap.
251
+ const excludeTagsRaw = params.exclude_tags ?? params.excludeTags ?? params.exclude_tag;
252
+ const excludeTags = normalizeTags(excludeTagsRaw);
253
+ // Route through `store.queryNotes` (not `noteOps.queryNotes`) so
254
+ // tag-hierarchy expansion fires for MCP callers the same as for
255
+ // HTTP REST callers — `tag: "manual"` matches descendants declared
256
+ // via `_tags/*` config notes. The previous direct-noteOps call
257
+ // bypassed the wrapper and silently dropped hierarchy expansion.
258
+ results = await store.queryNotes({
209
259
  tags,
210
260
  tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
211
- excludeTags: params.exclude_tags as string[] | undefined,
261
+ excludeTags,
212
262
  hasTags: params.has_tags as boolean | undefined,
213
263
  hasLinks: params.has_links as boolean | undefined,
214
264
  path: params.path as string | undefined,
215
265
  pathPrefix: params.path_prefix as string | undefined,
266
+ // Push the near-scope into the SQL WHERE so that LIMIT and ORDER
267
+ // BY apply to the neighborhood. Without this, queryNotes would
268
+ // fetch the first `limit` notes by created_at and then post-
269
+ // filter to the few in-scope ones — which silently empties the
270
+ // result whenever the neighborhood lies outside that prefix.
271
+ ids: nearScope ? [...nearScope] : undefined,
216
272
  metadata: params.metadata as Record<string, unknown> | undefined,
217
273
  dateFrom: params.date_from as string | undefined,
218
274
  dateTo: params.date_to as string | undefined,
275
+ dateFilter: params.date_filter as
276
+ | { field?: string; from?: string; to?: string }
277
+ | undefined,
219
278
  sort: params.sort as "asc" | "desc" | undefined,
220
279
  orderBy: params.order_by as string | undefined,
221
280
  limit: (params.limit as number) ?? 50,
@@ -223,8 +282,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
223
282
  });
224
283
  }
225
284
 
226
- // --- Apply near-scope filter ---
227
- if (nearScope) {
285
+ // For full-text search the post-filter is still the right shape — FTS
286
+ // owns its own ranked LIMIT and we just narrow to the neighborhood
287
+ // afterwards. Structured queries already pushed `ids` into SQL above.
288
+ if (nearScope && params.search) {
228
289
  results = results.filter((n) => nearScope!.has(n.id));
229
290
  }
230
291
 
@@ -315,26 +376,65 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
315
376
  const batch = params.notes as any[] | undefined;
316
377
  const items = batch ?? [params];
317
378
 
379
+ if (items.length > MAX_BATCH_SIZE) {
380
+ throw new BatchTooLargeError(items.length);
381
+ }
382
+
383
+ // Empty-note pre-validation (#213): make mixed batches atomic for
384
+ // the empty-note case. The Store will throw EmptyNoteError on the
385
+ // empty entry, but in a sequential batch loop the prefix would have
386
+ // already committed before we hit it. Pre-walk so the whole call
387
+ // either creates everything or nothing. The error carries
388
+ // `item_index` so MCP callers with multi-item batches can pinpoint
389
+ // the bad entry — parity with the HTTP route's response shape.
390
+ // TODO: tighten batch input type — `items[i] as any` mirrors the
391
+ // top-of-call cast at `params.notes as any[]`. A typed McpCreateNoteInput
392
+ // would let us drop both casts.
393
+ for (let i = 0; i < items.length; i++) {
394
+ const item = items[i] as any;
395
+ const content = ((item?.content as string | undefined) ?? "").toString();
396
+ const rawPath = item?.path;
397
+ const pathEmpty = rawPath === undefined || rawPath === null
398
+ || (typeof rawPath === "string" && rawPath.trim() === "");
399
+ if (!content.trim() && pathEmpty) {
400
+ throw new noteOps.EmptyNoteError(null, batch ? i : null);
401
+ }
402
+ }
403
+
318
404
  const created: Note[] = [];
319
- for (const item of items) {
320
- const note = await store.createNote(item.content as string ?? "", {
321
- path: item.path as string | undefined,
322
- tags: item.tags as string[] | undefined,
323
- metadata: item.metadata as Record<string, unknown> | undefined,
324
- created_at: item.created_at as string | undefined,
325
- });
405
+ // Wrap multi-item batches in a SQLite transaction so a mid-batch
406
+ // failure rolls back every prior insert see #236. The pre-walk
407
+ // above catches empty-note cases; this guards anything thrown from
408
+ // store.createNote / createLink (path conflict, etc.). Single-item
409
+ // calls skip the wrap to avoid colliding with concurrent callers
410
+ // on the shared bun:sqlite connection.
411
+ const batched = items.length > 1;
412
+ if (batched) db.exec("BEGIN");
413
+ try {
414
+ for (const item of items) {
415
+ const note = await store.createNote(item.content as string ?? "", {
416
+ path: item.path as string | undefined,
417
+ tags: item.tags as string[] | undefined,
418
+ metadata: item.metadata as Record<string, unknown> | undefined,
419
+ created_at: item.created_at as string | undefined,
420
+ });
326
421
 
327
- // Create explicit links (not wikilinks — those are automatic)
328
- if (item.links) {
329
- for (const link of item.links as { target: string; relationship: string }[]) {
330
- const target = resolveNote(db, link.target);
331
- if (target) {
332
- await store.createLink(note.id, target.id, link.relationship);
422
+ // Create explicit links (not wikilinks — those are automatic)
423
+ if (item.links) {
424
+ for (const link of item.links as { target: string; relationship: string }[]) {
425
+ const target = resolveNote(db, link.target);
426
+ if (target) {
427
+ await store.createLink(note.id, target.id, link.relationship);
428
+ }
333
429
  }
334
430
  }
335
- }
336
431
 
337
- created.push(noteOps.getNote(db, note.id) ?? note);
432
+ created.push(noteOps.getNote(db, note.id) ?? note);
433
+ }
434
+ if (batched) db.exec("COMMIT");
435
+ } catch (e) {
436
+ if (batched) db.exec("ROLLBACK");
437
+ throw e;
338
438
  }
339
439
 
340
440
  // Apply tag schema effects
@@ -344,7 +444,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
344
444
  }
345
445
  }
346
446
 
347
- return batch ? created : created[0];
447
+ // Re-read after schema-default population so the response reflects the
448
+ // final on-disk state, then attach `validation_status` from any
449
+ // `_schemas/*` config notes that match this note's path or tags.
450
+ const final = created.map((n) => attachValidationStatus(store, db, n));
451
+ return batch ? final : final[0];
348
452
  },
349
453
  },
350
454
 
@@ -355,20 +459,35 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
355
459
  name: "update-note",
356
460
  description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
357
461
 
462
+ - Three content-modification modes (mutually exclusive):
463
+ - \`content\` — full replace.
464
+ - \`append\` / \`prepend\` — atomic concatenation at the SQL layer. Multiple agents appending to the same note never overwrite each other. No separator is added; include trailing/leading whitespace yourself if needed. May be combined with each other.
465
+ - \`content_edit: { old_text, new_text }\` — surgical find-and-replace. \`old_text\` must occur exactly once; zero or multiple matches return an error. Add surrounding context to disambiguate.
358
466
  - \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
359
467
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
360
468
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
361
469
  - For batch: pass a \`notes\` array, each with an \`id\` field.
362
- - **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.`,
470
+ - **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).`,
363
471
  inputSchema: {
364
472
  type: "object",
365
473
  properties: {
366
474
  id: { type: "string", description: "Note ID or path" },
367
- content: { type: "string", description: "New content" },
475
+ content: { type: "string", description: "New content (full replace). Mutually exclusive with `append`/`prepend` and `content_edit`." },
476
+ append: { type: "string", description: "Text to append to the end of the note. Atomic at the SQL layer — concurrent appends are safe. Mutually exclusive with `content` and `content_edit`. No precondition required." },
477
+ prepend: { type: "string", description: "Text to prepend to the start of the note. Atomic at the SQL layer. Mutually exclusive with `content` and `content_edit`. May combine with `append`. No precondition required." },
478
+ content_edit: {
479
+ type: "object",
480
+ properties: {
481
+ old_text: { type: "string", description: "Exact text to find. Must match exactly once in the note's current content." },
482
+ new_text: { type: "string", description: "Replacement text." },
483
+ },
484
+ required: ["old_text", "new_text"],
485
+ description: "Find-and-replace one occurrence. Errors if `old_text` is not found or matches multiple locations. Mutually exclusive with `content` and `append`/`prepend`.",
486
+ },
368
487
  path: { type: "string", description: "New path" },
369
488
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
370
489
  created_at: { type: "string", description: "New created_at timestamp" },
371
- 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." },
490
+ 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." },
372
491
  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." },
373
492
  tags: {
374
493
  type: "object",
@@ -414,10 +533,20 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
414
533
  properties: {
415
534
  id: { type: "string" },
416
535
  content: { type: "string" },
536
+ append: { type: "string" },
537
+ prepend: { type: "string" },
538
+ content_edit: {
539
+ type: "object",
540
+ properties: {
541
+ old_text: { type: "string" },
542
+ new_text: { type: "string" },
543
+ },
544
+ required: ["old_text", "new_text"],
545
+ },
417
546
  path: { type: "string" },
418
547
  metadata: { type: "object" },
419
548
  created_at: { type: "string" },
420
- 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." },
549
+ 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." },
421
550
  force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
422
551
  tags: { type: "object" },
423
552
  links: { type: "object" },
@@ -432,24 +561,91 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
432
561
  const batch = params.notes as any[] | undefined;
433
562
  const items = batch ?? [params];
434
563
 
564
+ if (items.length > MAX_BATCH_SIZE) {
565
+ throw new BatchTooLargeError(items.length);
566
+ }
567
+
435
568
  const updated: Note[] = [];
569
+ // Wrap multi-item batches in a SQLite transaction so any mid-batch
570
+ // failure (precondition error, content_edit miss, ConflictError, …)
571
+ // rolls back every prior mutation in the batch — see #236.
572
+ // Single-item calls skip the wrap so concurrent callers don't
573
+ // collide on the shared bun:sqlite connection.
574
+ const batched = items.length > 1;
575
+ if (batched) db.exec("BEGIN");
576
+ try {
436
577
  for (const item of items) {
437
578
  const note = requireNote(db, item.id as string);
438
579
 
580
+ // --- Validate mutual exclusion of content modes ---
581
+ const hasContent = item.content !== undefined;
582
+ const hasAppendPrepend = item.append !== undefined || item.prepend !== undefined;
583
+ const hasContentEdit = item.content_edit !== undefined;
584
+ const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
585
+ if (contentModes > 1) {
586
+ throw new Error(
587
+ `update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
588
+ );
589
+ }
590
+
439
591
  // --- Safety-by-default: refuse mutations without a precondition ---
440
592
  // The caller must either echo the note's last-seen `updated_at`
441
593
  // (`if_updated_at`) so the conditional UPDATE can catch lost
442
594
  // writes, or explicitly opt out with `force: true`. This runs
443
595
  // *before* any DB writes so a rejection leaves the note untouched.
444
- if (item.if_updated_at === undefined && item.force !== true) {
596
+ //
597
+ // Append/prepend-only updates are exempt: they're SQL-atomic
598
+ // concatenations that can't lose data on a stale read, so the
599
+ // precondition would be ceremony for no benefit. Tag and link
600
+ // mutations are *not* exempt — they're idempotent set-ops at
601
+ // the SQL layer but still represent a non-content change the
602
+ // caller should have observed before re-asserting (#201).
603
+ const isAppendOnly = hasAppendPrepend
604
+ && !hasContent
605
+ && !hasContentEdit
606
+ && item.path === undefined
607
+ && item.metadata === undefined
608
+ && item.created_at === undefined
609
+ && item.tags === undefined
610
+ && item.links === undefined;
611
+ if (!isAppendOnly && item.if_updated_at === undefined && item.force !== true) {
445
612
  throw new PreconditionRequiredError(note.id, note.path ?? null);
446
613
  }
447
614
 
615
+ // --- Resolve content_edit into a full content string ---
616
+ // We do the find-and-replace at the JS level (read note.content,
617
+ // validate occurrence count, replace). The race window between
618
+ // this read and the UPDATE is closed by `if_updated_at` for
619
+ // strict callers; without it, content_edit is fail-closed —
620
+ // a stale read where someone else removed `old_text` produces
621
+ // a "not found" error instead of silently overwriting.
622
+ let contentOverride = item.content as string | undefined;
623
+ if (hasContentEdit) {
624
+ const ce = item.content_edit as { old_text: string; new_text: string };
625
+ if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
626
+ throw new Error(
627
+ "update-note: `content_edit` requires { old_text: string, new_text: string }.",
628
+ );
629
+ }
630
+ const idx = note.content.indexOf(ce.old_text);
631
+ if (idx < 0) {
632
+ throw new Error(
633
+ `update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
634
+ );
635
+ }
636
+ const second = note.content.indexOf(ce.old_text, idx + 1);
637
+ if (second >= 0) {
638
+ throw new Error(
639
+ `update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context to disambiguate.`,
640
+ );
641
+ }
642
+ contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
643
+ }
644
+
448
645
  // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
449
646
  // We compute the cleaned content so we can do the core UPDATE first
450
647
  // (with if_updated_at atomically) before any link deletions. If the
451
648
  // UPDATE fails on a conflict, nothing has been mutated.
452
- let contentOverride = item.content as string | undefined;
453
649
  const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
454
650
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
455
651
  if (linksRemove) {
@@ -458,7 +654,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
458
654
  if (!target) continue;
459
655
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
460
656
  if (link.relationship === "wikilink" && target.path) {
461
- const currentContent = contentOverride ?? note.content;
657
+ // Wikilink-removal bracket cleanup operates on the prospective
658
+ // *full* content. Coexists with content_edit; would fight
659
+ // append/prepend (which leave existing content untouched at
660
+ // the JS layer), so we pre-materialize the would-be content
661
+ // for those callers and switch to a `content`-style update.
662
+ const currentContent = contentOverride
663
+ ?? (hasAppendPrepend
664
+ ? (item.prepend as string ?? "") + note.content + (item.append as string ?? "")
665
+ : note.content);
462
666
  const cleaned = removeWikilinkBrackets(currentContent, target.path);
463
667
  if (cleaned !== currentContent) {
464
668
  contentOverride = cleaned;
@@ -469,7 +673,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
469
673
 
470
674
  // --- Core update (content, path, metadata, created_at + concurrency check) ---
471
675
  const updates: any = {};
472
- if (contentOverride !== undefined) updates.content = contentOverride;
676
+ if (contentOverride !== undefined) {
677
+ updates.content = contentOverride;
678
+ } else if (hasAppendPrepend) {
679
+ // No content_edit and no wikilink-removal pre-materialization —
680
+ // route the append/prepend down to the SQL-atomic path.
681
+ if (item.append !== undefined) updates.append = item.append;
682
+ if (item.prepend !== undefined) updates.prepend = item.prepend;
683
+ }
473
684
  if (item.path !== undefined) updates.path = item.path;
474
685
  if (item.metadata !== undefined) {
475
686
  // Merge metadata (don't replace wholesale)
@@ -519,8 +730,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
519
730
  // Re-read for final state
520
731
  updated.push(noteOps.getNote(db, note.id) ?? result);
521
732
  }
733
+ if (batched) db.exec("COMMIT");
734
+ } catch (e) {
735
+ if (batched) db.exec("ROLLBACK");
736
+ throw e;
737
+ }
522
738
 
523
- return batch ? updated : updated[0];
739
+ const final = updated.map((n) => attachValidationStatus(store, db, n));
740
+ return batch ? final : final[0];
524
741
  },
525
742
  },
526
743
 
@@ -549,39 +766,50 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
549
766
  // =====================================================================
550
767
  {
551
768
  name: "list-tags",
552
- description: `List tags with usage counts. Pass \`tag\` to get a single tag's details including its schema (description + fields). Pass \`include_schema: true\` to include schemas for all tags.`,
769
+ description: `List tags with usage counts. Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps). Pass \`include_schema: true\` to include the full record for every tag.`,
553
770
  inputSchema: {
554
771
  type: "object",
555
772
  properties: {
556
773
  tag: { type: "string", description: "Get details for a single tag" },
557
- include_schema: { type: "boolean", description: "Include schema (description + fields) for each tag (default: false)" },
774
+ include_schema: { type: "boolean", description: "Include full tag record (description, fields, relationships, parent_names, timestamps) for each tag (default: false)" },
558
775
  },
559
776
  },
560
777
  execute: (params) => {
561
778
  const singleTag = params.tag as string | undefined;
562
779
 
563
780
  if (singleTag) {
564
- // Single tag detail
565
781
  const allTags = noteOps.listTags(db);
566
782
  const found = allTags.find((t) => t.name === singleTag);
567
- const schema = tagSchemaOps.getTagSchema(db, singleTag);
783
+ const record = tagSchemaOps.getTagRecord(db, singleTag);
568
784
  return {
569
785
  name: singleTag,
570
786
  count: found?.count ?? 0,
571
- description: schema?.description ?? null,
572
- fields: schema?.fields ?? null,
787
+ description: record?.description ?? null,
788
+ fields: record?.fields ?? null,
789
+ relationships: record?.relationships ?? null,
790
+ parent_names: record?.parent_names ?? null,
791
+ created_at: record?.created_at ?? null,
792
+ updated_at: record?.updated_at ?? null,
573
793
  };
574
794
  }
575
795
 
576
- // All tags
577
796
  const tags = noteOps.listTags(db);
578
797
  if (params.include_schema) {
579
- const schemas = tagSchemaOps.getTagSchemaMap(db);
580
- return tags.map((t) => ({
581
- ...t,
582
- description: schemas[t.name]?.description ?? null,
583
- fields: schemas[t.name]?.fields ?? null,
584
- }));
798
+ const records = new Map(
799
+ tagSchemaOps.listTagRecords(db).map((r) => [r.tag, r] as const),
800
+ );
801
+ return tags.map((t) => {
802
+ const r = records.get(t.name);
803
+ return {
804
+ ...t,
805
+ description: r?.description ?? null,
806
+ fields: r?.fields ?? null,
807
+ relationships: r?.relationships ?? null,
808
+ parent_names: r?.parent_names ?? null,
809
+ created_at: r?.created_at ?? null,
810
+ updated_at: r?.updated_at ?? null,
811
+ };
812
+ });
585
813
  }
586
814
  return tags;
587
815
  },
@@ -592,7 +820,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
592
820
  // =====================================================================
593
821
  {
594
822
  name: "update-tag",
595
- description: "Create or update a tag's description and schema fields. If the tag doesn't exist, it's created. Fields are merged new keys are added, existing keys are replaced.",
823
+ 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.",
596
824
  inputSchema: {
597
825
  type: "object",
598
826
  properties: {
@@ -612,12 +840,32 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
612
840
  required: ["type"],
613
841
  },
614
842
  },
843
+ relationships: {
844
+ type: "object",
845
+ 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" } }',
846
+ additionalProperties: {
847
+ type: "object",
848
+ properties: {
849
+ target_tag: { type: "string", description: "Tag the relationship points at" },
850
+ cardinality: { type: "string", enum: ["one", "optional", "many", "many-required"], description: "How many targets this relationship may have" },
851
+ description: { type: "string", description: "Why this relationship exists; surfaced to AI clients" },
852
+ },
853
+ required: ["target_tag", "cardinality"],
854
+ },
855
+ },
856
+ parent_names: {
857
+ type: "array",
858
+ items: { type: "string" },
859
+ description: "Tag names this tag is a child of, for the query-time hierarchy. Replaces any prior parent list. Pass [] (empty array) or null to clear. E.g., parent_names: [\"manual\", \"note\"] makes this tag a descendant of both.",
860
+ },
615
861
  },
616
862
  required: ["tag"],
617
863
  },
618
- execute: (params) => {
864
+ execute: async (params) => {
619
865
  const tag = params.tag as string;
620
- const existing = tagSchemaOps.getTagSchema(db, tag);
866
+ const existing = tagSchemaOps.getTagRecord(db, tag);
867
+
868
+ // ---- fields: shallow-merge into existing (preserves prior keys).
621
869
  const incomingFields = (params.fields as Record<string, TagFieldSchema> | undefined) ?? {};
622
870
  const mergedFields: Record<string, TagFieldSchema> = {
623
871
  ...(existing?.fields ?? {}),
@@ -626,7 +874,6 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
626
874
 
627
875
  // Validate cross-tag consistency on fields being (re)declared in this
628
876
  // call. `type` and `indexed` are global — all declarers must agree.
629
- // `description` and `enum` are per-tag, so we don't compare them.
630
877
  const otherSchemas = tagSchemaOps
631
878
  .listTagSchemas(db)
632
879
  .filter((s) => s.tag !== tag);
@@ -657,15 +904,47 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
657
904
  }
658
905
  }
659
906
 
660
- // Persist the schema first, then reconcile indexing lifecycle. An
661
- // error here would leave the on-disk schema untouched, matching
662
- // prior behavior.
663
- const result = tagSchemaOps.upsertTagSchema(db, tag, {
664
- description: (params.description as string | undefined) ?? existing?.description,
665
- fields: Object.keys(mergedFields).length > 0 ? mergedFields : undefined,
907
+ // ---- relationships: replace wholesale when provided. Validate
908
+ // shape + cardinality vocabulary before persisting so a malformed
909
+ // payload can't leave the row in an inconsistent state.
910
+ let relationshipsPatch: Record<string, tagSchemaOps.TagRelationship> | null | undefined;
911
+ if (params.relationships === null) {
912
+ relationshipsPatch = null;
913
+ } else if (params.relationships !== undefined) {
914
+ relationshipsPatch = tagSchemaOps.validateRelationships(params.relationships);
915
+ }
916
+
917
+ // ---- parent_names: replace wholesale when provided. Empty array
918
+ // collapses to null (clear) — a tag with `parent_names = []` and
919
+ // a tag with `parent_names = null` are indistinguishable at the
920
+ // hierarchy layer.
921
+ let parentNamesPatch: string[] | null | undefined;
922
+ if (params.parent_names === null) {
923
+ parentNamesPatch = null;
924
+ } else if (params.parent_names !== undefined) {
925
+ if (!Array.isArray(params.parent_names)) {
926
+ throw new Error("parent_names must be an array of tag names");
927
+ }
928
+ const cleaned = (params.parent_names as unknown[])
929
+ .filter((p): p is string => typeof p === "string" && p.length > 0);
930
+ parentNamesPatch = cleaned.length > 0 ? cleaned : null;
931
+ }
932
+
933
+ // ---- Persist via the store wrapper so the hierarchy cache is
934
+ // invalidated when parent_names is touched.
935
+ const fieldsPatch = Object.keys(mergedFields).length > 0
936
+ ? mergedFields
937
+ : (params.fields !== undefined ? null : undefined);
938
+ const descriptionPatch =
939
+ params.description === undefined ? undefined : (params.description as string);
940
+ const result = await store.upsertTagRecord(tag, {
941
+ ...(descriptionPatch !== undefined ? { description: descriptionPatch } : {}),
942
+ ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
943
+ ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
944
+ ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
666
945
  });
667
946
 
668
- // Diff indexed state for this tag: what it indexed before vs. now.
947
+ // ---- Reconcile indexed-field lifecycle for this tag.
669
948
  const priorIndexed = new Set(
670
949
  Object.entries(existing?.fields ?? {})
671
950
  .filter(([, v]) => v.indexed === true)
@@ -706,9 +985,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
706
985
  },
707
986
  execute: async (params) => {
708
987
  const tag = params.tag as string;
709
- // Release any indexed fields this tag declared before the schema
710
- // row disappears. releaseField drops the generated column + index
711
- // when the declarer set empties.
988
+ // Release any indexed fields this tag declared before the row
989
+ // drops. releaseField drops the generated column + index when the
990
+ // declarer set empties.
712
991
  const schema = tagSchemaOps.getTagSchema(db, tag);
713
992
  if (schema?.fields) {
714
993
  for (const [fieldName, spec] of Object.entries(schema.fields)) {
@@ -717,12 +996,186 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
717
996
  }
718
997
  }
719
998
  }
720
- // Delete schema first (FK cascade would handle it, but be explicit)
721
- tagSchemaOps.deleteTagSchema(db, tag);
999
+ // Drop the row outright description/fields/relationships/parents
1000
+ // travel with it. (No more sidecar table to clear separately.)
722
1001
  return await store.deleteTag(tag);
723
1002
  },
724
1003
  },
725
1004
 
1005
+ // =====================================================================
1006
+ // 7a. list-note-schemas — read note_schemas + their mappings
1007
+ // =====================================================================
1008
+ {
1009
+ name: "list-note-schemas",
1010
+ description: "List note schemas (description, fields, required, timestamps). Pass `name` to get a single schema with its applied mapping rules. Schemas drive the validation_status warnings surfaced on create-note / update-note.",
1011
+ inputSchema: {
1012
+ type: "object",
1013
+ properties: {
1014
+ name: { type: "string", description: "Get a single schema by name (with its mappings)" },
1015
+ include_mappings: { type: "boolean", description: "When listing all schemas, include each schema's mappings (default: false)" },
1016
+ },
1017
+ },
1018
+ execute: async (params) => {
1019
+ const single = params.name as string | undefined;
1020
+ if (single) {
1021
+ const schema = await store.getNoteSchema(single);
1022
+ if (!schema) return null;
1023
+ const mappings = await store.listSchemaMappings({ schema_name: single });
1024
+ return { ...schema, mappings };
1025
+ }
1026
+ const schemas = await store.listNoteSchemas();
1027
+ if (params.include_mappings) {
1028
+ const allMappings = await store.listSchemaMappings();
1029
+ const byName = new Map<string, typeof allMappings>();
1030
+ for (const m of allMappings) {
1031
+ const list = byName.get(m.schema_name) ?? [];
1032
+ list.push(m);
1033
+ byName.set(m.schema_name, list);
1034
+ }
1035
+ return schemas.map((s) => ({ ...s, mappings: byName.get(s.name) ?? [] }));
1036
+ }
1037
+ return schemas;
1038
+ },
1039
+ },
1040
+
1041
+ // =====================================================================
1042
+ // 7b. update-note-schema — partial-upsert a schema definition
1043
+ // =====================================================================
1044
+ {
1045
+ name: "update-note-schema",
1046
+ description: "Create or update a note schema's definition: description, allowed/expected fields (type + enum + description), and a list of required field names. Auto-creates the schema row if missing. Pass null for description/fields/required to clear that column. Empty `required: []` collapses to null.",
1047
+ inputSchema: {
1048
+ type: "object",
1049
+ properties: {
1050
+ name: { type: "string", description: "Schema name (e.g., 'meeting', 'project')" },
1051
+ description: { type: "string", description: "Human-readable description of what this schema describes" },
1052
+ fields: {
1053
+ type: "object",
1054
+ description: 'Field declarations. E.g., { "title": { "type": "string" }, "status": { "type": "string", "enum": ["active", "done"] } }. Replaces fields wholesale when provided.',
1055
+ additionalProperties: {
1056
+ type: "object",
1057
+ properties: {
1058
+ type: { type: "string", enum: ["string", "number", "boolean", "array", "object"], description: "Expected JS type for this field" },
1059
+ enum: { type: "array", items: { type: "string" }, description: "Allowed values (string fields only)" },
1060
+ description: { type: "string" },
1061
+ },
1062
+ },
1063
+ },
1064
+ required: {
1065
+ type: "array",
1066
+ items: { type: "string" },
1067
+ description: "Field names that must be present on a note matching this schema. Pass [] or null to clear.",
1068
+ },
1069
+ },
1070
+ required: ["name"],
1071
+ },
1072
+ execute: async (params) => {
1073
+ const name = params.name as string;
1074
+ const patch: { description?: string | null; fields?: Record<string, NoteSchemaField> | null; required?: string[] | null } = {};
1075
+ if (params.description === null) patch.description = null;
1076
+ else if (params.description !== undefined) patch.description = params.description as string;
1077
+ if (params.fields === null) patch.fields = null;
1078
+ else if (params.fields !== undefined) patch.fields = params.fields as Record<string, NoteSchemaField>;
1079
+ if (params.required === null) patch.required = null;
1080
+ else if (params.required !== undefined) {
1081
+ if (!Array.isArray(params.required)) {
1082
+ throw new Error("required must be an array of field names");
1083
+ }
1084
+ patch.required = (params.required as unknown[]).filter((x): x is string => typeof x === "string");
1085
+ }
1086
+ return await store.upsertNoteSchema(name, patch);
1087
+ },
1088
+ },
1089
+
1090
+ // =====================================================================
1091
+ // 7c. delete-note-schema — drop schema + cascade its mappings
1092
+ // =====================================================================
1093
+ {
1094
+ name: "delete-note-schema",
1095
+ description: "Delete a note schema. Cascades: any schema_mappings pointing at it are removed via FK ON DELETE CASCADE. Notes themselves are untouched.",
1096
+ inputSchema: {
1097
+ type: "object",
1098
+ properties: {
1099
+ name: { type: "string", description: "Schema name to delete" },
1100
+ },
1101
+ required: ["name"],
1102
+ },
1103
+ execute: async (params) => {
1104
+ const name = params.name as string;
1105
+ const deleted = await store.deleteNoteSchema(name);
1106
+ return { deleted, name };
1107
+ },
1108
+ },
1109
+
1110
+ // =====================================================================
1111
+ // 7d. list-schema-mappings — read mapping rules
1112
+ // =====================================================================
1113
+ {
1114
+ name: "list-schema-mappings",
1115
+ description: "List schema mapping rules (path_prefix or tag → schema_name). Optionally filter by `schema_name` or `match_kind`. Mappings decide which schemas apply to a note at validation time.",
1116
+ inputSchema: {
1117
+ type: "object",
1118
+ properties: {
1119
+ schema_name: { type: "string", description: "Restrict to mappings for this schema" },
1120
+ match_kind: { type: "string", enum: [...MAPPING_KINDS], description: "Restrict to one match kind" },
1121
+ },
1122
+ },
1123
+ execute: async (params) => {
1124
+ const opts: { schema_name?: string; match_kind?: SchemaMappingKind } = {};
1125
+ if (typeof params.schema_name === "string") opts.schema_name = params.schema_name;
1126
+ if (typeof params.match_kind === "string") opts.match_kind = params.match_kind as SchemaMappingKind;
1127
+ return await store.listSchemaMappings(opts);
1128
+ },
1129
+ },
1130
+
1131
+ // =====================================================================
1132
+ // 7e. set-schema-mapping — add a mapping rule
1133
+ // =====================================================================
1134
+ {
1135
+ name: "set-schema-mapping",
1136
+ description: "Bind a schema to a path-prefix or tag. Idempotent — re-setting the same triple is a no-op. The schema must already exist (FK enforced). E.g., {schema_name: 'meeting', match_kind: 'path_prefix', match_value: 'Meetings/'} or {schema_name: 'project', match_kind: 'tag', match_value: 'project'}.",
1137
+ inputSchema: {
1138
+ type: "object",
1139
+ properties: {
1140
+ schema_name: { type: "string", description: "Schema name to bind" },
1141
+ match_kind: { type: "string", enum: [...MAPPING_KINDS], description: "Match by path prefix or by tag" },
1142
+ match_value: { type: "string", description: "The path prefix or tag value to match" },
1143
+ },
1144
+ required: ["schema_name", "match_kind", "match_value"],
1145
+ },
1146
+ execute: async (params) => {
1147
+ const schema_name = params.schema_name as string;
1148
+ const match_kind = params.match_kind as SchemaMappingKind;
1149
+ const match_value = params.match_value as string;
1150
+ await store.setSchemaMapping(schema_name, match_kind, match_value);
1151
+ return { ok: true, schema_name, match_kind, match_value };
1152
+ },
1153
+ },
1154
+
1155
+ // =====================================================================
1156
+ // 7f. delete-schema-mapping — remove a mapping rule
1157
+ // =====================================================================
1158
+ {
1159
+ name: "delete-schema-mapping",
1160
+ description: "Remove a single schema mapping rule. The schema definition is untouched.",
1161
+ inputSchema: {
1162
+ type: "object",
1163
+ properties: {
1164
+ schema_name: { type: "string", description: "Schema name" },
1165
+ match_kind: { type: "string", enum: [...MAPPING_KINDS], description: "Match kind" },
1166
+ match_value: { type: "string", description: "The path prefix or tag value to remove" },
1167
+ },
1168
+ required: ["schema_name", "match_kind", "match_value"],
1169
+ },
1170
+ execute: async (params) => {
1171
+ const schema_name = params.schema_name as string;
1172
+ const match_kind = params.match_kind as SchemaMappingKind;
1173
+ const match_value = params.match_value as string;
1174
+ const deleted = await store.deleteSchemaMapping(schema_name, match_kind, match_value);
1175
+ return { deleted, schema_name, match_kind, match_value };
1176
+ },
1177
+ },
1178
+
726
1179
  // =====================================================================
727
1180
  // 8. find-path — BFS between two notes
728
1181
  // =====================================================================
@@ -748,7 +1201,237 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
748
1201
  },
749
1202
 
750
1203
  // =====================================================================
751
- // 9. vault-infoget/update vault description + stats
1204
+ // 9. synthesize-notesgather a coherent neighborhood for a topic
1205
+ // =====================================================================
1206
+ {
1207
+ name: "synthesize-notes",
1208
+ description: `Gather the notes that, taken together, tell the story of a topic — for the agent to read and synthesize.
1209
+
1210
+ This is the graph-aware sibling of \`query-notes\`. Where \`query-notes\` returns flat matches, \`synthesize-notes\` pulls a *neighborhood*: anchor + linked notes + search hits + tag distribution + an oldest-first timeline, so the agent can write a coherent narrative without needing 4 separate calls.
1211
+
1212
+ **Inputs.** Pass at least one of \`anchor\` (note ID/path to seed graph traversal) or \`query\` (FTS search string). Optionally narrow with \`scope.tags\` / \`scope.path\` (path prefix). \`depth\` (1–3, default 2) caps anchor traversal hops. \`limit\` (default 25, max 50) caps the returned note count.
1213
+
1214
+ **What you get back.**
1215
+ - \`notes\`: ranked candidates with \`sources\` (which seed brought them in: \`anchor\` / \`neighbor\` / \`search\`), \`distance\` (hops from anchor), and a short \`snippet\`. Pass \`include_content: true\` to inline the full note body.
1216
+ - \`connections\`: direct links between notes in the result set — the edge structure of the neighborhood.
1217
+ - \`tags\`: tag distribution across the result set (count desc) — quickly shows the conceptual axes.
1218
+ - \`timeline\`: the same notes sorted oldest → newest by \`created_at\` — surfaces evolution of the topic.
1219
+ - \`truncated\`: true when more candidates were available than \`limit\` allowed.
1220
+
1221
+ **Synthesis is the caller's job.** The vault returns *what to read*; the agent writes the narrative. No LLM call is made server-side.`,
1222
+ inputSchema: {
1223
+ type: "object",
1224
+ properties: {
1225
+ anchor: { type: "string", description: "Note ID or path to seed graph traversal. Optional if `query` is set." },
1226
+ query: { type: "string", description: "Full-text search query. Optional if `anchor` is set." },
1227
+ scope: {
1228
+ type: "object",
1229
+ properties: {
1230
+ tags: { type: "array", items: { type: "string" }, description: "Restrict to notes carrying any of these tags." },
1231
+ path: { type: "string", description: "Restrict to notes whose path starts with this prefix (case-insensitive)." },
1232
+ },
1233
+ description: "Optional filters applied after seeding.",
1234
+ },
1235
+ depth: { type: "number", description: "Max graph hops from anchor (1–3, default 2). Ignored when no anchor is set." },
1236
+ limit: { type: "number", description: "Max notes returned (default 25, hard cap 50)." },
1237
+ include_content: { type: "boolean", description: "Inline full note content (default false — only a short snippet is included)." },
1238
+ },
1239
+ },
1240
+ execute: async (params) => {
1241
+ const anchorParam = typeof params.anchor === "string" && params.anchor.trim() ? params.anchor.trim() : null;
1242
+ const queryParam = typeof params.query === "string" && params.query.trim() ? params.query.trim() : null;
1243
+ if (!anchorParam && !queryParam) {
1244
+ return { error: "synthesize-notes requires at least one of `anchor` or `query`." };
1245
+ }
1246
+
1247
+ const depth = Math.max(1, Math.min((params.depth as number | undefined) ?? 2, 3));
1248
+ const limit = Math.max(1, Math.min((params.limit as number | undefined) ?? 25, 50));
1249
+ const includeContent = params.include_content === true;
1250
+ const scope = (params.scope as { tags?: string[]; path?: string } | undefined) ?? {};
1251
+ const scopeTags = Array.isArray(scope.tags) && scope.tags.length > 0 ? scope.tags : null;
1252
+ const scopePathPrefix = typeof scope.path === "string" && scope.path.trim() ? scope.path.trim().toLowerCase() : null;
1253
+
1254
+ // Pre-resolve the anchor so a bad ID/path errors out cheaply.
1255
+ let anchorNote: Note | null = null;
1256
+ if (anchorParam) {
1257
+ anchorNote = resolveNote(db, anchorParam);
1258
+ if (!anchorNote) {
1259
+ return { error: "Anchor note not found", anchor: anchorParam };
1260
+ }
1261
+ }
1262
+
1263
+ // ----- Candidate seeding -----
1264
+ // Each candidate tracks every signal that surfaced it (so the agent
1265
+ // can see whether a note came from the search hit, the graph, or
1266
+ // both) plus enough provenance to score it.
1267
+ type Candidate = {
1268
+ sources: Set<"anchor" | "neighbor" | "search">;
1269
+ distance: number | null; // hops from anchor; null if not on the graph
1270
+ ftsRank: number | null; // 0 = best FTS hit; null if not a search hit
1271
+ };
1272
+ const candidates = new Map<string, Candidate>();
1273
+
1274
+ const upsert = (id: string, patch: Partial<Candidate> & { source: "anchor" | "neighbor" | "search" }): void => {
1275
+ const existing = candidates.get(id);
1276
+ if (!existing) {
1277
+ candidates.set(id, {
1278
+ sources: new Set([patch.source]),
1279
+ distance: patch.distance ?? null,
1280
+ ftsRank: patch.ftsRank ?? null,
1281
+ });
1282
+ return;
1283
+ }
1284
+ existing.sources.add(patch.source);
1285
+ if (patch.distance !== undefined && patch.distance !== null) {
1286
+ existing.distance = existing.distance === null ? patch.distance : Math.min(existing.distance, patch.distance);
1287
+ }
1288
+ if (patch.ftsRank !== undefined && patch.ftsRank !== null) {
1289
+ existing.ftsRank = existing.ftsRank === null ? patch.ftsRank : Math.min(existing.ftsRank, patch.ftsRank);
1290
+ }
1291
+ };
1292
+
1293
+ if (anchorNote) {
1294
+ upsert(anchorNote.id, { source: "anchor", distance: 0 });
1295
+ const traversed = linkOps.traverseLinks(db, anchorNote.id, { max_depth: depth });
1296
+ for (const t of traversed) upsert(t.noteId, { source: "neighbor", distance: t.depth });
1297
+ }
1298
+
1299
+ if (queryParam) {
1300
+ // Cap the FTS pull at 2× limit so the post-scope filter still leaves
1301
+ // enough headroom to fill the result set with real hits.
1302
+ // Direct noteOps.searchNotes (no tag-hierarchy expansion) is intentional
1303
+ // here — synthesize-notes uses the FTS result only as a candidate seed,
1304
+ // and scope filtering happens post-hydration. Don't route through the
1305
+ // store.searchNotes wrapper for this specific tool.
1306
+ const searchHits = noteOps.searchNotes(db, queryParam, { limit: Math.min(limit * 2, 100) });
1307
+ searchHits.forEach((n, idx) => upsert(n.id, { source: "search", ftsRank: idx }));
1308
+ }
1309
+
1310
+ // ----- Hydrate + scope filter -----
1311
+ const ids = [...candidates.keys()];
1312
+ const noteMap = new Map<string, Note>();
1313
+ for (const id of ids) {
1314
+ const n = noteOps.getNote(db, id);
1315
+ if (n) noteMap.set(id, n);
1316
+ }
1317
+
1318
+ const passesScope = (note: Note): boolean => {
1319
+ if (scopeTags) {
1320
+ const tags = note.tags ?? [];
1321
+ if (!scopeTags.some((t) => tags.includes(t))) return false;
1322
+ }
1323
+ if (scopePathPrefix) {
1324
+ const p = (note.path ?? "").toLowerCase();
1325
+ if (!p.startsWith(scopePathPrefix)) return false;
1326
+ }
1327
+ return true;
1328
+ };
1329
+
1330
+ const inScope: { id: string; note: Note; cand: Candidate }[] = [];
1331
+ for (const [id, cand] of candidates) {
1332
+ const note = noteMap.get(id);
1333
+ if (!note) continue;
1334
+ if (!passesScope(note)) continue;
1335
+ inScope.push({ id, note, cand });
1336
+ }
1337
+
1338
+ // ----- Score + rank -----
1339
+ // Heuristic: anchor wins outright (5), search hits decay with FTS rank
1340
+ // toward 0 (max ≈ 3), graph proximity contributes 0–3 (1 hop = 2,
1341
+ // 2 hops = 1). Multi-source notes naturally rise — both axes add up.
1342
+ const scoreOf = (c: Candidate): number => {
1343
+ let s = 0;
1344
+ if (c.sources.has("anchor")) s += 5;
1345
+ if (c.sources.has("search") && c.ftsRank !== null) {
1346
+ const decay = Math.max(0, 1 - c.ftsRank / 50);
1347
+ s += 3 * decay;
1348
+ }
1349
+ if (c.sources.has("neighbor") && c.distance !== null) {
1350
+ s += Math.max(0, 3 - c.distance);
1351
+ }
1352
+ return s;
1353
+ };
1354
+
1355
+ inScope.sort((a, b) => {
1356
+ const sa = scoreOf(a.cand);
1357
+ const sb = scoreOf(b.cand);
1358
+ if (sb !== sa) return sb - sa;
1359
+ // Tie-break on recency so the agent surfaces the freshest take.
1360
+ return (b.note.updatedAt ?? b.note.createdAt).localeCompare(a.note.updatedAt ?? a.note.createdAt);
1361
+ });
1362
+
1363
+ const truncated = inScope.length > limit;
1364
+ const top = inScope.slice(0, limit);
1365
+
1366
+ // ----- Snippet (cheap: first ~200 chars of content, single-line) -----
1367
+ const snippetOf = (content: string): string => {
1368
+ const flat = content.replace(/\s+/g, " ").trim();
1369
+ return flat.length > 200 ? `${flat.slice(0, 197)}...` : flat;
1370
+ };
1371
+
1372
+ const notesOut = top.map(({ id, note, cand }) => {
1373
+ const out: Record<string, unknown> = {
1374
+ id,
1375
+ path: note.path ?? null,
1376
+ tags: note.tags ?? [],
1377
+ created_at: note.createdAt,
1378
+ updated_at: note.updatedAt ?? null,
1379
+ sources: [...cand.sources],
1380
+ score: Number(scoreOf(cand).toFixed(3)),
1381
+ };
1382
+ if (cand.distance !== null) out.distance = cand.distance;
1383
+ if (cand.ftsRank !== null) out.fts_rank = cand.ftsRank;
1384
+ if (includeContent) {
1385
+ out.content = note.content;
1386
+ } else {
1387
+ out.snippet = snippetOf(note.content);
1388
+ }
1389
+ return out;
1390
+ });
1391
+
1392
+ // ----- Connections (direct links among returned notes only) -----
1393
+ const idSet = new Set(top.map((t) => t.id));
1394
+ const connections: { source: string; target: string; relationship: string }[] = [];
1395
+ if (idSet.size > 1) {
1396
+ const placeholders = [...idSet].map(() => "?").join(",");
1397
+ const rows = db.prepare(
1398
+ `SELECT source_id, target_id, relationship FROM links
1399
+ WHERE source_id IN (${placeholders}) AND target_id IN (${placeholders})`,
1400
+ ).all(...idSet, ...idSet) as { source_id: string; target_id: string; relationship: string }[];
1401
+ for (const r of rows) {
1402
+ connections.push({ source: r.source_id, target: r.target_id, relationship: r.relationship });
1403
+ }
1404
+ }
1405
+
1406
+ // ----- Tag distribution + timeline -----
1407
+ const tagCounts = new Map<string, number>();
1408
+ for (const { note } of top) {
1409
+ for (const t of note.tags ?? []) tagCounts.set(t, (tagCounts.get(t) ?? 0) + 1);
1410
+ }
1411
+ const tags = [...tagCounts.entries()]
1412
+ .map(([name, count]) => ({ name, count }))
1413
+ .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
1414
+
1415
+ const timeline = [...top]
1416
+ .sort((a, b) => a.note.createdAt.localeCompare(b.note.createdAt))
1417
+ .map(({ id, note }) => ({ id, created_at: note.createdAt }));
1418
+
1419
+ return {
1420
+ topic: {
1421
+ ...(anchorNote ? { anchor: { id: anchorNote.id, path: anchorNote.path ?? null } } : {}),
1422
+ ...(queryParam ? { query: queryParam } : {}),
1423
+ },
1424
+ notes: notesOut,
1425
+ connections,
1426
+ tags,
1427
+ timeline,
1428
+ truncated,
1429
+ };
1430
+ },
1431
+ },
1432
+
1433
+ // =====================================================================
1434
+ // 10. vault-info — get/update vault description + stats
752
1435
  // =====================================================================
753
1436
  {
754
1437
  name: "vault-info",
@@ -818,19 +1501,50 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
818
1501
  }
819
1502
  }
820
1503
 
1504
+ // ---------------------------------------------------------------------------
1505
+ // `_schemas/*` validation — surface validation_status on create/update
1506
+ // ---------------------------------------------------------------------------
1507
+
1508
+ /**
1509
+ * Attach a `validation_status` field to the response when one or more
1510
+ * `_schemas/*` config notes match this note's path or tags. Validation is
1511
+ * advisory only — writes are never blocked. The agent receives warnings
1512
+ * (missing required, type mismatch, enum mismatch) so it can self-correct
1513
+ * on the next turn.
1514
+ *
1515
+ * Returns the note unchanged when no schemas apply, so callers without
1516
+ * `_schemas/*` config see no behavior change.
1517
+ */
1518
+ function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1519
+ // Short-circuit cheaply: when no `_schemas/*` notes are configured, the
1520
+ // resolver returns null without us paying a re-read of the note. The
1521
+ // re-read used to happen up-front and was wasteful on every write in
1522
+ // vaults that don't use schemas at all.
1523
+ const status = store.validateNoteAgainstSchemas({
1524
+ path: note.path,
1525
+ tags: note.tags,
1526
+ metadata: note.metadata as Record<string, unknown> | undefined,
1527
+ });
1528
+ if (!status) return note;
1529
+ return { ...note, validation_status: status } as Note & { validation_status: typeof status };
1530
+ }
1531
+
821
1532
  // ---------------------------------------------------------------------------
822
1533
  // Helpers
823
1534
  // ---------------------------------------------------------------------------
824
1535
 
825
1536
  function normalizeTags(tag: unknown): string[] | undefined {
826
1537
  if (!tag) return undefined;
827
- if (Array.isArray(tag)) return tag;
1538
+ // Defensive copy: callers downstream sometimes mutate the array (sort,
1539
+ // splice, push for hierarchy expansion). Returning a fresh array keeps
1540
+ // the original `params` object untouched.
1541
+ if (Array.isArray(tag)) return [...tag];
828
1542
  return [tag as string];
829
1543
  }
830
1544
 
831
1545
  // Re-exported for backward compat; defined in notes.ts alongside the
832
1546
  // conditional-UPDATE implementation that raises it.
833
- export { ConflictError } from "./notes.js";
1547
+ export { ConflictError, PathConflictError, EmptyNoteError, MAX_BATCH_SIZE } from "./notes.js";
834
1548
 
835
1549
  /**
836
1550
  * Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
@@ -854,3 +1568,23 @@ export class PreconditionRequiredError extends Error {
854
1568
  }
855
1569
  }
856
1570
 
1571
+ /**
1572
+ * Thrown by `create-note` / `update-note` when a batch exceeds
1573
+ * `MAX_BATCH_SIZE` (re-exported from `./notes.js` — single source of truth).
1574
+ * Bounds the blast radius of a runaway client — see #213, where one MCP
1575
+ * burst created 7,453 empty notes in minutes. Surfaces as 413 at the HTTP
1576
+ * layer.
1577
+ */
1578
+ export class BatchTooLargeError extends Error {
1579
+ code = "BATCH_TOO_LARGE" as const;
1580
+ limit: number;
1581
+ got: number;
1582
+
1583
+ constructor(got: number) {
1584
+ super(`batch_too_large: max ${MAX_BATCH_SIZE} notes per call, got ${got}`);
1585
+ this.name = "BatchTooLargeError";
1586
+ this.limit = MAX_BATCH_SIZE;
1587
+ this.got = got;
1588
+ }
1589
+ }
1590
+