@openparachute/vault 0.7.2 → 0.7.3-rc.10

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.
Files changed (74) hide show
  1. package/README.md +5 -3
  2. package/core/src/attachment/policy.test.ts +66 -0
  3. package/core/src/attachment/policy.ts +131 -0
  4. package/core/src/attachment/tickets.test.ts +45 -0
  5. package/core/src/attachment/tickets.ts +117 -0
  6. package/core/src/attachment-tickets-tool.test.ts +286 -0
  7. package/core/src/conformance.ts +2 -1
  8. package/core/src/contract-typed-index.test.ts +4 -3
  9. package/core/src/core.test.ts +607 -11
  10. package/core/src/display-title.test.ts +190 -0
  11. package/core/src/embedding/chunker.test.ts +97 -0
  12. package/core/src/embedding/chunker.ts +180 -0
  13. package/core/src/embedding/provider.ts +108 -0
  14. package/core/src/embedding/staleness.test.ts +87 -0
  15. package/core/src/embedding/staleness.ts +83 -0
  16. package/core/src/embedding/vector-codec.test.ts +99 -0
  17. package/core/src/embedding/vector-codec.ts +60 -0
  18. package/core/src/embedding/vectors.test.ts +163 -0
  19. package/core/src/embedding/vectors.ts +135 -0
  20. package/core/src/expand.ts +11 -3
  21. package/core/src/indexed-fields.test.ts +9 -3
  22. package/core/src/indexed-fields.ts +9 -1
  23. package/core/src/lede.test.ts +96 -0
  24. package/core/src/mcp-semantic-search.test.ts +160 -0
  25. package/core/src/mcp.ts +405 -10
  26. package/core/src/notes.semantic-search.test.ts +131 -0
  27. package/core/src/notes.ts +319 -7
  28. package/core/src/query-warnings.ts +40 -0
  29. package/core/src/schema-defaults.ts +85 -1
  30. package/core/src/schema-v27-note-vectors.test.ts +178 -0
  31. package/core/src/schema.ts +81 -1
  32. package/core/src/search-fts-v25.test.ts +9 -1
  33. package/core/src/search-query.test.ts +42 -0
  34. package/core/src/search-query.ts +27 -0
  35. package/core/src/search-title-boost.test.ts +125 -0
  36. package/core/src/seed-packs.test.ts +117 -1
  37. package/core/src/seed-packs.ts +217 -1
  38. package/core/src/store.semantic-search.test.ts +236 -0
  39. package/core/src/store.ts +162 -5
  40. package/core/src/tag-schemas.ts +27 -12
  41. package/core/src/types.ts +63 -1
  42. package/core/src/vault-projection.ts +48 -0
  43. package/package.json +6 -1
  44. package/src/add-pack.test.ts +32 -0
  45. package/src/attachment-tickets.test.ts +350 -0
  46. package/src/attachment-tickets.ts +264 -0
  47. package/src/auth.ts +8 -2
  48. package/src/cli.ts +5 -1
  49. package/src/contract-errors.test.ts +2 -1
  50. package/src/contract-honest-queries.test.ts +88 -0
  51. package/src/contract-search.test.ts +41 -0
  52. package/src/embedding/capability.test.ts +33 -0
  53. package/src/embedding/capability.ts +34 -0
  54. package/src/embedding/external-api.test.ts +154 -0
  55. package/src/embedding/external-api.ts +144 -0
  56. package/src/embedding/onnx-transformers.test.ts +113 -0
  57. package/src/embedding/onnx-transformers.ts +141 -0
  58. package/src/embedding/select.test.ts +99 -0
  59. package/src/embedding/select.ts +92 -0
  60. package/src/embedding-worker.test.ts +300 -0
  61. package/src/embedding-worker.ts +226 -0
  62. package/src/mcp-http.ts +13 -1
  63. package/src/mcp-tools.ts +49 -14
  64. package/src/onboarding-seed.test.ts +64 -0
  65. package/src/routes.ts +173 -92
  66. package/src/routing.ts +17 -0
  67. package/src/scribe-discovery.test.ts +76 -1
  68. package/src/scribe-discovery.ts +28 -9
  69. package/src/semantic-search-routes.test.ts +161 -0
  70. package/src/server.ts +21 -2
  71. package/src/vault-embeddings-capability.test.ts +82 -0
  72. package/src/vault-store-embedding-wiring.test.ts +69 -0
  73. package/src/vault-store.ts +53 -2
  74. package/src/vault.test.ts +62 -13
