@openparachute/vault 0.7.0-rc.8 → 0.7.1
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/core/src/aggregate.test.ts +260 -0
- package/core/src/contract-taxonomy.test.ts +26 -2
- package/core/src/contract-typed-index.test.ts +4 -2
- package/core/src/core.test.ts +1151 -0
- package/core/src/cursor.ts +1 -0
- package/core/src/doctor.ts +23 -1
- package/core/src/indexed-fields.test.ts +4 -1
- package/core/src/indexed-fields.ts +6 -1
- package/core/src/links.ts +22 -0
- package/core/src/mcp.ts +587 -79
- package/core/src/notes.ts +371 -18
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/seed-packs.test.ts +14 -0
- package/core/src/seed-packs.ts +42 -5
- package/core/src/store.ts +129 -5
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/types.ts +98 -0
- package/core/src/ulid.test.ts +56 -0
- package/core/src/ulid.ts +116 -0
- package/core/src/vault-projection.ts +19 -1
- package/core/src/wikilinks.test.ts +205 -1
- package/core/src/wikilinks.ts +244 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-search.test.ts +17 -0
- package/src/mcp-link-warnings-scope.test.ts +144 -0
- package/src/mcp-query-notes-aggregate-scope.test.ts +183 -0
- package/src/mcp-tools.ts +100 -19
- package/src/routes.ts +469 -39
- package/src/routing.test.ts +167 -0
- package/src/routing.ts +47 -11
- package/src/tag-integrity-mcp.test.ts +45 -6
- package/src/tag-scope.ts +10 -1
- package/src/vault.test.ts +923 -21
- package/web/ui/dist/assets/{index-B8pGeQam.js → index-CJ6NtIwh.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/mcp.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
1
|
import { Database } from "bun:sqlite";
|
|
2
|
-
import type { Store, Note } from "./types.js";
|
|
2
|
+
import type { Store, Note, QueryOpts } from "./types.js";
|
|
3
3
|
import { transactionAsync } from "./txn.js";
|
|
4
4
|
import * as noteOps from "./notes.js";
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
6
|
+
import { normalizePath } from "./paths.js";
|
|
6
7
|
import { QueryError } from "./query-operators.js";
|
|
7
8
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
8
9
|
import {
|
|
@@ -15,7 +16,13 @@ import {
|
|
|
15
16
|
} from "./query-warnings.js";
|
|
16
17
|
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
17
18
|
import * as linkOps from "./links.js";
|
|
18
|
-
import {
|
|
19
|
+
import {
|
|
20
|
+
resolveOrQueueLink,
|
|
21
|
+
resolveStructuredLinkNote,
|
|
22
|
+
getUnresolvedLinksForNote,
|
|
23
|
+
getUnresolvedLinksForNotes,
|
|
24
|
+
getContentWikilinkWarnings,
|
|
25
|
+
} from "./wikilinks.js";
|
|
19
26
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
20
27
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
21
28
|
import {
|
|
@@ -78,7 +85,9 @@ function structuredError(
|
|
|
78
85
|
|
|
79
86
|
/**
|
|
80
87
|
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
81
|
-
* path match
|
|
88
|
+
* path match, then (additive fallback) an H1-title match. Works
|
|
89
|
+
* everywhere a note reference is accepted (query-notes `id`, update-note
|
|
90
|
+
* `id`, delete-note `id`, find-path anchors).
|
|
82
91
|
*
|
|
83
92
|
* Path-with-extension form (vault#330 S1): a trailing `.<ext>` matching
|
|
84
93
|
* the extension pattern (`/^[a-z0-9]{1,16}$/i`) is parsed as
|
|
@@ -89,6 +98,12 @@ function structuredError(
|
|
|
89
98
|
* On ambiguous path with no extension hint, `getNoteByPath` throws
|
|
90
99
|
* `AmbiguousPathError` — `resolveNote` propagates it so MCP / REST
|
|
91
100
|
* handlers can surface a clear 4xx rather than picking arbitrarily.
|
|
101
|
+
*
|
|
102
|
+
* Title fallback (additive): reached only when id AND path/extension
|
|
103
|
+
* BOTH miss cleanly (no throw). Resolves via `noteOps.getNoteByTitle` —
|
|
104
|
+
* the note whose first `# ` content line equals `idOrPath`, exactly one
|
|
105
|
+
* match required. Same [[wikilink]] semantics as `resolveWikilink`; exact
|
|
106
|
+
* id/path always wins first.
|
|
92
107
|
*/
|
|
93
108
|
function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
94
109
|
// Try ID match first (fast, indexed)
|
|
@@ -104,7 +119,9 @@ function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
|
104
119
|
const explicit = noteOps.getNoteByPath(db, extMatch[1]!, extMatch[2]!);
|
|
105
120
|
if (explicit) return explicit;
|
|
106
121
|
}
|
|
107
|
-
|
|
122
|
+
const byPath = noteOps.getNoteByPath(db, idOrPath);
|
|
123
|
+
if (byPath) return byPath;
|
|
124
|
+
return noteOps.getNoteByTitle(db, idOrPath);
|
|
108
125
|
}
|
|
109
126
|
|
|
110
127
|
function requireNote(db: Database, idOrPath: string): Note {
|
|
@@ -195,19 +212,65 @@ export interface GenerateMcpToolsOpts {
|
|
|
195
212
|
* Omitted (unscoped / internal callers) → the full graph is walked.
|
|
196
213
|
*/
|
|
197
214
|
nearTraversable?: (noteId: string) => boolean;
|
|
215
|
+
/**
|
|
216
|
+
* `ifExistsVisible` (vault#555 auth-review must-fix) is an OPTIONAL per-note
|
|
217
|
+
* predicate gating the `create-note` `if_exists` upsert. `if_exists` resolves
|
|
218
|
+
* the target `path` VAULT-WIDE (`getNoteByPath`) and then returns (ignore) /
|
|
219
|
+
* updates / replaces the found note — so without a scope gate a tag-scoped
|
|
220
|
+
* caller could READ or OVERWRITE an out-of-scope note just by naming its
|
|
221
|
+
* path. When provided, `applyExistingNote` calls this predicate on the
|
|
222
|
+
* RESOLVED existing note and, if it returns false, throws `PathConflictError`
|
|
223
|
+
* instead (the path is taken but invisible to this caller — no content
|
|
224
|
+
* exposed, nothing mutated). It's applied INSIDE `applyExistingNote`, which
|
|
225
|
+
* BOTH the proactive-check site AND the concurrent-INSERT race-backstop site
|
|
226
|
+
* funnel through — so a TOCTOU race (both existence checks miss, the real
|
|
227
|
+
* INSERT then loses to a concurrent writer's out-of-scope note) can't slip a
|
|
228
|
+
* note past it. Core stays scope-unaware: it receives a plain
|
|
229
|
+
* `(note) => boolean` closure and never imports the server's tag-scope
|
|
230
|
+
* module. Omitted (unscoped / internal callers) → `if_exists` resolves
|
|
231
|
+
* against the full vault exactly as before.
|
|
232
|
+
*/
|
|
233
|
+
ifExistsVisible?: (note: Note) => boolean;
|
|
234
|
+
/**
|
|
235
|
+
* `aggregateVisibility` is an OPTIONAL per-note visibility predicate for
|
|
236
|
+
* `query-notes`'s `aggregate` mode. When provided, the aggregate is
|
|
237
|
+
* computed by first fetching every note the OTHER filters match
|
|
238
|
+
* (unpaginated), narrowing to the notes the predicate accepts, and THEN
|
|
239
|
+
* aggregating over just that visible id set — rather than the (faster)
|
|
240
|
+
* direct SQL GROUP BY the unscoped path takes. This mirrors
|
|
241
|
+
* `expandVisibility`/`nearTraversable`: core stays scope-unaware, it only
|
|
242
|
+
* invokes a plain `(note) => boolean` closure the server injects. Omitted
|
|
243
|
+
* (unscoped / internal callers) → the aggregate runs the fast direct-SQL
|
|
244
|
+
* path with no extra fetch.
|
|
245
|
+
*/
|
|
246
|
+
aggregateVisibility?: (note: Note) => boolean;
|
|
198
247
|
}
|
|
199
248
|
|
|
200
249
|
/**
|
|
201
250
|
* Generate the consolidated MCP tools for a vault. Surface (13):
|
|
202
|
-
* query-notes,
|
|
203
|
-
*
|
|
204
|
-
*
|
|
205
|
-
* layer (src/mcp-tools.ts), not here — see that file's doc
|
|
251
|
+
* query-notes, list-tags, find-path, vault-info, doctor (read); create-note,
|
|
252
|
+
* update-note, delete-note (write); update-tag, delete-tag, rename-tag,
|
|
253
|
+
* merge-tags, prune-schema (admin). `manage-token` (admin) is appended by
|
|
254
|
+
* the SERVER layer (src/mcp-tools.ts), not here — see that file's doc
|
|
255
|
+
* comment.
|
|
256
|
+
*
|
|
257
|
+
* **Re-tier (this PR):** content-authorship (write) is now separate from
|
|
258
|
+
* structure/taxonomy/schema-curation (admin). `update-tag`/`delete-tag`/
|
|
259
|
+
* `rename-tag`/`merge-tags` moved write → admin — they define schemas and
|
|
260
|
+
* restructure the tag graph across ALL notes, not just author content.
|
|
261
|
+
* `doctor` moved admin → read — it's a read-only, tag-scope-restricted
|
|
262
|
+
* diagnostic (see `applyTagScopeWrappers` in src/mcp-tools.ts), and
|
|
263
|
+
* read-scoped monitoring/tending jobs need to be able to run it without an
|
|
264
|
+
* admin credential. BREAKING: a `vault:write` token that used to
|
|
265
|
+
* rename/merge/delete/update tags now gets `insufficient_scope`; a
|
|
266
|
+
* `vault:read` token can now call `doctor`. See CHANGELOG.
|
|
206
267
|
*/
|
|
207
268
|
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
208
269
|
const db: Database = store.db;
|
|
209
270
|
const expandVisibility = opts?.expandVisibility;
|
|
210
271
|
const nearTraversable = opts?.nearTraversable;
|
|
272
|
+
const ifExistsVisible = opts?.ifExistsVisible;
|
|
273
|
+
const aggregateVisibility = opts?.aggregateVisibility;
|
|
211
274
|
// Write-attribution (vault#298) — captured once at tool-generation time
|
|
212
275
|
// (a fresh tool set is generated per MCP request, so this is request-scoped)
|
|
213
276
|
// and folded into every create/update the tools perform.
|
|
@@ -257,7 +320,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
257
320
|
requiredVerb: "read",
|
|
258
321
|
description: `Query notes. Returns notes matching the given filters.
|
|
259
322
|
|
|
260
|
-
- **Single note**: pass \`id\` (accepts note ID
|
|
323
|
+
- **Single note**: pass \`id\` (accepts note ID, path, e.g., "Projects/README", or — as a last-resort fallback when id/path both miss cleanly and exactly one note matches — its H1 title, e.g. "Weekly Review")
|
|
261
324
|
- **Filter**: pass \`tag\`, \`path\`, \`path_prefix\`, \`search\`, \`metadata\`, date range
|
|
262
325
|
- **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
|
|
263
326
|
- **No filters**: returns all notes (paginated)
|
|
@@ -270,10 +333,15 @@ Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bo
|
|
|
270
333
|
|
|
271
334
|
Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returned content. Tune with \`expand_depth\` (1–3, default 1) and \`expand_mode\` ("full" inlines full content, "summary" inlines only metadata.summary). Expansions are deduplicated across the query and cycle-guarded.
|
|
272
335
|
|
|
336
|
+
Broken links (vault#555): a \`[[wikilink]]\` or structured \`links\` target that never resolved to a note used to be invisible — silently dropped from the response with no signal it existed. Pass \`has_broken_links: true\`/\`false\` to filter notes by whether they have any dangling outbound link, and/or \`include_broken_links: true\` to attach each note's pending targets as \`broken_links: [{target, relationship}]\` (empty array when none). Both read the vault's pending-resolution table — the same source \`create-note\`/\`update-note\`'s \`unresolved_link\` warning draws from; a target created later (this session or any future one) backfills the edge automatically and the note drops out of \`has_broken_links: true\`.
|
|
337
|
+
|
|
273
338
|
Response shape (vault#550 — three variants, pick by what you passed):
|
|
274
339
|
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
275
340
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
276
341
|
- Warnings present (e.g. an unrecognized \`tag\`) and NOT in cursor mode: \`{notes: [...], warnings: [...]}\`. Cursor mode + warnings compose: \`{notes, next_cursor, warnings}\`. Absent \`warnings\` key means nothing to flag — don't assume its presence either way.
|
|
342
|
+
- \`aggregate\` mode: \`[{group, value}]\` — a rollup row per group, NOT notes. See \`aggregate\` below.
|
|
343
|
+
|
|
344
|
+
\`aggregate\` (group_by + count/sum): pass \`aggregate: {group_by, op, field?}\` to get counts/sums instead of note rows — e.g. "how many notes per status" (\`{group_by: "status", op: "count"}\`) or "total amount per category" (\`{group_by: "category", op: "sum", field: "amount"}\`). Every other filter (\`tag\`, \`metadata\`, date range, ...) narrows the input set FIRST, exactly like a normal query. \`group_by\` is either \"tag\" (group by tag membership) or an indexed metadata field; \`op: "sum"\` additionally requires \`field\` to be an indexed NUMERIC field. Mutually exclusive with \`search\`/\`near\`/\`cursor\`.
|
|
277
345
|
|
|
278
346
|
\`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.
|
|
279
347
|
|
|
@@ -281,7 +349,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
281
349
|
inputSchema: {
|
|
282
350
|
type: "object",
|
|
283
351
|
properties: {
|
|
284
|
-
id: { type: "string", description: "Get one note by ID or path" },
|
|
352
|
+
id: { type: "string", description: "Get one note by ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
285
353
|
tag: {
|
|
286
354
|
oneOf: [
|
|
287
355
|
{ type: "string" },
|
|
@@ -322,6 +390,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
322
390
|
},
|
|
323
391
|
has_tags: { type: "boolean", description: "Presence filter: true = only notes with at least one tag; false = only untagged notes. Ignored when `tag` is set." },
|
|
324
392
|
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)." },
|
|
393
|
+
has_broken_links: { type: "boolean", description: "Presence filter (vault#555): true = only notes with at least one dangling outbound link — a [[wikilink]] or structured `links` target that never resolved to a note; false = only notes with none. Backed by the unresolved_wikilinks table (same data `doctor`/list-unresolved surfaces); safe on a vault where no link has ever gone unresolved (true matches nothing, false is a no-op)." },
|
|
325
394
|
path: { type: "string", description: "Exact path match (case-insensitive)" },
|
|
326
395
|
path_prefix: { type: "string", description: "Path prefix match (e.g., 'Projects/')" },
|
|
327
396
|
extension: {
|
|
@@ -362,10 +431,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
362
431
|
},
|
|
363
432
|
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.",
|
|
364
433
|
},
|
|
434
|
+
aggregate: {
|
|
435
|
+
type: "object",
|
|
436
|
+
properties: {
|
|
437
|
+
group_by: { type: "string", description: "What to group by: an indexed metadata field name (declared `indexed: true` in a tag schema — same FIELD_NOT_INDEXED contract as `metadata` operator queries / `order_by`), or the special value \"tag\" to group by tag membership. Under \"tag\", a note carrying N of the tags present in the filtered result set contributes to N separate groups (a membership rollup, not a partition)." },
|
|
438
|
+
op: { type: "string", enum: ["count", "sum"], description: "\"count\": number of matching notes per group. \"sum\": sum of `field` per group." },
|
|
439
|
+
field: { type: "string", description: "Required when `op` is \"sum\"; ignored for \"count\". Must be an indexed metadata field with a numeric storage type (declared `type: \"integer\"` or `type: \"boolean\"` — the only indexable numeric shapes; a bare `type: \"number\"` field is never indexed and a TEXT-backed field can't be summed)." },
|
|
440
|
+
},
|
|
441
|
+
required: ["group_by", "op"],
|
|
442
|
+
description: "Aggregation / rollup mode. Every OTHER filter above (tag, metadata, date range, write-attribution, ...) is applied FIRST, exactly as a normal query would; the matching notes are then grouped and the response becomes `[{group, value}]` instead of note rows — one row per group, `value` is the count/sum. A note whose group_by value is absent collects into one `{group: null, value: ...}` row rather than being dropped. Mutually exclusive with `search`, `near`, and `cursor` (a rollup has no pagination/ranking/graph-neighborhood shape). Tag-scoped sessions see the SAME visibility enforcement as every other read — the rollup is computed only over notes the token can see.",
|
|
443
|
+
},
|
|
365
444
|
near: {
|
|
366
445
|
type: "object",
|
|
367
446
|
properties: {
|
|
368
|
-
note_id: { type: "string", description: "Anchor note ID or
|
|
447
|
+
note_id: { type: "string", description: "Anchor note ID, path, or (fallback) H1 title" },
|
|
369
448
|
depth: { type: "number", description: "Max hops from anchor (default 2, max 5)" },
|
|
370
449
|
relationship: { type: "string", description: "Only follow links with this relationship" },
|
|
371
450
|
},
|
|
@@ -404,6 +483,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
404
483
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
405
484
|
},
|
|
406
485
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
486
|
+
include_broken_links: { type: "boolean", description: "Include each note's dangling outbound links as `broken_links: [{target, relationship}]` (default: false; vault#555). `target` is the unresolved path/title the [[wikilink]] or structured `links` entry named; `relationship` is \"wikilink\" for content-parsed links or the caller's own relationship string for a structured link. Empty array when the note has none. One batched query per request regardless of page size — mirrors `has_broken_links` (same backing table) and `include_links`." },
|
|
407
487
|
include_link_count: {
|
|
408
488
|
type: "boolean",
|
|
409
489
|
description:
|
|
@@ -492,6 +572,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
492
572
|
if (params.include_links) {
|
|
493
573
|
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
494
574
|
}
|
|
575
|
+
if (params.include_broken_links) {
|
|
576
|
+
result.broken_links = getUnresolvedLinksForNote(db, note.id);
|
|
577
|
+
}
|
|
495
578
|
if (params.include_attachments) {
|
|
496
579
|
result.attachments = await store.getAttachments(note.id);
|
|
497
580
|
}
|
|
@@ -569,6 +652,89 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
569
652
|
expand = params.expand as TagExpandMode;
|
|
570
653
|
}
|
|
571
654
|
|
|
655
|
+
// --- Aggregation / rollup mode (top new-feature ask from a UX round) ---
|
|
656
|
+
// Mutually exclusive with `search`/`near`/`cursor` — a rollup returns
|
|
657
|
+
// one row per group, not a paginated / graph-scoped / ranked note
|
|
658
|
+
// list — so reject those combos loudly before touching the DB.
|
|
659
|
+
if (params.aggregate) {
|
|
660
|
+
if (params.search) {
|
|
661
|
+
throw new QueryError(
|
|
662
|
+
`aggregate is incompatible with full-text search — pick one.`,
|
|
663
|
+
"INVALID_QUERY",
|
|
664
|
+
{ error_type: "invalid_query", field: "aggregate", hint: "drop `search` when using `aggregate`" },
|
|
665
|
+
);
|
|
666
|
+
}
|
|
667
|
+
if (params.near) {
|
|
668
|
+
throw new QueryError(
|
|
669
|
+
`aggregate is incompatible with near (graph neighborhood).`,
|
|
670
|
+
"INVALID_QUERY",
|
|
671
|
+
{ error_type: "invalid_query", field: "aggregate", hint: "drop `near` when using `aggregate`" },
|
|
672
|
+
);
|
|
673
|
+
}
|
|
674
|
+
if (typeof params.cursor === "string") {
|
|
675
|
+
throw new QueryError(
|
|
676
|
+
`aggregate is incompatible with cursor pagination — a rollup has no watermark to page through.`,
|
|
677
|
+
"INVALID_QUERY",
|
|
678
|
+
{ error_type: "invalid_query", field: "aggregate", hint: "drop `cursor` when using `aggregate`" },
|
|
679
|
+
);
|
|
680
|
+
}
|
|
681
|
+
const aggRaw = params.aggregate as Record<string, unknown>;
|
|
682
|
+
if (typeof aggRaw !== "object" || aggRaw === null || Array.isArray(aggRaw)) {
|
|
683
|
+
throw new QueryError(
|
|
684
|
+
`aggregate must be an object: {group_by, op, field?}`,
|
|
685
|
+
"INVALID_QUERY",
|
|
686
|
+
{ error_type: "invalid_query", field: "aggregate", got: aggRaw },
|
|
687
|
+
);
|
|
688
|
+
}
|
|
689
|
+
// Shape coercion only — `group_by`/`op`/`field` validity (indexed
|
|
690
|
+
// field, numeric type, sum requires field, ...) is enforced by
|
|
691
|
+
// `aggregateNotes` itself, reusing the exact FIELD_NOT_INDEXED /
|
|
692
|
+
// INVALID_QUERY contract every other query surface uses.
|
|
693
|
+
const aggregateSpec = {
|
|
694
|
+
group_by: aggRaw.group_by as string,
|
|
695
|
+
op: aggRaw.op as "count" | "sum",
|
|
696
|
+
field: aggRaw.field as string | undefined,
|
|
697
|
+
};
|
|
698
|
+
const aggTags = normalizeTags(params.tag);
|
|
699
|
+
const aggExcludeTagsRaw = params.exclude_tags ?? params.excludeTags ?? params.exclude_tag;
|
|
700
|
+
const aggExcludeTags = normalizeTags(aggExcludeTagsRaw);
|
|
701
|
+
const aggFilterOpts: QueryOpts = {
|
|
702
|
+
tags: aggTags,
|
|
703
|
+
tagMatch: (params.tag_match as "all" | "any") ?? (aggTags && aggTags.length > 1 ? "any" : undefined),
|
|
704
|
+
expand,
|
|
705
|
+
excludeTags: aggExcludeTags,
|
|
706
|
+
hasTags: params.has_tags as boolean | undefined,
|
|
707
|
+
hasLinks: params.has_links as boolean | undefined,
|
|
708
|
+
hasBrokenLinks: params.has_broken_links as boolean | undefined,
|
|
709
|
+
path: params.path as string | undefined,
|
|
710
|
+
pathPrefix: params.path_prefix as string | undefined,
|
|
711
|
+
extension: params.extension as string | string[] | undefined,
|
|
712
|
+
metadata: params.metadata as Record<string, unknown> | undefined,
|
|
713
|
+
createdBy: params.created_by as string | undefined,
|
|
714
|
+
lastUpdatedBy: params.last_updated_by as string | undefined,
|
|
715
|
+
createdVia: params.created_via as string | undefined,
|
|
716
|
+
lastUpdatedVia: params.last_updated_via as string | undefined,
|
|
717
|
+
dateFrom: params.date_from as string | undefined,
|
|
718
|
+
dateTo: params.date_to as string | undefined,
|
|
719
|
+
dateFilter: params.date_filter as
|
|
720
|
+
| { field?: string; from?: string; to?: string }
|
|
721
|
+
| undefined,
|
|
722
|
+
};
|
|
723
|
+
if (!aggregateVisibility) {
|
|
724
|
+
return await store.aggregateNotes({ ...aggFilterOpts, aggregate: aggregateSpec });
|
|
725
|
+
}
|
|
726
|
+
// Tag-scoped: filter to visible notes FIRST — fetch every note the
|
|
727
|
+
// OTHER filters match (unpaginated, same `limit: 1000000` "get
|
|
728
|
+
// everything" convention `syncAllWikilinks` uses), narrow to what
|
|
729
|
+
// the injected predicate accepts, THEN aggregate over just that id
|
|
730
|
+
// set (reusing the `ids` semijoin `near` already pushes into SQL).
|
|
731
|
+
// Core stays scope-unaware — it only invokes the plain closure.
|
|
732
|
+
const aggAllMatches = await store.queryNotes({ ...aggFilterOpts, limit: 1000000 });
|
|
733
|
+
const aggVisibleIds = aggAllMatches.filter(aggregateVisibility).map((n) => n.id);
|
|
734
|
+
if (aggVisibleIds.length === 0) return [];
|
|
735
|
+
return await store.aggregateNotes({ ids: aggVisibleIds, aggregate: aggregateSpec });
|
|
736
|
+
}
|
|
737
|
+
|
|
572
738
|
// `search_mode` (vault#551) — validate loudly (same policy as
|
|
573
739
|
// `expand` above) so a typo'd value doesn't silently fall back to
|
|
574
740
|
// the default. Resolved here (before the search/structured branch)
|
|
@@ -682,6 +848,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
682
848
|
excludeTags,
|
|
683
849
|
hasTags: params.has_tags as boolean | undefined,
|
|
684
850
|
hasLinks: params.has_links as boolean | undefined,
|
|
851
|
+
hasBrokenLinks: params.has_broken_links as boolean | undefined,
|
|
685
852
|
path: params.path as string | undefined,
|
|
686
853
|
pathPrefix: params.path_prefix as string | undefined,
|
|
687
854
|
extension: params.extension as string | string[] | undefined,
|
|
@@ -800,8 +967,8 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
800
967
|
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
801
968
|
}
|
|
802
969
|
|
|
803
|
-
// --- Hydrate links/attachments per note if requested ---
|
|
804
|
-
if (params.include_links || params.include_attachments) {
|
|
970
|
+
// --- Hydrate links/attachments/broken-links per note if requested ---
|
|
971
|
+
if (params.include_links || params.include_attachments || params.include_broken_links) {
|
|
805
972
|
// Links hydrate for the WHOLE page in a constant number of
|
|
806
973
|
// queries (see getLinksHydratedForNotes) — the per-note variant
|
|
807
974
|
// cost (1 link query + 1 summary query + N tag queries) × page
|
|
@@ -809,10 +976,15 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
809
976
|
const linksByNote = params.include_links
|
|
810
977
|
? linkOps.getLinksHydratedForNotes(db, (output as any[]).map((n: any) => n.id))
|
|
811
978
|
: null;
|
|
979
|
+
// Same one-batched-query-for-the-page shape as links (vault#555).
|
|
980
|
+
const brokenLinksByNote = params.include_broken_links
|
|
981
|
+
? getUnresolvedLinksForNotes(db, (output as any[]).map((n: any) => n.id))
|
|
982
|
+
: null;
|
|
812
983
|
const enrichedOut: any[] = [];
|
|
813
984
|
for (const n of output as any[]) {
|
|
814
985
|
const enriched: any = { ...n };
|
|
815
986
|
if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
|
|
987
|
+
if (brokenLinksByNote) enriched.broken_links = brokenLinksByNote.get(n.id) ?? [];
|
|
816
988
|
if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
|
|
817
989
|
enrichedOut.push(enriched);
|
|
818
990
|
}
|
|
@@ -850,7 +1022,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
850
1022
|
{
|
|
851
1023
|
name: "create-note",
|
|
852
1024
|
requiredVerb: "write",
|
|
853
|
-
description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at
|
|
1025
|
+
description: `Create one or more notes. Pass a single note's fields directly, or pass a \`notes\` array for batch creation. Each note accepts content, path, metadata, tags, links, and created_at.
|
|
1026
|
+
|
|
1027
|
+
**Path-conflict handling** — \`if_exists: "error"|"ignore"|"update"|"replace"\` (vault#555, default \`"error"\`): what to do when the note's \`path\` already names an existing note.
|
|
1028
|
+
- \`"error"\` (DEFAULT — unchanged behavior): the write is rejected with a \`path_conflict\` error (409); nothing is mutated.
|
|
1029
|
+
- \`"ignore"\`: return the existing note UNCHANGED — no error and no mutation of any kind (content/metadata/tags/links untouched; no schema-default backfill runs either). Response carries \`existed: true\`. The idempotent-retry primitive: a crash-replay or the losing side of a create-race gets back the same note a first-time caller would have created, safely, any number of times.
|
|
1030
|
+
- \`"update"\`: merge this payload into the existing note — \`content\` (if provided) fully replaces the existing content, exactly like \`update-note\`'s \`content\` field (omit to leave it untouched); \`metadata\` (if provided) is RFC-7386 merged — existing keys preserved, incoming keys overwrite, an incoming \`null\` value deletes a key — same semantics as \`update-note\`; \`tags\`/\`links\` (if provided) are ADDED to the existing set (union — nothing already there is removed). Response carries \`existed: true\`.
|
|
1031
|
+
- \`"replace"\`: overwrite \`content\` and \`metadata\` WHOLESALE — \`content\` becomes exactly the incoming value (or \`""\` if omitted) and \`metadata\` becomes exactly the incoming object (or \`{}\` if omitted), NOT merged, so a prior metadata key absent from this payload is dropped. \`tags\`/\`links\` stay additive (same union behavior as \`"update"\`) — a replace targets the free-form fields, not the taxonomy/graph, so it can't silently orphan links or detach tags the caller didn't mention. The note's \`id\` and \`created_at\` are preserved either way.
|
|
1032
|
+
|
|
1033
|
+
A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` was one of \`"ignore"\`/\`"update"\`/\`"replace"\` — \`true\` when the collision branch fired, \`false\` when a normal fresh insert happened instead (including when \`path\` was never set, so there was nothing to conflict with — \`if_exists\` is a no-op without a \`path\`, but still reports \`existed: false\`). Absent entirely under the default \`"error"\` mode — a plain create-note call's response shape is byte-identical to before this feature. Batch-aware, per-item (like \`if_missing\` on \`update-note\`): set \`if_exists\` inside each \`notes[]\` entry — a top-level \`if_exists\` alongside a \`notes\` array is NOT inherited by items that omit their own (it only takes effect on the single-note form, where \`params\` IS the one item).
|
|
1034
|
+
|
|
1035
|
+
**Batch summary** — pass \`summary: true\` (batch/\`notes\` calls only; ignored on a single-note call) to receive a compact \`{created, ids, failed}\` shape instead of N full note objects: \`created\` counts items that resulted in a BRAND-NEW insert (excludes \`if_exists\` collisions); \`ids\` lists every resulting note id in item order (fresh creates AND \`existed\` hits alike); \`failed\` is reserved for future partial-batch-failure reporting — today a batch create is all-or-nothing (any thrown error aborts and rolls back the WHOLE call, same with or without \`summary\`), so it's always \`[]\`.`,
|
|
854
1036
|
inputSchema: {
|
|
855
1037
|
type: "object",
|
|
856
1038
|
properties: {
|
|
@@ -865,29 +1047,38 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
865
1047
|
items: {
|
|
866
1048
|
type: "object",
|
|
867
1049
|
properties: {
|
|
868
|
-
target: { type: "string", description: "Target note ID or
|
|
1050
|
+
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
869
1051
|
relationship: { type: "string", description: "Relationship type (e.g., mentions, related-to)" },
|
|
870
1052
|
},
|
|
871
1053
|
required: ["target", "relationship"],
|
|
872
1054
|
},
|
|
873
|
-
description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename
|
|
1055
|
+
description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename, then (only on a clean miss, and only when exactly one note matches) an H1-title fallback. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
|
|
874
1056
|
},
|
|
875
1057
|
created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
|
|
1058
|
+
if_exists: {
|
|
1059
|
+
type: "string",
|
|
1060
|
+
enum: ["error", "ignore", "update", "replace"],
|
|
1061
|
+
description: "What to do when `path` already names an existing note (vault#555). See the tool description for the full contract of each mode. Default \"error\" — unchanged path_conflict behavior.",
|
|
1062
|
+
},
|
|
1063
|
+
summary: {
|
|
1064
|
+
type: "boolean",
|
|
1065
|
+
description: "Batch calls only (a `notes` array): return a compact `{created, ids, failed}` shape instead of N full note objects. See the tool description. Ignored on a single-note call.",
|
|
1066
|
+
},
|
|
876
1067
|
// Batch
|
|
877
1068
|
notes: {
|
|
878
1069
|
type: "array",
|
|
879
1070
|
items: {
|
|
880
1071
|
type: "object",
|
|
881
1072
|
properties: {
|
|
882
|
-
content: { type: "string" },
|
|
1073
|
+
content: { type: "string", description: "Optional — defaults to \"\" (vault#555 fix: this item's schema previously marked it `required`, but it was never enforced; an empty-content batch item has always succeeded)." },
|
|
883
1074
|
path: { type: "string" },
|
|
884
1075
|
extension: { type: "string", description: "File extension (vault#328). See top-level docs." },
|
|
885
1076
|
metadata: { type: "object" },
|
|
886
1077
|
tags: { type: "array", items: { type: "string" } },
|
|
887
1078
|
links: { type: "array" },
|
|
888
1079
|
created_at: { type: "string" },
|
|
1080
|
+
if_exists: { type: "string", enum: ["error", "ignore", "update", "replace"], description: "Per-item: see top-level `if_exists` docs. Each batch item carries its own setting." },
|
|
889
1081
|
},
|
|
890
|
-
required: ["content"],
|
|
891
1082
|
},
|
|
892
1083
|
description: "Array of notes for batch creation",
|
|
893
1084
|
},
|
|
@@ -909,19 +1100,166 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
909
1100
|
// moment item 0's links were processed. Deferring makes
|
|
910
1101
|
// within-batch forward-refs resolve exactly like a same-content
|
|
911
1102
|
// [[wikilink]] already does. `pendingLinks` carries the SOURCE
|
|
912
|
-
// note id (known immediately — createNote assigns it synchronously
|
|
913
|
-
//
|
|
1103
|
+
// note id (known immediately — createNote assigns it synchronously,
|
|
1104
|
+
// and so does the `if_exists` existing-note branch below) alongside
|
|
1105
|
+
// each item's raw `links` array.
|
|
914
1106
|
const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
|
|
915
1107
|
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
916
1108
|
// Populated in the second pass; folded into each note's response
|
|
917
1109
|
// below, same pattern as `validation_status`.
|
|
918
1110
|
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1111
|
+
const pushLinkWarning = (noteId: string, warning: QueryWarning): void => {
|
|
1112
|
+
const list = linkWarningsByNote.get(noteId) ?? [];
|
|
1113
|
+
list.push(warning);
|
|
1114
|
+
linkWarningsByNote.set(noteId, list);
|
|
1115
|
+
};
|
|
1116
|
+
// Content wikilinks whose note+content pair needs an `unresolved_link`/
|
|
1117
|
+
// `ambiguous_link` warning check (vault#570). Deferred to the same
|
|
1118
|
+
// second pass as `pendingLinks` — a content `[[wikilink]]` to a
|
|
1119
|
+
// sibling created LATER in this batch resolves via the same
|
|
1120
|
+
// forward-ref backfill structured `links` use, so the classification
|
|
1121
|
+
// must run only after every item in the batch exists.
|
|
1122
|
+
const contentWikilinkNotes: { noteId: string; content: string }[] = [];
|
|
1123
|
+
// `if_exists` bookkeeping (vault#555). `existedMap` is set ONLY for
|
|
1124
|
+
// items whose `if_exists` engaged (ignore/update/replace) — absent
|
|
1125
|
+
// means the default "error" path ran, so a plain create-note call
|
|
1126
|
+
// sees zero response-shape change. `true` = the path already named
|
|
1127
|
+
// a note and that branch fired; `false` = if_exists was set but no
|
|
1128
|
+
// conflict occurred (a normal fresh insert). `noMutateIds` marks
|
|
1129
|
+
// "ignore" hits so the schema-defaults pass below skips them
|
|
1130
|
+
// entirely — "ignore" promises NO mutation, including backfill.
|
|
1131
|
+
const existedMap = new Map<string, boolean>();
|
|
1132
|
+
const noMutateIds = new Set<string>();
|
|
1133
|
+
/** Record an `if_exists` collision outcome — shared by both call sites below. */
|
|
1134
|
+
const recordExistedBranch = (resultNote: Note, noMutate: boolean): void => {
|
|
1135
|
+
existedMap.set(resultNote.id, true);
|
|
1136
|
+
if (noMutate) noMutateIds.add(resultNote.id);
|
|
1137
|
+
created.push(resultNote);
|
|
1138
|
+
};
|
|
1139
|
+
|
|
1140
|
+
/**
|
|
1141
|
+
* Handle an `if_exists` collision against an already-resolved
|
|
1142
|
+
* `existingNote` — shared by the proactive pre-check (the common,
|
|
1143
|
+
* non-racing path) and the insert-time PathConflictError catch
|
|
1144
|
+
* (the race-closing backstop: a concurrent writer's INSERT
|
|
1145
|
+
* committed between our check and ours). Returns the note to put
|
|
1146
|
+
* in the response and whether it must be excluded from the
|
|
1147
|
+
* schema-defaults mutation pass ("ignore" — genuinely untouched).
|
|
1148
|
+
*/
|
|
1149
|
+
const applyExistingNote = async (
|
|
1150
|
+
item: any,
|
|
1151
|
+
existingNote: Note,
|
|
1152
|
+
mode: "ignore" | "update" | "replace",
|
|
1153
|
+
): Promise<{ note: Note; noMutate: boolean }> => {
|
|
1154
|
+
// CRITICAL scope guard (vault#555 auth-review must-fix). `existingNote`
|
|
1155
|
+
// was resolved VAULT-WIDE by path (getNoteByPath) at whichever call
|
|
1156
|
+
// site reached here — the proactive check OR the concurrent-INSERT
|
|
1157
|
+
// race backstop. A tag-scoped MCP session injects `ifExistsVisible`
|
|
1158
|
+
// (see GenerateMcpToolsOpts); when the resolved note fails it, the
|
|
1159
|
+
// caller must not read (ignore) / update / replace an out-of-scope
|
|
1160
|
+
// note it named only by path — throw PathConflictError (path taken,
|
|
1161
|
+
// invisible to this caller) BEFORE any read or mutation, so no
|
|
1162
|
+
// content leaks and nothing is written. Placing it HERE — not only in
|
|
1163
|
+
// the server-layer wrapper's pre-check — is what closes the
|
|
1164
|
+
// race-backstop TOCTOU: the pre-check + core's proactive check can
|
|
1165
|
+
// BOTH miss a note a concurrent writer then INSERTs, and the backstop
|
|
1166
|
+
// re-resolves it into this exact call. Core stays scope-unaware: it
|
|
1167
|
+
// only invokes the injected closure. Unscoped/internal callers pass no
|
|
1168
|
+
// predicate → unchanged behavior.
|
|
1169
|
+
if (ifExistsVisible && !ifExistsVisible(existingNote)) {
|
|
1170
|
+
throw new noteOps.PathConflictError(existingNote.path ?? "");
|
|
1171
|
+
}
|
|
1172
|
+
if (mode === "ignore") {
|
|
1173
|
+
// No error, no mutation of any kind — not even schema-default
|
|
1174
|
+
// backfill. Return the existing note exactly as stored.
|
|
1175
|
+
return { note: existingNote, noMutate: true };
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// "update" / "replace" both apply tags/links additively (union) —
|
|
1179
|
+
// they differ only in how content/metadata combine with the
|
|
1180
|
+
// existing values. See the tool description for the full contract.
|
|
1181
|
+
const updates: { content?: string; metadata?: Record<string, unknown> } = {};
|
|
1182
|
+
if (mode === "update") {
|
|
1183
|
+
if (item.content !== undefined) updates.content = item.content as string;
|
|
1184
|
+
if (item.metadata !== undefined) {
|
|
1185
|
+
updates.metadata = noteOps.mergeMetadata(
|
|
1186
|
+
existingNote.metadata as Record<string, unknown> | null | undefined,
|
|
1187
|
+
item.metadata as Record<string, unknown>,
|
|
1188
|
+
);
|
|
1189
|
+
}
|
|
1190
|
+
} else {
|
|
1191
|
+
// "replace" — PUT semantics: content/metadata become exactly
|
|
1192
|
+
// this payload (empty default when omitted), never merged.
|
|
1193
|
+
updates.content = (item.content as string | undefined) ?? "";
|
|
1194
|
+
updates.metadata = (item.metadata as Record<string, unknown> | undefined) ?? {};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
const incomingTags = (item.tags as string[] | undefined) ?? [];
|
|
1198
|
+
const projectedTags = new Set<string>([...(existingNote.tags ?? []), ...incomingTags]);
|
|
1199
|
+
const projectedMetadata =
|
|
1200
|
+
updates.metadata ?? ((existingNote.metadata as Record<string, unknown>) ?? {});
|
|
1201
|
+
// Strict-schema gate (vault#299) against the PROJECTED final shape
|
|
1202
|
+
// (existing ∪ incoming), not the raw incoming item — a caller
|
|
1203
|
+
// updating one field of an already-conforming note shouldn't have
|
|
1204
|
+
// to re-supply every `required` field just to pass validation.
|
|
1205
|
+
enforceStrict({ path: existingNote.path, tags: [...projectedTags], metadata: projectedMetadata });
|
|
1206
|
+
|
|
1207
|
+
// vault#555 fix 2 (W8 fix-2 bug class, generalist must-fix): a
|
|
1208
|
+
// tags-only or links-only "update" leaves `updates` empty, so
|
|
1209
|
+
// gating store.updateNote on `updates` ALONE would skip it — and
|
|
1210
|
+
// then a genuine tag/link mutation (store.tagNote below) would never
|
|
1211
|
+
// bump `updated_at`, breaking cursor polling (`ORDER BY updated_at`)
|
|
1212
|
+
// and any since-last-check sync. Gate on the tag/link mutation too,
|
|
1213
|
+
// mirroring the update-note/PATCH path's hasTagMutation||hasLinkMutation.
|
|
1214
|
+
// store.updateNote with empty core fields still issues a real UPDATE
|
|
1215
|
+
// that bumps updated_at (+ last_updated_by/via). "replace" always
|
|
1216
|
+
// sets content+metadata, so it was never affected.
|
|
1217
|
+
const hasLinkMutation = item.links !== undefined;
|
|
1218
|
+
let result: Note = existingNote;
|
|
1219
|
+
if (Object.keys(updates).length > 0 || incomingTags.length > 0 || hasLinkMutation) {
|
|
1220
|
+
result = await store.updateNote(existingNote.id, {
|
|
1221
|
+
...updates,
|
|
1222
|
+
actor: writeActor,
|
|
1223
|
+
via: writeVia,
|
|
1224
|
+
});
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
// Content-wikilink warnings (vault#570) — this branch's content
|
|
1228
|
+
// update (if any) already ran `syncWikilinks` inside
|
|
1229
|
+
// `store.updateNote` above; queue the (noteId, content) pair for
|
|
1230
|
+
// the shared second-pass classification below (same treatment as
|
|
1231
|
+
// a fresh create's content).
|
|
1232
|
+
if (updates.content !== undefined) {
|
|
1233
|
+
contentWikilinkNotes.push({ noteId: result.id, content: updates.content });
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
if (incomingTags.length > 0) {
|
|
1237
|
+
await store.tagNote(result.id, incomingTags);
|
|
1238
|
+
// Note: applySchemaDefaults also runs in the outer batch loop for
|
|
1239
|
+
// this note (it's not in `noMutateIds` — only "ignore" hits are),
|
|
1240
|
+
// so this inline call is redundant. Harmless (idempotent — fills
|
|
1241
|
+
// only still-missing fields), kept so the update/replace branch is
|
|
1242
|
+
// self-contained; left un-gated to avoid coupling to the outer loop.
|
|
1243
|
+
await applySchemaDefaults(store, db, [result.id], incomingTags);
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
if (item.links) {
|
|
1247
|
+
pendingLinks.push({ sourceId: result.id, links: item.links as { target: string; relationship: string }[] });
|
|
1248
|
+
}
|
|
1249
|
+
|
|
1250
|
+
return { note: noteOps.getNote(db, result.id) ?? result, noMutate: false };
|
|
1251
|
+
};
|
|
1252
|
+
|
|
919
1253
|
// Wrap multi-item batches in a SQLite transaction so a mid-batch
|
|
920
1254
|
// failure rolls back every prior insert — see #236. This guards
|
|
921
1255
|
// anything thrown from store.createNote / createLink (path
|
|
922
1256
|
// conflict, etc.). Single-item calls skip the wrap to avoid
|
|
923
1257
|
// colliding with concurrent callers on the shared bun:sqlite
|
|
924
|
-
// connection.
|
|
1258
|
+
// connection. Catching a PathConflictError INSIDE this callback
|
|
1259
|
+
// (the if_exists race backstop below) does NOT roll back the
|
|
1260
|
+
// transaction — only a throw that escapes `runBatch` does (see
|
|
1261
|
+
// core/src/txn.ts) — so the fallback-to-existing-note path commits
|
|
1262
|
+
// normally alongside every other item in the batch.
|
|
925
1263
|
const batched = items.length > 1;
|
|
926
1264
|
const runBatch = async (): Promise<void> => {
|
|
927
1265
|
for (const item of items) {
|
|
@@ -931,6 +1269,28 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
931
1269
|
const extension = item.extension !== undefined
|
|
932
1270
|
? validateExtension(item.extension)
|
|
933
1271
|
: undefined;
|
|
1272
|
+
const effectiveExtension = extension ?? "md";
|
|
1273
|
+
const ifExists = (item.if_exists as string | undefined) ?? "error";
|
|
1274
|
+
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
1275
|
+
|
|
1276
|
+
// Proactive existence check (vault#555) — only possible when a
|
|
1277
|
+
// path is set (a pathless create can never conflict). Handles
|
|
1278
|
+
// the common, non-racing case cleanly: skip the raw-item strict
|
|
1279
|
+
// gate entirely (it would validate the wrong shape — see
|
|
1280
|
+
// `applyExistingNote`) and go straight to the ignore/update/
|
|
1281
|
+
// replace branch. The insert-time catch below is the backstop
|
|
1282
|
+
// for the rare true race.
|
|
1283
|
+
let existingNote: Note | null = null;
|
|
1284
|
+
if (upsertMode && item.path) {
|
|
1285
|
+
const normalized = normalizePath(item.path as string);
|
|
1286
|
+
existingNote = normalized ? noteOps.getNoteByPath(db, normalized, effectiveExtension) : null;
|
|
1287
|
+
}
|
|
1288
|
+
if (existingNote) {
|
|
1289
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, existingNote, ifExists as "ignore" | "update" | "replace");
|
|
1290
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1291
|
+
continue;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
934
1294
|
// Strict-schema gate (vault#299) — reject before any write so a
|
|
935
1295
|
// mid-batch violation rolls back the batch transaction.
|
|
936
1296
|
enforceStrict({
|
|
@@ -938,22 +1298,51 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
938
1298
|
tags: item.tags as string[] | undefined,
|
|
939
1299
|
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
940
1300
|
});
|
|
941
|
-
|
|
942
|
-
|
|
943
|
-
|
|
944
|
-
|
|
945
|
-
|
|
946
|
-
|
|
947
|
-
|
|
948
|
-
|
|
949
|
-
|
|
950
|
-
|
|
951
|
-
|
|
1301
|
+
let note: Note;
|
|
1302
|
+
try {
|
|
1303
|
+
note = await store.createNote(item.content as string ?? "", {
|
|
1304
|
+
path: item.path as string | undefined,
|
|
1305
|
+
tags: item.tags as string[] | undefined,
|
|
1306
|
+
metadata: item.metadata as Record<string, unknown> | undefined,
|
|
1307
|
+
created_at: item.created_at as string | undefined,
|
|
1308
|
+
...(extension !== undefined ? { extension } : {}),
|
|
1309
|
+
// Write-attribution (vault#298) — same actor/via for every item
|
|
1310
|
+
// in a batch (the whole call came from one authenticated session).
|
|
1311
|
+
actor: writeActor,
|
|
1312
|
+
via: writeVia,
|
|
1313
|
+
});
|
|
1314
|
+
} catch (err) {
|
|
1315
|
+
// Race backstop (vault#555): a concurrent writer's INSERT
|
|
1316
|
+
// committed between our proactive check (skipped or missed
|
|
1317
|
+
// above) and this one. Re-resolve the now-existing row and
|
|
1318
|
+
// fall through to the SAME branch a proactive hit would have
|
|
1319
|
+
// taken — closes the create-race gap even under true
|
|
1320
|
+
// concurrency, not just the sequential common case.
|
|
1321
|
+
if (upsertMode && err instanceof noteOps.PathConflictError) {
|
|
1322
|
+
const winner = noteOps.getNoteByPath(db, err.path, effectiveExtension);
|
|
1323
|
+
if (winner) {
|
|
1324
|
+
const { note: resultNote, noMutate } = await applyExistingNote(item, winner, ifExists as "ignore" | "update" | "replace");
|
|
1325
|
+
recordExistedBranch(resultNote, noMutate);
|
|
1326
|
+
continue;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
throw err;
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
if (upsertMode) existedMap.set(note.id, false);
|
|
952
1333
|
|
|
953
1334
|
if (item.links) {
|
|
954
1335
|
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
955
1336
|
}
|
|
956
1337
|
|
|
1338
|
+
// Content-wikilink warnings (vault#570) — `store.createNote`
|
|
1339
|
+
// above already ran `syncWikilinks` (gated on `content` truthy,
|
|
1340
|
+
// same condition here) — queue for the shared second-pass
|
|
1341
|
+
// classification below.
|
|
1342
|
+
if (item.content) {
|
|
1343
|
+
contentWikilinkNotes.push({ noteId: note.id, content: item.content as string });
|
|
1344
|
+
}
|
|
1345
|
+
|
|
957
1346
|
created.push(noteOps.getNote(db, note.id) ?? note);
|
|
958
1347
|
}
|
|
959
1348
|
|
|
@@ -963,24 +1352,44 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
963
1352
|
// STILL doesn't resolve (typo, or a note that arrives in a later
|
|
964
1353
|
// call) is queued for lazy resolution — it backfills automatically
|
|
965
1354
|
// the moment a matching note is created — and surfaces an
|
|
966
|
-
// `unresolved_link` warning naming the target.
|
|
1355
|
+
// `unresolved_link` warning naming the target. A target that
|
|
1356
|
+
// matched ≥2 notes (vault#570) is neither linked nor queued —
|
|
1357
|
+
// surfaces a distinct `ambiguous_link` warning instead. Never silent.
|
|
967
1358
|
for (const { sourceId, links } of pendingLinks) {
|
|
968
1359
|
for (const link of links) {
|
|
969
|
-
const
|
|
970
|
-
if (
|
|
971
|
-
await store.createLink(sourceId,
|
|
1360
|
+
const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1361
|
+
if (outcome.status === "resolved") {
|
|
1362
|
+
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
1363
|
+
} else if (outcome.status === "ambiguous") {
|
|
1364
|
+
pushLinkWarning(sourceId, {
|
|
1365
|
+
code: "ambiguous_link",
|
|
1366
|
+
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
1367
|
+
target: link.target,
|
|
1368
|
+
relationship: link.relationship,
|
|
1369
|
+
candidate_count: outcome.candidates.length,
|
|
1370
|
+
});
|
|
972
1371
|
} else {
|
|
973
|
-
|
|
974
|
-
list.push({
|
|
1372
|
+
pushLinkWarning(sourceId, {
|
|
975
1373
|
code: "unresolved_link",
|
|
976
1374
|
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
977
1375
|
target: link.target,
|
|
978
1376
|
relationship: link.relationship,
|
|
979
1377
|
});
|
|
980
|
-
linkWarningsByNote.set(sourceId, list);
|
|
981
1378
|
}
|
|
982
1379
|
}
|
|
983
1380
|
}
|
|
1381
|
+
|
|
1382
|
+
// --- Content-wikilink warnings (vault#570) ---
|
|
1383
|
+
// Same forward-ref-aware timing as the structured-links pass above
|
|
1384
|
+
// — every sibling in this batch exists by now, so a content
|
|
1385
|
+
// `[[wikilink]]` to a later batch item already resolved via the
|
|
1386
|
+
// pending-wikilink backfill (`resolveUnresolvedWikilinks`, run
|
|
1387
|
+
// inside each `store.createNote`/`updateNote` call above).
|
|
1388
|
+
for (const { noteId, content } of contentWikilinkNotes) {
|
|
1389
|
+
for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
|
|
1390
|
+
pushLinkWarning(noteId, warning);
|
|
1391
|
+
}
|
|
1392
|
+
}
|
|
984
1393
|
};
|
|
985
1394
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
986
1395
|
|
|
@@ -991,9 +1400,12 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
991
1400
|
// update-note path, which already re-reads post-defaults. The re-read
|
|
992
1401
|
// is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
|
|
993
1402
|
// entirely when no defaults were applied, so the common no-defaults
|
|
994
|
-
// path adds zero extra reads.
|
|
1403
|
+
// path adds zero extra reads. `noMutateIds` ("ignore" hits) are
|
|
1404
|
+
// skipped entirely — "ignore" promises zero mutation, including
|
|
1405
|
+
// backfill of a since-added default the existing note predates.
|
|
995
1406
|
const mutatedIds = new Set<string>();
|
|
996
1407
|
for (const note of created) {
|
|
1408
|
+
if (noMutateIds.has(note.id)) continue;
|
|
997
1409
|
if (note.tags && note.tags.length > 0) {
|
|
998
1410
|
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
999
1411
|
mutatedIds.add(id);
|
|
@@ -1014,14 +1426,33 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
1014
1426
|
// applies to this note, against the post-defaults state. Fold in any
|
|
1015
1427
|
// `unresolved_link` warnings collected above (vault#555) — additive,
|
|
1016
1428
|
// present only when this note's `links` had a target that didn't
|
|
1017
|
-
// resolve.
|
|
1429
|
+
// resolve. `existed` (vault#555) is attached ONLY for items whose
|
|
1430
|
+
// `if_exists` actually engaged — the default "error" path's response
|
|
1431
|
+
// shape is untouched.
|
|
1018
1432
|
const final = refreshed.map((n) => {
|
|
1019
1433
|
const validated = attachValidationStatus(store, db, n);
|
|
1020
1434
|
const warnings = linkWarningsByNote.get(n.id);
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1435
|
+
const existed = existedMap.get(n.id);
|
|
1436
|
+
let out: Note & { warnings?: QueryWarning[]; existed?: boolean } = validated;
|
|
1437
|
+
if (warnings && warnings.length > 0) out = { ...out, warnings };
|
|
1438
|
+
if (existed !== undefined) out = { ...out, existed };
|
|
1439
|
+
return out;
|
|
1024
1440
|
});
|
|
1441
|
+
|
|
1442
|
+
// Batch summary (vault#555) — compact shape instead of N full note
|
|
1443
|
+
// objects. Batch-only (a `notes` array); `summary` is ignored on a
|
|
1444
|
+
// single-note call, matching `if_exists`'s per-item batch scoping.
|
|
1445
|
+
if (batch && params.summary === true) {
|
|
1446
|
+
return {
|
|
1447
|
+
created: final.filter((n: any) => n.existed !== true).length,
|
|
1448
|
+
ids: final.map((n) => n.id),
|
|
1449
|
+
// Reserved for future partial-batch-failure reporting — a batch
|
|
1450
|
+
// create is all-or-nothing today (see the tool description), so
|
|
1451
|
+
// this is always empty.
|
|
1452
|
+
failed: [] as unknown[],
|
|
1453
|
+
};
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1025
1456
|
return batch ? final : final[0];
|
|
1026
1457
|
},
|
|
1027
1458
|
},
|
|
@@ -1032,7 +1463,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
1032
1463
|
{
|
|
1033
1464
|
name: "update-note",
|
|
1034
1465
|
requiredVerb: "write",
|
|
1035
|
-
description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
|
|
1466
|
+
description: `Update one or more notes. Accepts ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title. Supports content, path, metadata updates plus tag and link mutations.
|
|
1036
1467
|
|
|
1037
1468
|
- Three content-modification modes (mutually exclusive):
|
|
1038
1469
|
- \`content\` — full replace.
|
|
@@ -1050,7 +1481,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1050
1481
|
inputSchema: {
|
|
1051
1482
|
type: "object",
|
|
1052
1483
|
properties: {
|
|
1053
|
-
id: { type: "string", description: "Note ID or path" },
|
|
1484
|
+
id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
1054
1485
|
content: { type: "string", description: "New content (full replace). Mutually exclusive with `append`/`prepend` and `content_edit`." },
|
|
1055
1486
|
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." },
|
|
1056
1487
|
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." },
|
|
@@ -1096,7 +1527,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1096
1527
|
items: {
|
|
1097
1528
|
type: "object",
|
|
1098
1529
|
properties: {
|
|
1099
|
-
target: { type: "string", description: "Target note ID or
|
|
1530
|
+
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
1100
1531
|
relationship: { type: "string" },
|
|
1101
1532
|
},
|
|
1102
1533
|
required: ["target", "relationship"],
|
|
@@ -1107,14 +1538,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1107
1538
|
items: {
|
|
1108
1539
|
type: "object",
|
|
1109
1540
|
properties: {
|
|
1110
|
-
target: { type: "string", description: "Target note ID or
|
|
1541
|
+
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
1111
1542
|
relationship: { type: "string" },
|
|
1112
1543
|
},
|
|
1113
1544
|
required: ["target", "relationship"],
|
|
1114
1545
|
},
|
|
1115
1546
|
},
|
|
1116
1547
|
},
|
|
1117
|
-
description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename
|
|
1548
|
+
description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename, then (only on a clean miss, and only when exactly one note matches) an H1-title fallback — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
|
|
1118
1549
|
},
|
|
1119
1550
|
include_content: {
|
|
1120
1551
|
type: "boolean",
|
|
@@ -1216,6 +1647,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1216
1647
|
const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
|
|
1217
1648
|
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
1218
1649
|
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1650
|
+
const pushLinkWarning = (noteId: string, warning: QueryWarning): void => {
|
|
1651
|
+
const list = linkWarningsByNote.get(noteId) ?? [];
|
|
1652
|
+
list.push(warning);
|
|
1653
|
+
linkWarningsByNote.set(noteId, list);
|
|
1654
|
+
};
|
|
1655
|
+
// Content wikilinks whose note+content pair needs an `unresolved_link`/
|
|
1656
|
+
// `ambiguous_link` warning check (vault#570) — deferred to the same
|
|
1657
|
+
// second pass as `pendingLinks` for the same forward-ref-within-batch
|
|
1658
|
+
// reason.
|
|
1659
|
+
const contentWikilinkNotes: { noteId: string; content: string }[] = [];
|
|
1219
1660
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
1220
1661
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
1221
1662
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -1312,6 +1753,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1312
1753
|
if (linksAdd) {
|
|
1313
1754
|
pendingLinks.push({ sourceId: created.id, links: linksAdd });
|
|
1314
1755
|
}
|
|
1756
|
+
// Content-wikilink warnings (vault#570) — same deferral as
|
|
1757
|
+
// create-note's fresh-create branch.
|
|
1758
|
+
if (content) {
|
|
1759
|
+
contentWikilinkNotes.push({ noteId: created.id, content });
|
|
1760
|
+
}
|
|
1315
1761
|
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
1316
1762
|
updated.push(fresh);
|
|
1317
1763
|
createdIds.add(fresh.id);
|
|
@@ -1509,6 +1955,15 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1509
1955
|
const hasLinkMutation = (item.links as any)?.add !== undefined
|
|
1510
1956
|
|| (item.links as any)?.remove !== undefined;
|
|
1511
1957
|
|
|
1958
|
+
// Content-wikilink warnings (vault#570) gate on the SAME
|
|
1959
|
+
// condition `store.updateNote`/`syncWikilinks` use to decide
|
|
1960
|
+
// whether to re-sync content wikilinks at all — a tags/links-only
|
|
1961
|
+
// update must not spuriously re-warn about pre-existing broken
|
|
1962
|
+
// links this call never touched.
|
|
1963
|
+
const contentChanged = updates.content !== undefined
|
|
1964
|
+
|| updates.append !== undefined
|
|
1965
|
+
|| updates.prepend !== undefined;
|
|
1966
|
+
|
|
1512
1967
|
let result: Note;
|
|
1513
1968
|
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
1514
1969
|
// Write-attribution (vault#298): stamp the most-recent-write
|
|
@@ -1562,7 +2017,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1562
2017
|
}
|
|
1563
2018
|
|
|
1564
2019
|
// Re-read for final state
|
|
1565
|
-
|
|
2020
|
+
const finalNote = noteOps.getNote(db, note.id) ?? result;
|
|
2021
|
+
if (contentChanged) {
|
|
2022
|
+
contentWikilinkNotes.push({ noteId: note.id, content: finalNote.content });
|
|
2023
|
+
}
|
|
2024
|
+
updated.push(finalNote);
|
|
1566
2025
|
}
|
|
1567
2026
|
|
|
1568
2027
|
// --- Resolve structured `links.add` (vault#555) ---
|
|
@@ -1575,21 +2034,35 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1575
2034
|
// never silently dropped.
|
|
1576
2035
|
for (const { sourceId, links } of pendingLinks) {
|
|
1577
2036
|
for (const link of links) {
|
|
1578
|
-
const
|
|
1579
|
-
if (
|
|
1580
|
-
await store.createLink(sourceId,
|
|
2037
|
+
const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
2038
|
+
if (outcome.status === "resolved") {
|
|
2039
|
+
await store.createLink(sourceId, outcome.note_id, link.relationship, link.metadata);
|
|
2040
|
+
} else if (outcome.status === "ambiguous") {
|
|
2041
|
+
pushLinkWarning(sourceId, {
|
|
2042
|
+
code: "ambiguous_link",
|
|
2043
|
+
message: `link target "${link.target}" (relationship "${link.relationship}") matched ${outcome.candidates.length} notes — ambiguous, no link created. Use a more specific path or the note's ID to disambiguate.`,
|
|
2044
|
+
target: link.target,
|
|
2045
|
+
relationship: link.relationship,
|
|
2046
|
+
candidate_count: outcome.candidates.length,
|
|
2047
|
+
});
|
|
1581
2048
|
} else {
|
|
1582
|
-
|
|
1583
|
-
list.push({
|
|
2049
|
+
pushLinkWarning(sourceId, {
|
|
1584
2050
|
code: "unresolved_link",
|
|
1585
2051
|
message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
|
|
1586
2052
|
target: link.target,
|
|
1587
2053
|
relationship: link.relationship,
|
|
1588
2054
|
});
|
|
1589
|
-
linkWarningsByNote.set(sourceId, list);
|
|
1590
2055
|
}
|
|
1591
2056
|
}
|
|
1592
2057
|
}
|
|
2058
|
+
|
|
2059
|
+
// --- Content-wikilink warnings (vault#570) ---
|
|
2060
|
+
// Same forward-ref-aware timing as the structured-links pass above.
|
|
2061
|
+
for (const { noteId, content } of contentWikilinkNotes) {
|
|
2062
|
+
for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
|
|
2063
|
+
pushLinkWarning(noteId, warning);
|
|
2064
|
+
}
|
|
2065
|
+
}
|
|
1593
2066
|
};
|
|
1594
2067
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1595
2068
|
|
|
@@ -1647,11 +2120,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1647
2120
|
// genuinely append-only callers — gating WITHIN write rather
|
|
1648
2121
|
// than promoting deletes out of it.
|
|
1649
2122
|
requiredVerb: "write",
|
|
1650
|
-
description: "Permanently delete a note and all its tags and links. Accepts ID or path.",
|
|
2123
|
+
description: "Permanently delete a note and all its tags and links. Accepts ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title.",
|
|
1651
2124
|
inputSchema: {
|
|
1652
2125
|
type: "object",
|
|
1653
2126
|
properties: {
|
|
1654
|
-
id: { type: "string", description: "Note ID or path" },
|
|
2127
|
+
id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
1655
2128
|
},
|
|
1656
2129
|
required: ["id"],
|
|
1657
2130
|
},
|
|
@@ -1668,7 +2141,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1668
2141
|
{
|
|
1669
2142
|
name: "list-tags",
|
|
1670
2143
|
requiredVerb: "read",
|
|
1671
|
-
description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag.`,
|
|
2144
|
+
description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag. NOTE (vault#555): this list includes zero-membership tags (\`count: 0\` — a declared schema never yet applied, or a tag every note was since untagged from), so its length can run higher than \`vault-info\`'s stats \`tagCount\`, which counts only tags at least one note currently carries.`,
|
|
1672
2145
|
inputSchema: {
|
|
1673
2146
|
type: "object",
|
|
1674
2147
|
properties: {
|
|
@@ -1743,7 +2216,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1743
2216
|
// =====================================================================
|
|
1744
2217
|
{
|
|
1745
2218
|
name: "update-tag",
|
|
1746
|
-
|
|
2219
|
+
// `admin` (was `write`) — this PR: update-tag defines a tag's SCHEMA
|
|
2220
|
+
// (description, indexed-field types, relationship vocabulary,
|
|
2221
|
+
// hierarchy parents), which every note carrying the tag inherits.
|
|
2222
|
+
// That's structure/taxonomy curation, not content authorship — the
|
|
2223
|
+
// same distinction that keeps content out of admin and structure out
|
|
2224
|
+
// of write. See the `generateMcpTools` doc comment above for the full
|
|
2225
|
+
// re-tier rationale + BREAKING note.
|
|
2226
|
+
requiredVerb: "admin",
|
|
1747
2227
|
description: "Create or update a tag's identity row: description, indexed-field schemas, relationship-vocabulary map, and hierarchy parents. If the tag doesn't exist, it's created. Fields are merged (new keys added, existing keys replaced); relationships and parent_names are replaced wholesale when provided. Pass null for fields/relationships/parent_names to clear that column. See parachute-vault/docs/contracts/tag-data-model.md.",
|
|
1748
2228
|
inputSchema: {
|
|
1749
2229
|
type: "object",
|
|
@@ -1756,11 +2236,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1756
2236
|
additionalProperties: {
|
|
1757
2237
|
type: "object",
|
|
1758
2238
|
properties: {
|
|
1759
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all
|
|
2239
|
+
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object, reference — all seven are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean/reference are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field). `reference` is a DUAL-WRITE type (typed-reference-field): the value is stored + validated exactly like `string` (pass a note id, path, or title), AND create-note/update-note additionally resolve that value to a note and maintain a graph `links` edge from this note to it, with `relationship` set to the field name — kept in sync on every write that changes the field (a new value re-points the link; clearing the field drops it). A target that doesn't resolve yet is queued and backfills automatically, same as a structured `links` entry — see `docs/design/typed-reference-field.md`." },
|
|
1760
2240
|
description: { type: "string" },
|
|
1761
2241
|
enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
|
|
1762
2242
|
default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
|
|
1763
|
-
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
2243
|
+
indexed: { type: "boolean", description: "When true, a generated column + index are maintained on notes.metadata.<field>, making it queryable via metadata operator objects and order_by. Global: all tags declaring the field must agree on both type and indexed. Only string/integer/boolean/reference are indexable. Indexed ⇒ a type-mismatched write is HARD-REJECTED (schema_validation), not just warned — vault#553." },
|
|
1764
2244
|
strict: { type: "boolean", description: "vault#299. Default false (advisory). When true, ALL of this field's declared constraints (type + enum + required + cardinality) are ENFORCED — a violating write is rejected with a schema_validation error, not just warned. All-or-nothing per field; free-form fields on a strict tag simply leave strict off. Note: `indexed: true` fields enforce their TYPE constraint regardless of this flag (vault#553)." },
|
|
1765
2245
|
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
1766
2246
|
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
@@ -1879,11 +2359,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1879
2359
|
// =====================================================================
|
|
1880
2360
|
{
|
|
1881
2361
|
name: "delete-tag",
|
|
1882
|
-
// `write` — Aaron's
|
|
1883
|
-
// mgmt + future config writes; deletes
|
|
1884
|
-
//
|
|
1885
|
-
|
|
1886
|
-
|
|
2362
|
+
// `admin` (was `write` — Aaron's 2026-05-27 call reserved admin for
|
|
2363
|
+
// token mgmt + future config writes; deletes were write-tier
|
|
2364
|
+
// mutations, see delete-note's rationale). Superseded by this PR:
|
|
2365
|
+
// delete-tag removes a tag's identity row + schema and untags it
|
|
2366
|
+
// vault-wide — that's structure/taxonomy curation, the same class as
|
|
2367
|
+
// update-tag/rename-tag/merge-tags, not content authorship. See the
|
|
2368
|
+
// `generateMcpTools` doc comment above for the full re-tier rationale
|
|
2369
|
+
// + BREAKING note.
|
|
2370
|
+
requiredVerb: "admin",
|
|
2371
|
+
description: "Delete a tag, remove it from all notes, and delete its schema. Notes themselves are NOT deleted — just untagged. Refused with error_type \"tag_referenced_as_parent\" (vault#552) when another tag's parent_names still names this one — pass cascade OR detach (either — both mean the same thing: strip the stale reference from the referencing tag(s)' parent_names, never delete them) to proceed anyway. Also refused with error_type \"tag_in_use_by_tokens\" (vault#555 fix — this case existed pre-#555 but was undocumented here; see \"merge-tags\" for the identical guard) when the tag is referenced by a tag-scoped token's allowlist — revoke or re-mint the token(s) first. A no-op on a tag with no identity row and no notes returns {deleted: false, notes_untagged: 0} rather than erroring.",
|
|
1887
2372
|
inputSchema: {
|
|
1888
2373
|
type: "object",
|
|
1889
2374
|
properties: {
|
|
@@ -1917,7 +2402,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1917
2402
|
// =====================================================================
|
|
1918
2403
|
{
|
|
1919
2404
|
name: "rename-tag",
|
|
1920
|
-
|
|
2405
|
+
// `admin` (was `write`) — this PR: an atomic cascading rename across
|
|
2406
|
+
// note memberships, other tags' parent_names, tokens' allowlists,
|
|
2407
|
+
// indexed-field declarer lists, and inline #tag mentions is structural
|
|
2408
|
+
// taxonomy surgery, not content authorship. Same tier as
|
|
2409
|
+
// update-tag/delete-tag/merge-tags. See the `generateMcpTools` doc
|
|
2410
|
+
// comment above for the full re-tier rationale + BREAKING note.
|
|
2411
|
+
requiredVerb: "admin",
|
|
1921
2412
|
description:
|
|
1922
2413
|
"Atomically rename a tag across EVERY surface that references it: note memberships, OTHER tags' parent_names, tag-scoped tokens' allowlists, indexed-field declarer lists, inline #tag mentions in note bodies, and _tags/<name> config-note paths — all in one transaction. THIS is the fix for the manual retag→delete dance (create the new tag, retag notes, delete the old one): that dance silently orphans parent_names references (the renamed-away tag stays a live query surface via subtype expansion while list-tags reports it at count 0, and the new tag misses every child-tagged note) and leaves stale #tag mentions behind. Sub-tags rename recursively — renaming \"task\" to \"todo\" also renames \"task/work\" to \"todo/work\". Does NOT rewrite metadata values that happen to equal the old tag name (e.g. metadata.epic: \"task\") — that's a distinct drift class the doctor tool's dead_tag_metadata_reference finding flags heuristically; rename-tag's job is structural (tags/note_tags/parent_names/tokens/content), not a blind string search-and-replace over arbitrary metadata.",
|
|
1923
2414
|
inputSchema: {
|
|
@@ -1974,7 +2465,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1974
2465
|
// =====================================================================
|
|
1975
2466
|
{
|
|
1976
2467
|
name: "merge-tags",
|
|
1977
|
-
|
|
2468
|
+
// `admin` (was `write`) — this PR: merging N source tags into a
|
|
2469
|
+
// target (retagging every note, dropping the sources' identity rows)
|
|
2470
|
+
// is structural taxonomy surgery, not content authorship. Same tier
|
|
2471
|
+
// as update-tag/delete-tag/rename-tag. See the `generateMcpTools` doc
|
|
2472
|
+
// comment above for the full re-tier rationale + BREAKING note.
|
|
2473
|
+
requiredVerb: "admin",
|
|
1978
2474
|
description:
|
|
1979
2475
|
"Atomically merge one or more source tags into a target tag: every note carrying any source is retagged with the target, then the source tags (and their identity rows — description/fields/relationships/parent_names) are dropped. target is created if it doesn't exist yet; target's own schema is preserved (sources' schemas are consumed, not merged field-by-field). Sources that don't exist are reported at count 0. Refused with error_type \"tag_in_use_by_tokens\" if a source is referenced by a tag-scoped token — revoke or re-mint it first.",
|
|
1980
2476
|
inputSchema: {
|
|
@@ -2010,12 +2506,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2010
2506
|
{
|
|
2011
2507
|
name: "find-path",
|
|
2012
2508
|
requiredVerb: "read",
|
|
2013
|
-
description: "Find the shortest path between two notes in the link graph. Accepts IDs or
|
|
2509
|
+
description: "Find the shortest path between two notes in the link graph. Accepts IDs, paths, or (fallback, only when id/path both miss and exactly one note matches) H1 titles. Returns null if no path exists, else `{path, relationships, nodes, edges}`: `path` (note IDs, source→target) and `relationships` (relationships[i] connects path[i] to path[i+1]) are the original id-only shape; `nodes` (vault#550, additive) hydrates each id in `path` with the note's own `path` field — `[{id, path}]` in the same order; `edges` (additive) is the self-contained hop list — `[{source, target, relationship, sourcePath, targetPath}]` — for rendering the chain without cross-referencing `nodes`.",
|
|
2014
2510
|
inputSchema: {
|
|
2015
2511
|
type: "object",
|
|
2016
2512
|
properties: {
|
|
2017
|
-
source: { type: "string", description: "Starting note ID or
|
|
2018
|
-
target: { type: "string", description: "Destination note ID or
|
|
2513
|
+
source: { type: "string", description: "Starting note ID, path, or (fallback) H1 title" },
|
|
2514
|
+
target: { type: "string", description: "Destination note ID, path, or (fallback) H1 title" },
|
|
2019
2515
|
max_depth: { type: "number", description: "Max path length (default 5)" },
|
|
2020
2516
|
},
|
|
2021
2517
|
required: ["source", "target"],
|
|
@@ -2035,11 +2531,15 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2035
2531
|
{
|
|
2036
2532
|
name: "vault-info",
|
|
2037
2533
|
// `read` so vault:read callers can fetch stats. The
|
|
2038
|
-
// description-update branch performs an inner
|
|
2534
|
+
// description-update branch performs an inner ADMIN-check (see
|
|
2039
2535
|
// overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
|
|
2040
|
-
// `
|
|
2536
|
+
// `admin` or read-only callers lose the stats projection. Was an
|
|
2537
|
+
// inner write-check pre-this-PR; writing the vault's own
|
|
2538
|
+
// description/config is curation, not content, so it moved to the
|
|
2539
|
+
// same admin tier as the other structure-curation tools (update-tag
|
|
2540
|
+
// et al) — see the `generateMcpTools` doc comment above.
|
|
2041
2541
|
requiredVerb: "read",
|
|
2042
|
-
description: "Get a comprehensive vault projection: name, description, tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, and
|
|
2542
|
+
description: "Get a comprehensive vault projection: name, description, `coordinates` (this vault's own REST/MCP URL templates — `{name, base_url, rest_api, mcp}`, always present), tags-with-schemas (own + effective parents/fields per #270 inheritance), indexed metadata fields catalog, query hints, `map` (front-door structural orientation, always present — see below), and (when a seeded onboarding guide exists) a `getting_started` note pointer. Pass `include_stats: true` to add note/tag/link counts and the monthly distribution as a `stats` field. Pass `description` to update the vault description (changes how AI agents behave in future sessions) — requires the `vault:admin` scope for this vault even though the tool itself is read-gated (vault#555 originally required `vault:write` here; a later PR tightened it to `vault:admin` since a description edit is curation, not content — a `vault:read`-or-`vault:write`-only caller passing `description` gets a `Forbidden` rejection, not a silent no-op). Call this anytime mid-session to refresh schema context. NOTE (vault#555): the stats `tagCount` counts only tags at least one note currently carries (`COUNT(DISTINCT tag_name)` over note-tag memberships) — `list-tags`'s row count can run higher because it also lists zero-membership tags (an identity row from a declared schema or a since-untagged tag). Neither is wrong; they answer different questions. `map` — `{ total_notes, tags: [{name, count}], path_buckets: [{name, count}], unfiled_notes }` — is a compact, counts-only structural rollup (no content) meant to orient a fresh reader in this ONE call, no `include_stats` needed: every tag currently in use with its membership count, and every top-level path segment (the text before the first `/`) with how many notes live under it, plus how many notes carry no path at all. For a tag-scoped token, `map.tags`/`map.path_buckets`/`map.total_notes`/`map.unfiled_notes` cover only notes reachable through an in-scope tag — same confidentiality posture as the `tags`/`indexed_fields` catalogs above.",
|
|
2043
2543
|
inputSchema: {
|
|
2044
2544
|
type: "object",
|
|
2045
2545
|
properties: {
|
|
@@ -2095,11 +2595,19 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2095
2595
|
// =====================================================================
|
|
2096
2596
|
{
|
|
2097
2597
|
name: "doctor",
|
|
2098
|
-
// `admin` —
|
|
2099
|
-
//
|
|
2100
|
-
|
|
2598
|
+
// `read` (was `admin` — the original reasoning: same tier as
|
|
2599
|
+
// prune-schema, a diagnostic over the WHOLE vault's taxonomy, not
|
|
2600
|
+
// scoped to any one tag's write authority). Superseded by this PR:
|
|
2601
|
+
// doctor never mutates and is ALREADY tag-scope-restricted at the MCP
|
|
2602
|
+
// layer (see `applyTagScopeWrappers`'s `doctor` wrapper in
|
|
2603
|
+
// src/mcp-tools.ts, which re-runs the scan against the caller's
|
|
2604
|
+
// allowlist) — it's a read, not a curation op, and read-scoped
|
|
2605
|
+
// monitoring/tending jobs need to be able to run it without an admin
|
|
2606
|
+
// credential. The REST `GET /api/doctor` endpoint (routing.ts) is
|
|
2607
|
+
// re-tiered to `read` too, so both doors agree — no MCP/REST divergence.
|
|
2608
|
+
requiredVerb: "read",
|
|
2101
2609
|
description:
|
|
2102
|
-
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked.
|
|
2610
|
+
"Read-only integrity scan across the tag/metadata taxonomy — run this after any bulk tag reorg (rename/merge/delete/subtree move) to confirm nothing leaked. Returns {findings, summary, scanned_at} — findings is an array, each entry {type, severity, subject, detail, remedy} — NEVER auto-fixes; apply the suggested remedy (usually rename-tag/merge-tags/update-tag/prune-schema) yourself. Finding types: dangling_parent_name (a parent_names entry naming a tag with no identity row), parent_names_cycle (a tag reaching itself through its ancestor chain — traversal tolerates this, but it's dishonest hierarchy state), mixed_type_indexed_field (a note's metadata value for an indexed field has a JSON type disagreeing with the field's declared storage type — the ordering/filtering-goes-silently-wrong precursor), orphaned_indexed_field_declarer (an indexed field naming a dead declarer tag — see prune-schema), and dead_tag_metadata_reference (HEURISTIC, always carries heuristic:true — a metadata value that looks like a stale reference to a renamed/merged/deleted tag, inferred from sibling notes using the same metadata key with values that ARE live tags; can never be certain since vault keeps no tag-rename history).",
|
|
2103
2611
|
inputSchema: { type: "object", properties: {} },
|
|
2104
2612
|
execute: async () => {
|
|
2105
2613
|
return await store.doctor();
|