@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.
- package/core/src/display-title.test.ts +149 -0
- package/core/src/mcp.ts +2 -2
- package/core/src/notes.ts +111 -4
- package/core/src/search-fts-v25.test.ts +9 -1
- package/core/src/search-query.test.ts +42 -0
- package/core/src/search-query.ts +27 -0
- package/core/src/search-title-boost.test.ts +107 -0
- package/core/src/seed-packs.ts +41 -1
- package/core/src/types.ts +6 -0
- package/package.json +1 -1
- package/src/add-pack.test.ts +32 -0
- package/src/cli.ts +5 -1
- package/src/onboarding-seed.test.ts +64 -0
- package/src/vault.test.ts +16 -0
|
@@ -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
|
|
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
|
|
2708
|
-
* Shared between the `query-notes` MCP tool, HTTP
|
|
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
|
-
|
|
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
|
+
});
|
package/core/src/search-query.ts
CHANGED
|
@@ -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/seed-packs.ts
CHANGED
|
@@ -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
|
-
|
|
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
package/src/add-pack.test.ts
CHANGED
|
@@ -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
|
-
|
|
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 () => {
|