@@ -0,0 +1,131 @@
1
+ import { describe, it, expect, beforeEach } from "bun:test";
2
+ import { Database } from "bun:sqlite";
3
+ import { SqliteStore } from "./store.js";
4
+ import { semanticSearchNotes } from "./notes.js";
5
+ import { encodeVector, normalize } from "./embedding/vector-codec.js";
6
+
7
+ const MODEL = "test-model";
8
+ const DIMS = 4;
9
+
10
+ let db: Database;
11
+ let store: SqliteStore;
12
+
13
+ beforeEach(() => {
14
+ db = new Database(":memory:");
15
+ store = new SqliteStore(db);
16
+ });
17
+
18
+ /** Insert a chunk_ix=0 vector row for a note under the test model. */
19
+ function insertVector(noteId: string, vector: number[]): void {
20
+ const encoded = encodeVector(normalize(new Float32Array(vector)));
21
+ db.prepare(
22
+ `INSERT INTO note_vectors (note_id, chunk_ix, vector, dims, model, content_hash, embedded_at)
23
+ VALUES (?, 0, ?, ?, ?, ?, ?)`,
24
+ ).run(noteId, encoded, DIMS, MODEL, "hash", new Date().toISOString());
25
+ }
26
+
27
+ function insertChunkVector(noteId: string, ix: number, vector: number[]): void {
28
+ const encoded = encodeVector(normalize(new Float32Array(vector)));
29
+ db.prepare(
30
+ `INSERT INTO note_vectors (note_id, chunk_ix, vector, dims, model, content_hash, embedded_at)
31
+ VALUES (?, ?, ?, ?, ?, ?, ?)`,
32
+ ).run(noteId, ix, encoded, DIMS, MODEL, `hash-${ix}`, new Date().toISOString());
33
+ }
34
+
35
+ describe("semanticSearchNotes", () => {
36
+ it("ranks notes by cosine similarity, highest first", async () => {
37
+ const a = await store.createNote("note a", { path: "a" });
38
+ const b = await store.createNote("note b", { path: "b" });
39
+ const c = await store.createNote("note c", { path: "c" });
40
+
41
+ const query = normalize(new Float32Array([1, 0, 0, 0]));
42
+ insertVector(a.id, [1, 0, 0, 0]); // identical direction — score ~1
43
+ insertVector(b.id, [0, 1, 0, 0]); // orthogonal — score ~0
44
+ insertVector(c.id, [0.9, 0.1, 0, 0]); // close-ish — score high but < a
45
+
46
+ const result = semanticSearchNotes(db, query, {}, MODEL);
47
+ expect(result.notes.map((n) => n.id)).toEqual([a.id, c.id, b.id]);
48
+ expect(result.notes[0].score).toBeCloseTo(1, 4);
49
+ expect(result.totalCandidates).toBe(3);
50
+ expect(result.pendingCount).toBe(0);
51
+ });
52
+
53
+ it("respects limit (top-K)", async () => {
54
+ const query = normalize(new Float32Array([1, 0, 0, 0]));
55
+ const ids: string[] = [];
56
+ for (let i = 0; i < 5; i++) {
57
+ const n = await store.createNote(`note ${i}`, { path: `n${i}` });
58
+ ids.push(n.id);
59
+ insertVector(n.id, [1 - i * 0.01, 0.01 * i, 0, 0]);
60
+ }
61
+ const result = semanticSearchNotes(db, query, { limit: 2 }, MODEL);
62
+ expect(result.notes.length).toBe(2);
63
+ expect(result.totalCandidates).toBe(5);
64
+ });
65
+
66
+ it("filter-then-rank: structured filters (tags) narrow the candidate set BEFORE ranking", async () => {
67
+ const query = normalize(new Float32Array([1, 0, 0, 0]));
68
+ const tagged = await store.createNote("tagged note", { path: "tagged", tags: ["project"] });
69
+ const untagged = await store.createNote("untagged note", { path: "untagged" });
70
+ // untagged has the objectively CLOSER vector, but is filtered out by `tags`.
71
+ insertVector(tagged.id, [0.5, 0.5, 0, 0]);
72
+ insertVector(untagged.id, [1, 0, 0, 0]);
73
+
74
+ const result = semanticSearchNotes(db, query, { tags: ["project"] }, MODEL);
75
+ expect(result.notes.map((n) => n.id)).toEqual([tagged.id]);
76
+ expect(result.totalCandidates).toBe(1);
77
+ });
78
+
79
+ it("pendingCount: candidates with no vector row for the active model are excluded from ranking but counted as pending", async () => {
80
+ const query = normalize(new Float32Array([1, 0, 0, 0]));
81
+ const embedded = await store.createNote("embedded", { path: "embedded" });
82
+ const notYetEmbedded = await store.createNote("not yet embedded", { path: "pending" });
83
+ insertVector(embedded.id, [1, 0, 0, 0]);
84
+ // notYetEmbedded gets no note_vectors row at all.
85
+
86
+ const result = semanticSearchNotes(db, query, {}, MODEL);
87
+ expect(result.notes.map((n) => n.id)).toEqual([embedded.id]);
88
+ expect(result.totalCandidates).toBe(2);
89
+ expect(result.pendingCount).toBe(1);
90
+ });
91
+
92
+ it("ignores vectors recorded under a DIFFERENT model", async () => {
93
+ const query = normalize(new Float32Array([1, 0, 0, 0]));
94
+ const note = await store.createNote("note", { path: "n" });
95
+ const encoded = encodeVector(normalize(new Float32Array([1, 0, 0, 0])));
96
+ db.prepare(
97
+ `INSERT INTO note_vectors (note_id, chunk_ix, vector, dims, model, content_hash, embedded_at)
98
+ VALUES (?, 0, ?, ?, 'stale-model', 'hash', ?)`,
99
+ ).run(note.id, encoded, DIMS, new Date().toISOString());
100
+
101
+ const result = semanticSearchNotes(db, query, {}, MODEL);
102
+ expect(result.notes).toEqual([]);
103
+ expect(result.pendingCount).toBe(1); // no row under the ACTIVE model
104
+ });
105
+
106
+ it("best-chunk ranking: a long note's best-matching SECTION outranks a note with weak whole-note similarity", async () => {
107
+ // This is the exact case chunking exists for (P0's dominant miss
108
+ // pattern: whole-note embedding dilutes long, multi-topic notes).
109
+ const query = normalize(new Float32Array([1, 0, 0, 0]));
110
+
111
+ const longNote = await store.createNote("a long multi-section note", { path: "long" });
112
+ // Section 0 is about something unrelated (poor match); section 2 is a
113
+ // strong match for the query — best-chunk ranking must pick that up.
114
+ insertChunkVector(longNote.id, 0, [0, 1, 0, 0]); // orthogonal — weak
115
+ insertChunkVector(longNote.id, 1, [0.3, 0.3, 0.3, 0.3]); // mediocre
116
+ insertChunkVector(longNote.id, 2, [1, 0, 0, 0]); // strong match
117
+
118
+ const shortNote = await store.createNote("a short weakly-related note", { path: "short" });
119
+ insertVector(shortNote.id, [0.5, 0.5, 0, 0]); // weak whole-note similarity
120
+
121
+ const result = semanticSearchNotes(db, query, {}, MODEL);
122
+ expect(result.notes.map((n) => n.id)).toEqual([longNote.id, shortNote.id]);
123
+ expect(result.notes[0].score).toBeCloseTo(1, 4);
124
+ });
125
+
126
+ it("returns nothing (no throw) when the candidate set is empty", () => {
127
+ const query = normalize(new Float32Array([1, 0, 0, 0]));
128
+ const result = semanticSearchNotes(db, query, { tags: ["nonexistent"] }, MODEL);
129
+ expect(result).toEqual({ notes: [], pendingCount: 0, totalCandidates: 0 });
130
+ });
131
+ });
package/core/src/notes.ts CHANGED
@@ -1,5 +1,7 @@
1
1
  import { Database, type SQLQueryBindings } from "bun:sqlite";
