@openparachute/vault 0.7.0-rc.6 → 0.7.0-rc.8

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/mcp.ts CHANGED
@@ -5,9 +5,17 @@ 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
7
  import { TAG_EXPAND_MODES, stripTagHash, suggestSimilarTag, type TagExpandMode } from "./tag-hierarchy.js";
8
- import { collectUnknownTagWarnings, emptySearchWarning, ignoredParamWarning, type QueryWarning } from "./query-warnings.js";
8
+ import {
9
+ collectUnknownTagWarnings,
10
+ emptySearchWarning,
11
+ ignoredParamWarning,
12
+ computeSearchDidYouMean,
13
+ searchDidYouMeanWarning,
14
+ type QueryWarning,
15
+ } from "./query-warnings.js";
9
16
  import { SEARCH_MODES, buildLiteralSearchQuery, isValidSearchMode, type SearchMode } from "./search-query.js";
10
17
  import * as linkOps from "./links.js";
18
+ import { resolveOrQueueLink, resolveStructuredLinkNote } from "./wikilinks.js";
11
19
  import * as tagSchemaOps from "./tag-schemas.js";
12
20
  import type { TagFieldSchema } from "./tag-schemas.js";
13
21
  import {
@@ -256,6 +264,8 @@ export function generateMcpTools(store: Store, opts?: GenerateMcpToolsOpts): Mcp
256
264
 
257
265
  Defaults: include_content=true for single note, false for lists. include_links=false. tag_match="any".
258
266
 
267
+ Each result carries \`validation_status\` when any tag it carries declares \`fields\` (vault#555) — same advisory-warnings shape create-note/update-note attach, now also on reads (an out-of-enum value on a non-strict field is stored and findable, but still surfaces its \`enum_mismatch\` warning here, not just on the write that introduced it). Absent entirely when no tag on the note declares a schema.
268
+
259
269
  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.
260
270
 
261
271
  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.
@@ -265,7 +275,9 @@ Response shape (vault#550 — three variants, pick by what you passed):
265
275
  - Cursor mode (\`cursor\` param present — including \`cursor: ""\` to bootstrap): \`{notes: [...], next_cursor}\`. See \`cursor\` below for the bootstrap flow.
266
276
  - 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.
267
277
 
268
- \`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.`,
278
+ \`search\` is literal-by-default (vault#551): your text is escaped and phrase-quoted before it reaches FTS5, so ordinary punctuation ("didn't", "eleven-day", "18.6") is matched as literal content instead of being parsed as query syntax (a bare hyphen used to mean NOT; an apostrophe or decimal point used to break the parse and silently return \`[]\`). Pass \`search_mode: "advanced"\` to opt back into raw FTS5 syntax (AND/OR/NOT, manual phrase quoting, prefix \`*\`) — a malformed advanced query now throws a structured error instead of silently returning \`[]\`. \`sort\` is honored under \`search\` too: omit it for relevance ranking (default), or pass "asc"/"desc" to order by \`created_at\` instead.
279
+
280
+ \`search\` indexes BOTH a note's title (\`path\`) and its \`content\` (vault#551 WS2C, schema v25) — a title match is weighted far above a passing body mention, so a dedicated note on a topic outranks another note that merely references it. Every result carries a \`score\` field (higher = more relevant; only meaningful as a RELATIVE comparison within one result set). Word matching also stems regular English affixes ("firefighter" matches "firefighters", "microbe" matches "microbes") — irregular plurals with a consonant change ("wolf"/"wolves") aren't covered by stemming. A search that returns ZERO results may carry a \`search_did_you_mean\` warning suggesting the closest indexed term when one looks like a likely typo (only unscoped sessions — tag-scoped tokens never see it, since the suggestion is computed vault-wide).`,
269
281
  inputSchema: {
270
282
  type: "object",
271
283
  properties: {
@@ -322,7 +334,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
322
334
  search: {
323
335
  type: "string",
324
336
  description:
325
- '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.',
337
+ 'Full-text search query, matched against BOTH a note\'s title (path) and its content — a title match ranks far above a passing body mention. 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. Matching stems regular affixes ("firefighter"/"firefighters") but not irregular plurals ("wolf"/"wolves"). Results carry a `score` field (higher = more relevant, relative within this result set only). A zero-result search may carry a `search_did_you_mean` warning (unscoped sessions only).',
326
338
  },
327
339
  search_mode: {
328
340
  type: "string",
@@ -449,6 +461,24 @@ Response shape (vault#550 — three variants, pick by what you passed):
449
461
  // `expand`).
450
462
  if (contentRange && !includeContent) throw contentRangeRequiresContent();
451
463
  let result: any = includeContent ? { ...note } : noteOps.toNoteIndex(note);
464
+ // --- Attach validation_status (vault#555 fix 3) ---
465
+ // Mirrors create/update-note's `attachValidationStatus` — additive,
466
+ // present only when at least one tag on the note declares
467
+ // `fields`. Before this, `validation_status` (including an
468
+ // `enum_mismatch` on a non-strict indexed field) was surfaced ONLY
469
+ // on the one-time create/update WRITE response; a caller reading
470
+ // the note back via `query-notes` (the natural way to re-check a
471
+ // value after write) saw nothing at all — contradicting the
472
+ // documented "advisory violations surface as warnings" for every
473
+ // read after the initial write.
474
+ {
475
+ const status = store.validateNoteAgainstSchemas({
476
+ path: note.path,
477
+ tags: note.tags,
478
+ metadata: note.metadata as Record<string, unknown> | undefined,
479
+ });
480
+ if (status) result.validation_status = status;
481
+ }
452
482
  if (expandCtx && includeContent && typeof result.content === "string") {
453
483
  // Mark the top-level note as already expanded so it can't recursively inline itself.
454
484
  expandCtx.expanded.add(note.id);
@@ -603,6 +633,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
603
633
  mode,
604
634
  sort: params.sort as "asc" | "desc" | undefined,
605
635
  });
636
+ // Zero-result `did_you_mean` (vault#551 WS2B) — cheap (a bounded
637
+ // FTS5-vocabulary scan) and ONLY computed on the already-rare
638
+ // empty-result path, mirroring `unknown_tag`'s did_you_mean.
639
+ // Scope-unaware by construction (same as `collectUnknownTagWarnings`
640
+ // above) — safe here because `applyTagScopeWrappers`'s
641
+ // `query-notes` wrapper (`src/mcp-tools.ts`) strips the ENTIRE
642
+ // `warnings` array for a tag-scoped session before it reaches the
643
+ // caller, so this never leaks out-of-scope vocabulary to one.
644
+ if (results.length === 0) {
645
+ const suggestion = computeSearchDidYouMean(db, params.search as string);
646
+ if (suggestion) {
647
+ queryWarnings.push(searchDidYouMeanWarning(params.search as string, suggestion));
648
+ }
649
+ }
606
650
  }
607
651
  } else {
608
652
  // --- Structured query ---
@@ -715,6 +759,29 @@ Response shape (vault#550 — three variants, pick by what you passed):
715
759
  for (const n of output) applyContentRange(n, contentRange);
716
760
  }
717
761
 
762
+ // --- Attach validation_status (vault#555 fix 3) ---
763
+ // Same reasoning as the single-note path above — additive, present
764
+ // only when at least one tag on the note declares `fields`. Runs
765
+ // BEFORE metadata filtering so it sees the note's full metadata
766
+ // regardless of `include_metadata`; the `validation_status` key
767
+ // itself survives `filterMetadata` (which only touches `metadata`).
768
+ {
769
+ const statusById = new Map(
770
+ results.map((n) => [
771
+ n.id,
772
+ store.validateNoteAgainstSchemas({
773
+ path: n.path,
774
+ tags: n.tags,
775
+ metadata: n.metadata as Record<string, unknown> | undefined,
776
+ }),
777
+ ]),
778
+ );
779
+ for (const n of output as any[]) {
780
+ const status = statusById.get(n.id);
781
+ if (status) n.validation_status = status;
782
+ }
783
+ }
784
+
718
785
  // --- Apply metadata filtering ---
719
786
  if (includeMetadata !== undefined && includeMetadata !== true) {
720
787
  output = output.map((n: any) => filterMetadata(n, includeMetadata));
@@ -803,7 +870,7 @@ Response shape (vault#550 — three variants, pick by what you passed):
803
870
  },
804
871
  required: ["target", "relationship"],
805
872
  },
806
- description: "Links to create from this note",
873
+ description: "Links to create from this note. `target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title. A target created LATER in the same `notes` batch, or by a future call, resolves automatically (queued + backfilled) — the response carries an `unresolved_link` warning naming the target in the meantime; never silently dropped.",
807
874
  },
808
875
  created_at: { type: "string", description: "ISO timestamp (defaults to now)" },
809
876
  // Batch
@@ -835,6 +902,20 @@ Response shape (vault#550 — three variants, pick by what you passed):
835
902
  }
836
903
 
837
904
  const created: Note[] = [];
905
+ // Structured `links` are resolved in a SECOND pass, after every
906
+ // note in this batch has been created (vault#555) — resolving
907
+ // inline (the old behavior) meant a link from item 0 to item 1's
908
+ // path silently dropped, since item 1 didn't exist yet at the
909
+ // moment item 0's links were processed. Deferring makes
910
+ // within-batch forward-refs resolve exactly like a same-content
911
+ // [[wikilink]] already does. `pendingLinks` carries the SOURCE
912
+ // note id (known immediately — createNote assigns it synchronously)
913
+ // alongside each item's raw `links` array.
914
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string }[] }[] = [];
915
+ // Per-note `unresolved_link` warnings (vault#555 — "never silent").
916
+ // Populated in the second pass; folded into each note's response
917
+ // below, same pattern as `validation_status`.
918
+ const linkWarningsByNote = new Map<string, QueryWarning[]>();
838
919
  // Wrap multi-item batches in a SQLite transaction so a mid-batch
