@openparachute/vault 0.7.0-rc.1 → 0.7.0-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/cursor.ts +27 -9
- package/core/src/links.ts +63 -3
- package/core/src/mcp.ts +155 -23
- package/core/src/notes.ts +226 -37
- package/core/src/query-operators.ts +18 -1
- package/core/src/query-warnings.ts +219 -0
- package/core/src/search-query.test.ts +149 -0
- package/core/src/search-query.ts +0 -0
- package/core/src/store.ts +11 -3
- package/core/src/tag-hierarchy.ts +113 -0
- package/core/src/types.ts +32 -5
- package/package.json +1 -1
- package/src/contract-honest-queries.test.ts +350 -38
- package/src/contract-search.test.ts +263 -31
- package/src/live-match.test.ts +17 -0
- package/src/live-match.ts +4 -1
- package/src/mcp-http.ts +32 -0
- package/src/mcp-list-tags-scope.test.ts +154 -0
- package/src/mcp-tools.ts +49 -8
- package/src/routes.ts +327 -23
- package/src/ws-subscribe.test.ts +14 -0
- package/web/ui/dist/assets/{index-TJ-XN4OZ.js → index-B8pGeQam.js} +1 -1
- package/web/ui/dist/index.html +1 -1
package/core/src/cursor.ts
CHANGED
|
@@ -94,41 +94,59 @@ export function encodeCursor(payload: CursorPayload): string {
|
|
|
94
94
|
return Buffer.from(json, "utf8").toString("base64url");
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
/**
|
|
97
|
+
/**
|
|
98
|
+
* Trailing hint appended to every `cursor_invalid` message (vault#550) so a
|
|
99
|
+
* caller who passed a garbage cursor — or never understood the bootstrap
|
|
100
|
+
* shape in the first place — gets the actual recovery flow, not just "this
|
|
101
|
+
* string is broken." The bootstrap flow: an EMPTY string (`cursor: ""` /
|
|
102
|
+
* `?cursor=`) opts into cursor mode without a watermark yet; the response's
|
|
103
|
+
* `next_cursor` is what you pass on every call after that.
|
|
104
|
+
*/
|
|
105
|
+
const CURSOR_BOOTSTRAP_HINT =
|
|
106
|
+
'first call: pass cursor:"" (or omit `cursor` entirely for a plain, non-paginated list); the response carries `next_cursor` — pass that back on each subsequent call to resume from the watermark.';
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Decode a cursor string. Throws `CursorError` on any structural problem.
|
|
110
|
+
* An EMPTY string is deliberately NOT accepted here — callers (queryNotes /
|
|
111
|
+
* queryNotesPaged) must special-case `opts.cursor === ""` as "cursor mode,
|
|
112
|
+
* no watermark yet" and skip calling this at all (vault#550 bootstrap fix).
|
|
113
|
+
* Any caller that reaches this function with an empty string gets the same
|
|
114
|
+
* loud `cursor_invalid` as any other malformed cursor.
|
|
115
|
+
*/
|
|
98
116
|
export function decodeCursor(cursor: string): CursorPayload {
|
|
99
117
|
if (typeof cursor !== "string" || cursor.length === 0) {
|
|
100
|
-
throw new CursorError(
|
|
118
|
+
throw new CursorError(`cursor must be a non-empty string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
101
119
|
}
|
|
102
120
|
let json: string;
|
|
103
121
|
try {
|
|
104
122
|
json = Buffer.from(cursor, "base64url").toString("utf8");
|
|
105
123
|
} catch {
|
|
106
|
-
throw new CursorError(
|
|
124
|
+
throw new CursorError(`cursor is not valid base64url — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
107
125
|
}
|
|
108
126
|
let parsed: unknown;
|
|
109
127
|
try {
|
|
110
128
|
parsed = JSON.parse(json);
|
|
111
129
|
} catch {
|
|
112
|
-
throw new CursorError(
|
|
130
|
+
throw new CursorError(`cursor payload is not valid JSON — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
113
131
|
}
|
|
114
132
|
if (!parsed || typeof parsed !== "object") {
|
|
115
|
-
throw new CursorError(
|
|
133
|
+
throw new CursorError(`cursor payload must be an object — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
116
134
|
}
|
|
117
135
|
const p = parsed as Record<string, unknown>;
|
|
118
136
|
if (typeof p.v !== "number" || p.v !== CURSOR_VERSION) {
|
|
119
137
|
throw new CursorError(
|
|
120
|
-
`cursor schema version mismatch (expected ${CURSOR_VERSION}, got ${String(p.v)})`,
|
|
138
|
+
`cursor schema version mismatch (expected ${CURSOR_VERSION}, got ${String(p.v)}) — ${CURSOR_BOOTSTRAP_HINT}`,
|
|
121
139
|
"cursor_invalid",
|
|
122
140
|
);
|
|
123
141
|
}
|
|
124
142
|
if (typeof p.last_updated_at !== "number" || !Number.isFinite(p.last_updated_at)) {
|
|
125
|
-
throw new CursorError(
|
|
143
|
+
throw new CursorError(`cursor.last_updated_at must be a finite number — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
126
144
|
}
|
|
127
145
|
if (typeof p.last_id !== "string") {
|
|
128
|
-
throw new CursorError(
|
|
146
|
+
throw new CursorError(`cursor.last_id must be a string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
129
147
|
}
|
|
130
148
|
if (typeof p.query_hash !== "string" || p.query_hash.length === 0) {
|
|
131
|
-
throw new CursorError(
|
|
149
|
+
throw new CursorError(`cursor.query_hash must be a non-empty string — ${CURSOR_BOOTSTRAP_HINT}`, "cursor_invalid");
|
|
132
150
|
}
|
|
133
151
|
return {
|
|
134
152
|
v: p.v,
|
package/core/src/links.ts
CHANGED
|
@@ -404,6 +404,66 @@ export function traverseLinks(
|
|
|
404
404
|
return results;
|
|
405
405
|
}
|
|
406
406
|
|
|
407
|
+
/** Hydrated find-path result — see `hydratePathResult` for field semantics. */
|
|
408
|
+
export interface FindPathResult {
|
|
409
|
+
/** Note IDs, source → target (unchanged shape — back-compat). */
|
|
410
|
+
path: string[];
|
|
411
|
+
/** relationships[i] is the edge connecting path[i] to path[i+1] (unchanged shape). */
|
|
412
|
+
relationships: string[];
|
|
413
|
+
/**
|
|
414
|
+
* Hydrated companion to `path[]` (vault#550, additive): one entry per
|
|
415
|
+
* node, in the same order, carrying the note's `path` field alongside
|
|
416
|
+
* its `id` — so a caller doesn't have to round-trip each id through a
|
|
417
|
+
* separate note fetch just to render a human-legible chain.
|
|
418
|
+
*/
|
|
419
|
+
nodes: { id: string; path: string | null }[];
|
|
420
|
+
/**
|
|
421
|
+
* Hydrated companion to `relationships[]` (vault#550, additive): each
|
|
422
|
+
* hop as a self-contained `{source, target, relationship}` edge plus
|
|
423
|
+
* both endpoints' note `path`, so a graph-rendering client can draw the
|
|
424
|
+
* chain without cross-referencing `nodes`.
|
|
425
|
+
*/
|
|
426
|
+
edges: {
|
|
427
|
+
source: string;
|
|
428
|
+
target: string;
|
|
429
|
+
relationship: string;
|
|
430
|
+
sourcePath: string | null;
|
|
431
|
+
targetPath: string | null;
|
|
432
|
+
}[];
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
/**
|
|
436
|
+
* Hydrate a raw BFS result (`path` ids + `relationships`) with each node's
|
|
437
|
+
* note `path` field, in ONE batched query (not one fetch per hop). `path`
|
|
438
|
+
* and `relationships` pass through byte-identical to before this existed —
|
|
439
|
+
* `nodes`/`edges` are pure additions.
|
|
440
|
+
*/
|
|
441
|
+
function hydratePathResult(db: Database, path: string[], relationships: string[]): FindPathResult {
|
|
442
|
+
const pathById = new Map<string, string | null>();
|
|
443
|
+
for (const chunk of chunkForInClause(path)) {
|
|
444
|
+
const placeholders = chunk.map(() => "?").join(", ");
|
|
445
|
+
const rows = db.prepare(`SELECT id, path FROM notes WHERE id IN (${placeholders})`).all(...chunk) as
|
|
446
|
+
{ id: string; path: string | null }[];
|
|
447
|
+
for (const row of rows) pathById.set(row.id, row.path);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
const nodes = path.map((id) => ({ id, path: pathById.get(id) ?? null }));
|
|
451
|
+
const edges: FindPathResult["edges"] = [];
|
|
452
|
+
for (let i = 0; i < path.length - 1; i++) {
|
|
453
|
+
const source = path[i]!;
|
|
454
|
+
const target = path[i + 1]!;
|
|
455
|
+
edges.push({
|
|
456
|
+
source,
|
|
457
|
+
target,
|
|
458
|
+
relationship: relationships[i] ?? "",
|
|
459
|
+
sourcePath: pathById.get(source) ?? null,
|
|
460
|
+
targetPath: pathById.get(target) ?? null,
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
return { path, relationships, nodes, edges };
|
|
465
|
+
}
|
|
466
|
+
|
|
407
467
|
/**
|
|
408
468
|
* Find a path between two notes in the link graph.
|
|
409
469
|
* Returns the sequence of note IDs from source to target, or null if no path exists.
|
|
@@ -413,11 +473,11 @@ export function findPath(
|
|
|
413
473
|
sourceId: string,
|
|
414
474
|
targetId: string,
|
|
415
475
|
opts?: { max_depth?: number },
|
|
416
|
-
):
|
|
476
|
+
): FindPathResult | null {
|
|
417
477
|
const maxDepth = opts?.max_depth ?? 5;
|
|
418
478
|
|
|
419
479
|
if (sourceId === targetId) {
|
|
420
|
-
return
|
|
480
|
+
return hydratePathResult(db, [sourceId], []);
|
|
421
481
|
}
|
|
422
482
|
|
|
423
483
|
// BFS from source
|
|
@@ -460,7 +520,7 @@ export function findPath(
|
|
|
460
520
|
current = entry.parent;
|
|
461
521
|
}
|
|
462
522
|
path.unshift(sourceId);
|
|
463
|
-
return
|
|
523
|
+
return hydratePathResult(db, path, relationships);
|
|
464
524
|
}
|
|
465
525
|
}
|
|
466
526
|
}
|
package/core/src/mcp.ts
CHANGED
|
@@ -4,7 +4,9 @@ import { transactionAsync } from "./txn.js";
|
|
|
4
4
|
import * as noteOps from "./notes.js";
|
|
5
5
|
import { filterMetadata, MAX_BATCH_SIZE, validateExtension, ExtensionValidationError } from "./notes.js";
|
|
6
6
|
import { QueryError } from "./query-operators.js";
|
|
7
|
-
import { TAG_EXPAND_MODES, stripTagHash, type TagExpandMode } from "./tag-hierarchy.js";
|
|
7
|
+
import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
|
|
8
|
+
import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "./query-warnings.js";
|
|
9
|
+
import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
|
|
8
10
|
import * as linkOps from "./links.js";
|
|
9
11
|
import * as tagSchemaOps from "./tag-schemas.js";
|
|
10
12
|
import type { TagFieldSchema } from "./tag-schemas.js";
|
|
@@ -237,7 +239,14 @@ Defaults: include_content=true for single note, false for lists. include_links=f
|
|
|
237
239
|
|
|
238
240
|
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.
|
|
239
241
|
|
|
240
|
-
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
|
|
242
|
+
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.
|
|
243
|
+
|
|
244
|
+
Response shape (vault#550 — three variants, pick by what you passed):
|
|
245
|
+
- Default (no \`cursor\`, no warnings): a bare array of notes.
|
|
246
|
+
- Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
|
|
247
|
+
- 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.
|
|
248
|
+
|
|
249
|
+
\`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.`,
|
|
241
250
|
inputSchema: {
|
|
242
251
|
type: "object",
|
|
243
252
|
properties: {
|
|
@@ -291,7 +300,17 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
291
300
|
],
|
|
292
301
|
description: "Filter by file extension (vault#328). Pass a single extension (e.g. \"csv\") or an array (e.g. [\"csv\", \"yaml\", \"json\"]). Notes default to \"md\"; case-insensitive match.",
|
|
293
302
|
},
|
|
294
|
-
search: {
|
|
303
|
+
search: {
|
|
304
|
+
type: "string",
|
|
305
|
+
description:
|
|
306
|
+
'Full-text search query. Literal-by-default (vault#551): your text is escaped and phrase-quoted before reaching FTS5, so punctuation ("didn\'t", "eleven-day", "18.6") is matched as literal content rather than parsed as FTS5 query syntax. Pass `search_mode: "advanced"` for raw FTS5 syntax (boolean/phrase/prefix operators). `sort` is honored under search (see below) — default is relevance ranking.',
|
|
307
|
+
},
|
|
308
|
+
search_mode: {
|
|
309
|
+
type: "string",
|
|
310
|
+
enum: [...SEARCH_MODES],
|
|
311
|
+
description:
|
|
312
|
+
'How `search` text is turned into an FTS5 query (vault#551). "literal" (DEFAULT): escape + phrase-quote the text so punctuation is literal content, not FTS5 syntax — the fix for `search: "didn\'t"` / "eleven-day" / "18.6" silently returning `[]`. "advanced": pass the text through to FTS5 raw, for callers who want boolean (AND/OR/NOT), manual phrase quoting, or prefix (`*`) syntax — a malformed advanced query throws a structured error (`error_type: "invalid_search_syntax"`) instead of silently returning `[]`. Has no effect without `search` (an `ignored_param` warning fires if you pass it without `search`). Omit for the default ("literal").',
|
|
313
|
+
},
|
|
295
314
|
metadata: {
|
|
296
315
|
type: "object",
|
|
297
316
|
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.",
|
|
@@ -322,13 +341,18 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
322
341
|
required: ["note_id"],
|
|
323
342
|
description: "Scope results to notes within N hops of an anchor note",
|
|
324
343
|
},
|
|
325
|
-
sort: {
|
|
344
|
+
sort: {
|
|
345
|
+
type: "string",
|
|
346
|
+
enum: ["asc", "desc"],
|
|
347
|
+
description:
|
|
348
|
+
'Sort by created_at. Under a structured query this is the only ordering (default "asc"). Under `search` (vault#551): omit for FTS5 relevance ranking (default, unchanged) — pass "asc"/"desc" to EXPLICITLY switch to created_at ordering instead of relevance.',
|
|
349
|
+
},
|
|
326
350
|
limit: { type: "number", description: "Max results (default 50)" },
|
|
327
351
|
offset: { type: "number", description: "Pagination offset (default 0)" },
|
|
328
352
|
cursor: {
|
|
329
353
|
type: "string",
|
|
330
354
|
description:
|
|
331
|
-
"Opaque cursor for 'since last checked' agent loops (vault#313).
|
|
355
|
+
"Opaque cursor for 'since last checked' agent loops (vault#313). Bootstrap flow (vault#550): FIRST call passes `cursor: \"\"` (empty string) — this opts into cursor mode with no watermark yet and the response comes back as `{notes, next_cursor}`. Persist `next_cursor` and pass it back verbatim as `cursor` on every SUBSEQUENT call to receive only notes created or updated since the prior page. Omitting `cursor` entirely (not passing the key at all) is a DIFFERENT thing — a plain one-shot list with no cursor envelope and no way to resume; use that when you don't want pagination at all. The cursor binds to the query's filters (tag, path, metadata, etc.); changing them between calls returns a structured `cursor_query_mismatch` error, and a malformed/expired cursor returns `cursor_invalid` naming the bootstrap flow again. Pagination via cursor orders results by `updated_at ASC` and is mutually exclusive with `order_by` and `sort: \"desc\"`.",
|
|
332
356
|
},
|
|
333
357
|
include_content: { type: "boolean", description: "Include note content (default: true for single, false for list)" },
|
|
334
358
|
content_offset: {
|
|
@@ -466,7 +490,11 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
466
490
|
// neighborhood every call to be cursor-stable; we punt for now).
|
|
467
491
|
// Both surface as INVALID_QUERY rather than silently returning
|
|
468
492
|
// wrong rows.
|
|
469
|
-
|
|
493
|
+
// Presence, not truthiness (vault#550 bootstrap fix) — `cursor: ""`
|
|
494
|
+
// is the bootstrap call ("I want to paginate, no watermark yet") and
|
|
495
|
+
// must still engage cursor mode. Before this fix `"".length > 0` was
|
|
496
|
+
// false, so the very first call could never obtain a `next_cursor`.
|
|
497
|
+
const cursorMode = typeof params.cursor === "string";
|
|
470
498
|
if (cursorMode && params.search) {
|
|
471
499
|
throw new QueryError(
|
|
472
500
|
`cursor is incompatible with full-text search — FTS has its own ordering. Use date_filter on updated_at for since-last-checked search.`,
|
|
@@ -492,24 +520,85 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
492
520
|
expand = params.expand as TagExpandMode;
|
|
493
521
|
}
|
|
494
522
|
|
|
523
|
+
// `search_mode` (vault#551) — validate loudly (same policy as
|
|
524
|
+
// `expand` above) so a typo'd value doesn't silently fall back to
|
|
525
|
+
// the default. Resolved here (before the search/structured branch)
|
|
526
|
+
// because BOTH branches need to know whether it was passed: the
|
|
527
|
+
// search branch to select escaping behavior, the structured branch
|
|
528
|
+
// to warn that it's being ignored.
|
|
529
|
+
let searchMode: SearchMode | undefined;
|
|
530
|
+
if (params.search_mode !== undefined && params.search_mode !== null) {
|
|
531
|
+
if (typeof params.search_mode !== "string" || !isValidSearchMode(params.search_mode)) {
|
|
532
|
+
throw new QueryError(
|
|
533
|
+
`invalid \`search_mode\` value ${JSON.stringify(params.search_mode)} — must be one of ${SEARCH_MODES.map((m) => `"${m}"`).join(", ")}. Omit for the default ("literal").`,
|
|
534
|
+
"INVALID_QUERY",
|
|
535
|
+
{
|
|
536
|
+
error_type: "invalid_query",
|
|
537
|
+
field: "search_mode",
|
|
538
|
+
got: params.search_mode,
|
|
539
|
+
hint: `pass "literal" or "advanced", or omit for the default ("literal")`,
|
|
540
|
+
},
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
searchMode = params.search_mode;
|
|
544
|
+
}
|
|
545
|
+
|
|
495
546
|
// --- Full-text search ---
|
|
496
547
|
let results: Note[];
|
|
497
548
|
let nextCursor: string | null = null;
|
|
549
|
+
// Warnings channel (vault#550). `search=` warnings (`empty_search`,
|
|
550
|
+
// `ignored_param` for a stray `search_mode`) joined the channel at
|
|
551
|
+
// #551 — the rest (`unknown_tag`) stays structured-query only.
|
|
552
|
+
// Scope-unaware by design (see `core/src/query-warnings.ts` doc
|
|
553
|
+
// comment) — a tag-scoped MCP session gets these stripped by the
|
|
554
|
+
// `applyTagScopeWrappers` query-notes wrapper in `src/mcp-tools.ts`
|
|
555
|
+
// before the result reaches the caller, so an out-of-scope tag name
|
|
556
|
+
// never leaks via `did_you_mean`.
|
|
557
|
+
let queryWarnings: QueryWarning[] = [];
|
|
498
558
|
if (params.search) {
|
|
499
559
|
// Normalize tag param
|
|
500
560
|
const tags = normalizeTags(params.tag);
|
|
501
|
-
|
|
502
|
-
//
|
|
503
|
-
//
|
|
504
|
-
//
|
|
505
|
-
//
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
}
|
|
561
|
+
const mode: SearchMode = searchMode ?? "literal";
|
|
562
|
+
// "Only whitespace/quotes" (vault#551 edge case): short-circuit
|
|
563
|
+
// BEFORE ever calling FTS5 — an empty/all-punctuation phrase can
|
|
564
|
+
// be a syntax error (or a meaningless always-empty query)
|
|
565
|
+
// depending on exactly how it degenerates, so this is checked
|
|
566
|
+
// here rather than left to the DB layer to (maybe) reject.
|
|
567
|
+
if (mode === "literal" && buildLiteralSearchQuery(params.search as string).isEmpty) {
|
|
568
|
+
results = [];
|
|
569
|
+
queryWarnings.push(emptySearchWarning());
|
|
570
|
+
} else {
|
|
571
|
+
// Route through `store.searchNotes` (not `noteOps.searchNotes`) so
|
|
572
|
+
// tag-hierarchy expansion fires for MCP callers the same as for
|
|
573
|
+
// HTTP REST callers — `tag: "manual"` matches descendants declared
|
|
574
|
+
// via `_tags/*` config notes. Mirrors the structured-query fix
|
|
575
|
+
// from #214; same class of bypass bug (tracked as #227). A
|
|
576
|
+
// malformed advanced-mode query throws here (structured
|
|
577
|
+
// `invalid_search_syntax`, vault#551) — uncaught on purpose, it
|
|
578
|
+
// propagates to `src/mcp-http.ts`, which maps it to a JSON-RPC
|
|
579
|
+
// error the same way it maps `invalid_query`.
|
|
580
|
+
results = await store.searchNotes(params.search as string, {
|
|
581
|
+
tags,
|
|
582
|
+
limit: (params.limit as number) ?? 50,
|
|
583
|
+
expand,
|
|
584
|
+
mode,
|
|
585
|
+
sort: params.sort as "asc" | "desc" | undefined,
|
|
586
|
+
});
|
|
587
|
+
}
|
|
511
588
|
} else {
|
|
512
589
|
// --- Structured query ---
|
|
590
|
+
// `search_mode` only shapes how `search` text becomes an FTS5
|
|
591
|
+
// query — passing it without `search` is almost always a mistake
|
|
592
|
+
// (meant to pass `search` too), so flag it rather than silently
|
|
593
|
+
// doing nothing with it.
|
|
594
|
+
if (searchMode !== undefined) {
|
|
595
|
+
queryWarnings.push(
|
|
596
|
+
ignoredParamWarning(
|
|
597
|
+
"search_mode",
|
|
598
|
+
"no `search` was provided — search_mode only affects full-text search query parsing",
|
|
599
|
+
),
|
|
600
|
+
);
|
|
601
|
+
}
|
|
513
602
|
const tags = normalizeTags(params.tag);
|
|
514
603
|
// Accept canonical `exclude_tags` plus camelCase / singular aliases.
|
|
515
604
|
// LLM callers frequently pick the wrong name (training-data drift
|
|
@@ -556,6 +645,12 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
556
645
|
offset: params.offset as number | undefined,
|
|
557
646
|
cursor: cursorMode ? (params.cursor as string) : undefined,
|
|
558
647
|
};
|
|
648
|
+
// Concatenate (not overwrite) — `queryWarnings` may already carry
|
|
649
|
+
// the `ignored_param` warning for a stray `search_mode` pushed
|
|
650
|
+
// above (vault#551).
|
|
651
|
+
queryWarnings = queryWarnings.concat(
|
|
652
|
+
collectUnknownTagWarnings(db, queryOpts.tags, queryOpts.expand, store.getTagHierarchy()),
|
|
653
|
+
);
|
|
559
654
|
if (cursorMode) {
|
|
560
655
|
const page = await store.queryNotesPaged(queryOpts);
|
|
561
656
|
results = page.notes;
|
|
@@ -637,13 +732,29 @@ Link expansion: pass \`expand_links: true\` to inline [[wikilinks]] from returne
|
|
|
637
732
|
}
|
|
638
733
|
// Cursor mode wraps the list in `{notes, next_cursor}` so callers can
|
|
639
734
|
// chain calls without tracking a watermark client-side. Legacy
|
|
640
|
-
// callers (no `cursor` param) still get the flat
|
|
641
|
-
|
|
642
|
-
|
|
735
|
+
// callers (no `cursor` param, no warnings) still get the flat
|
|
736
|
+
// array (vault#550 — warnings channel is additive, never forces
|
|
737
|
+
// the envelope on its own outside cursor mode... except when
|
|
738
|
+
// there ARE warnings, in which case the envelope is the only way
|
|
739
|
+
// to attach them).
|
|
740
|
+
if (cursorMode) {
|
|
741
|
+
return {
|
|
742
|
+
notes: enrichedOut,
|
|
743
|
+
next_cursor: nextCursor,
|
|
744
|
+
...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}),
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
return queryWarnings.length > 0 ? { notes: enrichedOut, warnings: queryWarnings } : enrichedOut;
|
|
643
748
|
}
|
|
644
749
|
|
|
645
|
-
if (cursorMode)
|
|
646
|
-
|
|
750
|
+
if (cursorMode) {
|
|
751
|
+
return {
|
|
752
|
+
notes: output,
|
|
753
|
+
next_cursor: nextCursor,
|
|
754
|
+
...(queryWarnings.length > 0 ? { warnings: queryWarnings } : {}),
|
|
755
|
+
};
|
|
756
|
+
}
|
|
757
|
+
return queryWarnings.length > 0 ? { notes: output, warnings: queryWarnings } : output;
|
|
647
758
|
},
|
|
648
759
|
},
|
|
649
760
|
|
|
@@ -1353,7 +1464,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1353
1464
|
{
|
|
1354
1465
|
name: "list-tags",
|
|
1355
1466
|
requiredVerb: "read",
|
|
1356
|
-
description: `List tags with usage counts. Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps). Pass \`include_schema: true\` to include the full record for every tag.`,
|
|
1467
|
+
description: `List tags with usage counts. Each row carries \`count\` (notes carrying the EXACT tag) and \`expanded_count\` (vault#550 — distinct notes matching the tag OR any transitive descendant under the default subtypes expansion; use this to see a parent tag's true rollup when its notes are actually tagged with a more specific child). Pass \`tag\` to get a single tag's full record (description, fields, relationships, parent_names, timestamps) — errors with \`error_type: "tag_not_found"\` (plus a \`did_you_mean\` hint when a close match exists) if the tag has no identity row and no notes. Pass \`include_schema: true\` to include the full record for every tag.`,
|
|
1357
1468
|
inputSchema: {
|
|
1358
1469
|
type: "object",
|
|
1359
1470
|
properties: {
|
|
@@ -1368,9 +1479,30 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1368
1479
|
const allTags = noteOps.listTags(db);
|
|
1369
1480
|
const found = allTags.find((t) => t.name === singleTag);
|
|
1370
1481
|
const record = tagSchemaOps.getTagRecord(db, singleTag);
|
|
1482
|
+
// vault#550 — a tag with no identity row AND no note carrying it
|
|
1483
|
+
// isn't a legitimate (if empty) tag, it's a typo or a tag from a
|
|
1484
|
+
// different vault. Return a structured miss instead of a
|
|
1485
|
+
// synthesized all-null 200. `did_you_mean` searches the full
|
|
1486
|
+
// vault-wide tag catalog — core is scope-unaware by architecture.
|
|
1487
|
+
// Tag-scope enforcement lives in the server layer's list-tags
|
|
1488
|
+
// wrapper (src/mcp-tools.ts:applyTagScopeWrappers): a scoped
|
|
1489
|
+
// session's out-of-scope `tag` param short-circuits to
|
|
1490
|
+
// tag_not_found BEFORE this executes, and an in-scope miss gets
|
|
1491
|
+
// its `did_you_mean` dropped unless the suggestion is also
|
|
1492
|
+
// in-scope.
|
|
1493
|
+
if (!found && !record) {
|
|
1494
|
+
const suggestion = suggestSimilarTag(allTags.map((t) => t.name), singleTag);
|
|
1495
|
+
return {
|
|
1496
|
+
error: "Tag not found",
|
|
1497
|
+
error_type: "tag_not_found",
|
|
1498
|
+
tag: singleTag,
|
|
1499
|
+
...(suggestion ? { did_you_mean: suggestion } : {}),
|
|
1500
|
+
};
|
|
1501
|
+
}
|
|
1371
1502
|
return {
|
|
1372
1503
|
name: singleTag,
|
|
1373
1504
|
count: found?.count ?? 0,
|
|
1505
|
+
expanded_count: found?.expanded_count ?? 0,
|
|
1374
1506
|
description: record?.description ?? null,
|
|
1375
1507
|
fields: record?.fields ?? null,
|
|
1376
1508
|
relationships: record?.relationships ?? null,
|
|
@@ -1589,7 +1721,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
|
|
|
1589
1721
|
{
|
|
1590
1722
|
name: "find-path",
|
|
1591
1723
|
requiredVerb: "read",
|
|
1592
|
-
description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. Returns
|
|
1724
|
+
description: "Find the shortest path between two notes in the link graph. Accepts IDs or paths. 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`.",
|
|
1593
1725
|
inputSchema: {
|
|
1594
1726
|
type: "object",
|
|
1595
1727
|
properties: {
|