2
- import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow } from "./types.js";
2
+ import type { Note, NoteIndex, QueryOpts, QueryNotesPage, VaultStats, VaultMap, AggregateSpec, AggregateRow, SemanticSearchResult } from "./types.js";
3
+ import { decodeVector, dot } from "./embedding/vector-codec.js";
4
+ import { nonBlankContentClause } from "./embedding/vectors.js";
3
5
  import { normalizePath } from "./paths.js";
4
6
  import { transaction } from "./txn.js";
5
7
  import {
@@ -23,6 +25,7 @@ import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-
23
25
  import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
24
26
  import {
25
27
  buildLiteralSearchQuery,
28
+ extractLiteralBoostTerms,
26
29
  SEARCH_WEIGHT_PATH,
27
30
  SEARCH_WEIGHT_CONTENT,
28
31
  type SearchMode,
@@ -962,7 +965,7 @@ function resolveUpdatedAtBoundMs(field: string, value: string): number {
962
965
  * semantics a normal query applies, rather than risking the two drifting
963
966
  * apart.
964
967
  */
965
- function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
968
+ export function buildFilterConditions(db: Database, opts: QueryOpts): { conditions: string[]; params: SQLQueryBindings[] } {
966
969
  const conditions: string[] = [];
967
970
  const params: SQLQueryBindings[] = [];
968
971
 
@@ -1808,6 +1811,61 @@ function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): Qu
1808
1811
  });
1809
1812
  }
1810
1813
 