839
920
  // failure rolls back every prior insert — see #236. This guards
840
921
  // anything thrown from store.createNote / createLink (path
@@ -869,18 +950,37 @@ Response shape (vault#550 — three variants, pick by what you passed):
869
950
  via: writeVia,
870
951
  });
871
952
 
872
- // Create explicit links (not wikilinks — those are automatic)
873
953
  if (item.links) {
874
- for (const link of item.links as { target: string; relationship: string }[]) {
875
- const target = resolveNote(db, link.target);
876
- if (target) {
877
- await store.createLink(note.id, target.id, link.relationship);
878
- }
879
- }
954
+ pendingLinks.push({ sourceId: note.id, links: item.links as { target: string; relationship: string }[] });
880
955
  }
881
956
 
882
957
  created.push(noteOps.getNote(db, note.id) ?? note);
883
958
  }
959
+
960
+ // --- Resolve structured links (vault#555) ---
961
+ // Same semantics as [[wikilinks]]: ID or path/title match, tried
962
+ // now that every sibling note in this batch exists. A target that
963
+ // STILL doesn't resolve (typo, or a note that arrives in a later
964
+ // call) is queued for lazy resolution — it backfills automatically
965
+ // the moment a matching note is created — and surfaces an
966
+ // `unresolved_link` warning naming the target. Never silent.
967
+ for (const { sourceId, links } of pendingLinks) {
968
+ for (const link of links) {
969
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
970
+ if (targetId) {
971
+ await store.createLink(sourceId, targetId, link.relationship);
972
+ } else {
973
+ const list = linkWarningsByNote.get(sourceId) ?? [];
974
+ list.push({
975
+ code: "unresolved_link",
976
+ message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
977
+ target: link.target,
978
+ relationship: link.relationship,
979
+ });
980
+ linkWarningsByNote.set(sourceId, list);
981
+ }
982
+ }
983
+ }
884
984
  };
