@openparachute/vault 0.7.3-rc.5 → 0.7.3-rc.6

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.
@@ -0,0 +1,149 @@
1
+ /**
2
+ * Title axis (ratified 2026-07-17): a note's title IS its first line —
3
+ * derived from content, never stored. This file covers:
4
+ * - `computeDisplayTitle` derivation (first non-empty line, heading
5
+ * markers stripped, truncation, null-on-empty);
6
+ * - `displayTitle` presence on the lean `NoteIndex` shape, both via the
7
+ * MCP `query-notes` list path and the REST `GET /notes` list path.
8
+ *
9
+ * The search title-BOOST (ranking notes whose first line matches query
10
+ * terms above the rest) lives in `search-title-boost.test.ts` — a
11
+ * different concern (ranking) from derivation (this file).
12
+ */
13
+ import { describe, it, expect, beforeEach } from "bun:test";
14
+ import { Database } from "bun:sqlite";
15
+ import { SqliteStore } from "./store.js";
16
+ import { generateMcpTools } from "./mcp.js";
17
+ import { computeDisplayTitle, toNoteIndex, DISPLAY_TITLE_MAX_LEN } from "./notes.js";
18
+ import type { Note } from "./types.js";
19
+
20
+ describe("computeDisplayTitle", () => {
21
+ it("returns the first line when content is a single line", () => {
22
+ expect(computeDisplayTitle("Grocery List")).toBe("Grocery List");
23
+ });
24
+
25
+ it("returns the first NON-EMPTY line, skipping leading blank lines", () => {
26
+ expect(computeDisplayTitle("\n\n \nReal Title\nbody text")).toBe("Real Title");
27
+ });
28
+
29
+ it("strips a leading markdown heading marker + its whitespace", () => {
30
+ expect(computeDisplayTitle("# Grocery List\nmilk, eggs")).toBe("Grocery List");
31
+ expect(computeDisplayTitle("## Section Two\nbody")).toBe("Section Two");
32
+ expect(computeDisplayTitle("###### h6\nbody")).toBe("h6");
33
+ });
34
+
35
+ it("strips a heading marker with no space after it", () => {
36
+ expect(computeDisplayTitle("###Title\nbody")).toBe("Title");
37
+ });
38
+
39
+ it("treats a heading-marker-only line as blank and moves to the next line", () => {
40
+ expect(computeDisplayTitle("##\nReal Title")).toBe("Real Title");
41
+ });
42
+
43
+ it("does not strip markers past the first six (never a valid heading)", () => {
44
+ // 7 leading `#`s: the regex only matches 1-6, so the 7th `#` survives
45
+ // as ordinary content.
46
+ expect(computeDisplayTitle("####### Title")).toBe("# Title");
47
+ });
48
+
49
+ it("returns null for empty content", () => {
50
+ expect(computeDisplayTitle("")).toBeNull();
51
+ });
52
+
53
+ it("returns null for null/undefined content", () => {
54
+ expect(computeDisplayTitle(null)).toBeNull();
55
+ expect(computeDisplayTitle(undefined)).toBeNull();
56
+ });
57
+
58
+ it("returns null when content is whitespace-only", () => {
59
+ expect(computeDisplayTitle(" \n\t\n ")).toBeNull();
60
+ });
61
+
62
+ it("returns null when every line is a bare heading marker", () => {
63
+ expect(computeDisplayTitle("#\n##\n### ")).toBeNull();
64
+ });
65
+
66
+ it("truncates to DISPLAY_TITLE_MAX_LEN code points", () => {
67
+ const longTitle = "a".repeat(200);
68
+ const result = computeDisplayTitle(longTitle)!;
69
+ expect(result.length).toBe(DISPLAY_TITLE_MAX_LEN);
70
+ expect(result).toBe("a".repeat(DISPLAY_TITLE_MAX_LEN));
71
+ });
72
+
73
+ it("does not truncate a title at or under the limit", () => {
74
+ const exact = "a".repeat(DISPLAY_TITLE_MAX_LEN);
75
+ expect(computeDisplayTitle(exact)).toBe(exact);
76
+ const short = "short title";
77
+ expect(computeDisplayTitle(short)).toBe(short);
78
+ });
79
+
80
+ it("truncates by Unicode code point, not UTF-16 code unit (no split surrogate pairs)", () => {
81
+ // Astral-plane emoji are 2 UTF-16 code units each but 1 code point.
82
+ const emojiTitle = "😀".repeat(150);
83
+ const result = computeDisplayTitle(emojiTitle)!;
84
+ expect(Array.from(result).length).toBe(DISPLAY_TITLE_MAX_LEN);
85
+ });
86
+ });
87
+
88
+ describe("toNoteIndex — displayTitle wiring", () => {
89
+ const baseNote: Note = {
90
+ id: "n1",
91
+ content: "# Real Title\nbody",
92
+ createdAt: new Date().toISOString(),
93
+ tags: [],
94
+ metadata: {},
95
+ };
96
+
97
+ it("carries the computed display title onto the lean shape", () => {
98
+ const index = toNoteIndex(baseNote);
99
+ expect(index.displayTitle).toBe("Real Title");
100
+ });
101
+
102
+ it("is null when content is empty", () => {
103
+ const index = toNoteIndex({ ...baseNote, content: "" });
104
+ expect(index.displayTitle).toBeNull();
105
+ });
106
+
107
+ it("is null when content is missing entirely (defensive — matches preview's ?? \"\" fallback)", () => {
108
+ const { content: _drop, ...rest } = baseNote;
109
+ const index = toNoteIndex(rest as Note);
110
+ expect(index.displayTitle).toBeNull();
111
+ });
112
+ });
113
+
114
+ let store: SqliteStore;
115
+ let db: Database;
116
+
117
+ beforeEach(() => {
118
+ db = new Database(":memory:");
119
+ store = new SqliteStore(db);
120
+ });
121
+
122
+ describe("displayTitle — list-shape presence (MCP)", () => {
123
+ it("query-notes list mode (include_content default false) carries displayTitle", async () => {
124
+ await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
125
+ const tools = generateMcpTools(store);
126
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
127
+ const result = await queryNotes.execute({}) as any[];
128
+ expect(result).toHaveLength(1);
129
+ expect(result[0].displayTitle).toBe("Meeting Notes");
130
+ expect(result[0].content).toBeUndefined();
131
+ });
132
+
133
+ it("query-notes include_content=true does NOT carry displayTitle (full Note shape, unchanged)", async () => {
134
+ await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
135
+ const tools = generateMcpTools(store);
136
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
137
+ const result = await queryNotes.execute({ include_content: true }) as any[];
138
+ expect(result[0].content).toBeTruthy();
139
+ expect(result[0].displayTitle).toBeUndefined();
140
+ });
141
+
142
+ it("displayTitle is null on the lean shape for an empty note", async () => {
143
+ await store.createNote("", { path: "empty" });
144
+ const tools = generateMcpTools(store);
145
+ const queryNotes = tools.find((t) => t.name === "query-notes")!;
146
+ const result = await queryNotes.execute({}) as any[];
147
+ expect(result[0].displayTitle).toBeNull();
148
+ });
149
+ });
package/core/src/mcp.ts CHANGED
@@ -1619,7 +1619,7 @@ A note's response carries \`existed\` (true/false) whenever ITS \`if_exists\` wa
1619
1619
  - For batch: pass a \`notes\` array, each with an \`id\` field.
1620
1620
  - **Optimistic concurrency is required by default.** Pass \`if_updated_at\` with the \`updated_at\` value you last read — the update is rejected with a conflict error if the note has changed since. Re-read, reconcile, and retry. To skip the safety check (e.g. bulk migration), pass \`force: true\` instead; the update then runs unconditionally. \`force\` only waives the *requirement to supply* \`if_updated_at\` — if you pass both, the precondition you supplied still applies and a mismatch returns a conflict error. \`append\` / \`prepend\` only updates are exempt from the precondition (no-conflict-by-design). **Batch default (vault#554):** a top-level \`force\` and/or \`if_updated_at\` alongside a \`notes\` array applies as the DEFAULT for every item that doesn't set its own — e.g. \`{force: true, notes: [{id: "a", content: "..."}, {id: "b", content: "...", if_updated_at: "..."}]}\` forces item "a" but still enforces the precondition on item "b" (its own \`if_updated_at\` wins). Per-item values always take precedence over the top-level default.
1621
1621
  - **Idempotent upsert via \`if_missing: "create"\`** — when the note doesn't exist, create it from this same payload (content/path/tags/metadata become the create fields; OC precondition skipped — nothing to conflict with). Response carries \`created: true\`. Useful for nightly sync loops that don't know ahead of time whether the note exists. Default \`"fail"\` (current behavior — missing note errors). See vault#309.
1622
- - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present.
1622
+ - \`include_content\` (default \`true\`) — set \`false\` to receive a lean index shape (\`id\`, \`path\`, \`createdAt\`, \`updatedAt\`, \`createdBy\`, \`createdVia\`, \`lastUpdatedBy\`, \`lastUpdatedVia\`, \`tags\`, \`metadata\`, \`byteSize\`, \`preview\`, \`displayTitle\`) instead of full content. Useful for agents making frequent small edits to large notes (e.g. via \`append\` or \`content_edit\`) where re-receiving the body is the dominant cost. \`validation_status\` is preserved on the lean shape when present. \`displayTitle\` is the note's first non-empty content line (heading markers stripped, ~120 chars max), \`null\` when content is empty — never stored, computed fresh from content already in hand.
1623
1623
 
1624
1624
  Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\` (the principal + interface of the first write) and \`lastUpdatedBy\`/\`lastUpdatedVia\` (the most recent write). NULL on notes written before attribution existed. Filter on them with \`created_by\`/\`last_updated_by\`/\`created_via\`/\`last_updated_via\`.`,