1814
+ /**
1815
+ * Post-rank a search result page (title axis, ratified 2026-07-17) so
1816
+ * notes whose display title (first line of content) contains EVERY boost
1817
+ * term outrank the rest — a title-match tier ahead of a body-only-match
1818
+ * tier. Stable partition: within a tier, the incoming order (FTS5
1819
+ * relevance, or an explicit `sort`) is preserved untouched — two notes
1820
+ * that both match, or both don't, keep their relative order.
1821
+ *
1822
+ * No-migration by design (see the search-seam note on `searchNotes`
1823
+ * below): re-orders the already-fetched page in memory instead of adding
1824
+ * a weighted title column to `notes_fts`, which would need a schema
1825
+ * migration + backfill. `notes_fts`'s existing `SEARCH_WEIGHT_PATH` bm25
1826
+ * weighting (above) already boosts by note PATH — a different axis from
1827
+ * the ratified title model (title = first line of CONTENT, not path).
1828
+ * This function is the one that boosts by the content-derived title.
1829
+ */
1830
+ function boostTitleMatches(notes: Note[], terms: string[]): Note[] {
1831
+ if (terms.length === 0) return notes;
1832
+ return notes
1833
+ .map((note, index) => ({ note, index, titleMatch: titleMatchesAllTerms(note.content, terms) }))
1834
+ .sort((a, b) => {
1835
+ if (a.titleMatch === b.titleMatch) return a.index - b.index; // stable
1836
+ return a.titleMatch ? -1 : 1;
1837
+ })
1838
+ .map((x) => x.note);
1839
+ }
1840
+
1841
+ function titleMatchesAllTerms(content: string | null | undefined, terms: string[]): boolean {
1842
+ const title = computeDisplayTitle(content);
1843
+ if (!title) return false;
1844
+ const lower = title.toLowerCase();
1845
+ return terms.every((t) => lower.includes(t));
1846
+ }
1847
+
1848
+ /**
1849
+ * Apply the title-boost post-rank to a fetched search page. Skipped
1850
+ * whenever it would fight the caller's intent:
1851
+ * - an explicit `sort: "asc"|"desc"` means the caller wants chronological
1852
+ * order, not relevance re-ranking;
1853
+ * - `search_mode: "advanced"` text carries raw FTS5 syntax (boolean
1854
+ * operators, column filters, prefix `*`) — naively tokenizing it into
1855
+ * "terms" would misread operators as content (see
1856
+ * `extractLiteralBoostTerms`'s doc comment). Only literal-mode queries
1857
+ * (the default) get boosted today.
1858
+ */
1859
+ function applySearchTitleBoost(
1860
+ notes: Note[],
1861
+ mode: SearchMode,
1862
+ rawQuery: string,
1863
+ sort: "asc" | "desc" | undefined,
1864
+ ): Note[] {
1865
+ if (sort !== undefined || mode !== "literal") return notes;
1866
+ return boostTitleMatches(notes, extractLiteralBoostTerms(rawQuery));
1867
+ }
1868
+
1811
1869
  export function searchNotes(
1812
1870
  db: Database,
1813
1871
  query: string,
@@ -1844,6 +1902,14 @@ export function searchNotes(
1844
1902
  // The weights are our own numeric constants (not user input) interpolated
1845
1903
  // directly — bm25()'s weight arguments are positional per-column
1846
1904
  // multipliers, not general SQL expressions callers can influence.
1905
+ //
1906
+ // NOTE: "title" here means the note's `path` — this predates the title
1907
+ // axis (ratified 2026-07-17), under which a note's title is its first
1908
+ // CONTENT line, not its path. `applySearchTitleBoost` (below) is the
1909
+ // mechanism for THAT axis: a post-rank pass over the already-scored page,
1910
+ // not a third FTS column. The two are complementary, not redundant — a
1911
+ // note can have a matching path with a non-matching first line, or vice
1912
+ // versa.
1847
1913
  const scoreExpr = `(-1.0 * bm25(notes_fts, ${SEARCH_WEIGHT_PATH}, ${SEARCH_WEIGHT_CONTENT}))`;
1848
1914
 
1849
1915
  // `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
@@ -1884,7 +1950,7 @@ export function searchNotes(
1884
1950
  ORDER BY ${orderBy}
1885
1951
  LIMIT ?
1886
1952
  `).all(ftsQuery, ...searchTags, limit) as (NoteRow & { score: number })[];
1887
- return notesWithTags(db, rows, scoresById(rows));
1953
+ return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
1888
1954
  } catch (err) {
1889
1955
  // Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
1890
1956
  // advanced mode expects it (the caller passed raw syntax); literal
@@ -1906,13 +1972,121 @@ export function searchNotes(
1906
1972
  ORDER BY ${orderBy}
1907
1973
  LIMIT ?
1908
1974
  `).all(ftsQuery, limit) as (NoteRow & { score: number })[];
1909
- return notesWithTags(db, rows, scoresById(rows));
1975
+ return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
1910
1976
  } catch (err) {
1911
1977
  if (err instanceof QueryError) throw err;
1912
1978
  throw searchSyntaxError(query, err, mode);
1913
1979
  }
1914
1980
  }