885
985
  await (batched ? transactionAsync(db, runBatch) : runBatch());
886
986
 
@@ -911,8 +1011,17 @@ Response shape (vault#550 — three variants, pick by what you passed):
911
1011
  })();
912
1012
 
913
1013
  // Attach `validation_status` from any tag's `fields` declaration that
914
- // applies to this note, against the post-defaults state.
915
- const final = refreshed.map((n) => attachValidationStatus(store, db, n));
1014
+ // applies to this note, against the post-defaults state. Fold in any
1015
+ // `unresolved_link` warnings collected above (vault#555) additive,
1016
+ // present only when this note's `links` had a target that didn't
1017
+ // resolve.
1018
+ const final = refreshed.map((n) => {
1019
+ const validated = attachValidationStatus(store, db, n);
1020
+ const warnings = linkWarningsByNote.get(n.id);
1021
+ return warnings && warnings.length > 0
1022
+ ? ({ ...validated, warnings } as Note & { warnings: QueryWarning[] })
1023
+ : validated;
1024
+ });
916
1025
  return batch ? final : final[0];
917
1026
  },
918
1027
  },
@@ -1005,7 +1114,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1005
1114
  },
1006
1115
  },
1007
1116
  },
1008
- description: "Links to add/remove",
1117
+ description: "Links to add/remove. `add[].target` resolves with the SAME semantics as a [[wikilink]] (vault#555) — ID, then exact path, then basename/title — and lazily backfills (queued) when the target arrives later; the response carries an `unresolved_link` warning naming the target in the meantime, never a silent drop.",
1009
1118
  },
