@openparachute/vault 0.3.3 → 0.4.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/.parachute/module.json +15 -0
  2. package/README.md +133 -0
  3. package/core/src/core.test.ts +2990 -92
  4. package/core/src/links.ts +1 -1
  5. package/core/src/mcp.ts +413 -68
  6. package/core/src/notes.ts +693 -42
  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 +331 -0
  11. package/core/src/schema.ts +467 -11
  12. package/core/src/store.ts +262 -8
  13. package/core/src/tag-hierarchy.ts +171 -0
  14. package/core/src/tag-schemas.ts +242 -42
  15. package/core/src/types.ts +96 -7
  16. package/core/src/vault-projection.ts +309 -0
  17. package/core/src/wikilinks.ts +3 -3
  18. package/package.json +13 -3
  19. package/src/admin-spa.test.ts +161 -0
  20. package/src/admin-spa.ts +161 -0
  21. package/src/auth-hub-jwt.test.ts +360 -0
  22. package/src/auth-status.ts +84 -0
  23. package/src/auth.test.ts +135 -23
  24. package/src/auth.ts +173 -15
  25. package/src/backup.ts +4 -7
  26. package/src/cli.ts +322 -57
  27. package/src/config.test.ts +44 -0
  28. package/src/config.ts +68 -40
  29. package/src/hub-jwt.test.ts +307 -0
  30. package/src/hub-jwt.ts +88 -0
  31. package/src/init.test.ts +216 -0
  32. package/src/mcp-http.ts +33 -29
  33. package/src/mcp-install.ts +1 -1
  34. package/src/mcp-tools.ts +318 -19
  35. package/src/module-config.ts +1 -1
  36. package/src/oauth.test.ts +345 -0
  37. package/src/oauth.ts +85 -14
  38. package/src/owner-auth.ts +57 -1
  39. package/src/prompt.ts +6 -5
  40. package/src/routes.ts +796 -61
  41. package/src/routing.test.ts +466 -1
  42. package/src/routing.ts +106 -24
  43. package/src/scopes.test.ts +66 -8
  44. package/src/scopes.ts +163 -37
  45. package/src/server.ts +24 -2
  46. package/src/services-manifest.test.ts +20 -0
  47. package/src/services-manifest.ts +9 -2
  48. package/src/stop-signal.test.ts +85 -0
  49. package/src/storage.test.ts +92 -0
  50. package/src/tag-scope.ts +118 -0
  51. package/src/token-store.test.ts +47 -0
  52. package/src/token-store.ts +128 -13
  53. package/src/tokens-routes.test.ts +727 -0
  54. package/src/tokens-routes.ts +392 -0
  55. package/src/transcription-worker.test.ts +5 -0
  56. package/src/triggers.ts +1 -1
  57. package/src/two-factor.ts +2 -2
  58. package/src/vault-create.test.ts +193 -0
  59. package/src/vault-name.test.ts +123 -0
  60. package/src/vault-name.ts +80 -0
  61. package/src/vault.test.ts +1626 -183
  62. package/tsconfig.json +8 -1
  63. package/.claude/settings.local.json +0 -8
  64. package/.dockerignore +0 -8
  65. package/.env.example +0 -9
  66. package/CHANGELOG.md +0 -175
  67. package/CLAUDE.md +0 -125
  68. package/Caddyfile +0 -3
  69. package/Dockerfile +0 -22
  70. package/bun.lock +0 -219
  71. package/bunfig.toml +0 -2
  72. package/deploy/parachute-vault.service +0 -20
  73. package/docker-compose.yml +0 -50
  74. package/docs/HTTP_API.md +0 -434
  75. package/docs/auth-model.md +0 -340
  76. package/fly.toml +0 -24
  77. package/package/package.json +0 -32
  78. package/railway.json +0 -14
  79. package/scripts/migrate-audio-to-opus.test.ts +0 -237
  80. package/scripts/migrate-audio-to-opus.ts +0 -499
package/core/src/mcp.ts CHANGED
@@ -1,7 +1,7 @@
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";
@@ -68,7 +68,9 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
68
68
  // ---------------------------------------------------------------------------
69
69
 
70
70
  /**
71
- * Generate the 9 consolidated MCP tools for a vault.
71
+ * Generate the consolidated MCP tools for a vault. Post-v17 surface (9):
72
+ * query-notes, create-note, update-note, delete-note, list-tags, update-tag,
73
+ * delete-tag, find-path, vault-info.
72
74
  */
73
75
  export function generateMcpTools(store: Store): McpToolDef[] {
74
76
  const db: Database = (store as any).db;
@@ -102,7 +104,31 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
102
104
  description: "Filter by tag(s)",
103
105
  },
