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