1010
1119
  include_content: {
1011
1120
  type: "boolean",
@@ -1099,6 +1208,14 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1099
1208
  // key this so the create-on-missing branch, which assigns the id
1100
1209
  // late, can register correctly.
1101
1210
  const echoLinkIds = new Set<string>();
1211
+ // Structured `links.add` entries, deferred to a second pass AFTER
1212
+ // every item in this batch has been created/updated (vault#555) —
1213
+ // resolving inline would silently drop a link from item 0 to a note
1214
+ // item 1 (later in the same batch) creates via `if_missing:
1215
+ // "create"`. Same fix as create-note's `pendingLinks`.
1216
+ const pendingLinks: { sourceId: string; links: { target: string; relationship: string; metadata?: Record<string, unknown> }[] }[] = [];
1217
+ // Per-note `unresolved_link` warnings (vault#555 — "never silent").
1218
+ const linkWarningsByNote = new Map<string, QueryWarning[]>();
1102
1219
  // Wrap multi-item batches in a SQLite transaction so any mid-batch
1103
1220
  // failure (precondition error, content_edit miss, ConflictError, …)
1104
1221
  // rolls back every prior mutation in the batch — see #236.
@@ -1189,13 +1306,11 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1189
1306
  });
1190
1307
  const created = await store.createNote(content, createOpts);
1191
1308
  await applySchemaDefaults(store, db, [created.id], created.tags ?? []);
1192
- // Apply links.add if the caller declared any.
1309
+ // Defer links.add resolution to the second pass (vault#555)
1310
+ // — same reasoning as create-note.
1193
1311
  const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1194
1312
  if (linksAdd) {
1195
- for (const link of linksAdd) {
1196
- const target = resolveNote(db, link.target);
1197
- if (target) await store.createLink(created.id, target.id, link.relationship, link.metadata);
1198
- }
1313
+ pendingLinks.push({ sourceId: created.id, links: linksAdd });
1199
1314
  }
1200
1315
  const fresh = noteOps.getNote(db, created.id) ?? created;
1201
1316
  updated.push(fresh);
@@ -1303,7 +1418,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1303
1418
  const resolvedLinksToRemove: { targetId: string; relationship: string }[] = [];