104
106
  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" },
107
+ exclude_tags: {
108
+ oneOf: [
109
+ { type: "string" },
110
+ { type: "array", items: { type: "string" } },
111
+ ],
112
+ 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`).",
113
+ },
114
+ // The runtime alias-fallback chain accepts these too. Declared
115
+ // here so schema-introspecting clients (Claude, MCP clients
116
+ // that surface tool schemas) see them as valid inputs rather
117
+ // than thinking the canonical is the only option.
118
+ excludeTags: {
119
+ oneOf: [
120
+ { type: "string" },
121
+ { type: "array", items: { type: "string" } },
122
+ ],
123
+ description: "Alias for `exclude_tags` (camelCase). Same shape and semantics — pick whichever is more natural for your client.",
124
+ },
125
+ exclude_tag: {
126
+ oneOf: [
127
+ { type: "string" },
128
+ { type: "array", items: { type: "string" } },
129
+ ],
130
+ description: "Alias for `exclude_tags` (singular). Same shape and semantics — accepts a single tag or an array.",
131
+ },
106
132
  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
133
  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
134
  path: { type: "string", description: "Exact path match (case-insensitive)" },
@@ -113,8 +139,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
113
139
  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
140
  },
115
141
  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)" },
142
+ date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
143
+ date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
144
+ date_filter: {
145
+ type: "object",
146
+ properties: {
147
+ field: { type: "string", description: "Field to filter on. Defaults to `created_at` (vault ingestion time). `updated_at` is also recognized as a real column — use it for incremental rebuilds (\"what changed since X\"). Any other field must be declared `indexed: true` in a tag schema — same contract as metadata operator queries and `order_by`." },
148
+ from: { type: "string", description: "Inclusive lower bound (ISO date)." },
149
+ to: { type: "string", description: "Exclusive upper bound (ISO date)." },
150
+ },
151
+ 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) rather than the vault ingestion time, or when paging by `updated_at` for incremental rebuilds. Mutually exclusive with the top-level `date_from` / `date_to` shorthand.",
152
+ },
118
153
  near: {
119
154
  type: "object",
120
155
  properties: {
@@ -198,24 +233,49 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
198
233
  if (params.search) {
199
234
  // Normalize tag param
200
235
  const tags = normalizeTags(params.tag);
201
- results = noteOps.searchNotes(db, params.search as string, {
236
+ // Route through `store.searchNotes` (not `noteOps.searchNotes`) so
237
+ // tag-hierarchy expansion fires for MCP callers the same as for
238
+ // HTTP REST callers — `tag: "manual"` matches descendants declared
239
+ // via `_tags/*` config notes. Mirrors the structured-query fix
240
+ // from #214; same class of bypass bug (tracked as #227).
241
+ results = await store.searchNotes(params.search as string, {
202
242
  tags,
203
243
  limit: (params.limit as number) ?? 50,
204
244
  });
205
245
  } else {
206
246
  // --- Structured query ---
207
247
  const tags = normalizeTags(params.tag);
208
- results = noteOps.queryNotes(db, {
248
+ // Accept canonical `exclude_tags` plus camelCase / singular aliases.
249
+ // LLM callers frequently pick the wrong name (training-data drift
250
+ // toward camelCase across MCP tools) and the JSON-RPC layer drops
251
+ // unknown keys silently; aliasing here closes the silent-no-op gap.
252
+ const excludeTagsRaw = params.exclude_tags ?? params.excludeTags ?? params.exclude_tag;
253
+ const excludeTags = normalizeTags(excludeTagsRaw);
254
+ // Route through `store.queryNotes` (not `noteOps.queryNotes`) so
255
+ // tag-hierarchy expansion fires for MCP callers the same as for
256
+ // HTTP REST callers — `tag: "manual"` matches descendants declared
257
+ // via `_tags/*` config notes. The previous direct-noteOps call
258
+ // bypassed the wrapper and silently dropped hierarchy expansion.
259
+ results = await store.queryNotes({
209
260
  tags,
210
261
  tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
211
- excludeTags: params.exclude_tags as string[] | undefined,
262
+ excludeTags,
212
263
  hasTags: params.has_tags as boolean | undefined,
213
264
  hasLinks: params.has_links as boolean | undefined,
214
265
  path: params.path as string | undefined,
215
266
  pathPrefix: params.path_prefix as string | undefined,
267
+ // Push the near-scope into the SQL WHERE so that LIMIT and ORDER
268
+ // BY apply to the neighborhood. Without this, queryNotes would
269
+ // fetch the first `limit` notes by created_at and then post-
270
+ // filter to the few in-scope ones — which silently empties the
271
+ // result whenever the neighborhood lies outside that prefix.
272
+ ids: nearScope ? [...nearScope] : undefined,
216
273
  metadata: params.metadata as Record<string, unknown> | undefined,
217
274
  dateFrom: params.date_from as string | undefined,
218
275
  dateTo: params.date_to as string | undefined,
276
+ dateFilter: params.date_filter as
277
+ | { field?: string; from?: string; to?: string }
278
+ | undefined,
219
279
  sort: params.sort as "asc" | "desc" | undefined,
220
280
  orderBy: params.order_by as string | undefined,
221
281
  limit: (params.limit as number) ?? 50,
@@ -223,8 +283,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
223
283
  });
224
284
  }
225
285
 
226
- // --- Apply near-scope filter ---
227
- if (nearScope) {
286
+ // For full-text search the post-filter is still the right shape — FTS
287
+ // owns its own ranked LIMIT and we just narrow to the neighborhood
288
+ // afterwards. Structured queries already pushed `ids` into SQL above.
289
+ if (nearScope && params.search) {
228
290
  results = results.filter((n) => nearScope!.has(n.id));
229
291
  }
230
292
 
@@ -315,26 +377,65 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
315
377
  const batch = params.notes as any[] | undefined;
316
378
  const items = batch ?? [params];
317
379
 
380
+ if (items.length > MAX_BATCH_SIZE) {
381
+ throw new BatchTooLargeError(items.length);
382
+ }
383
+
384
+ // Empty-note pre-validation (#213): make mixed batches atomic for
385
+ // the empty-note case. The Store will throw EmptyNoteError on the
386
+ // empty entry, but in a sequential batch loop the prefix would have
387
+ // already committed before we hit it. Pre-walk so the whole call
388
+ // either creates everything or nothing. The error carries
389
+ // `item_index` so MCP callers with multi-item batches can pinpoint
390
+ // the bad entry — parity with the HTTP route's response shape.
391
+ // TODO: tighten batch input type — `items[i] as any` mirrors the
392
+ // top-of-call cast at `params.notes as any[]`. A typed McpCreateNoteInput
393
+ // would let us drop both casts.
394
+ for (let i = 0; i < items.length; i++) {
395
+ const item = items[i] as any;
396
+ const content = ((item?.content as string | undefined) ?? "").toString();
397
+ const rawPath = item?.path;
398
+ const pathEmpty = rawPath === undefined || rawPath === null
399
+ || (typeof rawPath === "string" && rawPath.trim() === "");
400
+ if (!content.trim() && pathEmpty) {
401
+ throw new noteOps.EmptyNoteError(null, batch ? i : null);
402
+ }
403
+ }
404
+
318
405
  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
- });
406
+ // Wrap multi-item batches in a SQLite transaction so a mid-batch
407
+ // failure rolls back every prior insert see #236. The pre-walk
408
+ // above catches empty-note cases; this guards anything thrown from
409
+ // store.createNote / createLink (path conflict, etc.). Single-item
410
+ // calls skip the wrap to avoid colliding with concurrent callers
411
+ // on the shared bun:sqlite connection.
412
+ const batched = items.length > 1;
413
+ if (batched) db.exec("BEGIN");
414
+ try {
415
+ for (const item of items) {
416
+ const note = await store.createNote(item.content as string ?? "", {
417
+ path: item.path as string | undefined,
418
+ tags: item.tags as string[] | undefined,
419
+ metadata: item.metadata as Record<string, unknown> | undefined,
420
+ created_at: item.created_at as string | undefined,
421
+ });
326
422
 
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);
423
+ // Create explicit links (not wikilinks — those are automatic)
424
+ if (item.links) {
425
+ for (const link of item.links as { target: string; relationship: string }[]) {
426
+ const target = resolveNote(db, link.target);
427
+ if (target) {
428
+ await store.createLink(note.id, target.id, link.relationship);
429
+ }
333
430
  }
334
431
  }
335
- }
336
432
 
337
- created.push(noteOps.getNote(db, note.id) ?? note);
433
+ created.push(noteOps.getNote(db, note.id) ?? note);
434
+ }
435
+ if (batched) db.exec("COMMIT");
436
+ } catch (e) {
437
+ if (batched) db.exec("ROLLBACK");
438
+ throw e;
338
439
  }
339
440
 
340
441
  // Apply tag schema effects
@@ -344,7 +445,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
344
445
  }
345
446
  }
346
447
 
347
- return batch ? created : created[0];
448
+ // Re-read after schema-default population so the response reflects the
449
+ // final on-disk state, then attach `validation_status` from any
450
+ // tag's `fields` declaration that applies to this note.
451
+ const final = created.map((n) => attachValidationStatus(store, db, n));
452
+ return batch ? final : final[0];
348
453
  },
349
454
  },
350
455
 
@@ -355,20 +460,36 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
355
460
  name: "update-note",
356
461
  description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
357
462
 
463
+ - Three content-modification modes (mutually exclusive):
464
+ - \`content\` — full replace.
465
+ - \`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.
466
+ - \`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
467
  - \`tags: { add: ["x"], remove: ["y"] }\` — add/remove tags
359
468
  - \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
360
469
  - When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
361
470
  - 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.`,
471
+ - **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).
472
+ - \`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.`,
363
473
  inputSchema: {
364
474
  type: "object",
365
475
  properties: {
366
476
  id: { type: "string", description: "Note ID or path" },
367
- content: { type: "string", description: "New content" },
477
+ content: { type: "string", description: "New content (full replace). Mutually exclusive with `append`/`prepend` and `content_edit`." },
478
+ 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." },
479
+ 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." },
480
+ content_edit: {
481
+ type: "object",
482
+ properties: {
483
+ old_text: { type: "string", description: "Exact text to find. Must match exactly once in the note's current content." },
484
+ new_text: { type: "string", description: "Replacement text." },
485
+ },
486
+ required: ["old_text", "new_text"],
487
+ description: "Find-and-replace one occurrence. Errors if `old_text` is not found or matches multiple locations. Mutually exclusive with `content` and `append`/`prepend`.",
488
+ },
368
489
  path: { type: "string", description: "New path" },
369
490
  metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
370
491
  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." },
492
+ 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
493
  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
494
  tags: {
374
495
  type: "object",
@@ -406,6 +527,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
406
527
  },
407
528
  description: "Links to add/remove",
408
529
  },
530
+ include_content: {
531
+ type: "boolean",
532
+ 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.",
533
+ },
409
534
  // Batch
410
535
  notes: {
411
536
  type: "array",
@@ -414,10 +539,20 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
414
539
  properties: {
415
540
  id: { type: "string" },
416
541
  content: { type: "string" },
542
+ append: { type: "string" },
543
+ prepend: { type: "string" },
544
+ content_edit: {
545
+ type: "object",
546
+ properties: {
547
+ old_text: { type: "string" },
548
+ new_text: { type: "string" },
549
+ },
550
+ required: ["old_text", "new_text"],
551
+ },
417
552
  path: { type: "string" },
418
553
  metadata: { type: "object" },
419
554
  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." },
555
+ 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
556
  force: { type: "boolean", description: "Override the required `if_updated_at` check for this item." },
422
557
  tags: { type: "object" },
423
558
  links: { type: "object" },
@@ -432,24 +567,91 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
432
567
  const batch = params.notes as any[] | undefined;
433
568
  const items = batch ?? [params];
434
569
 
570
+ if (items.length > MAX_BATCH_SIZE) {
571
+ throw new BatchTooLargeError(items.length);
572
+ }
573
+
435
574
  const updated: Note[] = [];
575
+ // Wrap multi-item batches in a SQLite transaction so any mid-batch
576
+ // failure (precondition error, content_edit miss, ConflictError, …)
577
+ // rolls back every prior mutation in the batch — see #236.
578
+ // Single-item calls skip the wrap so concurrent callers don't
579
+ // collide on the shared bun:sqlite connection.
580
+ const batched = items.length > 1;
581
+ if (batched) db.exec("BEGIN");
582
+ try {
436
583
  for (const item of items) {
437
584
  const note = requireNote(db, item.id as string);
438
585
 
586
+ // --- Validate mutual exclusion of content modes ---
587
+ const hasContent = item.content !== undefined;
588
+ const hasAppendPrepend = item.append !== undefined || item.prepend !== undefined;
589
+ const hasContentEdit = item.content_edit !== undefined;
590
+ const contentModes = (hasContent ? 1 : 0) + (hasAppendPrepend ? 1 : 0) + (hasContentEdit ? 1 : 0);
591
+ if (contentModes > 1) {
592
+ throw new Error(
593
+ `update-note: \`content\`, \`append\`/\`prepend\`, and \`content_edit\` are mutually exclusive — pick one mode of content update for note "${note.id}".`,
594
+ );
595
+ }
596
+
439
597
  // --- Safety-by-default: refuse mutations without a precondition ---
440
598
  // The caller must either echo the note's last-seen `updated_at`
441
599
  // (`if_updated_at`) so the conditional UPDATE can catch lost
442
600
  // writes, or explicitly opt out with `force: true`. This runs
443
601
  // *before* any DB writes so a rejection leaves the note untouched.
444
- if (item.if_updated_at === undefined && item.force !== true) {
602
+ //
603
+ // Append/prepend-only updates are exempt: they're SQL-atomic
604
+ // concatenations that can't lose data on a stale read, so the
605
+ // precondition would be ceremony for no benefit. Tag and link
606
+ // mutations are *not* exempt — they're idempotent set-ops at
607
+ // the SQL layer but still represent a non-content change the
608
+ // caller should have observed before re-asserting (#201).
609
+ const isAppendOnly = hasAppendPrepend
610
+ && !hasContent
611
+ && !hasContentEdit
612
+ && item.path === undefined
613
+ && item.metadata === undefined
614
+ && item.created_at === undefined
615
+ && item.tags === undefined
616
+ && item.links === undefined;
617
+ if (!isAppendOnly && item.if_updated_at === undefined && item.force !== true) {
445
618
  throw new PreconditionRequiredError(note.id, note.path ?? null);
446
619
  }
447
620
 
621
+ // --- Resolve content_edit into a full content string ---
622
+ // We do the find-and-replace at the JS level (read note.content,
623
+ // validate occurrence count, replace). The race window between
624
+ // this read and the UPDATE is closed by `if_updated_at` for
625
+ // strict callers; without it, content_edit is fail-closed —
626
+ // a stale read where someone else removed `old_text` produces
627
+ // a "not found" error instead of silently overwriting.
628
+ let contentOverride = item.content as string | undefined;
629
+ if (hasContentEdit) {
630
+ const ce = item.content_edit as { old_text: string; new_text: string };
631
+ if (typeof ce?.old_text !== "string" || typeof ce?.new_text !== "string") {
632
+ throw new Error(
633
+ "update-note: `content_edit` requires { old_text: string, new_text: string }.",
634
+ );
635
+ }
636
+ const idx = note.content.indexOf(ce.old_text);
637
+ if (idx < 0) {
638
+ throw new Error(
639
+ `update-note content_edit: \`old_text\` not found in note "${note.id}". The note may have been edited — re-read and retry.`,
640
+ );
641
+ }
642
+ const second = note.content.indexOf(ce.old_text, idx + 1);
643
+ if (second >= 0) {
644
+ throw new Error(
645
+ `update-note content_edit: \`old_text\` matches multiple times in note "${note.id}" — must match exactly once. Add surrounding context to disambiguate.`,
646
+ );
647
+ }
648
+ contentOverride = note.content.slice(0, idx) + ce.new_text + note.content.slice(idx + ce.old_text.length);
649
+ }
650
+
448
651
  // --- Plan bracket cleanup for wikilink removals (no DB writes yet) ---
449
652
  // We compute the cleaned content so we can do the core UPDATE first
450
653
  // (with if_updated_at atomically) before any link deletions. If the
451
654
  // UPDATE fails on a conflict, nothing has been mutated.
452
- let contentOverride = item.content as string | undefined;
453
655
  const linksRemove = (item.links as any)?.remove as { target: string; relationship: string }[] | undefined;
454
656
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
455
657
  if (linksRemove) {
@@ -458,7 +660,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
458
660
  if (!target) continue;
459
661
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
460
662
  if (link.relationship === "wikilink" && target.path) {
461
- const currentContent = contentOverride ?? note.content;
663
+ // Wikilink-removal bracket cleanup operates on the prospective
664
+ // *full* content. Coexists with content_edit; would fight
665
+ // append/prepend (which leave existing content untouched at
666
+ // the JS layer), so we pre-materialize the would-be content
667
+ // for those callers and switch to a `content`-style update.
668
+ const currentContent = contentOverride
669
+ ?? (hasAppendPrepend
670
+ ? (item.prepend as string ?? "") + note.content + (item.append as string ?? "")
671
+ : note.content);
462
672
  const cleaned = removeWikilinkBrackets(currentContent, target.path);
463
673
  if (cleaned !== currentContent) {
464
674
  contentOverride = cleaned;
@@ -469,7 +679,14 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
469
679
 
470
680
  // --- Core update (content, path, metadata, created_at + concurrency check) ---
471
681
  const updates: any = {};
472
- if (contentOverride !== undefined) updates.content = contentOverride;
682
+ if (contentOverride !== undefined) {
683
+ updates.content = contentOverride;
684
+ } else if (hasAppendPrepend) {
685
+ // No content_edit and no wikilink-removal pre-materialization —
686
+ // route the append/prepend down to the SQL-atomic path.
687
+ if (item.append !== undefined) updates.append = item.append;
688
+ if (item.prepend !== undefined) updates.prepend = item.prepend;
689
+ }
473
690
  if (item.path !== undefined) updates.path = item.path;
474
691
  if (item.metadata !== undefined) {
475
692
  // Merge metadata (don't replace wholesale)
@@ -519,8 +736,26 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
519
736
  // Re-read for final state
520
737
  updated.push(noteOps.getNote(db, note.id) ?? result);
521
738
  }
739
+ if (batched) db.exec("COMMIT");
740
+ } catch (e) {
741
+ if (batched) db.exec("ROLLBACK");
742
+ throw e;
743
+ }
522
744
 
523
- return batch ? updated : updated[0];
745
+ // Response shape: full Note (back-compat default) or lean NoteIndex
746
+ // (#285 friction point 2.response — opt-out for callers making
747
+ // frequent small edits to large notes). `validation_status` from
748
+ // `tags.fields` is preserved across either shape.
749
+ const includeContent = params.include_content !== false;
750
+ const final = updated.map((n) => {
751
+ const validated = attachValidationStatus(store, db, n);
752
+ if (includeContent) return validated;
753
+ const lean: any = noteOps.toNoteIndex(validated);
754
+ const vs = (validated as any).validation_status;
755
+ if (vs !== undefined) lean.validation_status = vs;
756
+ return lean;
757
+ });
758
+ return batch ? final : final[0];
524
759
  },
525
760
  },
526
761
 
@@ -549,39 +784,50 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
549
784
  // =====================================================================
550
785
  {
551
786
  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.`,
787
+ 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
788
  inputSchema: {
554
789
  type: "object",
555
790
  properties: {
556
791
  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)" },
792
+ include_schema: { type: "boolean", description: "Include full tag record (description, fields, relationships, parent_names, timestamps) for each tag (default: false)" },
558
793
  },
559
794
  },
560
795
  execute: (params) => {
561
796
  const singleTag = params.tag as string | undefined;
562
797
 
563
798
  if (singleTag) {
564
- // Single tag detail
565
799
  const allTags = noteOps.listTags(db);
566
800
  const found = allTags.find((t) => t.name === singleTag);
567
- const schema = tagSchemaOps.getTagSchema(db, singleTag);
801
+ const record = tagSchemaOps.getTagRecord(db, singleTag);
568
802
  return {
569
803
  name: singleTag,
570
804
  count: found?.count ?? 0,
571
- description: schema?.description ?? null,
572
- fields: schema?.fields ?? null,
805
+ description: record?.description ?? null,
806
+ fields: record?.fields ?? null,
807
+ relationships: record?.relationships ?? null,
808
+ parent_names: record?.parent_names ?? null,
809
+ created_at: record?.created_at ?? null,
810
+ updated_at: record?.updated_at ?? null,
573
811
  };
574
812
  }
575
813
 
576
- // All tags
577
814
  const tags = noteOps.listTags(db);
578
815
  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
- }));
816
+ const records = new Map(
817
+ tagSchemaOps.listTagRecords(db).map((r) => [r.tag, r] as const),
818
+ );
819
+ return tags.map((t) => {
820
+ const r = records.get(t.name);
821
+ return {
822
+ ...t,
823
+ description: r?.description ?? null,
824
+ fields: r?.fields ?? null,
825
+ relationships: r?.relationships ?? null,
826
+ parent_names: r?.parent_names ?? null,
827
+ created_at: r?.created_at ?? null,
828
+ updated_at: r?.updated_at ?? null,
829
+ };
830
+ });
585
831
  }
586
832
  return tags;
587
833
  },
@@ -592,7 +838,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
592
838
  // =====================================================================
593
839
  {
594
840
  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.",
841
+ 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
842
  inputSchema: {
597
843
  type: "object",
598
844
  properties: {
@@ -612,12 +858,32 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
612
858
  required: ["type"],
613
859
  },
614
860
  },
861
+ relationships: {
862
+ type: "object",
863
+ 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" } }',
864
+ additionalProperties: {
865
+ type: "object",
866
+ properties: {
867
+ target_tag: { type: "string", description: "Tag the relationship points at" },
868
+ cardinality: { type: "string", enum: ["one", "optional", "many", "many-required"], description: "How many targets this relationship may have" },
869
+ description: { type: "string", description: "Why this relationship exists; surfaced to AI clients" },
870
+ },
871
+ required: ["target_tag", "cardinality"],
872
+ },
873
+ },
874
+ parent_names: {
875
+ type: "array",
876
+ items: { type: "string" },
877
+ 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.",
878
+ },
615
879
  },
616
880
  required: ["tag"],
617
881
  },
618
- execute: (params) => {
882
+ execute: async (params) => {
619
883
  const tag = params.tag as string;
620
- const existing = tagSchemaOps.getTagSchema(db, tag);
884
+ const existing = tagSchemaOps.getTagRecord(db, tag);
885
+
886
+ // ---- fields: shallow-merge into existing (preserves prior keys).
621
887
  const incomingFields = (params.fields as Record<string, TagFieldSchema> | undefined) ?? {};
622
888
  const mergedFields: Record<string, TagFieldSchema> = {
623
889
  ...(existing?.fields ?? {}),
@@ -626,7 +892,6 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
626
892
 
627
893
  // Validate cross-tag consistency on fields being (re)declared in this
628
894
  // call. `type` and `indexed` are global — all declarers must agree.
629
- // `description` and `enum` are per-tag, so we don't compare them.
630
895
  const otherSchemas = tagSchemaOps
631
896
  .listTagSchemas(db)
632
897
  .filter((s) => s.tag !== tag);
@@ -657,15 +922,47 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
657
922
  }
658
923
  }
659
924
 
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,
925
+ // ---- relationships: replace wholesale when provided. Validate
926
+ // shape + cardinality vocabulary before persisting so a malformed
927
+ // payload can't leave the row in an inconsistent state.
928
+ let relationshipsPatch: Record<string, tagSchemaOps.TagRelationship> | null | undefined;
929
+ if (params.relationships === null) {
930
+ relationshipsPatch = null;
931
+ } else if (params.relationships !== undefined) {
932
+ relationshipsPatch = tagSchemaOps.validateRelationships(params.relationships);
933
+ }
934
+
935
+ // ---- parent_names: replace wholesale when provided. Empty array
936
+ // collapses to null (clear) — a tag with `parent_names = []` and
937
+ // a tag with `parent_names = null` are indistinguishable at the
938
+ // hierarchy layer.
939
+ let parentNamesPatch: string[] | null | undefined;
940
+ if (params.parent_names === null) {
941
+ parentNamesPatch = null;
942
+ } else if (params.parent_names !== undefined) {
943
+ if (!Array.isArray(params.parent_names)) {
944
+ throw new Error("parent_names must be an array of tag names");
945
+ }
946
+ const cleaned = (params.parent_names as unknown[])
947
+ .filter((p): p is string => typeof p === "string" && p.length > 0);
948
+ parentNamesPatch = cleaned.length > 0 ? cleaned : null;
949
+ }
950
+
951
+ // ---- Persist via the store wrapper so the hierarchy cache is
952
+ // invalidated when parent_names is touched.
953
+ const fieldsPatch = Object.keys(mergedFields).length > 0
954
+ ? mergedFields
955
+ : (params.fields !== undefined ? null : undefined);
956
+ const descriptionPatch =
957
+ params.description === undefined ? undefined : (params.description as string);
958
+ const result = await store.upsertTagRecord(tag, {
959
+ ...(descriptionPatch !== undefined ? { description: descriptionPatch } : {}),
960
+ ...(fieldsPatch !== undefined ? { fields: fieldsPatch } : {}),
961
+ ...(relationshipsPatch !== undefined ? { relationships: relationshipsPatch } : {}),
962
+ ...(parentNamesPatch !== undefined ? { parent_names: parentNamesPatch } : {}),
666
963
  });
667
964
 
668
- // Diff indexed state for this tag: what it indexed before vs. now.
965
+ // ---- Reconcile indexed-field lifecycle for this tag.
669
966
  const priorIndexed = new Set(
670
967
  Object.entries(existing?.fields ?? {})
671
968
  .filter(([, v]) => v.indexed === true)
@@ -706,9 +1003,9 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
706
1003
  },
707
1004
  execute: async (params) => {
708
1005
  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.
1006
+ // Release any indexed fields this tag declared before the row
1007
+ // drops. releaseField drops the generated column + index when the
1008
+ // declarer set empties.
712
1009
  const schema = tagSchemaOps.getTagSchema(db, tag);
713
1010
  if (schema?.fields) {
714
1011
  for (const [fieldName, spec] of Object.entries(schema.fields)) {
@@ -717,8 +1014,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
717
1014
  }
718
1015
  }
719
1016
  }
720
- // Delete schema first (FK cascade would handle it, but be explicit)
721
- tagSchemaOps.deleteTagSchema(db, tag);
1017
+ // Drop the row outright description/fields/relationships/parents
1018
+ // travel with it. (No more sidecar table to clear separately.)
722
1019
  return await store.deleteTag(tag);
723
1020
  },
724
1021
  },
@@ -752,11 +1049,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
752
1049
  // =====================================================================
753
1050
  {
754
1051
  name: "vault-info",
755
- description: "Get vault description and optionally stats (note/tag/link counts, distribution). Pass `description` to update the vault description (changes how AI agents behave in future sessions).",
1052
+ description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and query hints. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution. Pass `description` to update the vault description (changes how AI agents behave in future sessions). Call this anytime mid-session to refresh schema context.",
756
1053
  inputSchema: {
757
1054
  type: "object",
758
1055
  properties: {
759
- include_stats: { type: "boolean", description: "Include note count, tag count, distribution by month (default: false)" },
1056
+ include_stats: { type: "boolean", description: "Include note count, tag count, attachment/link counts, and the monthly note distribution (default: false)" },
760
1057
  description: { type: "string", description: "If provided, updates the vault description" },
761
1058
  },
762
1059
  },
@@ -818,19 +1115,47 @@ function defaultForField(field: { type: string; enum?: string[] }): unknown {
818
1115
  }
819
1116
  }
820
1117
 
1118
+ // ---------------------------------------------------------------------------
1119
+ // `tags.fields` validation — surface validation_status on create/update
1120
+ // ---------------------------------------------------------------------------
1121
+
1122
+ /**
1123
+ * Attach a `validation_status` field to the response when at least one tag
1124
+ * on the note declares `fields` on its `tags` row. Validation is advisory
1125
+ * only — writes are never blocked. The agent receives warnings (type
1126
+ * mismatch, enum mismatch) so it can self-correct on the next turn.
1127
+ *
1128
+ * Returns the note unchanged when no tag declares fields, so callers
1129
+ * without any tag schemas see no behavior change.
1130
+ */
1131
+ function attachValidationStatus(store: Store, _db: Database, note: Note): Note {
1132
+ // Short-circuit cheaply: when no tag declares fields, the resolver
1133
+ // returns null without us paying a re-read of the note.
1134
+ const status = store.validateNoteAgainstSchemas({
1135
+ path: note.path,
1136
+ tags: note.tags,
1137
+ metadata: note.metadata as Record<string, unknown> | undefined,
1138
+ });
1139
+ if (!status) return note;
1140
+ return { ...note, validation_status: status } as Note & { validation_status: typeof status };
1141
+ }
1142
+
821
1143
  // ---------------------------------------------------------------------------
822
1144
  // Helpers
823
1145
  // ---------------------------------------------------------------------------
824
1146
 
825
1147
  function normalizeTags(tag: unknown): string[] | undefined {
826
1148
  if (!tag) return undefined;
827
- if (Array.isArray(tag)) return tag;
1149
+ // Defensive copy: callers downstream sometimes mutate the array (sort,
1150
+ // splice, push for hierarchy expansion). Returning a fresh array keeps
1151
+ // the original `params` object untouched.
1152
+ if (Array.isArray(tag)) return [...tag];
828
1153
  return [tag as string];
829
1154
  }
830
1155
 
831
1156
  // Re-exported for backward compat; defined in notes.ts alongside the
832
1157
  // conditional-UPDATE implementation that raises it.
833
- export { ConflictError } from "./notes.js";
1158
+ export { ConflictError, PathConflictError, EmptyNoteError, MAX_BATCH_SIZE } from "./notes.js";
834
1159
 
835
1160
  /**
836
1161
  * Thrown by the `update-note` MCP tool (and the REST PATCH handler) when a
@@ -854,3 +1179,23 @@ export class PreconditionRequiredError extends Error {
854
1179
  }
855
1180
  }
856
1181
 
1182
+ /**
1183
+ * Thrown by `create-note` / `update-note` when a batch exceeds
1184
+ * `MAX_BATCH_SIZE` (re-exported from `./notes.js` — single source of truth).
1185
+ * Bounds the blast radius of a runaway client — see #213, where one MCP
1186
+ * burst created 7,453 empty notes in minutes. Surfaces as 413 at the HTTP
1187
+ * layer.
1188
+ */
1189
+ export class BatchTooLargeError extends Error {
1190
+ code = "BATCH_TOO_LARGE" as const;
1191
+ limit: number;
1192
+ got: number;
1193
+
1194
+ constructor(got: number) {
1195
+ super(`batch_too_large: max ${MAX_BATCH_SIZE} notes per call, got ${got}`);
1196
+ this.name = "BatchTooLargeError";
1197
+ this.limit = MAX_BATCH_SIZE;
1198
+ this.got = got;
1199
+ }
1200
+ }
1201
+