@openparachute/vault 0.7.3-rc.4 → 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
+ });
@@ -1025,6 +1025,14 @@ export interface ApplySeedPackResult {
1025
1025
  pack: string;
1026
1026
  /** Tag names upserted (upserts are idempotent; always every declared tag). */
1027
1027
  tags: string[];
1028
+ /**
1029
+ * Subset of `tags` whose DESCRIPTION was left untouched this run because it
1030
+ * had been hand-edited away from the pack's canonical text (see
1031
+ * `applySeedPack`'s description-preservation rule below). `fields` /
1032
+ * `parent_names` / `relationships` are still upserted unconditionally for
1033
+ * these tags — only the description write was skipped.
1034
+ */
1035
+ preservedTagDescriptions: string[];
1028
1036
  /** Note paths written this run (absent ones). */
1029
1037
  seededNotes: string[];
1030
1038
  /** Note paths skipped because a note already lives there (idempotency). */
@@ -1037,6 +1045,30 @@ export interface ApplySeedPackResult {
1037
1045
  * rows — so a re-run can never duplicate, and never clobbers a note the
1038
1046
  * operator/AI has since edited or recreated.
1039
1047
  *
1048
+ * **Tag description preservation** (Aaron-ratified 2026-07-17): a pack writes
1049
+ * a tag's `description` only when (a) the tag has no prior description (new
1050
+ * tag, or an existing bare row nothing ever described), or (b) the prior
1051
+ * description is still byte-identical to the pack's own text — i.e. no one
1052
+ * has touched it. The moment a description differs from the pack's current
1053
+ * text, it's treated as a deliberate hand edit (a user-tuned "constitution")
1054
+ * and is never overwritten by a re-apply; omitting the `description` key from
1055
+ * the `upsertTagRecord` patch is what preserves it (the store keeps whatever
1056
+ * is already there). `fields` / `parent_names` / `relationships` are NOT
1057
+ * given this treatment here — they still upsert unconditionally, same as
1058
+ * before this change; those axes are more entangled with schema-validation
1059
+ * side effects (indexed-column lifecycle, cross-tag type agreement) to
1060
+ * safely split out in this pass. Description-only is the ratified scope.
1061
+ *
1062
+ * Known, accepted tradeoff: this compares against the pack's CURRENT text
1063
+ * only, not any prior canonical version. If a pack's canonical description
1064
+ * changes upstream, a vault that never touched it reads as "matches the OLD
1065
+ * text, therefore edited" from the new pack's point of view, and keeps the
1066
+ * old text too — same outcome as a genuine hand edit. That's fine:
1067
+ * constitutions aren't security patches that must propagate; a future
1068
+ * doctor-style scan can surface "description drifted from the current pack
1069
+ * text" as an informational finding without this applier needing to guess
1070
+ * intent. See CHANGELOG.
1071
+ *
1040
1072
  * Errors propagate — best-effort semantics (seed-must-never-fail-a-create)
1041
1073
  * belong to the caller, which knows its failure policy.
1042
1074
  *
@@ -1051,6 +1083,7 @@ export async function applySeedPack(
1051
1083
  const result: ApplySeedPackResult = {
1052
1084
  pack: pack.name,
1053
1085
  tags: [],
1086
+ preservedTagDescriptions: [],
1054
1087
  seededNotes: [],
1055
1088
  skippedNotes: [],
1056
1089
  };
@@ -1058,12 +1091,19 @@ export async function applySeedPack(
1058
1091
  // Parents first so `parent_names` reads naturally in logs (the store accepts
1059
1092
  // forward references, but the order matches the conceptual model).
1060
1093
  for (const decl of pack.tags) {
1094
+ const existing = await store.getTagRecord(decl.name);
1095
+ const priorDescription = existing?.description ?? null;
1096
+ const isUserEdited = priorDescription != null && priorDescription !== decl.description;
1097
+
1061
1098
  await store.upsertTagRecord(decl.name, {
1062
- description: decl.description,
1099
+ // Omitted (not `undefined`-valued) when preserving — `upsertTagRecord`
1100
+ // treats an absent `description` key as "keep the current value".
1101
+ ...(isUserEdited ? {} : { description: decl.description }),
1063
1102
  ...(decl.parent_names ? { parent_names: decl.parent_names } : {}),
1064
1103
  ...(decl.fields ? { fields: decl.fields } : {}),
1065
1104
  });
1066
1105
  result.tags.push(decl.name);
1106
+ if (isUserEdited) result.preservedTagDescriptions.push(decl.name);
1067
1107
  }
1068
1108
 
1069
1109
  for (const note of pack.notes) {
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.4",
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",
@@ -140,3 +140,35 @@ describe("add-pack surface-starter", () => {
140
140
  expect(stdout).toContain("~ tag capture (upserted)");
141
141
  });
142
142
  });
143
+
144
+ describe("add-pack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
145
+ test("a hand-edited description survives a re-apply and is reported 'kept', not 'upserted'", () => {
146
+ expect(runCli(["create", "packed", "--json"], { PARACHUTE_HOME: home }).exitCode).toBe(0);
147
+
148
+ // Simulate an operator/AI hand-editing the capture tag's constitution.
149
+ const dbPath = join(home, "vault", "data", "packed", "vault.db");
150
+ const editedDescription = "Our house rules for capture — not the default text.";
151
+ const writeDb = new Database(dbPath);
152
+ writeDb.query("UPDATE tags SET description = ? WHERE name = ?").run(
153
+ editedDescription,
154
+ "capture",
155
+ );
156
+ writeDb.close();
157
+
158
+ const { exitCode, stdout } = runCli(
159
+ ["add-pack", "welcome", "--vault", "packed"],
160
+ { PARACHUTE_HOME: home },
161
+ );
162
+ expect(exitCode).toBe(0);
163
+ expect(stdout).toContain("~ tag capture (kept user description)");
164
+ // guide wasn't edited, so it still reports the old vocabulary.
165
+ expect(stdout).toContain("~ tag guide (upserted)");
166
+
167
+ const readDb = new Database(dbPath, { readonly: true });
168
+ const row = readDb
169
+ .query("SELECT description FROM tags WHERE name = ?")
170
+ .get("capture") as { description: string };
171
+ readDb.close();
172
+ expect(row.description).toBe(editedDescription);
173
+ });
174
+ });
package/src/cli.ts CHANGED
@@ -4842,7 +4842,11 @@ async function cmdAddPack(args: string[]) {
4842
4842
  console.log(` = note ${path} (already exists — left untouched)`);
4843
4843
  }
4844
4844
  for (const tag of result.tags) {
4845
- console.log(` ~ tag ${tag} (upserted)`);
4845
+ if (result.preservedTagDescriptions.includes(tag)) {
4846
+ console.log(` ~ tag ${tag} (kept user description)`);
4847
+ } else {
4848
+ console.log(` ~ tag ${tag} (upserted)`);
4849
+ }
4846
4850
  }
4847
4851
  if (result.seededNotes.length === 0 && result.tags.length === 0) {
4848
4852
  console.log(" Nothing to add — everything was already in place.");
@@ -23,14 +23,17 @@ import { BunStore } from "./vault-store.ts";
23
23
  import { seedOnboardingNotes } from "./onboarding-seed.ts";
24
24
  import {
25
25
  applySeedPack,
26
+ ARCHIVED_TAG,
26
27
  CAPTURE_ANYTHING_PATH,
27
28
  CONNECT_AI_PATH,
28
29
  GETTING_STARTED_PATH,
29
30
  NOTES_REQUIRED_TAGS,
31
+ STARTER_ONTOLOGY_PACK,
30
32
  SURFACE_STARTER_PACK,
31
33
  SURFACE_STARTER_PATH,
32
34
  TAGS_GRAPH_PATH,
33
35
  WELCOME_PATH,
36
+ welcomePack,
34
37
  YOURS_TO_KEEP_PATH,
35
38
  } from "../core/src/seed-packs.ts";
36
39
  import {
@@ -290,6 +293,67 @@ describe("applySeedPack — surface-starter via add-pack", () => {
290
293
  });
291
294
  });
292
295
 
296
+ describe("applySeedPack — tag description preservation on re-apply (Aaron-ratified 2026-07-17)", () => {
297
+ test("fresh apply: a brand-new tag gets the pack's description, reported as touched not preserved", async () => {
298
+ const result = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
299
+ expect(result.tags).toContain(ARCHIVED_TAG.name);
300
+ expect(result.preservedTagDescriptions).toEqual([]);
301
+
302
+ const record = await store.getTagRecord(ARCHIVED_TAG.name);
303
+ expect(record!.description).toBe(ARCHIVED_TAG.description);
304
+ });
305
+
306
+ test("re-apply unmodified: description is still the pack's text — upserted, not preserved", async () => {
307
+ await applySeedPack(store, STARTER_ONTOLOGY_PACK);
308
+ const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
309
+ expect(rerun.preservedTagDescriptions).toEqual([]);
310
+
311
+ const record = await store.getTagRecord(ARCHIVED_TAG.name);
312
+ expect(record!.description).toBe(ARCHIVED_TAG.description);
313
+ });
314
+
315
+ test("re-apply after a user edit: the hand-tuned description survives and is reported preserved", async () => {
316
+ await applySeedPack(store, STARTER_ONTOLOGY_PACK);
317
+ const edited = "My own house rules for archiving — nothing like the default.";
318
+ await store.upsertTagRecord(ARCHIVED_TAG.name, { description: edited });
319
+
320
+ const rerun = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
321
+ expect(rerun.preservedTagDescriptions).toContain(ARCHIVED_TAG.name);
322
+ // Still listed in `tags` — the tag itself was touched (fields/parent_names
323
+ // upsert unconditionally); only the description write was skipped.
324
+ expect(rerun.tags).toContain(ARCHIVED_TAG.name);
325
+
326
+ const record = await store.getTagRecord(ARCHIVED_TAG.name);
327
+ expect(record!.description).toBe(edited);
328
+ });
329
+
330
+ test("capture byte-identity anchor (NOTES_REQUIRED_TAGS) holds under either apply order", async () => {
331
+ // welcome, then starter-ontology.
332
+ await applySeedPack(store, welcomePack());
333
+ expect((await store.getTagRecord("capture"))!.description).toBe(
334
+ NOTES_REQUIRED_TAGS[0]!.description,
335
+ );
336
+ const afterOntology = await applySeedPack(store, STARTER_ONTOLOGY_PACK);
337
+ expect(afterOntology.preservedTagDescriptions).not.toContain("capture");
338
+ expect((await store.getTagRecord("capture"))!.description).toBe(
339
+ NOTES_REQUIRED_TAGS[0]!.description,
340
+ );
341
+ });
342
+
343
+ test("capture byte-identity anchor holds in the reverse order too", async () => {
344
+ // starter-ontology, then welcome.
345
+ await applySeedPack(store, STARTER_ONTOLOGY_PACK);
346
+ expect((await store.getTagRecord("capture"))!.description).toBe(
347
+ NOTES_REQUIRED_TAGS[0]!.description,
348
+ );
349
+ const afterWelcome = await applySeedPack(store, welcomePack());
350
+ expect(afterWelcome.preservedTagDescriptions).not.toContain("capture");
351
+ expect((await store.getTagRecord("capture"))!.description).toBe(
352
+ NOTES_REQUIRED_TAGS[0]!.description,
353
+ );
354
+ });
355
+ });
356
+
293
357
  describe("vault-info / projection pointer (A2)", () => {
294
358
  test("projection carries getting_started when the note exists", async () => {
295
359
  // Absent before seeding → no pointer.
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 () => {