1304
1419
  if (linksRemove) {
1305
1420
  for (const link of linksRemove) {
1306
- const target = resolveNote(db, link.target);
1421
+ const target = resolveStructuredLinkNote(db, link.target);
1307
1422
  if (!target) continue;
1308
1423
  resolvedLinksToRemove.push({ targetId: target.id, relationship: link.relationship });
1309
1424
  if (link.relationship === "wikilink" && target.path) {
@@ -1377,8 +1492,25 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1377
1492
  enforceStrict({ path: note.path, tags: [...projectedTags], metadata: projectedMeta });
1378
1493
  }
1379
1494
 
1495
+ // vault#555 fix 2 — tag and link mutations must bump `updated_at`
1496
+ // too, not just core-field (content/path/metadata) changes. Before
1497
+ // this, a tags-only or links-only call with `force: true` (no
1498
+ // `if_updated_at`) left `updates` completely empty — the branch
1499
+ // below skipped `store.updateNote` ENTIRELY, so `updated_at` never
1500
+ // moved even though tags/note_tags or links genuinely changed.
1501
+ // (A tags-only call that happened to pass `if_updated_at` already
1502
+ // worked by accident — it populated `updates.if_updated_at` — which
1503
+ // is why this was easy to miss in ad hoc testing.) This breaks
1504
+ // cursor polling (`ORDER BY updated_at`) and any `updated_at`-based
1505
+ // sync filter: the mutation is real but invisible to a
1506
+ // since-last-check loop.
1507
+ const hasTagMutation = ((item.tags as any)?.add?.length ?? 0) > 0
1508
+ || ((item.tags as any)?.remove?.length ?? 0) > 0;
1509
+ const hasLinkMutation = (item.links as any)?.add !== undefined
1510
+ || (item.links as any)?.remove !== undefined;
1511
+
1380
1512
  let result: Note;
1381
- if (Object.keys(updates).length > 0) {
1513
+ if (Object.keys(updates).length > 0 || hasTagMutation || hasLinkMutation) {
1382
1514
  // Write-attribution (vault#298): stamp the most-recent-write
1383
1515
  // columns on the same UPDATE that bumps `updated_at`. Only set when
1384
1516
  // there's a real change to write (the empty-updates branch below
@@ -1389,7 +1521,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1389
1521
  // store.updateNote routes through noteOps.updateNote, which runs
1390
1522
  // the UPDATE (with optional `AND updated_at IS ?`) atomically and
1391
1523
  // throws ConflictError on mismatch. No mutations have happened
1392
- // yet, so a throw here leaves the note untouched.
1524
+ // yet, so a throw here leaves the note untouched. `updates` may
1525
+ // carry no core fields at all (a pure tag/link mutation) — that's
1526
+ // fine: `noteOps.updateNote` unconditionally SETs
1527
+ // `updated_at`/`last_updated_by`/`last_updated_via` whenever
1528
+ // `skipUpdatedAt` isn't set, so this still issues a real UPDATE
1529
+ // (not the true no-op/precondition-only branch, which only fires
1530
+ // when the caller passes ZERO fields AND no mutation is pending).
1393
1531
  result = await store.updateNote(note.id, updates);
1394
1532
  } else {
1395
1533
  result = note;
@@ -1410,15 +1548,10 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1410
1548
  await store.untagNote(note.id, tagsOp.remove);
1411
1549
  }
1412
1550
 
1413
- // --- Add links ---
1551
+ // --- Add links (deferred to the second pass — vault#555) ---
1414
1552
  const linksAdd = (item.links as any)?.add as { target: string; relationship: string; metadata?: Record<string, unknown> }[] | undefined;
1415
1553
  if (linksAdd) {
1416
- for (const link of linksAdd) {
1417
- const target = resolveNote(db, link.target);
1418
- if (target) {
1419
- await store.createLink(note.id, target.id, link.relationship, link.metadata);
1420
- }
1421
- }
1554
+ pendingLinks.push({ sourceId: note.id, links: linksAdd });
1422
1555
  }
1423
1556
 
1424
1557
  // Echo links if this update mutated them (`links.add`/`links.remove`)
@@ -1431,6 +1564,32 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1431
1564
  // Re-read for final state
1432
1565
  updated.push(noteOps.getNote(db, note.id) ?? result);
1433
1566
  }
1567
+
1568
+ // --- Resolve structured `links.add` (vault#555) ---
1569
+ // Deferred until every item in this batch has been created/
1570
+ // updated — same [[wikilink]] semantics (ID or path/title match),
1571
+ // now tried against the FULL post-batch note set so a forward-ref
1572
+ // to a sibling item resolves. A target that still doesn't resolve
1573
+ // is queued for lazy resolution (backfills when a matching note
1574
+ // is created later) and surfaces an `unresolved_link` warning —
1575
+ // never silently dropped.
1576
+ for (const { sourceId, links } of pendingLinks) {
1577
+ for (const link of links) {
1578
+ const targetId = resolveOrQueueLink(db, sourceId, link.target, link.relationship);
1579
+ if (targetId) {
1580
+ await store.createLink(sourceId, targetId, link.relationship, link.metadata);
1581
+ } else {
1582
+ const list = linkWarningsByNote.get(sourceId) ?? [];
1583
+ list.push({
1584
+ code: "unresolved_link",
1585
+ message: `link target "${link.target}" (relationship "${link.relationship}") did not resolve to any note — queued and will backfill automatically if a matching note is created later.`,
1586
+ target: link.target,
1587
+ relationship: link.relationship,
1588
+ });
1589
+ linkWarningsByNote.set(sourceId, list);
1590
+ }
1591
+ }
1592
+ }
1434
1593
  };
1435
1594
  await (batched ? transactionAsync(db, runBatch) : runBatch());
1436
1595
 
@@ -1452,9 +1611,13 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1452
1611
  // when triggered — mirrors the GET / query-notes shape exactly via
1453
1612
  // the shared `linkOps.getLinksHydrated` call. vault feedback #8.
1454
1613
  const echoLinks = echoLinkIds.has(n.id);
1614
+ // `unresolved_link` warnings (vault#555) — additive, present only
1615
+ // when this note's `links.add` had a target that didn't resolve.
1616
+ const warnings = linkWarningsByNote.get(n.id);
1455
1617
  if (includeContent) {
1456
1618
  const full: any = { ...validated, created };
1457
1619
  if (echoLinks) full.links = linkOps.getLinksHydrated(db, n.id);
1620
+ if (warnings && warnings.length > 0) full.warnings = warnings;
1458
1621
  return full as Note & { created: boolean };
1459
1622
  }
1460
1623
  const lean: any = noteOps.toNoteIndex(validated);
@@ -1464,6 +1627,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1464
1627
  // Carry the link echo across the lean conversion — `toNoteIndex`
1465
1628
  // drops unknown fields.
1466
1629
  if (echoLinks) lean.links = linkOps.getLinksHydrated(db, n.id);
1630
+ if (warnings && warnings.length > 0) lean.warnings = warnings;
1467
1631
  return lean;
1468
1632
  });