1915
1981
 
1982
+ /**
1983
+ * Semantic-search scan (EXPERIMENTAL — `SEMANTIC-MVP-PLAN.md`). Structural
1984
+ * sibling of `searchNotes`: text-shaped filters compose the same way, but
1985
+ * ranking is cosine similarity over `note_vectors` instead of FTS5 bm25.
1986
+ *
1987
+ * Filter-then-rank (per the plan): `buildFilterConditions` — the SAME
1988
+ * filter builder `queryNotes` uses — narrows to the candidate note set
1989
+ * FIRST (tags/metadata/dates/path/etc. all apply), so "notes about X that
1990
+ * are also #project" is one call, exactly like `search` composes with
1991
+ * structured filters. Only THEN does the brute-force cosine scan run, and
1992
+ * only over that (usually much smaller) candidate set — this is the perf
1993
+ * lever the architecture doc calls out (filter shrinks N before the O(N)
1994
+ * vector work).
1995
+ *
1996
+ * `model` restricts the scan to vectors recorded under the CURRENTLY
1997
+ * configured embedding model — a row left over from a prior model (see
1998
+ * `core/src/embedding/staleness.ts`) never contributes to ranking, so a
1999
+ * model change can't silently blend two incomparable vector spaces.
2000
+ *
2001
+ * Ranking is best-CHUNK-per-note (max cosine over a note's chunks, per the
2002
+ * ratified per-section chunking decision), but the returned unit is
2003
+ * always the NOTE (via `fetchNotesByIdsOrdered`), never a bare chunk —
2004
+ * matching `search`'s "text in → ranked notes out" contract.
2005
+ *
2006
+ * `queryVector` MUST already be L2-normalized (so the dot product below
2007
+ * IS cosine similarity) — `Store.semanticSearch` normalizes right after
2008
+ * embedding the query text; stored vectors are normalized at write time.
2009
+ */
2010
+ export function semanticSearchNotes(
2011
+ db: Database,
2012
+ queryVector: Float32Array,
2013
+ opts: QueryOpts,
2014
+ model: string,
2015
+ ): SemanticSearchResult {
2016
+ const limit = typeof opts.limit === "number" ? opts.limit : 10;
2017
+ const { conditions, params } = buildFilterConditions(db, opts);
2018
+ // Blank/whitespace-only notes are never candidates: they have nothing
2019
+ // embeddable and never get a note_vectors row (see `embedding-worker.ts`'s
2020
+ // chunk filter), so counting one as a "pending" candidate would produce a
2021
+ // phantom embeddings_pending warning that never drains. See
2022
+ // `nonBlankContentClause`'s doc comment.
2023
+ const allConditions = [...conditions, nonBlankContentClause("n.content")];
2024
+ const whereClause = `WHERE ${allConditions.join(" AND ")}`;
2025
+
2026
+ const idRows = db.prepare(`SELECT n.id FROM notes n ${whereClause}`).all(...params) as {
2027
+ id: string;
2028
+ }[];
2029
+ const candidateIds = idRows.map((r) => r.id);
2030
+ if (candidateIds.length === 0) {
2031
+ return { notes: [], pendingCount: 0, totalCandidates: 0 };
2032
+ }
2033
+
2034
+ // Pull every candidate's vector rows for the ACTIVE model, batched to
2035
+ // stay under the DO 100-bound-param cap (same chunking `fetchNotesByIdsOrdered`
2036
+ // uses). A note absent from this map has no fresh vector yet — pending.
2037
+ const vecRowsByNote = new Map<string, { chunk_ix: number; vector: Uint8Array }[]>();
2038
+ for (const idChunk of chunkForInClause(candidateIds)) {
2039
+ const placeholders = idChunk.map(() => "?").join(", ");
2040
+ const rows = db.prepare(
2041
+ `SELECT note_id, chunk_ix, vector FROM note_vectors WHERE model = ? AND note_id IN (${placeholders})`,
2042
+ ).all(model, ...idChunk) as { note_id: string; chunk_ix: number; vector: Uint8Array }[];
2043
+ for (const row of rows) {
2044
+ let list = vecRowsByNote.get(row.note_id);
2045
+ if (!list) {
2046
+ list = [];
2047
+ vecRowsByNote.set(row.note_id, list);
2048
+ }
2049
+ list.push(row);
2050
+ }
2051
+ }
2052
+
2053
+ // Best-chunk-per-note cosine (max over the note's chunks) — a long note
2054
+ // whose 3rd section matches outranks a note with weak whole-note
2055
+ // similarity, which is the whole point of per-section chunking.
2056
+ const scored: { id: string; score: number }[] = [];
2057
+ for (const id of candidateIds) {
2058
+ const rows = vecRowsByNote.get(id);
2059
+ if (!rows || rows.length === 0) continue;
2060
+ let best = -Infinity;
2061
+ for (const row of rows) {
2062
+ const score = dot(decodeVector(row.vector), queryVector);
2063
+ if (score > best) best = score;
2064
+ }
2065
+ scored.push({ id, score: best });
2066
+ }
2067
+ scored.sort((a, b) => b.score - a.score);
2068
+ const top = scored.slice(0, limit);
2069
+
2070
+ // Reuse the existing hydration machinery — same as every other id-list
2071
+ // fetch path (tags batched, no N+1).
2072
+ const hydrated = fetchNotesByIdsOrdered(db, top.map((t) => t.id));
2073
+ const byId = new Map(hydrated.map((n) => [n.id, n]));
2074
+ const scoreById = new Map(top.map((t) => [t.id, t.score]));
2075
+ const notes: Note[] = [];
2076
+ for (const t of top) {
2077
+ const note = byId.get(t.id);
2078
+ if (!note) continue; // defensive: row vanished between the two queries
2079
+ note.score = scoreById.get(t.id);
2080
+ notes.push(note);
2081
+ }
2082
+
2083
+ return {
2084
+ notes,
2085
+ pendingCount: candidateIds.length - vecRowsByNote.size,
2086
+ totalCandidates: candidateIds.length,
2087
+ };
2088
+ }
2089
+
1916
2090
  /**
1917
2091
  * Map rows → Notes with tags hydrated in one batched query. `scores`
1918
2092
  * (vault#551 WS2C) is an optional id → weighted-bm25-score map, attached
@@ -2592,10 +2766,142 @@ export function mergeTags(
2592
2766
  /** Max code points in a NoteIndex preview. */
