@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.
- package/.parachute/module.json +15 -0
- package/README.md +133 -0
- package/core/src/core.test.ts +2990 -92
- package/core/src/links.ts +1 -1
- package/core/src/mcp.ts +413 -68
- package/core/src/notes.ts +693 -42
- package/core/src/obsidian.ts +3 -3
- package/core/src/paths.ts +1 -1
- package/core/src/query-operators.ts +23 -7
- package/core/src/schema-defaults.ts +331 -0
- package/core/src/schema.ts +467 -11
- package/core/src/store.ts +262 -8
- package/core/src/tag-hierarchy.ts +171 -0
- package/core/src/tag-schemas.ts +242 -42
- package/core/src/types.ts +96 -7
- package/core/src/vault-projection.ts +309 -0
- package/core/src/wikilinks.ts +3 -3
- package/package.json +13 -3
- package/src/admin-spa.test.ts +161 -0
- package/src/admin-spa.ts +161 -0
- package/src/auth-hub-jwt.test.ts +360 -0
- package/src/auth-status.ts +84 -0
- package/src/auth.test.ts +135 -23
- package/src/auth.ts +173 -15
- package/src/backup.ts +4 -7
- package/src/cli.ts +322 -57
- package/src/config.test.ts +44 -0
- package/src/config.ts +68 -40
- package/src/hub-jwt.test.ts +307 -0
- package/src/hub-jwt.ts +88 -0
- package/src/init.test.ts +216 -0
- package/src/mcp-http.ts +33 -29
- package/src/mcp-install.ts +1 -1
- package/src/mcp-tools.ts +318 -19
- package/src/module-config.ts +1 -1
- package/src/oauth.test.ts +345 -0
- package/src/oauth.ts +85 -14
- package/src/owner-auth.ts +57 -1
- package/src/prompt.ts +6 -5
- package/src/routes.ts +796 -61
- package/src/routing.test.ts +466 -1
- package/src/routing.ts +106 -24
- package/src/scopes.test.ts +66 -8
- package/src/scopes.ts +163 -37
- package/src/server.ts +24 -2
- package/src/services-manifest.test.ts +20 -0
- package/src/services-manifest.ts +9 -2
- package/src/stop-signal.test.ts +85 -0
- package/src/storage.test.ts +92 -0
- package/src/tag-scope.ts +118 -0
- package/src/token-store.test.ts +47 -0
- package/src/token-store.ts +128 -13
- package/src/tokens-routes.test.ts +727 -0
- package/src/tokens-routes.ts +392 -0
- package/src/transcription-worker.test.ts +5 -0
- package/src/triggers.ts +1 -1
- package/src/two-factor.ts +2 -2
- package/src/vault-create.test.ts +193 -0
- package/src/vault-name.test.ts +123 -0
- package/src/vault-name.ts +80 -0
- package/src/vault.test.ts +1626 -183
- package/tsconfig.json +8 -1
- package/.claude/settings.local.json +0 -8
- package/.dockerignore +0 -8
- package/.env.example +0 -9
- package/CHANGELOG.md +0 -175
- package/CLAUDE.md +0 -125
- package/Caddyfile +0 -3
- package/Dockerfile +0 -22
- package/bun.lock +0 -219
- package/bunfig.toml +0 -2
- package/deploy/parachute-vault.service +0 -20
- package/docker-compose.yml +0 -50
- package/docs/HTTP_API.md +0 -434
- package/docs/auth-model.md +0 -340
- package/fly.toml +0 -24
- package/package/package.json +0 -32
- package/railway.json +0 -14
- package/scripts/migrate-audio-to-opus.test.ts +0 -237
- 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
|
|
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: {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
227
|
-
|
|
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
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
801
|
+
const record = tagSchemaOps.getTagRecord(db, singleTag);
|
|
568
802
|
return {
|
|
569
803
|
name: singleTag,
|
|
570
804
|
count: found?.count ?? 0,
|
|
571
|
-
description:
|
|
572
|
-
fields:
|
|
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
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
661
|
-
//
|
|
662
|
-
//
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
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
|
-
//
|
|
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
|
|
710
|
-
//
|
|
711
|
-
//
|
|
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
|
-
//
|
|
721
|
-
|
|
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
|
|
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,
|
|
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
|
-
|
|
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
|
+
|