1469
1633
  return batch ? final : final[0];
@@ -1592,7 +1756,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1592
1756
  additionalProperties: {
1593
1757
  type: "object",
1594
1758
  properties: {
1595
- type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation. Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
1759
+ type: { type: "string", description: "Field type: string, boolean, integer, number, array, object — all six are accepted for storage + advisory validation; any OTHER value is rejected outright (error_type invalid_field_type, vault#555 — bundled with every other violation in the same call, see the `update-tag` tool description). Only string/integer/boolean are INDEXABLE (see `indexed` below); declaring `indexed: true` with number/array/object is rejected (unsupported_indexed_type / invalid_indexed_field)." },
1596
1760
  description: { type: "string" },
1597
1761
  enum: { type: "array", items: { type: "string" }, description: "Allowed values. Does NOT auto-backfill — a note that omits this field stays without it unless `default` is also set (vault#553; the pre-0.7.0 behavior of silently defaulting to the first enum value is retired). Set `default` explicitly if you want backfill." },
1598
1762
  default: { description: "Explicit backfill value (vault#553) applied when a note gains this tag without setting the field. Must conform to this field's own `type` (and `enum`, if declared) — a non-conforming default is rejected (invalid_default / invalid_field_default) rather than silently stored. Omit entirely to leave the field ABSENT (not backfilled) on notes that don't set it — this is what makes `exists:false` a trustworthy \"never set\" query." },
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 {
@@ -1862,11 +1926,17 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1862
1926
  const candidates = db
1863
1927
  .prepare(`SELECT id, content FROM notes WHERE content IS NOT NULL AND content != '' AND (${orClauses})`)
1864
1928
  .all(...params) as { id: string; content: string }[];
1865
- const updateStmt = db.prepare("UPDATE notes SET content = ? WHERE id = ?");
1929
+ // vault#555 fix 2 this content rewrite is a real persisted-state
1930
+ // change (the note's `#oldtag` references literally became
1931
+ // `#newtag`) and must bump `updated_at` like any other content
1932
+ // write, or a cursor/sync-poll loop never sees it. Shares the one
1933
+ // `now` timestamp for the whole cascade (same convention as the
1934
+ // tag-row rename pass above).
1935
+ const updateStmt = db.prepare("UPDATE notes SET content = ?, updated_at = ? WHERE id = ?");
1866
1936
  for (const row of candidates) {
1867
1937
  const next = rewriteNoteBody(row.content, renames);
1868
1938
  if (next === row.content) continue;
1869
- updateStmt.run(next, row.id);
1939
+ updateStmt.run(next, now, row.id);
1870
1940
  notesRewritten++;
1871
1941
  }
1872
1942
  }
@@ -1881,11 +1951,13 @@ export function renameTag(db: Database, oldName: string, newName: string): Renam
1881
1951
  const candidates = db
1882
1952
  .prepare(`SELECT id, path FROM notes WHERE path IS NOT NULL AND (${orClauses})`)
1883
1953
  .all(...params) as { id: string; path: string }[];
1884
- const updateStmt = db.prepare("UPDATE notes SET path = ? WHERE id = ?");
1954
+ // vault#555 fix 2 same reasoning as the content rewrite above: a
1955
+ // path rewrite is a real persisted-state change.
1956
+ const updateStmt = db.prepare("UPDATE notes SET path = ?, updated_at = ? WHERE id = ?");
1885
1957
  for (const row of candidates) {
1886
1958
  const next = rewriteTagConfigPath(row.path, renames);
1887
1959
  if (next === row.path) continue;
1888
- updateStmt.run(next, row.id);
1960
+ updateStmt.run(next, now, row.id);
1889
1961
  pathsRenamed++;
1890
1962
  }
1891
1963
  }
@@ -2120,6 +2192,7 @@ export function toNoteIndex(note: Note): NoteIndex {
2120
2192
  metadata: note.metadata,
2121
2193
  byteSize,
2122
2194
  preview,
2195
+ ...(note.score !== undefined ? { score: note.score } : {}),
2123
2196
  };
2124
2197
  }
2125
2198