@openparachute/vault 0.7.0-rc.5 → 0.7.0-rc.7

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/notes.ts CHANGED
@@ -22,7 +22,12 @@ import {
22
22
  import { getIndexedField, releaseField } from "./indexed-fields.js";
23
23
  import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-hierarchy.js";
24
24
  import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
25
- import { buildLiteralSearchQuery, type SearchMode } from "./search-query.js";
25
+ import {
26
+ buildLiteralSearchQuery,
27
+ SEARCH_WEIGHT_PATH,
28
+ SEARCH_WEIGHT_CONTENT,
29
+ type SearchMode,
30
+ } from "./search-query.js";
26
31
 
27
32
  let idCounter = 0;
28
33
 
@@ -1336,12 +1341,41 @@ export function queryNotesPaged(db: Database, opts: QueryOpts): QueryNotesPage {
1336
1341
  * invariant is ever broken it still surfaces as an honest error, not a
1337
1342
  * 500. Signals a vault bug worth reporting.
1338
1343
  */
1344
+ /**
1345
+ * FTS5's own error text for a syntax mistake is written for someone who
1346
+ * already knows FTS5's grammar — "no such column: espresso" for a bare
1347
+ * leading `-espresso` (NOT with no left-hand term — FTS5's `-`/`NOT` is a
1348
+ * BINARY operator, so a lone `-token` with nothing before it gets
1349
+ * misparsed as `column:term` filter syntax and fails looking for a column
1350
+ * named after the token) is confusing/leaky rather than actionable: it
1351
+ * reads as if a column literally named "espresso" was expected, which
1352
+ * exposes FTS5-internal parsing behavior instead of explaining the actual
1353
+ * mistake (vault#551 WS2B item 4 — interim harness finding). Detected
1354
+ * generically off the FTS5 error text (`/no such column:/i`) rather than
1355
+ * re-parsing the query ourselves, since the SAME message also covers the
1356
+ * other real cause (an explicit `column:term` filter naming a column that
1357
+ * isn't one of `notes_fts`'s declared columns, `path`/`content`) — the hint
1358
+ * below covers both without claiming to know which one happened.
1359
+ */
1360
+ function advancedModeColumnHint(causeMessage: string): string | null {
1361
+ if (!/no such column:/i.test(causeMessage)) return null;
1362
+ return (
1363
+ `FTS5 read part of this query as column-filter syntax ("column:term") or as a ` +
1364
+ `leading "-" with no term to its left — NOT/"-" is a BINARY operator in FTS5 ` +
1365
+ `("good -bad", not "-bad" alone). Indexed columns are "path" and "content". ` +
1366
+ `Add a preceding positive term before a NOT, quote the phrase to search it ` +
1367
+ `literally, or use search_mode:"literal" (the default) to skip advanced syntax entirely.`
1368
+ );
1369
+ }
1370
+
1339
1371
  function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): QueryError {
1340
1372
  const causeMessage = err instanceof Error ? err.message : String(err);
1373
+ const columnHint = mode === "advanced" ? advancedModeColumnHint(causeMessage) : null;
1341
1374
  const hint =
1342
- mode === "advanced"
1375
+ columnHint ??
1376
+ (mode === "advanced"
1343
1377
  ? `FTS5 rejected this as advanced query syntax (${causeMessage}). Fix the syntax, or omit search_mode:"advanced" for literal (punctuation-safe) search.`
1344
- : `FTS5 rejected the escaped literal query (${causeMessage}) — this should be impossible after literal-mode escaping + control-char sanitization; please report it as a vault bug.`;
1378
+ : `FTS5 rejected the escaped literal query (${causeMessage}) — this should be impossible after literal-mode escaping + control-char sanitization; please report it as a vault bug.`);
1345
1379
  return new QueryError(`invalid search syntax: ${causeMessage}`, "INVALID_QUERY", {
1346
1380
  error_type: "invalid_search_syntax",
1347
1381
  field: "search",
@@ -1377,20 +1411,33 @@ export function searchNotes(
1377
1411
  ftsQuery = query;
1378
1412
  }
1379
1413
 
1414
+ // Weighted bm25 relevance expression (vault#551 WS2C, schema v25):
1415
+ // `notes_fts` now indexes `path` (title, column 0) then `content` (body,
1416
+ // column 1) — SEARCH_WEIGHT_PATH/SEARCH_WEIGHT_CONTENT bias ranking so a
1417
+ // title match outranks a passing body mention. Raw SQLite bm25 is
1418
+ // negative-is-better; `-1.0 * bm25(...)` flips it so bigger is more
1419
+ // relevant (see `Note.score`'s doc comment for the external contract).
1420
+ // The weights are our own numeric constants (not user input) interpolated
1421
+ // directly — bm25()'s weight arguments are positional per-column
1422
+ // multipliers, not general SQL expressions callers can influence.
1423
+ const scoreExpr = `(-1.0 * bm25(notes_fts, ${SEARCH_WEIGHT_PATH}, ${SEARCH_WEIGHT_CONTENT}))`;
1424
+
1380
1425
  // `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
1381
- // relevance (`rank`, unchanged); an EXPLICIT `sort: "asc"|"desc"` switches
1382
- // to `created_at` ordering. Checked against the literal string values (not
1383
- // truthiness) so an absent `sort` can never accidentally match a branch.
1384
- // `n.id ${direction}` is appended as a deterministic tiebreaker — same
1385
- // rationale as `queryNotes` (two notes at the same created_at millisecond
1386
- // would otherwise return in arbitrary/unstable order). `rank` needs no
1387
- // tiebreaker (bm25 score is effectively unique per row).
1426
+ // relevance (the weighted `score` expression); an EXPLICIT
1427
+ // `sort: "asc"|"desc"` switches to `created_at` ordering. Checked against
1428
+ // the literal string values (not truthiness) so an absent `sort` can
1429
+ // never accidentally match a branch. `n.id ${direction}` is appended as a
1430
+ // deterministic tiebreaker in every branch — two notes at the same
1431
+ // created_at millisecond (structured-sort branches) OR with an
1432
+ // identical weighted score (relevance branch much more likely than
1433
+ // with unweighted `rank`, since many notes share "zero path matches")
1434
+ // would otherwise return in arbitrary/unstable order.
1388
1435
  const orderBy =
1389
1436
  opts?.sort === "asc"
1390
1437
  ? "n.created_at ASC, n.id ASC"
1391
1438
  : opts?.sort === "desc"
1392
1439
  ? "n.created_at DESC, n.id DESC"
1393
- : "rank";
1440
+ : "score DESC, n.id ASC";
1394
1441
 
1395
1442
  if (opts?.tags && opts.tags.length > 0) {
1396
1443
  // Canonical-bare-tag guard backstop (vault#XXX) for direct-core callers.
@@ -1406,14 +1453,14 @@ export function searchNotes(
1406
1453
  // DISTINCT over full rows. The FTS join itself is 1:1 on rowid.
1407
1454
  const tagPlaceholders = searchTags.map(() => "?").join(", ");
1408
1455
  const rows = db.prepare(`
1409
- SELECT n.* FROM notes n
1456
+ SELECT n.*, ${scoreExpr} AS score FROM notes n
1410
1457
  JOIN notes_fts fts ON fts.rowid = n.rowid
1411
1458
  WHERE notes_fts MATCH ?
1412
1459
  AND n.id IN (SELECT note_id FROM note_tags WHERE tag_name IN (${tagPlaceholders}))
1413
1460
  ORDER BY ${orderBy}
1414
1461
  LIMIT ?
1415
- `).all(ftsQuery, ...searchTags, limit) as NoteRow[];
1416
- return notesWithTags(db, rows);
1462
+ `).all(ftsQuery, ...searchTags, limit) as (NoteRow & { score: number })[];
1463
+ return notesWithTags(db, rows, scoresById(rows));
1417
1464
  } catch (err) {
1418
1465
  // Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
1419
1466
  // advanced mode expects it (the caller passed raw syntax); literal
@@ -1429,27 +1476,44 @@ export function searchNotes(
1429
1476
 
1430
1477
  try {
1431
1478
  const rows = db.prepare(`
1432
- SELECT n.* FROM notes n
1479
+ SELECT n.*, ${scoreExpr} AS score FROM notes n
1433
1480
  JOIN notes_fts fts ON fts.rowid = n.rowid
1434
1481
  WHERE notes_fts MATCH ?
1435
1482
  ORDER BY ${orderBy}
1436
1483
  LIMIT ?
1437
- `).all(ftsQuery, limit) as NoteRow[];
1438
- return notesWithTags(db, rows);
1484
+ `).all(ftsQuery, limit) as (NoteRow & { score: number })[];
1485
+ return notesWithTags(db, rows, scoresById(rows));
1439
1486
  } catch (err) {
1440
1487
  if (err instanceof QueryError) throw err;
1441
1488
  throw searchSyntaxError(query, err, mode);
1442
1489
  }
1443
1490
  }
1444
1491
 
1445
- /** Map rows → Notes with tags hydrated in one batched query. */
1446
- function notesWithTags(db: Database, rows: NoteRow[]): Note[] {
1492
+ /**
1493
+ * Map rows Notes with tags hydrated in one batched query. `scores`
1494
+ * (vault#551 WS2C) is an optional id → weighted-bm25-score map, attached
1495
+ * onto each returned `Note.score` when present — ONLY `searchNotes`'s two
1496
+ * callers pass it; every other caller (plain `queryNotes`, `getNotesByIds`,
1497
+ * ...) omits it and gets byte-identical output to before `score` existed.
1498
+ */
1499
+ function notesWithTags(db: Database, rows: NoteRow[], scores?: Map<string, number>): Note[] {
1447
1500
  const notes = rows.map(rowToNote);
1448
1501
  const tagsById = getNoteTagsForNotes(db, notes.map((n) => n.id));
1449
- for (const note of notes) note.tags = tagsById.get(note.id) ?? [];
1502
+ for (const note of notes) {
1503
+ note.tags = tagsById.get(note.id) ?? [];
1504
+ if (scores) {
1505
+ const s = scores.get(note.id);
1506
+ if (s !== undefined) note.score = s;
1507
+ }
1508
+ }
1450
1509
  return notes;
1451
1510
  }
1452
1511
 
1512
+ /** Build an id → score map from search result rows carrying a `score` column. */
1513
+ function scoresById(rows: (NoteRow & { score: number })[]): Map<string, number> {
1514
+ return new Map(rows.map((r) => [r.id, r.score]));
1515
+ }
1516
+
1453
1517
  // ---- Tag Operations ----
1454
1518
 
1455
1519
  export function tagNote(db: Database, noteId: string, tags: string[]): void {
@@ -2120,6 +2184,7 @@ export function toNoteIndex(note: Note): NoteIndex {
2120
2184
  metadata: note.metadata,
2121
2185
  byteSize,
2122
2186
  preview,
2187
+ ...(note.score !== undefined ? { score: note.score } : {}),
2123
2188
  };
2124
2189
  }
2125
2190
 
@@ -217,3 +217,113 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
217
217
  param,
218
218
  };
219
219
  }
220
+
221
+ /**
222
+ * Cheap zero-result search suggestion (vault#551 WS2B — mirrors the tag
223
+ * `did_you_mean` above via the SAME `suggestSimilarTag` scorer, just over a
224
+ * different candidate pool). Callers MUST only invoke this AFTER a search
225
+ * already returned zero rows — it is never on the hot non-empty path, so
226
+ * the extra vocabulary scan costs nothing in the overwhelmingly common
227
+ * case where the search actually found something.
228
+ *
229
+ * Candidates are drawn from two cheap-to-reach sources, unioned:
230
+ *
231
+ * - the FTS5 vocabulary — a `notes_fts_vocab` `fts5vocab('row')` table
232
+ * created here LAZILY and BEST-EFFORT (not part of the schema/
233
+ * migration — see the try/catch below) — the terms actually indexed,
234
+ * POST-tokenization/stemming (porter). A suggestion therefore
235
+ * sometimes reads as a stemmed form ("propoli" rather than "propolis")
236
+ * rather than the original dictionary word — an accepted tradeoff
237
+ * (documented in docs/HTTP_API.md) rather than maintaining a second,
238
+ * unstemmed index just for spelling suggestions.
239
+ * - tag names, when the caller passes `tagNames` (already cached by the
240
+ * caller's tag hierarchy — free to include).
241
+ *
242
+ * `fts5vocab` is registered by the same FTS5 extension init as `notes_fts`
243
+ * itself (not a separately-enabled module) so it's expected to be
244
+ * available anywhere search already works — but this is NOT exercised
245
+ * against Cloudflare DO SQLite (flagged for the wire reviewer). The WHOLE
246
+ * function is wrapped in try/catch and degrades to "no suggestion" on ANY
247
+ * failure — including a runtime where `fts5vocab` is unavailable — rather
248
+ * than ever throwing; a spelling hint is a nicety, never worth risking the
249
+ * search response itself.
250
+ *
251
+ * Per-token length filtering (`length(term) BETWEEN len-2 AND len+2`) keeps
252
+ * the vocabulary scan bounded on a large vault without a hardcoded row cap
253
+ * that could silently exclude the very term a caller needs (a `LIMIT`
254
+ * ORDER-BY-frequency would bias toward common words, which is backwards
255
+ * for a name/typo lookup — the word you're trying to find is often rare).
256
+ */
257
+ export function computeSearchDidYouMean(
258
+ db: Database,
259
+ rawQuery: string,
260
+ tagNames?: Iterable<string>,
261
+ ): string | undefined {
262
+ try {
263
+ const tokens = rawQuery.trim().split(/\s+/).filter(Boolean);
264
+ if (tokens.length === 0) return undefined;
265
+
266
+ db.exec("CREATE VIRTUAL TABLE IF NOT EXISTS notes_fts_vocab USING fts5vocab(notes_fts, 'row')");
267
+ const exactStmt = db.prepare("SELECT 1 FROM notes_fts_vocab WHERE term = ? LIMIT 1");
268
+ const rangeStmt = db.prepare("SELECT term FROM notes_fts_vocab WHERE length(term) BETWEEN ? AND ?");
269
+
270
+ let changed = false;
271
+ const corrected = tokens.map((tok) => {
272
+ // Strip leading/trailing punctuation for the lookup (the FTS5
273
+ // vocabulary never contains punctuation) but leave short tokens
274
+ // (numbers, "a", "of", ...) alone — too little signal to safely
275
+ // "correct" without a high false-positive rate.
276
+ const clean = tok.replace(/^[^\p{L}\p{N}]+|[^\p{L}\p{N}]+$/gu, "");
277
+ if (clean.length < 3) return tok;
278
+ const lower = clean.toLowerCase();
279
+
280
+ // Already indexed verbatim (post-stemming) — no typo signal.
281
+ if (exactStmt.get(lower)) return tok;
282
+
283
+ const lo = Math.max(1, clean.length - 2);
284
+ const hi = clean.length + 2;
285
+ const rows = rangeStmt.all(lo, hi) as { term: string }[];
286
+ const candidates = new Set<string>(rows.map((r) => r.term));
287
+ if (tagNames) for (const t of tagNames) candidates.add(t);
288
+
289
+ const suggestion = suggestSimilarTag(candidates, lower);
290
+ if (suggestion && suggestion.toLowerCase() !== lower) {
291
+ changed = true;
292
+ return suggestion;
293
+ }
294
+ return tok;
295
+ });
296
+
297
+ if (!changed) return undefined;
298
+ return corrected.join(" ");
299
+ } catch {
300
+ return undefined;
301
+ }
302
+ }
303
+
304
+ /**
305
+ * `search_did_you_mean` warning (vault#551 WS2B) — wraps
306
+ * {@link computeSearchDidYouMean}'s suggestion in the standard warning
307
+ * shape, mirroring `unknown_tag`'s `did_you_mean` field. Returns
308
+ * `undefined` (nothing to push) when no suggestion clears the bar — a
309
+ * zero-result search is a perfectly ordinary outcome on its own and does
310
+ * NOT warrant a warning by itself; only an actual spelling suggestion does.
311
+ *
312
+ * Scope-unaware by construction, exactly like `collectUnknownTagWarnings`
313
+ * (see this file's top-of-file doc comment) — the FTS5 vocabulary spans
314
+ * the WHOLE vault regardless of any caller's tag scope. Callers on a
315
+ * tag-scoped session MUST NOT surface this warning directly:
316
+ * `src/mcp-tools.ts`'s `query-notes` wrapper already strips the entire
317
+ * `warnings` array for a scoped caller (so MCP is safe with no extra
318
+ * work); `src/routes.ts` must gate the call itself behind
319
+ * `tagScope.allowed === null`, same as it already does for
320
+ * `collectUnknownTagWarnings`.
321
+ */
322
+ export function searchDidYouMeanWarning(rawQuery: string, suggestion: string): QueryWarning {
323
+ return {
324
+ code: "search_did_you_mean",
325
+ message: `no results for "${rawQuery}" — did you mean "${suggestion}"?`,
326
+ query: rawQuery,
327
+ did_you_mean: suggestion,
328
+ };
329
+ }
@@ -60,6 +60,20 @@ export interface SchemaField {
60
60
  type?: "string" | "number" | "integer" | "boolean" | "array" | "object";
61
61
  enum?: string[];
62
62
  description?: string;
63
+ /**
64
+ * Mirrors `TagFieldSchema.indexed` (core/src/tag-schemas.ts) — this field
65
+ * has a generated column + B-tree index maintained on `notes` (see
66
+ * core/src/indexed-fields.ts). Parsed here (not just there) because
67
+ * `validateNote` uses it: an indexed field is a QUERY CONTRACT, not just a
68
+ * storage hint, so a `type_mismatch` on an indexed field is always
69
+ * enforced (`strict: true` on that ONE warning) regardless of this field's
70
+ * own `strict` flag (vault#553 Decision A). Every other constraint
71
+ * (enum/required/cardinality) on an indexed field stays governed by
72
+ * `strict` as before — only the TYPE contract is unconditional, because
73
+ * that's the one a type-mismatched write can silently poison range
74
+ * queries with (SQLite's TEXT-sorts-above-INTEGER affinity ordering).
75
+ */
76
+ indexed?: boolean;
63
77
  /**
64
78
  * Strict enforcement opt-in (vault#299, Part A). Default `false` — when
65
79
  * unset/false, ALL constraints on this field are advisory: violations
@@ -169,6 +183,7 @@ function parseFieldsJson(raw: string | null): Record<string, SchemaField> {
169
183
  if (typeof f.type === "string") field.type = f.type as SchemaField["type"];
170
184
  if (Array.isArray(f.enum)) field.enum = f.enum.filter((x): x is string => typeof x === "string");
171
185
  if (typeof f.description === "string") field.description = f.description;
186
+ if (f.indexed === true) field.indexed = true;
172
187
  if (f.strict === true) field.strict = true;
173
188
  if (f.required === true) field.required = true;
174
189
  if (f.cardinality === "one" || f.cardinality === "many") field.cardinality = f.cardinality;
@@ -319,6 +334,20 @@ function stringArraysEqual(a: string[] | undefined, b: string[] | undefined): bo
319
334
  return true;
320
335
  }
321
336
 
337
+ /**
338
+ * Human-readable JSON-shape name for a value, used to name the "got" side of
339
+ * a `type_mismatch` message (vault#553 — "message names field + expected
340
+ * type + got type"). Distinct from SQLite's `json_type()` vocabulary
341
+ * (integer/real/true/false/text/...) — this is the JSON-Schema-ish vocabulary
342
+ * `SchemaField.type` already uses, so the message reads as "expected X, got
343
+ * Y" in the SAME words the schema declares.
344
+ */
345
+ function jsonTypeOf(value: unknown): string {
346
+ if (value === null) return "null";
347
+ if (Array.isArray(value)) return "array";
348
+ return typeof value; // "string" | "number" | "boolean" | "object" | ...
349
+ }
350
+
322
351
  function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
323
352
  if (type === undefined) return true;
324
353
  switch (type) {
@@ -357,11 +386,15 @@ function valueMatchesType(value: unknown, type: SchemaField["type"]): boolean {
357
386
  * → `cardinality_mismatch`
358
387
  *
359
388
  * Every violation carries `strict: true` iff its field declared `strict:true`
360
- * (vault#299). The list itself is the SAME whether or not a field is strict —
361
- * the difference is only the per-warning `strict` flag, which the write path
362
- * uses to decide block-vs-warn. Under `strict:false` this is byte-identical to
363
- * the historical advisory behavior PLUS the new `required`/`cardinality`
364
- * advisory reasons (which fire for any field declaring those, strict or not).
389
+ * (vault#299) EXCEPT a `type_mismatch` on an `indexed: true` field, which is
390
+ * ALWAYS `strict: true` regardless of the field's own `strict` setting
391
+ * (vault#553 Decision A an indexed field's type is a query contract, not
392
+ * just guidance). The list itself is the SAME whether or not a field is
393
+ * strict the difference is only the per-warning `strict` flag, which the
394
+ * write path uses to decide block-vs-warn. Under `strict:false` (and
395
+ * `indexed:false`) this is byte-identical to the historical advisory
396
+ * behavior PLUS the new `required`/`cardinality` advisory reasons (which
397
+ * fire for any field declaring those, strict or not).
365
398
  *
366
399
  * Fields not declared by any ancestor's schema are ignored entirely.
367
400
  */
@@ -397,12 +430,20 @@ export function validateNote(
397
430
  if (absent) continue;
398
431
 
399
432
  if (spec.type && !valueMatchesType(value, spec.type)) {
433
+ // Decision A (vault#553): an INDEXED field's type is a query
434
+ // contract — a type-mismatched write poisons range-query ordering
435
+ // (SQLite's TEXT-sorts-above-INTEGER affinity) regardless of whether
436
+ // this field opted into `strict`. Force `strict: true` on THIS
437
+ // warning alone when the field is indexed; every other constraint
438
+ // (enum/required/cardinality) stays governed by `spec.strict` as
439
+ // before.
440
+ const indexedTypeStrict = spec.indexed === true;
400
441
  warnings.push({
401
442
  field: fieldName,
402
443
  schema: sourceTag,
403
444
  reason: "type_mismatch",
404
- message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}')`,
405
- ...strictFlag,
445
+ message: `'${fieldName}' should be ${spec.type} (tag '${sourceTag}'), got ${jsonTypeOf(value)}${indexedTypeStrict ? " — indexed fields reject type-mismatched writes (vault#553)" : ""}`,
446
+ ...(strictFlag.strict || indexedTypeStrict ? { strict: true } : {}),
406
447
  });
407
448
  }
408
449