1625
1625
  inputSchema: {
@@ -1693,7 +1693,7 @@ Write-attribution (vault#298): every result carries \`createdBy\`/\`createdVia\`
1693
1693
  },
1694
1694
  include_content: {
1695
1695
  type: "boolean",
1696
- description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize` and a whitespace-collapsed `preview`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
1696
+ description: "Response shape opt-out. Default `true` (returns the full Note with content). Set `false` to receive the lean index shape (drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a computed `displayTitle`). `validation_status` is preserved on the lean shape when present. Applies uniformly to single and batch responses.",
1697
1697
  },
1698
1698
  include_links: {
1699
1699
  type: "boolean",
package/core/src/notes.ts CHANGED
@@ -25,6 +25,7 @@ import { computeExpandedTagCounts, loadTagHierarchy, stripTagHash } from "./tag-
25
25
  import { chunkForInClause, IN_VIA_JSON_EACH, jsonEachParam } from "./sql-in.js";
26
26
  import {
27
27
  buildLiteralSearchQuery,
28
+ extractLiteralBoostTerms,
28
29
  SEARCH_WEIGHT_PATH,
29
30
  SEARCH_WEIGHT_CONTENT,
30
31
  type SearchMode,
@@ -1810,6 +1811,61 @@ function searchSyntaxError(rawQuery: string, err: unknown, mode: SearchMode): Qu
1810
1811
  });
1811
1812
  }
1812
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
+
1813
1869
  export function searchNotes(
1814
1870
  db: Database,
1815
1871
  query: string,
@@ -1846,6 +1902,14 @@ export function searchNotes(
1846
1902
  // The weights are our own numeric constants (not user input) interpolated
1847
1903
  // directly — bm25()'s weight arguments are positional per-column
1848
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.
1849
1913
  const scoreExpr = `(-1.0 * bm25(notes_fts, ${SEARCH_WEIGHT_PATH}, ${SEARCH_WEIGHT_CONTENT}))`;
1850
1914
 
1851
1915
  // `sort` honored under search (vault#551 WS2A item 3): default stays FTS5
@@ -1886,7 +1950,7 @@ export function searchNotes(
1886
1950
  ORDER BY ${orderBy}
1887
1951
  LIMIT ?
1888
1952
  `).all(ftsQuery, ...searchTags, limit) as (NoteRow & { score: number })[];
1889
- return notesWithTags(db, rows, scoresById(rows));
1953
+ return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
1890
1954
  } catch (err) {
1891
1955
  // Surface EVERY FTS5 error structured, never a raw rethrow (vault#551):
1892
1956
  // advanced mode expects it (the caller passed raw syntax); literal
@@ -1908,7 +1972,7 @@ export function searchNotes(
1908
1972
  ORDER BY ${orderBy}
1909
1973
  LIMIT ?
1910
1974
  `).all(ftsQuery, limit) as (NoteRow & { score: number })[];
1911
- return notesWithTags(db, rows, scoresById(rows));
1975
+ return applySearchTitleBoost(notesWithTags(db, rows, scoresById(rows)), mode, query, opts?.sort);
1912
1976
  } catch (err) {
1913
1977
  if (err instanceof QueryError) throw err;
1914
1978
  throw searchSyntaxError(query, err, mode);
@@ -2702,10 +2766,52 @@ export function mergeTags(
2702
2766
  /** Max code points in a NoteIndex preview. */
2703
2767
  export const NOTE_INDEX_PREVIEW_LEN = 120;
2704
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
+ * Derive a note's display title: the first non-empty line of `content`,
2774
+ * with a leading markdown heading marker (`#` through `######`) and its
2775
+ * following whitespace stripped, truncated to `DISPLAY_TITLE_MAX_LEN` code
2776
+ * points. `null` when content has no non-empty line (empty or
2777
+ * whitespace/heading-marker-only note).
2778
+ *
2779
+ * The ratified title model (2026-07-17): a note's title IS its first line
2780
+ * — derived from content, NEVER stored as its own column. This function is
2781
+ * the one place that derivation happens; callers (`toNoteIndex` below) call
2782
+ * it fresh at read time. Timestamp-path formatting — what a surface shows
2783
+ * in place of a `null` title — is deliberately NOT this function's job; it
2784
+ * reports the honest content-derived value (or its absence) and leaves
2785
+ * rendering to the caller.
2786
+ */
2787
+ export function computeDisplayTitle(content: string | null | undefined): string | null {
2788
+ if (!content) return null;
2789
+ for (const rawLine of content.split("\n")) {
2790
+ const stripped = rawLine.replace(/^#{1,6}\s*/, "").trim();
2791
+ if (stripped === "") continue;
2792
+ // Iterate by Unicode code points so we don't split surrogate pairs mid-character.
2793
+ const codePoints = Array.from(stripped);
2794
+ return codePoints.length > DISPLAY_TITLE_MAX_LEN
2795
+ ? codePoints.slice(0, DISPLAY_TITLE_MAX_LEN).join("")
2796
+ : stripped;
2797
+ }
2798
+ return null;
2799
+ }
2800
+
2705
2801
  /**
2706
2802
  * Convert a full Note into its lean index shape:
2707
- * drops `content`, adds `byteSize` and a whitespace-collapsed `preview`.
2708
- * Shared between the `query-notes` MCP tool, HTTP /notes endpoints, and /graph.
2803
+ * drops `content`, adds `byteSize`, a whitespace-collapsed `preview`, and a
2804
+ * computed `displayTitle`. Shared between the `query-notes` MCP tool, HTTP
2805
+ * /notes endpoints, and /graph.
2806
+ *
2807
+ * Perf note: `displayTitle` costs nothing extra here — every caller of this
2808
+ * function already has the full `Note` (content included) in hand by the
2809
+ * time it's called. `queryNotes`'s two-phase page fetch selects a narrow
2810
+ * `id`-only column list for the ORDER BY/pagination phase, but the second
2811
+ * phase (`fetchNotesByIdsOrdered`) always `SELECT *`s the page's full rows
2812
+ * before `toNoteIndex` ever runs — same as `preview`/`byteSize` today. If a
2813
+ * future lean-list path is added that genuinely avoids a content read, it
2814
+ * must NOT call this function (or `toNoteIndex`) on a content-less row.
2709
2815
  */
2710
2816
  export function toNoteIndex(note: Note): NoteIndex {
2711
2817
  const content = note.content ?? "";
@@ -2732,6 +2838,7 @@ export function toNoteIndex(note: Note): NoteIndex {
2732
2838
  metadata: note.metadata,
2733
2839
  byteSize,
2734
2840
  preview,
2841
+ displayTitle: computeDisplayTitle(note.content),
2735
2842
  ...(note.score !== undefined ? { score: note.score } : {}),
2736
2843
  };
2737
2844
  }
@@ -132,7 +132,15 @@ describe("search — v24 → v25 migration (legacy vault upgrade)", () => {
132
132
  );
133
133
  insert.run(
134
134
  "legacy-1",
135
- "a dedicated writeup mentioning propolis only once in passing text",
135
+ // Deliberately multi-line, with the "propolis" mention on a LATER
136
+ // line — this fixture isolates the PATH-weighted bm25 axis (below)
137
+ // from the separate content-title-boost axis (title axis, ratified
138
+ // 2026-07-17): that boost derives a note's "title" from its FIRST
139
+ // content line, and a single-line note IS its own title in full, so
140
+ // a one-line body mentioning "propolis" would (correctly, under the
141
+ // newer axis) count as a title match and confound this test's
142
+ // path-only signal. See `search-title-boost.test.ts` for that axis.
143
+ "Notes on foraging patterns\n\na dedicated writeup mentioning propolis only once in passing text",
136
144
  "beekeeping-notes",
137
145
  "2026-01-01T00:00:00.000Z",
138
146
  "2026-01-01T00:00:00.000Z",
@@ -4,6 +4,7 @@ import {
4
4
  escapeFtsToken,
5
5
  buildLiteralSearchQuery,
6
6
  isValidSearchMode,
7
+ extractLiteralBoostTerms,
7
8
  } from "./search-query.js";
8
9
 
9
10
  describe("escapeFtsToken", () => {
@@ -147,3 +148,44 @@ describe("isValidSearchMode / SEARCH_MODES", () => {
147
148
  expect(isValidSearchMode(["literal"])).toBe(false);
148
149
  });
149
150
  });
151
+
152
+ describe("extractLiteralBoostTerms (search title-boost input, title axis)", () => {
153
+ it("lowercases and whitespace-splits", () => {
154
+ expect(extractLiteralBoostTerms("Budget Review")).toEqual(["budget", "review"]);
155
+ });
156
+
157
+ it("collapses whitespace runs", () => {
158
+ expect(extractLiteralBoostTerms("widgets gadgets")).toEqual(["widgets", "gadgets"]);
159
+ });
160
+
161
+ it("trims leading/trailing whitespace", () => {
162
+ expect(extractLiteralBoostTerms(" widgets ")).toEqual(["widgets"]);
163
+ });
164
+
165
+ it("strips literal quote characters a caller typed, unlike buildLiteralSearchQuery", () => {
166
+ // buildLiteralSearchQuery treats a manually-typed `"` as ordinary
167
+ // content (escaped into the FTS phrase); the boost-term extractor
168
+ // strips it instead, since a title-string comparison shouldn't
169
+ // require the title to contain a literal quote mark.
170
+ expect(extractLiteralBoostTerms(`"eleven-day capping delay"`)).toEqual([
171
+ "eleven-day",
172
+ "capping",
173
+ "delay",
174
+ ]);
175
+ });
176
+
177
+ it("preserves punctuation that isn't a quote (hyphen, period, apostrophe)", () => {
178
+ expect(extractLiteralBoostTerms("eleven-day")).toEqual(["eleven-day"]);
179
+ expect(extractLiteralBoostTerms("18.6")).toEqual(["18.6"]);
180
+ expect(extractLiteralBoostTerms("didn't")).toEqual(["didn't"]);
181
+ });
182
+
183
+ it("returns an empty array for blank/whitespace-only input", () => {
184
+ expect(extractLiteralBoostTerms("")).toEqual([]);
185
+ expect(extractLiteralBoostTerms(" ")).toEqual([]);
186
+ });
187
+
188
+ it("sanitizes control characters as separators, matching buildLiteralSearchQuery", () => {
189
+ expect(extractLiteralBoostTerms("hello\x00world")).toEqual(["hello", "world"]);
190
+ });
191
+ });
@@ -153,3 +153,30 @@ export function buildLiteralSearchQuery(raw: string): LiteralSearchQuery {
153
153
  const tokens = cleaned.trim().split(/\s+/).filter(Boolean);
154
154
  return { query: tokens.map(escapeFtsToken).join(" "), isEmpty: false };
155
155
  }
156
+
157
+ /**
158
+ * Extract lowercase whitespace-delimited terms from raw literal-mode search
159
+ * text, for the search title-boost post-rank (title axis, ratified
160
+ * 2026-07-17) — NOT used to build the FTS5 MATCH query itself (that's
161
+ * `buildLiteralSearchQuery`, above). Sanitizes control characters the same
162
+ * way, plus strips any literal `"` a caller typed (kept as ordinary content
163
+ * by `buildLiteralSearchQuery`, but a display-title substring check
164
+ * shouldn't require the title to contain a literal quote mark).
165
+ *
166
+ * This is a heuristic re-rank signal, not a second index — it deliberately
167
+ * does not attempt FTS5-tokenizer parity (stemming, unicode normalization).
168
+ * It only needs to answer "does the note's first line plausibly contain the
169
+ * query," not reproduce FTS5's match semantics exactly. Restricted to
170
+ * literal-mode callers by convention (see `core/src/notes.ts` `searchNotes`)
171
+ * — `search_mode: "advanced"` text carries FTS5 boolean/column/prefix
172
+ * operators (`AND`, `path:`, `foo*`) that would leak into the term list as
173
+ * false "content" if tokenized the same naive way.
174
+ */
175
+ export function extractLiteralBoostTerms(raw: string): string[] {
176
+ const cleaned = raw.replace(CONTROL_CHARS, " ").toLowerCase();
177
+ return cleaned
178
+ .trim()
179
+ .split(/\s+/)
180
+ .map((t) => t.replace(/^"+|"+$/g, ""))
181
+ .filter(Boolean);
182
+ }
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Search title-boost (title axis, ratified 2026-07-17): literal-mode
3
+ * search results whose `displayTitle` (first non-empty content line, NOT
4
+ * `path`) contains every query term are post-ranked ahead of body-only
5
+ * matches. No-migration — an in-memory re-rank of the already-fetched
6
+ * page, not a `notes_fts` schema change (see `applySearchTitleBoost` /
7
+ * `boostTitleMatches` in `notes.ts`).
8
+ *
9
+ * This is a DIFFERENT axis from the existing `path`-weighted bm25 ranking
10
+ * covered in `search-fts-v25.test.ts` — a note's title here is its first
11
+ * CONTENT line, not its `path`.
12
+ */
13
+ import { describe, it, expect, beforeEach } from "bun:test";
14
+ import { Database } from "bun:sqlite";
15
+ import { SqliteStore } from "./store.js";
16
+
17
+ let store: SqliteStore;
18
+ let db: Database;
19
+
20
+ beforeEach(() => {
21
+ db = new Database(":memory:");
22
+ store = new SqliteStore(db);
23
+ });
24
+
25
+ describe("search title-boost", () => {
26
+ it("ranks a first-line (title) match above a body-only match", async () => {
27
+ // Body-only match: "budget" appears only deep in the body, first line
28
+ // is unrelated.
29
+ await store.createNote(
30
+ "Weekly Standup\nlots of unrelated notes here, but somewhere we discuss the budget in passing",
31
+ { path: "standup-notes" },
32
+ );
33
+ // First-line (title) match: "Budget" is the first line itself.
34
+ await store.createNote("Budget Review\nQ3 numbers", { path: "budget-review" });
35
+
36
+ const hits = await store.searchNotes("budget");
37
+ expect(hits.length).toBeGreaterThanOrEqual(2);
38
+ expect(hits[0].path).toBe("budget-review");
39
+ });
40
+
41
+ it("requires ALL query terms to be present in the first line to boost (a partial title match doesn't qualify)", async () => {
42
+ // "budget" is in the title but "review" is only in the body — a
43
+ // PARTIAL title match, which stays in the body-only tier.
44
+ await store.createNote("Budget notes\nsee the quarterly review for details", { path: "partial" });
45
+ // Both "budget" and "review" are in the title — a FULL title match.
46
+ await store.createNote("Budget Review\nQ3 numbers", { path: "full-match" });
47
+
48
+ const hits = await store.searchNotes("budget review");
49
+ expect(hits.map((n) => n.path)).toEqual(["full-match", "partial"]);
50
+ });
51
+
52
+ it("ties (both notes in the same tier) keep the existing relevance order", async () => {
53
+ // Two title-matching notes plus one body-only note. Comparing literal
54
+ // mode (boosted) against advanced mode with the same plain-word query
55
+ // (identical FTS5 syntax, so identical underlying bm25 order, but no
56
+ // boost pass) isolates the boost's effect: within the title-match
57
+ // tier, the boost must preserve whatever relative order the
58
+ // underlying relevance ranking already gave — a stable sort, not a
59
+ // fresh comparator that could reshuffle ties.
60
+ await store.createNote("Project Widget\nfirst, mentions widget again for relevance", { path: "w1" });
61
+ await store.createNote("Project Widget\nsecond", { path: "w2" });
62
+ await store.createNote("Unrelated\nbody-only mention of widget project", { path: "body-only" });
63
+
64
+ const boosted = await store.searchNotes("widget project");
65
+ const rawOrder = await store.searchNotes("widget project", { mode: "advanced" });
66
+
67
+ const titleTierBoosted = boosted.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
68
+ const titleTierRaw = rawOrder.map((n) => n.path).filter((p) => p === "w1" || p === "w2");
69
+ expect(titleTierBoosted).toEqual(titleTierRaw);
70
+ // The title-match tier precedes the body-only note in the boosted result.
71
+ expect(boosted.map((n) => n.path).indexOf("body-only")).toBeGreaterThan(
72
+ Math.max(...["w1", "w2"].map((p) => boosted.map((n) => n.path).indexOf(p))),
73
+ );
74
+ });
75
+
76
+ it("does not disturb ordering when an explicit sort is requested", async () => {
77
+ await store.createNote("Zebra body-only mention of gadget", { path: "z" });
78
+ await store.createNote("Gadget Launch\nfirst line title match", { path: "a" });
79
+
80
+ const asc = await store.searchNotes("gadget", { sort: "asc" });
81
+ // created_at ASC means "z" (created first) comes before "a" — the
82
+ // title-boost must NOT override an explicit sort.
83
+ expect(asc.map((n) => n.path)).toEqual(["z", "a"]);
84
+ });
85
+
86
+ it("does not apply the boost under search_mode: advanced (raw FTS5 syntax, not naively tokenizable)", async () => {
87
+ await store.createNote("Body only mentions gadget in passing text here", { path: "body-only" });
88
+ await store.createNote("Gadget\nfirst line title match", { path: "title-match" });
89
+
90
+ const advanced = await store.searchNotes("gadget", { mode: "advanced" });
91
+ const literal = await store.searchNotes("gadget");
92
+
93
+ // Literal mode boosts the title match to the front.
94
+ expect(literal[0].path).toBe("title-match");
95
+ // Advanced mode is unaffected by the boost — order comes from bm25
96
+ // relevance alone (title-match still likely ranks first via the
97
+ // EXISTING path-weighted bm25, but that's a different mechanism; the
98
+ // test here just confirms advanced mode doesn't throw and returns
99
+ // both notes, not that ordering matches/mismatches literal mode).
100
+ expect(advanced.map((n) => n.path).sort()).toEqual(["body-only", "title-match"]);
101
+ });
102
+
103
+ it("a query with no results is unaffected (empty array in, empty array out)", async () => {
104
+ const hits = await store.searchNotes("nonexistent_term_xyz");
105
+ expect(hits).toEqual([]);
106
+ });
107
+ });
package/core/src/types.ts CHANGED
@@ -400,6 +400,12 @@ export interface NoteIndex {
400
400
  metadata?: Record<string, unknown>;
401
401
  byteSize: number;
402
402
  preview: string;
403
+ /** Derived from the first non-empty line of content (title axis, ratified
404
+ * 2026-07-17) — NEVER stored, computed fresh at read time by
405
+ * `computeDisplayTitle`. `null` when content has no non-empty line.
406
+ * Surfaces decide how to render a `null` title (e.g. a timestamp/path
407
+ * fallback); core just reports the honest content-derived value. */
408
+ displayTitle: string | null;
403
409
  /** Opt-in link degree (see `Note.linkCount`). */
404
410
  linkCount?: number;
405
411
  /** Full-text search relevance score (see `Note.score`). Carried onto the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openparachute/vault",
3
- "version": "0.7.3-rc.5",
3
+ "version": "0.7.3-rc.6",
4
4
  "description": "Agent-native knowledge graph. Notes, tags, links over MCP.",
5
5
  "module": "src/cli.ts",
6
6
  "type": "module",
package/src/vault.test.ts CHANGED
@@ -2139,6 +2139,22 @@ describe("HTTP /notes", async () => {
2139
2139
  expect(body[0]).not.toHaveProperty("content");
2140
2140
  expect(body[0]).toHaveProperty("byteSize");
2141
2141
  expect(body[0]).toHaveProperty("preview");
2142
+ expect(body[0]).toHaveProperty("displayTitle");
2143
+ });
2144
+
2145
+ // Title axis (ratified 2026-07-17) — displayTitle on the REST lean shape.
2146
+ test("GET /notes lean shape carries the computed displayTitle", async () => {
2147
+ await store.createNote("# Meeting Notes\nagenda: budget", { path: "meeting" });
2148
+ const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2149
+ const body = await res.json() as any[];
2150
+ expect(body[0].displayTitle).toBe("Meeting Notes");
2151
+ });
2152
+
2153
+ test("GET /notes lean shape reports null displayTitle for an empty note", async () => {
2154
+ await store.createNote("", { path: "empty" });
2155
+ const res = await handleNotes(mkReq("GET", "/notes"), store, "");
2156
+ const body = await res.json() as any[];
2157
+ expect(body[0].displayTitle).toBeNull();
2142
2158
  });
2143
2159
 
2144
2160
  test("GET /notes?include_content=true returns full notes", async () => {