@openparachute/vault 0.6.0-rc.1 → 0.6.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/.parachute/module.json +14 -3
- package/README.md +32 -7
- package/core/src/content-range.test.ts +374 -0
- package/core/src/content-range.ts +185 -0
- package/core/src/core.test.ts +279 -26
- package/core/src/expand-visibility.test.ts +102 -0
- package/core/src/expand.ts +31 -3
- package/core/src/indexed-fields.ts +1 -1
- package/core/src/link-count.test.ts +301 -0
- package/core/src/links.ts +172 -22
- package/core/src/mcp.ts +254 -34
- package/core/src/notes.ts +172 -48
- package/core/src/obsidian-alignment.test.ts +375 -0
- package/core/src/obsidian.ts +234 -14
- package/core/src/portable-md.test.ts +40 -0
- package/core/src/portable-md.ts +142 -16
- package/core/src/query-perf-routing.test.ts +208 -0
- package/core/src/schema.ts +87 -11
- package/core/src/store.ts +69 -22
- package/core/src/tag-expand-axis.test.ts +301 -0
- package/core/src/tag-hierarchy.ts +80 -0
- package/core/src/tag-schemas.ts +61 -46
- package/core/src/triggers-store.test.ts +100 -0
- package/core/src/triggers-store.ts +165 -0
- package/core/src/types.ts +68 -4
- package/core/src/vault-projection.ts +20 -0
- package/core/src/wikilinks.ts +2 -2
- package/package.json +2 -3
- package/src/admin-spa.test.ts +100 -10
- package/src/admin-spa.ts +48 -3
- package/src/auth-hub-jwt.test.ts +8 -1
- package/src/auth-status.ts +2 -2
- package/src/auth.test.ts +39 -3
- package/src/auth.ts +31 -2
- package/src/auto-transcribe.test.ts +51 -0
- package/src/auto-transcribe.ts +24 -6
- package/src/autostart.test.ts +75 -0
- package/src/autostart.ts +84 -0
- package/src/cli.ts +434 -140
- package/src/config.test.ts +109 -0
- package/src/config.ts +157 -10
- package/src/content-range-routes.test.ts +178 -0
- package/src/export-watch.test.ts +23 -0
- package/src/export-watch.ts +14 -0
- package/src/git-preflight.test.ts +70 -0
- package/src/git-preflight.ts +68 -0
- package/src/github-device-flow.test.ts +265 -6
- package/src/github-device-flow.ts +297 -45
- package/src/hub-jwt.test.ts +75 -2
- package/src/hub-jwt.ts +43 -6
- package/src/init-summary.test.ts +120 -5
- package/src/init-summary.ts +67 -25
- package/src/live-match.test.ts +198 -0
- package/src/live-match.ts +310 -0
- package/src/mcp-install.test.ts +93 -0
- package/src/mcp-install.ts +106 -0
- package/src/mcp-tools.ts +80 -7
- package/src/mirror-config.test.ts +14 -0
- package/src/mirror-config.ts +11 -0
- package/src/mirror-credentials.test.ts +20 -0
- package/src/mirror-credentials.ts +6 -2
- package/src/mirror-import.test.ts +110 -0
- package/src/mirror-import.ts +71 -13
- package/src/mirror-manager.test.ts +51 -0
- package/src/mirror-manager.ts +73 -11
- package/src/mirror-routes.test.ts +1331 -110
- package/src/mirror-routes.ts +787 -30
- package/src/oauth-discovery.test.ts +55 -0
- package/src/oauth-discovery.ts +24 -5
- package/src/routes.ts +763 -122
- package/src/routing.test.ts +451 -5
- package/src/routing.ts +121 -5
- package/src/scopes.ts +1 -1
- package/src/server.ts +66 -4
- package/src/storage.test.ts +162 -0
- package/src/subscribe.test.ts +588 -0
- package/src/subscribe.ts +248 -0
- package/src/subscriptions.ts +295 -0
- package/src/tag-expand-routes.test.ts +45 -0
- package/src/tag-scope.ts +68 -1
- package/src/token-store.ts +7 -7
- package/src/transcription-worker.test.ts +471 -5
- package/src/transcription-worker.ts +212 -44
- package/src/triggers-api.test.ts +533 -0
- package/src/triggers-api.ts +295 -0
- package/src/triggers.ts +93 -7
- package/src/usage.test.ts +362 -0
- package/src/usage.ts +318 -0
- package/src/vault-create.test.ts +340 -12
- package/src/vault-name.test.ts +61 -3
- package/src/vault-name.ts +62 -14
- package/src/vault-remove.test.ts +187 -0
- package/src/vault-store.ts +10 -3
- package/src/vault.test.ts +1353 -62
- package/web/ui/dist/assets/index-BPgyIjR7.js +61 -0
- package/web/ui/dist/assets/index-J0pVP7I-.css +1 -0
- package/web/ui/dist/index.html +2 -2
- package/web/ui/dist/assets/index-DBe8Xiah.css +0 -1
- package/web/ui/dist/assets/index-DDRo6F4u.js +0 -60
package/core/src/mcp.ts
CHANGED
|
@@ -3,6 +3,7 @@ import type { Store, Note } from "./types.js";
|
|
|
3
3
|
import * as noteOps from "./notes.js";
|
|
4
4
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
5
5
|
import { QueryError } from "./query-operators.js";
|
|
6
|
+
import { TAG_EXPAND_MODES, type TagExpandMode } from "./tag-hierarchy.js";
|
|
6
7
|
import * as linkOps from "./links.js";
|
|
7
8
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
8
9
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -14,6 +15,12 @@ import {
|
|
|
14
15
|
type ExpandContext,
|
|
15
16
|
type ExpandMode,
|
|
16
17
|
} from "./expand.js";
|
|
18
|
+
import {
|
|
19
|
+
parseContentRange,
|
|
20
|
+
applyContentRange,
|
|
21
|
+
contentRangeRequiresContent,
|
|
22
|
+
MIN_CONTENT_LENGTH,
|
|
23
|
+
} from "./content-range.js";
|
|
17
24
|
|
|
18
25
|
export interface McpToolDef {
|
|
19
26
|
name: string;
|
|
@@ -99,13 +106,41 @@ function removeWikilinkBrackets(content: string, targetPath: string): string {
|
|
|
99
106
|
// Tool generation
|
|
100
107
|
// ---------------------------------------------------------------------------
|
|
101
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Options for {@link generateMcpTools}.
|
|
111
|
+
*
|
|
112
|
+
* `expandVisibility` (vault security review) is an OPTIONAL per-note
|
|
113
|
+
* visibility predicate threaded into the wikilink-expansion context for
|
|
114
|
+
* `query-notes`. When provided, `expand_links` inlining leaves any wikilink
|
|
115
|
+
* whose target fails the predicate UNRESOLVED — so a tag-scoped MCP session
|
|
116
|
+
* can't inline out-of-scope note content during expansion (the filtering
|
|
117
|
+
* happens DURING expansion, not after). Core stays scope-unaware: it
|
|
118
|
+
* receives a plain `(note) => boolean` closure and never imports the
|
|
119
|
+
* server's tag-scope module. Omitted (every internal / unscoped caller) →
|
|
120
|
+
* expansion behaves exactly as before.
|
|
121
|
+
*/
|
|
122
|
+
export interface GenerateMcpToolsOpts {
|
|
123
|
+
expandVisibility?: (note: Note) => boolean;
|
|
124
|
+
/**
|
|
125
|
+
* `nearTraversable` (vault#439) is an OPTIONAL per-note predicate threaded
|
|
126
|
+
* into the `near[]` graph BFS. When provided, the traversal refuses to walk
|
|
127
|
+
* THROUGH any note that fails the predicate — making a tag-scoped `near[]`
|
|
128
|
+
* query symmetric with `find-path` (scope is a wall, not a sieve). Core
|
|
129
|
+
* stays scope-unaware: it receives a plain `(noteId) => boolean` closure.
|
|
130
|
+
* Omitted (unscoped / internal callers) → the full graph is walked.
|
|
131
|
+
*/
|
|
132
|
+
nearTraversable?: (noteId: string) => boolean;
|
|
133
|
+
}
|
|
134
|
+
|
|
102
135
|
/**
|
|
103
136
|
* Generate the consolidated MCP tools for a vault. Surface (10):
|
|
104
137
|
* query-notes, create-note, update-note, delete-note, list-tags, update-tag,
|
|
105
138
|
* delete-tag, find-path, vault-info, prune-schema (admin).
|
|
106
139
|
*/
|
|
107
|
-
export function generateMcpTools(store: Store): McpToolDef[] {
|
|
108
|
-
const db: Database =
|
|
140
|
+
export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): McpToolDef[] {
|
|
141
|
+
const db: Database = store.db;
|
|
142
|
+
const expandVisibility = opts?.expandVisibility;
|
|
143
|
+
const nearTraversable = opts?.nearTraversable;
|
|
109
144
|
|
|
110
145
|
return [
|
|
111
146
|
|
|
@@ -124,6 +159,8 @@ export function generateMcpTools(store: Store): McpToolDef[] {
|
|
|
124
159
|
|
|
125
160
|
Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
|
|
126
161
|
|
|
162
|
+
Large notes: pass \`content_offset\` / \`content_length\` (UTF-8 bytes) for a bounded read of note content — the response carries the slice plus \`content_total_length\` and \`content_next_offset\` (null when complete). Loop, feeding \`content_next_offset\` back as \`content_offset\`, to read a note too large for one response.
|
|
163
|
+
|
|
127
164
|
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.`,
|
|
128
165
|
inputSchema: {
|
|
129
166
|
type: "object",
|
|
@@ -137,6 +174,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
137
174
|
description: "Filter by tag(s)",
|
|
138
175
|
},
|
|
139
176
|
tag_match: { type: "string", enum: ["any", "all"], description: "How to match multiple tags: 'any' (OR, default) or 'all' (AND)" },
|
|
177
|
+
expand: {
|
|
178
|
+
type: "string",
|
|
179
|
+
enum: ["subtypes", "namespace", "both", "exact"],
|
|
180
|
+
description: "How each `tag` expands. 'subtypes' (DEFAULT): the tag plus its declared parent_names descendants — the semantic is-a axis (e.g. tag:entity also matches person/work). 'namespace': the tag plus everything filed under it by NAME (tag:entity also matches entity/archived) — the lexical filing axis. 'both': union of the two. 'exact': only the literal tag, no expansion. Omit for 'subtypes' (current behavior).",
|
|
181
|
+
},
|
|
140
182
|
exclude_tags: {
|
|
141
183
|
oneOf: [
|
|
142
184
|
{ type: "string" },
|
|
@@ -178,7 +220,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
178
220
|
type: "object",
|
|
179
221
|
description: "Filter by metadata values. Each value is either a primitive (exact match, scans JSON) or an operator object: `{eq|ne|gt|gte|lt|lte|in|not_in|exists: value}`. Operator objects require the field to be declared `indexed: true` in a tag schema — they route through the backing B-tree index. Multiple operators on one field AND together (e.g. `{gt: 5, lt: 10}`). `in`/`not_in` take arrays; `exists` takes a boolean.",
|
|
180
222
|
},
|
|
181
|
-
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
|
|
223
|
+
order_by: { type: "string", description: "Sort by an indexed metadata field instead of `created_at`. Field must be declared `indexed: true`; errors otherwise. The special value `link_count` sorts by link DEGREE (both-directions raw row count) — no declaration needed — matching the `include_link_count` field for every note. Direction is taken from `sort` (default 'asc'); `created_at` is appended as a stable tiebreaker." },
|
|
182
224
|
date_from: { type: "string", description: "Start date (ISO, inclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', from }`." },
|
|
183
225
|
date_to: { type: "string", description: "End date (ISO, exclusive). Filters on `created_at` (vault ingestion time). Shorthand for `date_filter: { field: 'created_at', to }`." },
|
|
184
226
|
date_filter: {
|
|
@@ -209,6 +251,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
209
251
|
"Opaque cursor for 'since last checked' agent loops (vault#313). First call: omit. The response will include `next_cursor` — pass it on the subsequent call to receive only notes created or updated since the prior page. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`. The response shape switches to `{notes, next_cursor}` when this parameter is present.",
|
|
210
252
|
},
|
|
211
253
|
include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
|
|
254
|
+
content_offset: {
|
|
255
|
+
type: "number",
|
|
256
|
+
description:
|
|
257
|
+
"Byte offset (UTF-8) into note content to start reading from (default 0). For reading a note too large for one response: pass the previous response's `content_next_offset` here to continue. An offset landing mid-codepoint is aligned DOWN to the codepoint's leading byte (chained `content_next_offset` values are always aligned); the effective start is echoed back as `content_offset` on the response. Requires content in the response — errors when combined with include_content=false (or a list query without include_content=true).",
|
|
258
|
+
},
|
|
259
|
+
content_length: {
|
|
260
|
+
type: "number",
|
|
261
|
+
description:
|
|
262
|
+
`Maximum bytes (UTF-8) of note content to return (minimum ${MIN_CONTENT_LENGTH}). When this or content_offset is set, the returned \`content\` is the byte slice and the response gains \`content_offset\` (effective start), \`content_total_length\` (full content size in bytes), and \`content_next_offset\` (pass back as content_offset to continue; null when the slice reaches the end). Slices end on a UTF-8 codepoint boundary, so a slice may be up to 3 bytes under the budget — never over. Concatenating the slices from offset 0 through content_next_offset=null reconstructs the content byte-for-byte. On list queries the same window applies to each note's content independently. When expand_links=true the range applies to the returned (expanded) content.`,
|
|
263
|
+
},
|
|
212
264
|
include_metadata: {
|
|
213
265
|
oneOf: [
|
|
214
266
|
{ type: "boolean" },
|
|
@@ -217,6 +269,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
217
269
|
description: "Control metadata in response: true (all, default), false (none), or array of field names to include",
|
|
218
270
|
},
|
|
219
271
|
include_links: { type: "boolean", description: "Include inbound + outbound links per note (default: false)" },
|
|
272
|
+
include_link_count: {
|
|
273
|
+
type: "boolean",
|
|
274
|
+
description:
|
|
275
|
+
"Include the note's link DEGREE as a `linkCount` field, without hauling the link objects (default: false). Degree is a raw row count: outbound (source) + inbound (target). A self-loop counts as 2. Cheap COUNT over indexes; batched once per request. For a tag-scoped token, `linkCount` is the raw degree and MAY include edges to notes the token can't see — only the number leaks, not the neighbor.",
|
|
276
|
+
},
|
|
277
|
+
link_count_direction: {
|
|
278
|
+
type: "string",
|
|
279
|
+
enum: ["both", "outbound", "inbound"],
|
|
280
|
+
description:
|
|
281
|
+
"Which edges `include_link_count` counts: both (default), outbound only (source_id), or inbound only (target_id). order_by=link_count always uses the both-directions degree.",
|
|
282
|
+
},
|
|
220
283
|
include_attachments: { type: "boolean", description: "Include attachment records (default: false)" },
|
|
221
284
|
expand_links: { type: "boolean", description: "Inline [[wikilinks]] in returned content (default: false). Has no effect if content is not included (e.g., default list mode with include_content=false); wikilinks inside fenced or inline code are not expanded." },
|
|
222
285
|
expand_depth: { type: "number", description: "Recursion depth for link expansion (default 1, max 3). Only meaningful in 'full' mode — 'summary' mode does not recurse." },
|
|
@@ -235,20 +298,43 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
235
298
|
),
|
|
236
299
|
);
|
|
237
300
|
const expandCtx: ExpandContext | null = expandLinks
|
|
238
|
-
? {
|
|
301
|
+
? {
|
|
302
|
+
db,
|
|
303
|
+
mode: expandMode,
|
|
304
|
+
expanded: new Set(),
|
|
305
|
+
// Tag-scope confidentiality (security review): when a visibility
|
|
306
|
+
// predicate was injected, wikilinks to out-of-scope notes are
|
|
307
|
+
// left unresolved DURING inlining — never embedded. Unscoped
|
|
308
|
+
// callers pass no predicate and inlining is unchanged.
|
|
309
|
+
...(expandVisibility ? { isVisible: expandVisibility } : {}),
|
|
310
|
+
}
|
|
239
311
|
: null;
|
|
240
312
|
|
|
313
|
+
// --- Content range (bounded reads for large notes) ---
|
|
314
|
+
// Validates loudly: bad values throw QueryError here, before any
|
|
315
|
+
// query work. Null when neither param is present — response shape
|
|
316
|
+
// stays byte-identical to the no-pagination behavior.
|
|
317
|
+
const contentRange = parseContentRange(params.content_offset, params.content_length);
|
|
318
|
+
|
|
241
319
|
// --- Single note by ID/path ---
|
|
242
320
|
if (params.id) {
|
|
243
321
|
const note = resolveNote(db, params.id as string);
|
|
244
322
|
if (!note) return { error: "Note not found", id: params.id };
|
|
245
323
|
const includeContent = params.include_content !== false; // default true for single
|
|
324
|
+
// Range params are meaningless on a content-less shape — error
|
|
325
|
+
// rather than silently ignore (same loud-validation policy as
|
|
326
|
+
// `expand`).
|
|
327
|
+
if (contentRange && !includeContent) throw contentRangeRequiresContent();
|
|
246
328
|
let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
|
|
247
329
|
if (expandCtx && includeContent && typeof result.content === "string") {
|
|
248
330
|
// Mark the top-level note as already expanded so it can't recursively inline itself.
|
|
249
331
|
expandCtx.expanded.add(note.id);
|
|
250
332
|
result.content = expandContent(result.content, expandCtx, expandDepth);
|
|
251
333
|
}
|
|
334
|
+
// Range applies to the FINAL returned content — after wikilink
|
|
335
|
+
// expansion — so the window the client pages through is the same
|
|
336
|
+
// document it would have received unpaged.
|
|
337
|
+
if (contentRange && includeContent) applyContentRange(result, contentRange);
|
|
252
338
|
result = filterMetadata(result, params.include_metadata as boolean | string[] | undefined);
|
|
253
339
|
if (params.include_links) {
|
|
254
340
|
result.links = linkOps.getLinksHydrated(db, note.id);
|
|
@@ -256,10 +342,27 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
256
342
|
if (params.include_attachments) {
|
|
257
343
|
result.attachments = await store.getAttachments(note.id);
|
|
258
344
|
}
|
|
345
|
+
// linkCount injected after filterMetadata on purpose — same as
|
|
346
|
+
// links/attachments above; filterMetadata only touches `metadata`.
|
|
347
|
+
if (params.include_link_count) {
|
|
348
|
+
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
349
|
+
result.linkCount = linkOps.getLinkCounts(db, [note.id], dir).get(note.id) ?? 0;
|
|
350
|
+
}
|
|
259
351
|
return result;
|
|
260
352
|
}
|
|
261
353
|
|
|
262
354
|
// --- Build near-scope (graph-filtered set of allowed IDs) ---
|
|
355
|
+
//
|
|
356
|
+
// Tag-scope policy for `near[]` (vault#439 — hop-guard, symmetric with
|
|
357
|
+
// find-path): when the session is tag-scoped the server injects a
|
|
358
|
+
// `nearTraversable` predicate (mcp-tools.ts), and the BFS refuses to
|
|
359
|
+
// walk THROUGH out-of-scope notes — scope is a wall, not a sieve. So a
|
|
360
|
+
// token scoped to ["work"] can't reach an in-scope note at depth 2 via
|
|
361
|
+
// a #personal intermediary at depth 1. Core stays scope-unaware: it
|
|
362
|
+
// only invokes the injected closure. Unscoped sessions pass no
|
|
363
|
+
// predicate → the FULL graph is walked exactly as before. The
|
|
364
|
+
// `applyTagScopeWrappers` result-filter still runs afterward (defense
|
|
365
|
+
// in depth), but the wall makes it redundant for `near[]`.
|
|
263
366
|
let nearScope: Set<string> | null = null;
|
|
264
367
|
if (params.near) {
|
|
265
368
|
const near = params.near as { note_id: string; depth?: number; relationship?: string };
|
|
@@ -269,6 +372,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
269
372
|
const traversed = linkOps.traverseLinks(db, anchor.id, {
|
|
270
373
|
max_depth: depth,
|
|
271
374
|
relationship: near.relationship,
|
|
375
|
+
isTraversable: nearTraversable,
|
|
272
376
|
});
|
|
273
377
|
nearScope = new Set([anchor.id, ...traversed.map((t) => t.noteId)]);
|
|
274
378
|
}
|
|
@@ -295,6 +399,18 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
295
399
|
"INVALID_QUERY",
|
|
296
400
|
);
|
|
297
401
|
}
|
|
402
|
+
// Tag-expansion axis (vault tag `expand` axis). Validate loudly so a
|
|
403
|
+
// typo'd value doesn't silently fall back to the default.
|
|
404
|
+
let expand: TagExpandMode | undefined;
|
|
405
|
+
if (params.expand !== undefined && params.expand !== null) {
|
|
406
|
+
if (typeof params.expand !== "string" || !(TAG_EXPAND_MODES as readonly string[]).includes(params.expand)) {
|
|
407
|
+
throw new QueryError(
|
|
408
|
+
`invalid \`expand\` value ${JSON.stringify(params.expand)} — must be one of ${TAG_EXPAND_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("subtypes").`,
|
|
409
|
+
"INVALID_QUERY",
|
|
410
|
+
);
|
|
411
|
+
}
|
|
412
|
+
expand = params.expand as TagExpandMode;
|
|
413
|
+
}
|
|
298
414
|
|
|
299
415
|
// --- Full-text search ---
|
|
300
416
|
let results: Note[];
|
|
@@ -310,6 +426,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
310
426
|
results = await store.searchNotes(params.search as string, {
|
|
311
427
|
tags,
|
|
312
428
|
limit: (params.limit as number) ?? 50,
|
|
429
|
+
expand,
|
|
313
430
|
});
|
|
314
431
|
} else {
|
|
315
432
|
// --- Structured query ---
|
|
@@ -329,6 +446,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
329
446
|
const queryOpts = {
|
|
330
447
|
tags,
|
|
331
448
|
tagMatch: (params.tag_match as "all" | "any") ?? (tags && tags.length > 1 ? "any" : undefined),
|
|
449
|
+
expand,
|
|
332
450
|
excludeTags,
|
|
333
451
|
hasTags: params.has_tags as boolean | undefined,
|
|
334
452
|
hasLinks: params.has_links as boolean | undefined,
|
|
@@ -371,6 +489,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
371
489
|
|
|
372
490
|
// --- Format output ---
|
|
373
491
|
const includeContent = params.include_content === true; // default false for list
|
|
492
|
+
// Range params require content in the response — on lists that
|
|
493
|
+
// means an explicit include_content=true (the lean default carries
|
|
494
|
+
// no content to slice). Error rather than silently ignore.
|
|
495
|
+
if (contentRange && !includeContent) throw contentRangeRequiresContent();
|
|
374
496
|
const includeMetadata = params.include_metadata as boolean | string[] | undefined;
|
|
375
497
|
let output: any[] = includeContent ? results.map((n) => ({ ...n })) : results.map(noteOps.toNoteIndex);
|
|
376
498
|
|
|
@@ -385,17 +507,46 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
385
507
|
}
|
|
386
508
|
}
|
|
387
509
|
|
|
510
|
+
// --- Content range (per-note, post-expansion) ---
|
|
511
|
+
// The same byte window applies to EACH note's content independently
|
|
512
|
+
// — the primary use is a single large note, but list mode keeps the
|
|
513
|
+
// simple per-note semantic (every note reports its own
|
|
514
|
+
// content_total_length / content_next_offset).
|
|
515
|
+
if (contentRange && includeContent) {
|
|
516
|
+
for (const n of output) applyContentRange(n, contentRange);
|
|
517
|
+
}
|
|
518
|
+
|
|
388
519
|
// --- Apply metadata filtering ---
|
|
389
520
|
if (includeMetadata !== undefined && includeMetadata !== true) {
|
|
390
521
|
output = output.map((n: any) => filterMetadata(n, includeMetadata));
|
|
391
522
|
}
|
|
392
523
|
|
|
524
|
+
// --- Opt-in link degree (vault feedback #4) ---
|
|
525
|
+
// ONE batch count over all result ids (NOT per-note), so the field
|
|
526
|
+
// stays O(2 index scans) per request regardless of page size.
|
|
527
|
+
// Injected on the same objects the enrichment loop copies below.
|
|
528
|
+
// Ordering: runs AFTER the filterMetadata pass above on purpose —
|
|
529
|
+
// filterMetadata only touches the `metadata` key, so linkCount
|
|
530
|
+
// survives. Don't casually swap the order.
|
|
531
|
+
if (params.include_link_count) {
|
|
532
|
+
const dir = normalizeLinkCountDirection(params.link_count_direction);
|
|
533
|
+
const counts = linkOps.getLinkCounts(db, output.map((n: any) => n.id), dir);
|
|
534
|
+
for (const n of output) n.linkCount = counts.get(n.id) ?? 0;
|
|
535
|
+
}
|
|
536
|
+
|
|
393
537
|
// --- Hydrate links/attachments per note if requested ---
|
|
394
538
|
if (params.include_links || params.include_attachments) {
|
|
539
|
+
// Links hydrate for the WHOLE page in a constant number of
|
|
540
|
+
// queries (see getLinksHydratedForNotes) — the per-note variant
|
|
541
|
+
// cost (1 link query + 1 summary query + N tag queries) × page
|
|
542
|
+
// size. 2026-06-10 perf measurements.
|
|
543
|
+
const linksByNote = params.include_links
|
|
544
|
+
? linkOps.getLinksHydratedForNotes(db, (output as any[]).map((n: any) => n.id))
|
|
545
|
+
: null;
|
|
395
546
|
const enrichedOut: any[] = [];
|
|
396
547
|
for (const n of output as any[]) {
|
|
397
548
|
const enriched: any = { ...n };
|
|
398
|
-
if (
|
|
549
|
+
if (linksByNote) enriched.links = linksByNote.get(n.id) ?? [];
|
|
399
550
|
if (params.include_attachments) enriched.attachments = await store.getAttachments(n.id);
|
|
400
551
|
enrichedOut.push(enriched);
|
|
401
552
|
}
|
|
@@ -512,17 +663,35 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
512
663
|
throw e;
|
|
513
664
|
}
|
|
514
665
|
|
|
515
|
-
// Apply tag schema effects
|
|
666
|
+
// Apply tag schema effects, then re-read the notes whose metadata was
|
|
667
|
+
// actually default-filled so the response reflects the final on-disk
|
|
668
|
+
// state (the `created` entries were read before `applySchemaDefaults`
|
|
669
|
+
// ran, so default-filled metadata isn't on them yet). This mirrors the
|
|
670
|
+
// update-note path, which already re-reads post-defaults. The re-read
|
|
671
|
+
// is batched (`getNotes` = one `WHERE id IN (...)`) and skipped
|
|
672
|
+
// entirely when no defaults were applied, so the common no-defaults
|
|
673
|
+
// path adds zero extra reads.
|
|
674
|
+
const mutatedIds = new Set<string>();
|
|
516
675
|
for (const note of created) {
|
|
517
676
|
if (note.tags && note.tags.length > 0) {
|
|
518
|
-
await applySchemaDefaults(store, db, [note.id], note.tags)
|
|
677
|
+
for (const id of await applySchemaDefaults(store, db, [note.id], note.tags)) {
|
|
678
|
+
mutatedIds.add(id);
|
|
679
|
+
}
|
|
519
680
|
}
|
|
520
681
|
}
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
682
|
+
const refreshed =
|
|
683
|
+
mutatedIds.size === 0
|
|
684
|
+
? created
|
|
685
|
+
: (() => {
|
|
686
|
+
const byId = new Map(
|
|
687
|
+
noteOps.getNotes(db, [...mutatedIds]).map((n) => [n.id, n]),
|
|
688
|
+
);
|
|
689
|
+
return created.map((n) => byId.get(n.id) ?? n);
|
|
690
|
+
})();
|
|
691
|
+
|
|
692
|
+
// Attach `validation_status` from any tag's `fields` declaration that
|
|
693
|
+
// applies to this note, against the post-defaults state.
|
|
694
|
+
const final = refreshed.map((n) => attachValidationStatus(store, db, n));
|
|
526
695
|
return batch ? final : final[0];
|
|
527
696
|
},
|
|
528
697
|
},
|
|
@@ -543,7 +712,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
543
712
|
- \`links: { add: [{ target, relationship }], remove: [{ target, relationship }] }\` — add/remove links
|
|
544
713
|
- When removing a wikilink-type link, \`[[brackets]]\` are also removed from content.
|
|
545
714
|
- For batch: pass a \`notes\` array, each with an \`id\` field.
|
|
546
|
-
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
715
|
+
- **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design).
|
|
547
716
|
- **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
|
|
548
717
|
- \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.`,
|
|
549
718
|
inputSchema: {
|
|
@@ -567,7 +736,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
567
736
|
metadata: { type: "object", description: "Metadata to merge (keys are merged, not replaced wholesale)" },
|
|
568
737
|
created_at: { type: "string", description: "New created_at timestamp" },
|
|
569
738
|
if_updated_at: { type: "string", description: "Optimistic concurrency check: the updated_at value you last read. Rejects with a conflict error if the note has been modified since. Required unless `force: true` is set or the call is `append`/`prepend`-only." },
|
|
570
|
-
force: { type: "boolean", description: "
|
|
739
|
+
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` and run the update unconditionally. Use only for bulk migrations or scripted writes where concurrency is known-safe. Note: this does not override an `if_updated_at` you actually pass — if you supply both, the precondition still applies and a mismatch returns a conflict error." },
|
|
571
740
|
if_missing: { type: "string", enum: ["fail", "create"], description: "What to do when the note (by `id`/path) doesn't exist. `\"fail\"` (default) — error, current behavior. `\"create\"` — create the note from this same payload (content/path/tags/metadata become the create fields; the response carries `created: true`). Skips the `if_updated_at` precondition on the create branch (nothing to conflict with). Idempotent for sync loops that don't know ahead of time whether the note exists. See vault#309." },
|
|
572
741
|
tags: {
|
|
573
742
|
type: "object",
|
|
@@ -609,6 +778,10 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
609
778
|
type: "boolean",
|
|
610
779
|
description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
|
|
611
780
|
},
|
|
781
|
+
include_links: {
|
|
782
|
+
type: "boolean",
|
|
783
|
+
description: "Echo the note's hydrated inbound + outbound links on the response (vault feedback #8). Links are *also* echoed automatically whenever the update itself mutated links (`links.add`/`links.remove`), so you rarely need to set this — its purpose is to fetch the current link set on an update that didn't touch links. Default: `false` (and absent from the response unless mutated or requested). Mirrors `query-notes`'s `include_links`. This top-level flag applies to the single-note form only; for a batch, set `include_links` on each note object in `notes` (a top-level `include_links` is ignored when `notes` is present).",
|
|
784
|
+
},
|
|
612
785
|
// Batch
|
|
613
786
|
notes: {
|
|
614
787
|
type: "array",
|
|
@@ -632,10 +805,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
632
805
|
metadata: { type: "object" },
|
|
633
806
|
created_at: { type: "string" },
|
|
634
807
|
if_updated_at: { type: "string", description: "Optimistic concurrency check for this item; rejects with a conflict error if the note has been modified since. Required unless `force: true` is set on this item or the item is `append`/`prepend`-only." },
|
|
635
|
-
force: { type: "boolean", description: "
|
|
808
|
+
force: { type: "boolean", description: "Waive the *requirement to supply* `if_updated_at` for this item. Does not override an `if_updated_at` you actually pass — a supplied precondition still applies and a mismatch conflicts." },
|
|
636
809
|
if_missing: { type: "string", enum: ["fail", "create"], description: "Per-item: see top-level `if_missing` docs. Each batch item carries its own setting." },
|
|
637
810
|
tags: { type: "object" },
|
|
638
811
|
links: { type: "object" },
|
|
812
|
+
include_links: { type: "boolean", description: "Per-item: echo hydrated links on this item's response (vault feedback #8). Also implied when this item mutates links." },
|
|
639
813
|
},
|
|
640
814
|
required: ["id"],
|
|
641
815
|
},
|
|
@@ -657,6 +831,15 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
657
831
|
// sync-loop caller (Gitcoin Brain et al) reads this to know which
|
|
658
832
|
// path fired without doing a separate query. vault#309.
|
|
659
833
|
const createdIds = new Set<string>();
|
|
834
|
+
// Track which note IDs should echo hydrated links on the response.
|
|
835
|
+
// A note qualifies when this request mutated its links
|
|
836
|
+
// (`links.add`/`links.remove`) OR the caller set `include_links`.
|
|
837
|
+
// vault feedback #8 — previously the update response omitted links
|
|
838
|
+
// entirely, forcing a re-query just to confirm a link the caller had
|
|
839
|
+
// just added/removed. Per-item on batch. Note IDs (not item indices)
|
|
840
|
+
// key this so the create-on-missing branch, which assigns the id
|
|
841
|
+
// late, can register correctly.
|
|
842
|
+
const echoLinkIds = new Set<string>();
|
|
660
843
|
// Wrap multi-item batches in a SQLite transaction so any mid-batch
|
|
661
844
|
// failure (precondition error, content_edit miss, ConflictError, …)
|
|
662
845
|
// rolls back every prior mutation in the batch — see #236.
|
|
@@ -745,6 +928,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
745
928
|
const fresh = noteOps.getNote(db, created.id) ?? created;
|
|
746
929
|
updated.push(fresh);
|
|
747
930
|
createdIds.add(fresh.id);
|
|
931
|
+
// Echo links if this create-on-missing declared `links.add`
|
|
932
|
+
// (the only link op honored on create) or asked explicitly.
|
|
933
|
+
if (linksAdd !== undefined || item.include_links === true) {
|
|
934
|
+
echoLinkIds.add(fresh.id);
|
|
935
|
+
}
|
|
748
936
|
continue;
|
|
749
937
|
}
|
|
750
938
|
// Fallthrough: not-found + no if_missing → existing error
|
|
@@ -907,6 +1095,13 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
907
1095
|
}
|
|
908
1096
|
}
|
|
909
1097
|
|
|
1098
|
+
// Echo links if this update mutated them (`links.add`/`links.remove`)
|
|
1099
|
+
// or the caller asked explicitly. vault feedback #8.
|
|
1100
|
+
const linkMutated = (item.links as any)?.add !== undefined || (item.links as any)?.remove !== undefined;
|
|
1101
|
+
if (linkMutated || item.include_links === true) {
|
|
1102
|
+
echoLinkIds.add(note.id);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
910
1105
|
// Re-read for final state
|
|
911
1106
|
updated.push(noteOps.getNote(db, note.id) ?? result);
|
|
912
1107
|
}
|
|
@@ -929,11 +1124,23 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
929
1124
|
const final = updated.map((n) => {
|
|
930
1125
|
const validated = attachValidationStatus(store, db, n);
|
|
931
1126
|
const created = createdIds.has(n.id);
|
|
932
|
-
|
|
1127
|
+
// Echo hydrated links when this note was flagged for it (mutated
|
|
1128
|
+
// its links or `include_links` was set). Additive key, present only
|
|
1129
|
+
// when triggered — mirrors the GET / query-notes shape exactly via
|
|
1130
|
+
// the shared `linkOps.getLinksHydrated` call. vault feedback #8.
|
|
1131
|
+
const echoLinks = echoLinkIds.has(n.id);
|
|
1132
|
+
if (includeContent) {
|
|
1133
|
+
const full: any = { ...validated, created };
|
|
1134
|
+
if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
|
|
1135
|
+
return full as Note & { created: boolean };
|
|
1136
|
+
}
|
|
933
1137
|
const lean: any = noteOps.toNoteIndex(validated);
|
|
934
1138
|
const vs = (validated as any).validation_status;
|
|
935
1139
|
if (vs !== undefined) lean.validation_status = vs;
|
|
936
1140
|
lean.created = created;
|
|
1141
|
+
// Carry the link echo across the lean conversion — `toNoteIndex`
|
|
1142
|
+
// drops unknown fields.
|
|
1143
|
+
if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
|
|
937
1144
|
return lean;
|
|
938
1145
|
});
|
|
939
1146
|
return batch ? final : final[0];
|
|
@@ -1029,7 +1236,7 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1029
1236
|
{
|
|
1030
1237
|
name: "update-tag",
|
|
1031
1238
|
requiredVerb: "write",
|
|
1032
|
-
description: "Create or update a tag's identity row: description, indexed-field schemas,
|
|
1239
|
+
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-patterns/patterns/tag-data-model.md.",
|
|
1033
1240
|
inputSchema: {
|
|
1034
1241
|
type: "object",
|
|
1035
1242
|
properties: {
|
|
@@ -1051,16 +1258,8 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1051
1258
|
},
|
|
1052
1259
|
relationships: {
|
|
1053
1260
|
type: "object",
|
|
1054
|
-
description: '
|
|
1055
|
-
additionalProperties:
|
|
1056
|
-
type: "object",
|
|
1057
|
-
properties: {
|
|
1058
|
-
target_tag: { type: "string", description: "Tag the relationship points at" },
|
|
1059
|
-
cardinality: { type: "string", enum: ["one", "optional", "many", "many-required"], description: "How many targets this relationship may have" },
|
|
1060
|
-
description: { type: "string", description: "Why this relationship exists; surfaced to AI clients" },
|
|
1061
|
-
},
|
|
1062
|
-
required: ["target_tag", "cardinality"],
|
|
1063
|
-
},
|
|
1261
|
+
description: 'Opaque relationship-vocabulary map: keys are relationship names, values are arbitrary JSON the declaring app interprets. Vault stores and returns the values verbatim and does NOT enforce any inner shape — only that this is a JSON object (a map), not an array or primitive. Replaces any prior map wholesale when provided; pass null to clear. The historical typed shape { "lives_in": { "target_tag": "place", "cardinality": "one" } } is still a valid value, as is any app-defined shape e.g. { "works-on": { "from": "person", "to": "project" } }.',
|
|
1262
|
+
additionalProperties: true,
|
|
1064
1263
|
},
|
|
1065
1264
|
parent_names: {
|
|
1066
1265
|
type: "array",
|
|
@@ -1124,10 +1323,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1124
1323
|
}
|
|
1125
1324
|
}
|
|
1126
1325
|
|
|
1127
|
-
// ---- relationships: replace wholesale when provided.
|
|
1128
|
-
//
|
|
1129
|
-
//
|
|
1130
|
-
|
|
1326
|
+
// ---- relationships: replace wholesale when provided. `relationships`
|
|
1327
|
+
// is an opaque vocabulary map (relationship-name → arbitrary JSON the
|
|
1328
|
+
// app interprets). Validate only that it's a JSON object (a map), then
|
|
1329
|
+
// persist verbatim — no inner-shape enforcement.
|
|
1330
|
+
let relationshipsPatch: tagSchemaOps.TagRelationshipMap | null | undefined;
|
|
1131
1331
|
if (params.relationships === null) {
|
|
1132
1332
|
relationshipsPatch = null;
|
|
1133
1333
|
} else if (params.relationships !== undefined) {
|
|
@@ -1296,9 +1496,16 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
1296
1496
|
// Tag schema effects — auto-populate defaults when tags are applied
|
|
1297
1497
|
// ---------------------------------------------------------------------------
|
|
1298
1498
|
|
|
1299
|
-
|
|
1499
|
+
/**
|
|
1500
|
+
* Fill schema-declared default values into the metadata of the given notes
|
|
1501
|
+
* for any field they omitted. Returns the IDs of the notes whose metadata was
|
|
1502
|
+
* actually written — callers use this to re-read ONLY the mutated notes (and
|
|
1503
|
+
* to skip the re-read entirely when nothing changed). The common no-schema /
|
|
1504
|
+
* no-defaults path returns an empty array.
|
|
1505
|
+
*/
|
|
1506
|
+
async function applySchemaDefaults(store: Store, db: Database, noteIds: string[], tags: string[]): Promise<string[]> {
|
|
1300
1507
|
const schemas = tagSchemaOps.getTagSchemaMap(db);
|
|
1301
|
-
if (Object.keys(schemas).length === 0) return;
|
|
1508
|
+
if (Object.keys(schemas).length === 0) return [];
|
|
1302
1509
|
|
|
1303
1510
|
const defaults: Record<string, unknown> = {};
|
|
1304
1511
|
for (const tag of tags) {
|
|
@@ -1310,8 +1517,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1310
1517
|
}
|
|
1311
1518
|
}
|
|
1312
1519
|
}
|
|
1313
|
-
if (Object.keys(defaults).length === 0) return;
|
|
1520
|
+
if (Object.keys(defaults).length === 0) return [];
|
|
1314
1521
|
|
|
1522
|
+
const mutated: string[] = [];
|
|
1315
1523
|
for (const noteId of noteIds) {
|
|
1316
1524
|
const note = noteOps.getNote(db, noteId);
|
|
1317
1525
|
if (!note) continue;
|
|
@@ -1327,7 +1535,9 @@ async function applySchemaDefaults(store: Store, db: Database, noteIds: string[]
|
|
|
1327
1535
|
metadata: { ...existing, ...missing },
|
|
1328
1536
|
skipUpdatedAt: true,
|
|
1329
1537
|
});
|
|
1538
|
+
mutated.push(noteId);
|
|
1330
1539
|
}
|
|
1540
|
+
return mutated;
|
|
1331
1541
|
}
|
|
1332
1542
|
|
|
1333
1543
|
function defaultForField(field: { type: string; enum?: string[] }): unknown {
|
|
@@ -1382,6 +1592,16 @@ function normalizeTags(tag: unknown): string[] | undefined {
|
|
|
1382
1592
|
return [tag as string];
|
|
1383
1593
|
}
|
|
1384
1594
|
|
|
1595
|
+
/**
|
|
1596
|
+
* Coerce the `link_count_direction` MCP param to a known value, defaulting
|
|
1597
|
+
* to "both" (matches the REST `parseLinkCountDirection` fallback). A typo
|
|
1598
|
+
* silently degrades to the documented default rather than erroring.
|
|
1599
|
+
*/
|
|
1600
|
+
function normalizeLinkCountDirection(v: unknown): "both" | "outbound" | "inbound" {
|
|
1601
|
+
if (v === "outbound" || v === "inbound") return v;
|
|
1602
|
+
return "both";
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1385
1605
|
// Re-exported for backward compat; defined in notes.ts alongside the
|
|
1386
1606
|
// conditional-UPDATE implementation that raises it. AmbiguousPathError
|
|
1387
1607
|
// joins the set (vault#331 N2) so external callers can `instanceof`
|