@openparachute/vault 0.7.0-rc.9 → 0.7.2-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +737 -2
- package/core/src/cursor-keyset-ms.test.ts +537 -0
- package/core/src/cursor.ts +76 -6
- package/core/src/doctor.ts +23 -1
- package/core/src/hooks.ts +9 -0
- 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 +322 -53
- package/core/src/notes.ts +518 -67
- package/core/src/paths.ts +4 -0
- package/core/src/portable-md.test.ts +161 -0
- package/core/src/portable-md.ts +140 -9
- package/core/src/query-warnings.ts +60 -12
- package/core/src/schema-defaults.ts +7 -1
- package/core/src/schema.ts +128 -2
- package/core/src/seed-packs.ts +55 -15
- package/core/src/store.ts +141 -7
- package/core/src/tag-schemas.ts +20 -7
- package/core/src/txn.test.ts +100 -4
- package/core/src/txn.ts +119 -24
- package/core/src/types.ts +89 -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 +200 -35
- package/package.json +1 -1
- package/src/aggregate-routes.test.ts +230 -0
- package/src/cli.ts +6 -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 +76 -17
- package/src/mirror-import.ts +5 -0
- package/src/routes.ts +298 -24
- 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 +557 -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,8 @@
|
|
|
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
|
-
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
|
+
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError, validatePath } from "./notes.js";
|
|
6
6
|
import { normalizePath } from "./paths.js";
|
|
7
7
|
import { QueryError } from "./query-operators.js";
|
|
8
8
|
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
@@ -21,6 +21,7 @@ import {
|
|
|
21
21
|
resolveStructuredLinkNote,
|
|
22
22
|
getUnresolvedLinksForNote,
|
|
23
23
|
getUnresolvedLinksForNotes,
|
|
24
|
+
getContentWikilinkWarnings,
|
|
24
25
|
} from "./wikilinks.js";
|
|
25
26
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
26
27
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -84,7 +85,9 @@ function structuredError(
|
|
|
84
85
|
|
|
85
86
|
/**
|
|
86
87
|
* Resolve a note identifier — tries ID first, then case-insensitive
|
|
87
|
-
* 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).
|
|
88
91
|
*
|
|
89
92
|
* Path-with-extension form (vault#330 S1): a trailing `.<ext>` matching
|
|
90
93
|
* the extension pattern (`/^[a-z0-9]{1,16}$/i`) is parsed as
|
|
@@ -95,6 +98,12 @@ function structuredError(
|
|
|
95
98
|
* On ambiguous path with no extension hint, `getNoteByPath` throws
|
|
96
99
|
* `AmbiguousPathError` — `resolveNote` propagates it so MCP / REST
|
|
97
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.
|
|
98
107
|
*/
|
|
99
108
|
function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
100
109
|
// Try ID match first (fast, indexed)
|
|
@@ -110,7 +119,9 @@ function resolveNote(db: Database, idOrPath: string): Note | null {
|
|
|
110
119
|
const explicit = noteOps.getNoteByPath(db, extMatch[1]!, extMatch[2]!);
|
|
111
120
|
if (explicit) return explicit;
|
|
112
121
|
}
|
|
113
|
-
|
|
122
|
+
const byPath = noteOps.getNoteByPath(db, idOrPath);
|
|
123
|
+
if (byPath) return byPath;
|
|
124
|
+
return noteOps.getNoteByTitle(db, idOrPath);
|
|
114
125
|
}
|
|
115
126
|
|
|
116
127
|
function requireNote(db: Database, idOrPath: string): Note {
|
|
@@ -220,20 +231,46 @@ export interface GenerateMcpToolsOpts {
|
|
|
220
231
|
* against the full vault exactly as before.
|
|
221
232
|
*/
|
|
222
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;
|
|
223
247
|
}
|
|
224
248
|
|
|
225
249
|
/**
|
|
226
250
|
* Generate the consolidated MCP tools for a vault. Surface (13):
|
|
227
|
-
* query-notes,
|
|
228
|
-
*
|
|
229
|
-
*
|
|
230
|
-
* 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.
|
|
231
267
|
*/
|
|
232
268
|
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
233
269
|
const db: Database = store.db;
|
|
234
270
|
const expandVisibility = opts?.expandVisibility;
|
|
235
271
|
const nearTraversable = opts?.nearTraversable;
|
|
236
272
|
const ifExistsVisible = opts?.ifExistsVisible;
|
|
273
|
+
const aggregateVisibility = opts?.aggregateVisibility;
|
|
237
274
|
// Write-attribution (vault#298) — captured once at tool-generation time
|
|
238
275
|
// (a fresh tool set is generated per MCP request, so this is request-scoped)
|
|
239
276
|
// and folded into every create/update the tools perform.
|
|
@@ -283,7 +320,7 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
|
|
|
283
320
|
requiredVerb: "read",
|
|
284
321
|
description: `Query notes. Returns notes matching the given filters.
|
|
285
322
|
|
|
286
|
-
- **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")
|
|
287
324
|
- **Filter**: pass \`tag\`, \`path\`, \`path_prefix\`, \`search\`, \`metadata\`, date range
|
|
288
325
|
- **Graph neighborhood**: pass \`near\` to scope results to notes within N hops of an anchor note
|
|
289
326
|
- **No filters**: returns all notes (paginated)
|
|
@@ -302,6 +339,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
302
339
|
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
303
340
|
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
304
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\`.
|
|
305
345
|
|
|
306
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.
|
|
307
347
|
|
|
@@ -309,7 +349,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
309
349
|
inputSchema: {
|
|
310
350
|
type: "object",
|
|
311
351
|
properties: {
|
|
312
|
-
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" },
|
|
313
353
|
tag: {
|
|
314
354
|
oneOf: [
|
|
315
355
|
{ type: "string" },
|
|
@@ -391,10 +431,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
391
431
|
},
|
|
392
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.",
|
|
393
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
|
+
},
|
|
394
444
|
near: {
|
|
395
445
|
type: "object",
|
|
396
446
|
properties: {
|
|
397
|
-
note_id: { type: "string", description: "Anchor note ID or
|
|
447
|
+
note_id: { type: "string", description: "Anchor note ID, path, or (fallback) H1 title" },
|
|
398
448
|
depth: { type: "number", description: "Max hops from anchor (default 2, max 5)" },
|
|
399
449
|
relationship: { type: "string", description: "Only follow links with this relationship" },
|
|
400
450
|
},
|
|
@@ -602,6 +652,89 @@ Response shape (vault#550 — three variants, pick by what you passed):
|
|
|
602
652
|
expand = params.expand as TagExpandMode;
|
|
603
653
|
}
|
|
604
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
|
+
|
|
605
738
|
// `search_mode` (vault#551) — validate loudly (same policy as
|
|
606
739
|
// `expand` above) so a typo'd value doesn't silently fall back to
|
|
607
740
|
// the default. Resolved here (before the search/structured branch)
|
|
@@ -914,12 +1047,12 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
914
1047
|
items: {
|
|
915
1048
|
type: "object",
|
|
916
1049
|
properties: {
|
|
917
|
-
target: { type: "string", description: "Target note ID or
|
|
1050
|
+
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
918
1051
|
relationship: { type: "string", description: "Relationship type (e.g., mentions, related-to)" },
|
|
919
1052
|
},
|
|
920
1053
|
required: ["target", "relationship"],
|
|
921
1054
|
},
|
|
922
|
-
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.",
|
|
923
1056
|
},
|
|
924
1057
|
created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
|
|
925
1058
|
if_exists: {
|
|
@@ -975,6 +1108,18 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
975
1108
|
// Populated in the second pass; folded into each note's response
|
|
976
1109
|
// below, same pattern as `validation_status`.
|
|
977
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 }[] = [];
|
|
978
1123
|
// `if_exists` bookkeeping (vault#555). `existedMap` is set ONLY for
|
|
979
1124
|
// items whose `if_exists` engaged (ignore/update/replace) — absent
|
|
980
1125
|
// means the default "error" path ran, so a plain create-note call
|
|
@@ -1079,6 +1224,15 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1079
1224
|
});
|
|
1080
1225
|
}
|
|
1081
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
|
+
|
|
1082
1236
|
if (incomingTags.length > 0) {
|
|
1083
1237
|
await store.tagNote(result.id, incomingTags);
|
|
1084
1238
|
// Note: applySchemaDefaults also runs in the outer batch loop for
|
|
@@ -1115,6 +1269,10 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1115
1269
|
const extension = item.extension !== undefined
|
|
1116
1270
|
? validateExtension(item.extension)
|
|
1117
1271
|
: undefined;
|
|
1272
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
1273
|
+
// Throws inside the batch transaction — rolls it back, same as a
|
|
1274
|
+
// path conflict; mcp-http's generic error_type mapping → clean 400.
|
|
1275
|
+
validatePath(item.path);
|
|
1118
1276
|
const effectiveExtension = extension ?? "md";
|
|
1119
1277
|
const ifExists = (item.if_exists as string | undefined) ?? "error";
|
|
1120
1278
|
const upsertMode = ifExists === "ignore" || ifExists === "update" || ifExists === "replace";
|
|
@@ -1181,6 +1339,14 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1181
1339
|
pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
|
|
1182
1340
|
}
|
|
1183
1341
|
|
|
1342
|
+
// Content-wikilink warnings (vault#570) — `store.createNote`
|
|
1343
|
+
// above already ran `syncWikilinks` (gated on `content` truthy,
|
|
1344
|
+
// same condition here) — queue for the shared second-pass
|
|
1345
|
+
// classification below.
|
|
1346
|
+
if (item.content) {
|
|
1347
|
+
contentWikilinkNotes.push({ noteId: note.id, content: item.content as string });
|
|
1348
|
+
}
|
|
1349
|
+
|
|
1184
1350
|
created.push(noteOps.getNote(db, note.id) ?? note);
|
|
1185
1351
|
}
|
|
1186
1352
|
|
|
@@ -1190,24 +1356,44 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1190
1356
|
// STILL doesn't resolve (typo, or a note that arrives in a later
|
|
1191
1357
|
// call) is queued for lazy resolution — it backfills automatically
|
|
1192
1358
|
// the moment a matching note is created — and surfaces an
|
|
1193
|
-
// `unresolved_link` warning naming the target.
|
|
1359
|
+
// `unresolved_link` warning naming the target. A target that
|
|
1360
|
+
// matched ≥2 notes (vault#570) is neither linked nor queued —
|
|
1361
|
+
// surfaces a distinct `ambiguous_link` warning instead. Never silent.
|
|
1194
1362
|
for (const { sourceId, links } of pendingLinks) {
|
|
1195
1363
|
for (const link of links) {
|
|
1196
|
-
const
|
|
1197
|
-
if (
|
|
1198
|
-
await store.createLink(sourceId,
|
|
1364
|
+
const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
1365
|
+
if (outcome.status === "resolved") {
|
|
1366
|
+
await store.createLink(sourceId, outcome.note_id, link.relationship);
|
|
1367
|
+
} else if (outcome.status === "ambiguous") {
|
|
1368
|
+
pushLinkWarning(sourceId, {
|
|
1369
|
+
code: "ambiguous_link",
|
|
1370
|
+
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.`,
|
|
1371
|
+
target: link.target,
|
|
1372
|
+
relationship: link.relationship,
|
|
1373
|
+
candidate_count: outcome.candidates.length,
|
|
1374
|
+
});
|
|
1199
1375
|
} else {
|
|
1200
|
-
|
|
1201
|
-
list.push({
|
|
1376
|
+
pushLinkWarning(sourceId, {
|
|
1202
1377
|
code: "unresolved_link",
|
|
1203
1378
|
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.`,
|
|
1204
1379
|
target: link.target,
|
|
1205
1380
|
relationship: link.relationship,
|
|
1206
1381
|
});
|
|
1207
|
-
linkWarningsByNote.set(sourceId, list);
|
|
1208
1382
|
}
|
|
1209
1383
|
}
|
|
1210
1384
|
}
|
|
1385
|
+
|
|
1386
|
+
// --- Content-wikilink warnings (vault#570) ---
|
|
1387
|
+
// Same forward-ref-aware timing as the structured-links pass above
|
|
1388
|
+
// — every sibling in this batch exists by now, so a content
|
|
1389
|
+
// `[[wikilink]]` to a later batch item already resolved via the
|
|
1390
|
+
// pending-wikilink backfill (`resolveUnresolvedWikilinks`, run
|
|
1391
|
+
// inside each `store.createNote`/`updateNote` call above).
|
|
1392
|
+
for (const { noteId, content } of contentWikilinkNotes) {
|
|
1393
|
+
for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
|
|
1394
|
+
pushLinkWarning(noteId, warning);
|
|
1395
|
+
}
|
|
1396
|
+
}
|
|
1211
1397
|
};
|
|
1212
1398
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1213
1399
|
|
|
@@ -1281,7 +1467,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
|
|
|
1281
1467
|
{
|
|
1282
1468
|
name: "update-note",
|
|
1283
1469
|
requiredVerb: "write",
|
|
1284
|
-
description: `Update one or more notes. Accepts ID or path. Supports content, path, metadata updates plus tag and link mutations.
|
|
1470
|
+
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.
|
|
1285
1471
|
|
|
1286
1472
|
- Three content-modification modes (mutually exclusive):
|
|
1287
1473
|
- \`content\` — full replace.
|
|
@@ -1299,7 +1485,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1299
1485
|
inputSchema: {
|
|
1300
1486
|
type: "object",
|
|
1301
1487
|
properties: {
|
|
1302
|
-
id: { type: "string", description: "Note ID or path" },
|
|
1488
|
+
id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
1303
1489
|
content: { type: "string", description: "New content (full replace). Mutually exclusive with `append`/`prepend` and `content_edit`." },
|
|
1304
1490
|
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." },
|
|
1305
1491
|
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." },
|
|
@@ -1345,7 +1531,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1345
1531
|
items: {
|
|
1346
1532
|
type: "object",
|
|
1347
1533
|
properties: {
|
|
1348
|
-
target: { type: "string", description: "Target note ID or
|
|
1534
|
+
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
1349
1535
|
relationship: { type: "string" },
|
|
1350
1536
|
},
|
|
1351
1537
|
required: ["target", "relationship"],
|
|
@@ -1356,14 +1542,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1356
1542
|
items: {
|
|
1357
1543
|
type: "object",
|
|
1358
1544
|
properties: {
|
|
1359
|
-
target: { type: "string", description: "Target note ID or
|
|
1545
|
+
target: { type: "string", description: "Target note ID, path, or (fallback) H1 title" },
|
|
1360
1546
|
relationship: { type: "string" },
|
|
1361
1547
|
},
|
|
1362
1548
|
required: ["target", "relationship"],
|
|
1363
1549
|
},
|
|
1364
1550
|
},
|
|
1365
1551
|
},
|
|
1366
|
-
description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename
|
|
1552
|
+
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.",
|
|
1367
1553
|
},
|
|
1368
1554
|
include_content: {
|
|
1369
1555
|
type: "boolean",
|
|
@@ -1465,6 +1651,16 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1465
1651
|
const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
|
|
1466
1652
|
// Per-note `unresolved_link` warnings (vault#555 — "never silent").
|
|
1467
1653
|
const linkWarningsByNote = new Map<string, QueryWarning[]>();
|
|
1654
|
+
const pushLinkWarning = (noteId: string, warning: QueryWarning): void => {
|
|
1655
|
+
const list = linkWarningsByNote.get(noteId) ?? [];
|
|
1656
|
+
list.push(warning);
|
|
1657
|
+
linkWarningsByNote.set(noteId, list);
|
|
1658
|
+
};
|
|
1659
|
+
// Content wikilinks whose note+content pair needs an `unresolved_link`/
|
|
1660
|
+
// `ambiguous_link` warning check (vault#570) — deferred to the same
|
|
1661
|
+
// second pass as `pendingLinks` for the same forward-ref-within-batch
|
|
1662
|
+
// reason.
|
|
1663
|
+
const contentWikilinkNotes: { noteId: string; content: string }[] = [];
|
|
1468
1664
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
1469
1665
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
1470
1666
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -1527,6 +1723,8 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1527
1723
|
const createExt = item.extension !== undefined
|
|
1528
1724
|
? validateExtension(item.extension)
|
|
1529
1725
|
: undefined;
|
|
1726
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
1727
|
+
validatePath(explicitPath ?? (idLooksLikePath ? idOrPath : undefined));
|
|
1530
1728
|
const createOpts: Parameters<Store["createNote"]>[1] = {
|
|
1531
1729
|
...(idLooksLikePath ? { path: explicitPath ?? idOrPath } : { id: idOrPath, ...(explicitPath !== undefined ? { path: explicitPath } : {}) }),
|
|
1532
1730
|
...(item.tags && Array.isArray((item.tags as any).add)
|
|
@@ -1561,6 +1759,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1561
1759
|
if (linksAdd) {
|
|
1562
1760
|
pendingLinks.push({ sourceId: created.id, links: linksAdd });
|
|
1563
1761
|
}
|
|
1762
|
+
// Content-wikilink warnings (vault#570) — same deferral as
|
|
1763
|
+
// create-note's fresh-create branch.
|
|
1764
|
+
if (content) {
|
|
1765
|
+
contentWikilinkNotes.push({ noteId: created.id, content });
|
|
1766
|
+
}
|
|
1564
1767
|
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
1565
1768
|
updated.push(fresh);
|
|
1566
1769
|
createdIds.add(fresh.id);
|
|
@@ -1698,7 +1901,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1698
1901
|
if (item.append !== undefined) updates.append = item.append;
|
|
1699
1902
|
if (item.prepend !== undefined) updates.prepend = item.prepend;
|
|
1700
1903
|
}
|
|
1701
|
-
if (item.path !== undefined)
|
|
1904
|
+
if (item.path !== undefined) {
|
|
1905
|
+
// Reject NUL / `..` paths at the write surface (vault#589 / FIX 2).
|
|
1906
|
+
validatePath(item.path);
|
|
1907
|
+
updates.path = item.path;
|
|
1908
|
+
}
|
|
1702
1909
|
if (item.extension !== undefined) {
|
|
1703
1910
|
updates.extension = validateExtension(item.extension);
|
|
1704
1911
|
}
|
|
@@ -1758,6 +1965,15 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1758
1965
|
const hasLinkMutation = (item.links as any)?.add !== undefined
|
|
1759
1966
|
|| (item.links as any)?.remove !== undefined;
|
|
1760
1967
|
|
|
1968
|
+
// Content-wikilink warnings (vault#570) gate on the SAME
|
|
1969
|
+
// condition `store.updateNote`/`syncWikilinks` use to decide
|
|
1970
|
+
// whether to re-sync content wikilinks at all — a tags/links-only
|
|
1971
|
+
// update must not spuriously re-warn about pre-existing broken
|
|
1972
|
+
// links this call never touched.
|
|
1973
|
+
const contentChanged = updates.content !== undefined
|
|
1974
|
+
|| updates.append !== undefined
|
|
1975
|
+
|| updates.prepend !== undefined;
|
|
1976
|
+
|
|
1761
1977
|
let result: Note;
|
|
1762
1978
|
if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
|
|
1763
1979
|
// Write-attribution (vault#298): stamp the most-recent-write
|
|
@@ -1811,7 +2027,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1811
2027
|
}
|
|
1812
2028
|
|
|
1813
2029
|
// Re-read for final state
|
|
1814
|
-
|
|
2030
|
+
const finalNote = noteOps.getNote(db, note.id) ?? result;
|
|
2031
|
+
if (contentChanged) {
|
|
2032
|
+
contentWikilinkNotes.push({ noteId: note.id, content: finalNote.content });
|
|
2033
|
+
}
|
|
2034
|
+
updated.push(finalNote);
|
|
1815
2035
|
}
|
|
1816
2036
|
|
|
1817
2037
|
// --- Resolve structured `links.add` (vault#555) ---
|
|
@@ -1824,21 +2044,35 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1824
2044
|
// never silently dropped.
|
|
1825
2045
|
for (const { sourceId, links } of pendingLinks) {
|
|
1826
2046
|
for (const link of links) {
|
|
1827
|
-
const
|
|
1828
|
-
if (
|
|
1829
|
-
await store.createLink(sourceId,
|
|
2047
|
+
const outcome = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
|
|
2048
|
+
if (outcome.status === "resolved") {
|
|
2049
|
+
await store.createLink(sourceId, outcome.note_id, link.relationship, link.metadata);
|
|
2050
|
+
} else if (outcome.status === "ambiguous") {
|
|
2051
|
+
pushLinkWarning(sourceId, {
|
|
2052
|
+
code: "ambiguous_link",
|
|
2053
|
+
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.`,
|
|
2054
|
+
target: link.target,
|
|
2055
|
+
relationship: link.relationship,
|
|
2056
|
+
candidate_count: outcome.candidates.length,
|
|
2057
|
+
});
|
|
1830
2058
|
} else {
|
|
1831
|
-
|
|
1832
|
-
list.push({
|
|
2059
|
+
pushLinkWarning(sourceId, {
|
|
1833
2060
|
code: "unresolved_link",
|
|
1834
2061
|
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.`,
|
|
1835
2062
|
target: link.target,
|
|
1836
2063
|
relationship: link.relationship,
|
|
1837
2064
|
});
|
|
1838
|
-
linkWarningsByNote.set(sourceId, list);
|
|
1839
2065
|
}
|
|
1840
2066
|
}
|
|
1841
2067
|
}
|
|
2068
|
+
|
|
2069
|
+
// --- Content-wikilink warnings (vault#570) ---
|
|
2070
|
+
// Same forward-ref-aware timing as the structured-links pass above.
|
|
2071
|
+
for (const { noteId, content } of contentWikilinkNotes) {
|
|
2072
|
+
for (const warning of getContentWikilinkWarnings(db, noteId, content)) {
|
|
2073
|
+
pushLinkWarning(noteId, warning);
|
|
2074
|
+
}
|
|
2075
|
+
}
|
|
1842
2076
|
};
|
|
1843
2077
|
await (batched ? transactionAsync(db, runBatch) : runBatch());
|
|
1844
2078
|
|
|
@@ -1896,11 +2130,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1896
2130
|
// genuinely append-only callers — gating WITHIN write rather
|
|
1897
2131
|
// than promoting deletes out of it.
|
|
1898
2132
|
requiredVerb: "write",
|
|
1899
|
-
description: "Permanently delete a note and all its tags and links. Accepts ID or path.",
|
|
2133
|
+
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.",
|
|
1900
2134
|
inputSchema: {
|
|
1901
2135
|
type: "object",
|
|
1902
2136
|
properties: {
|
|
1903
|
-
id: { type: "string", description: "Note ID or path" },
|
|
2137
|
+
id: { type: "string", description: "Note ID, path, or (fallback, only when id/path both miss and exactly one note matches) its H1 title" },
|
|
1904
2138
|
},
|
|
1905
2139
|
required: ["id"],
|
|
1906
2140
|
},
|
|
@@ -1992,7 +2226,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1992
2226
|
// =====================================================================
|
|
1993
2227
|
{
|
|
1994
2228
|
name: "update-tag",
|
|
1995
|
-
|
|
2229
|
+
// `admin` (was `write`) — this PR: update-tag defines a tag's SCHEMA
|
|
2230
|
+
// (description, indexed-field types, relationship vocabulary,
|
|
2231
|
+
// hierarchy parents), which every note carrying the tag inherits.
|
|
2232
|
+
// That's structure/taxonomy curation, not content authorship — the
|
|
2233
|
+
// same distinction that keeps content out of admin and structure out
|
|
2234
|
+
// of write. See the `generateMcpTools` doc comment above for the full
|
|
2235
|
+
// re-tier rationale + BREAKING note.
|
|
2236
|
+
requiredVerb: "admin",
|
|
1996
2237
|
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.",
|
|
1997
2238
|
inputSchema: {
|
|
1998
2239
|
type: "object",
|
|
@@ -2005,11 +2246,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2005
2246
|
additionalProperties: {
|
|
2006
2247
|
type: "object",
|
|
2007
2248
|
properties: {
|
|
2008
|
-
type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all
|
|
2249
|
+
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`." },
|
|
2009
2250
|
description: { type: "string" },
|
|
2010
2251
|
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." },
|
|
2011
2252
|
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." },
|
|
2012
|
-
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." },
|
|
2253
|
+
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." },
|
|
2013
2254
|
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)." },
|
|
2014
2255
|
required: { type: "boolean", description: "vault#299. The field must be present + non-null on a note with this tag. Advisory unless `strict: true`." },
|
|
2015
2256
|
cardinality: { type: "string", enum: ["one", "many"], description: "vault#299. 'one' (scalar, default) or 'many' (array). Advisory unless `strict: true`." },
|
|
@@ -2128,10 +2369,15 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2128
2369
|
// =====================================================================
|
|
2129
2370
|
{
|
|
2130
2371
|
name: "delete-tag",
|
|
2131
|
-
// `write` — Aaron's
|
|
2132
|
-
// mgmt + future config writes; deletes
|
|
2133
|
-
//
|
|
2134
|
-
|
|
2372
|
+
// `admin` (was `write` — Aaron's 2026-05-27 call reserved admin for
|
|
2373
|
+
// token mgmt + future config writes; deletes were write-tier
|
|
2374
|
+
// mutations, see delete-note's rationale). Superseded by this PR:
|
|
2375
|
+
// delete-tag removes a tag's identity row + schema and untags it
|
|
2376
|
+
// vault-wide — that's structure/taxonomy curation, the same class as
|
|
2377
|
+
// update-tag/rename-tag/merge-tags, not content authorship. See the
|
|
2378
|
+
// `generateMcpTools` doc comment above for the full re-tier rationale
|
|
2379
|
+
// + BREAKING note.
|
|
2380
|
+
requiredVerb: "admin",
|
|
2135
2381
|
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.",
|
|
2136
2382
|
inputSchema: {
|
|
2137
2383
|
type: "object",
|
|
@@ -2166,7 +2412,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2166
2412
|
// =====================================================================
|
|
2167
2413
|
{
|
|
2168
2414
|
name: "rename-tag",
|
|
2169
|
-
|
|
2415
|
+
// `admin` (was `write`) — this PR: an atomic cascading rename across
|
|
2416
|
+
// note memberships, other tags' parent_names, tokens' allowlists,
|
|
2417
|
+
// indexed-field declarer lists, and inline #tag mentions is structural
|
|
2418
|
+
// taxonomy surgery, not content authorship. Same tier as
|
|
2419
|
+
// update-tag/delete-tag/merge-tags. See the `generateMcpTools` doc
|
|
2420
|
+
// comment above for the full re-tier rationale + BREAKING note.
|
|
2421
|
+
requiredVerb: "admin",
|
|
2170
2422
|
description:
|
|
2171
2423
|
"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.",
|
|
2172
2424
|
inputSchema: {
|
|
@@ -2223,7 +2475,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2223
2475
|
// =====================================================================
|
|
2224
2476
|
{
|
|
2225
2477
|
name: "merge-tags",
|
|
2226
|
-
|
|
2478
|
+
// `admin` (was `write`) — this PR: merging N source tags into a
|
|
2479
|
+
// target (retagging every note, dropping the sources' identity rows)
|
|
2480
|
+
// is structural taxonomy surgery, not content authorship. Same tier
|
|
2481
|
+
// as update-tag/delete-tag/rename-tag. See the `generateMcpTools` doc
|
|
2482
|
+
// comment above for the full re-tier rationale + BREAKING note.
|
|
2483
|
+
requiredVerb: "admin",
|
|
2227
2484
|
description:
|
|
2228
2485
|
"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.",
|
|
2229
2486
|
inputSchema: {
|
|
@@ -2259,12 +2516,12 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2259
2516
|
{
|
|
2260
2517
|
name: "find-path",
|
|
2261
2518
|
requiredVerb: "read",
|
|
2262
|
-
description: "Find the shortest path between two notes in the link graph. Accepts IDs or
|
|
2519
|
+
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`.",
|
|
2263
2520
|
inputSchema: {
|
|
2264
2521
|
type: "object",
|
|
2265
2522
|
properties: {
|
|
2266
|
-
source: { type: "string", description: "Starting note ID or
|
|
2267
|
-
target: { type: "string", description: "Destination note ID or
|
|
2523
|
+
source: { type: "string", description: "Starting note ID, path, or (fallback) H1 title" },
|
|
2524
|
+
target: { type: "string", description: "Destination note ID, path, or (fallback) H1 title" },
|
|
2268
2525
|
max_depth: { type: "number", description: "Max path length (default 5)" },
|
|
2269
2526
|
},
|
|
2270
2527
|
required: ["source", "target"],
|
|
@@ -2284,11 +2541,15 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2284
2541
|
{
|
|
2285
2542
|
name: "vault-info",
|
|
2286
2543
|
// `read` so vault:read callers can fetch stats. The
|
|
2287
|
-
// description-update branch performs an inner
|
|
2544
|
+
// description-update branch performs an inner ADMIN-check (see
|
|
2288
2545
|
// overrideVaultInfo in src/mcp-tools.ts) — do not promote this to
|
|
2289
|
-
// `
|
|
2546
|
+
// `admin` or read-only callers lose the stats projection. Was an
|
|
2547
|
+
// inner write-check pre-this-PR; writing the vault's own
|
|
2548
|
+
// description/config is curation, not content, so it moved to the
|
|
2549
|
+
// same admin tier as the other structure-curation tools (update-tag
|
|
2550
|
+
// et al) — see the `generateMcpTools` doc comment above.
|
|
2290
2551
|
requiredVerb: "read",
|
|
2291
|
-
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, 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:
|
|
2552
|
+
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.",
|
|
2292
2553
|
inputSchema: {
|
|
2293
2554
|
type: "object",
|
|
2294
2555
|
properties: {
|
|
@@ -2344,9 +2605,17 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
2344
2605
|
// =====================================================================
|
|
2345
2606
|
{
|
|
2346
2607
|
name: "doctor",
|
|
2347
|
-
// `admin` —
|
|
2348
|
-
//
|
|
2349
|
-
|
|
2608
|
+
// `read` (was `admin` — the original reasoning: same tier as
|
|
2609
|
+
// prune-schema, a diagnostic over the WHOLE vault's taxonomy, not
|
|
2610
|
+
// scoped to any one tag's write authority). Superseded by this PR:
|
|
2611
|
+
// doctor never mutates and is ALREADY tag-scope-restricted at the MCP
|
|
2612
|
+
// layer (see `applyTagScopeWrappers`'s `doctor` wrapper in
|
|
2613
|
+
// src/mcp-tools.ts, which re-runs the scan against the caller's
|
|
2614
|
+
// allowlist) — it's a read, not a curation op, and read-scoped
|
|
2615
|
+
// monitoring/tending jobs need to be able to run it without an admin
|
|
2616
|
+
// credential. The REST `GET /api/doctor` endpoint (routing.ts) is
|
|
2617
|
+
// re-tiered to `read` too, so both doors agree — no MCP/REST divergence.
|
|
2618
|
+
requiredVerb: "read",
|
|
2350
2619
|
description:
|
|
2351
2620
|
"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).",
|
|
2352
2621
|
inputSchema: { type: "object", properties: {} },
|