2593
2767
  export const NOTE_INDEX_PREVIEW_LEN = 120;
2594
2768
 
2769
+ /** Max code points in a computed `displayTitle` (title axis, ratified 2026-07-17). */
2770
+ export const DISPLAY_TITLE_MAX_LEN = 120;
2771
+
2772
+ /**
2773
+ * Bounded line-count a leading frontmatter block's closing fence is
2774
+ * searched within (see `computeDisplayTitle`). Frontmatter blocks are a
2775
+ * handful of lines in practice; capping the scan keeps a pathological
2776
+ * "content starts with `---` but never closes it" note O(1)-ish rather
2777
+ * than a full-content scan on every derivation.
2778
+ */
2779
+ const FRONTMATTER_SCAN_LINES = 100;
2780
+
2781
+ /**
2782
+ * Shared by `computeDisplayTitle` and `computeLede`: given `content` already
2783
+ * split into `lines`, return the index of the first line that counts as the
2784
+ * start of the DOCUMENT body — after a leading closed frontmatter block, if
2785
+ * `content` opens with one (see `computeDisplayTitle`'s frontmatter-skip
2786
+ * doc for the full rationale). `0` when there's no leading frontmatter.
2787
+ */
2788
+ function skipLeadingFrontmatter(lines: string[]): number {
2789
+ if (lines[0]?.trim() !== "---") return 0;
2790
+ const scanLimit = Math.min(lines.length, FRONTMATTER_SCAN_LINES);
2791
+ for (let i = 1; i < scanLimit; i++) {
2792
+ if (lines[i]?.trim() === "---") return i + 1;
2793
+ }
2794
+ return 0;
2795
+ }
2796
+
2797
+ /**
2798
+ * Derive a note's display title: the first non-empty line of `content`,
2799
+ * with a leading markdown heading marker (`#` through `######`) and its
2800
+ * following whitespace stripped, truncated to `DISPLAY_TITLE_MAX_LEN` code
2801
+ * points. `null` when content has no non-empty line (empty or
2802
+ * whitespace/heading-marker-only note).
2803
+ *
2804
+ * The ratified title model (2026-07-17): a note's title IS its first line
2805
+ * — derived from content, NEVER stored as its own column. This function is
2806
+ * the one place that derivation happens; callers (`toNoteIndex` below) call
2807
+ * it fresh at read time. Timestamp-path formatting — what a surface shows
2808
+ * in place of a `null` title — is deliberately NOT this function's job; it
2809
+ * reports the honest content-derived value (or its absence) and leaves
2810
+ * rendering to the caller.
2811
+ *
2812
+ * Frontmatter skip: normal ingestion strips a YAML frontmatter block into
2813
+ * `metadata` before create, so `content` never carries one — but a direct
2814
+ * MCP/REST create can paste raw frontmatter-bearing text
2815
+ * (`---\ntitle: X\n---\n# Real Title`), and the first line of that
2816
+ * DOCUMENT is not the first line of its delimiter. When `content` opens
2817
+ * with a `---` line, derivation starts after the matching CLOSING `---`
2818
+ * (searched within the first `FRONTMATTER_SCAN_LINES` lines). An
2819
+ * unterminated opening `---` falls back to the pre-existing behavior —
2820
+ * scanning from line 0 — so a note whose real first line is literally
2821
+ * `---` isn't mangled.
2822
+ */
2823
+ export function computeDisplayTitle(content: string | null | undefined): string | null {
2824
+ if (!content) return null;
2825
+ const lines = content.split("\n");
2826
+ const startIndex = skipLeadingFrontmatter(lines);
2827
+ for (let i = startIndex; i < lines.length; i++) {
2828
+ const stripped = lines[i]!.replace(/^#{1,6}\s*/, "").trim();
2829
+ if (stripped === "") continue;
2830
+ // Iterate by Unicode code points so we don't split surrogate pairs mid-character.
2831
+ const codePoints = Array.from(stripped);
2832
+ return codePoints.length > DISPLAY_TITLE_MAX_LEN
2833
+ ? codePoints.slice(0, DISPLAY_TITLE_MAX_LEN).join("")
2834
+ : stripped;
2835
+ }
2836
+ return null;
2837
+ }
2838
+
2839
+ /** Max code points in a computed `lede` (summaries-as-content, 2026-07-17 dialogue). */
2840
+ export const LEDE_MAX_LEN = 400;
2841
+
2842
+ /**
2843
+ * Derive a note's "lede": the first non-empty PARAGRAPH after the title
2844
+ * line (a run of consecutive non-blank lines, whitespace-collapsed to one
2845
+ * line, truncated to `LEDE_MAX_LEN` code points). `null` when there's no
2846
+ * paragraph after the title — a title-only note has no lede to report, and
2847
+ * callers must not fall back to repeating the title itself.
2848
+ *
2849
+ * This is the machinery half of a soft convention (2026-07-17 dialogue):
2850
+ * summaries work better as visible CONTENT — a note's opening paragraph —
2851
+ * than as hidden metadata, because visible text gets corrected by the notes
2852
+ * that reference it while hidden metadata rots unnoticed. Nothing validates
2853
+ * that a note actually opens with a title + lede; this function just reports
2854
+ * what it finds, honestly, using the SAME title-line rule as
2855
+ * `computeDisplayTitle` (including its frontmatter skip) so a caller that
2856
+ * shows both title and lede sees them agree on where the title ends.
2857
+ */
2858
+ export function computeLede(content: string | null | undefined): string | null {
2859
+ if (!content) return null;
2860
+ const lines = content.split("\n");
2861
+ const bodyStart = skipLeadingFrontmatter(lines);
2862
+
2863
+ let titleLine = -1;
2864
+ for (let i = bodyStart; i < lines.length; i++) {
2865
+ if (lines[i]!.replace(/^#{1,6}\s*/, "").trim() !== "") {
2866
+ titleLine = i;
2867
+ break;
2868
+ }
2869
+ }
2870
+ if (titleLine === -1) return null; // no title at all — nothing to find a lede after
2871
+
2872
+ let i = titleLine + 1;
2873
+ while (i < lines.length && lines[i]!.trim() === "") i++; // skip blank lines after the title
2874
+
2875
+ const paragraphLines: string[] = [];
2876
+ while (i < lines.length && lines[i]!.trim() !== "") {
2877
+ paragraphLines.push(lines[i]!);
2878
+ i++;
2879
+ }
2880
+ if (paragraphLines.length === 0) return null; // title-only note
2881
+
2882
+ const paragraph = paragraphLines.join(" ").replace(/\s+/g, " ").trim();
2883
+ if (paragraph === "") return null;
2884
+
2885
+ const codePoints = Array.from(paragraph);
2886
+ return codePoints.length > LEDE_MAX_LEN
2887
+ ? codePoints.slice(0, LEDE_MAX_LEN).join("")
2888
+ : paragraph;
2889
+ }
2890
+
2595
2891
  /**
2596
2892
  * Convert a full Note into its lean index shape:
2597
- * drops `content`, adds `byteSize` and a whitespace-collapsed `preview`.
2598
- * Shared between the `query-notes` MCP tool, HTTP /notes endpoints, and /graph.
2893
+ * drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a
2894
+ * computed `displayTitle`. Shared between the `query-notes` MCP tool, HTTP
2895
+ * /notes endpoints, and /graph.
2896
+ *
2897
+ * Perf note: `displayTitle` costs nothing extra here — every caller of this
2898
+ * function already has the full `Note` (content included) in hand by the
2899
+ * time it's called. `queryNotes`'s two-phase page fetch selects a narrow
2900
+ * `id`-only column list for the ORDER BY/pagination phase, but the second
2901
+ * phase (`fetchNotesByIdsOrdered`) always `SELECT *`s the page's full rows
2902
+ * before `toNoteIndex` ever runs — same as `preview`/`byteSize` today. If a
2903
+ * future lean-list path is added that genuinely avoids a content read, it
2904
+ * must NOT call this function (or `toNoteIndex`) on a content-less row.
2599
2905
  */
2600
2906
  export function toNoteIndex(note: Note): NoteIndex {
2601
2907
  const content = note.content ?? "";
@@ -2622,6 +2928,7 @@ export function toNoteIndex(note: Note): NoteIndex {
2622
2928
  metadata: note.metadata,
2623
2929
  byteSize,
2624
2930
  preview,
2931
+ displayTitle: computeDisplayTitle(note.content),
2625
2932
  ...(note.score !== undefined ? { score: note.score } : {}),
2626
2933
  };
2627
2934
  }
@@ -2980,7 +3287,12 @@ interface NoteRow {
2980
3287
  }
2981
3288
 
2982
3289
  function rowToNote(row: NoteRow): Note {
2983
- let metadata: Record<string, unknown> | undefined;
3290
+ // `metadata` is always present on the wire — NULL/"{}"/unparseable all
3291
+ // collapse to `{}` rather than an absent key. `tags` is already always
3292
+ // `[]` (see getNote/queryNotes/searchNotes hydration below); this keeps
3293
+ // the note shape internally consistent and fixes a real third-party
3294
+ // crash on clients that assumed `note.metadata` always exists.
3295
+ let metadata: Record<string, unknown> = {};
2984
3296
  if (row.metadata && row.metadata !== "{}") {
2985
3297
  try { metadata = JSON.parse(row.metadata); } catch {}
2986
3298
  }
@@ -219,6 +219,26 @@ export function ignoredParamWarning(param: string, reason: string): QueryWarning
219
219
  };
220
220
  }
221
221
 
222
+ /**
223
+ * `truncated` warning (vault contracts-brief V1.3) — a structured, non-
224
+ * cursor list query returned exactly `limit` rows with no watermark to page
225
+ * from. That's ambiguous on its own: it might be the whole result set
226
+ * (coincidence), or there might be more rows silently cut off. Combined
227
+ * with the engine's default order (`created_at ASC` — oldest first,
228
+ * core/src/notes.ts) this is the concrete failure mode: a bare `GET
229
+ * /api/notes` on a >limit-note vault returns the OLDEST rows with zero
230
+ * signal that the NEWEST ones didn't make it. `?cursor=` (bootstrap or
231
+ * watermark) sidesteps this entirely — the warning only fires in its
232
+ * absence.
233
+ */
234
+ export function truncatedResultsWarning(limit: number): QueryWarning {
235
+ return {
236
+ code: "truncated",
237
+ message: `${limit} = limit rows returned; there may be more. Pass ?cursor= to page, or sort=desc for newest-first.`,
238
+ limit,
239
+ };
240
+ }
241
+
222
242
  /**
223
243
  * Cheap zero-result search suggestion (vault#551 WS2B — mirrors the tag
224
244
  * `did_you_mean` above via the SAME `suggestSimilarTag` scorer, just over a
@@ -375,3 +395,23 @@ export function searchDidYouMeanWarning(rawQuery: string, suggestion: string): Q
375
395
  did_you_mean: suggestion,
376
396
  };
377
397
  }
398
+
399
+ /**
400
+ * `embeddings_pending` warning (semantic search MVP, EXPERIMENTAL —
401
+ * `SEMANTIC-MVP-PLAN.md`) — a `semantic: true` query ran and returned
402
+ * REAL results, but `pending` of the `total_candidates` structured-filter
403
+ * matches have no vector yet for the active embedding model (a fresh
404
+ * note, or the backfill sweep hasn't reached it yet). Attached alongside
405
+ * honest (possibly incomplete) results — NEVER instead of them, and never
406
+ * a silent keyword fallback: the caller always knows it got a partial
407
+ * semantic ranking, not a complete one. Callers only push this when
408
+ * `pending > 0` (the common steady-state case attaches nothing).
409
+ */
410
+ export function embeddingsPendingWarning(pending: number, totalCandidates: number): QueryWarning {
411
+ return {
412
+ code: "embeddings_pending",
413
+ message: `${pending} of ${totalCandidates} candidate note(s) have no embedding yet for the active model — results may be incomplete until the backfill catches up.`,
414
+ pending,
415
+ total_candidates: totalCandidates,
416
+ };
417
+ }