@openparachute/vault 0.7.2 → 0.7.3-rc.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +5 -3
- package/core/src/attachment/policy.test.ts +66 -0
- package/core/src/attachment/policy.ts +131 -0
- package/core/src/attachment/tickets.test.ts +45 -0
- package/core/src/attachment/tickets.ts +117 -0
- package/core/src/attachment-tickets-tool.test.ts +286 -0
- package/core/src/conformance.ts +2 -1
- package/core/src/contract-typed-index.test.ts +4 -3
- package/core/src/core.test.ts +607 -11
- package/core/src/display-title.test.ts +190 -0
- package/core/src/embedding/chunker.test.ts +97 -0
- package/core/src/embedding/chunker.ts +180 -0
- package/core/src/embedding/provider.ts +108 -0
- package/core/src/embedding/staleness.test.ts +87 -0
- package/core/src/embedding/staleness.ts +83 -0
- package/core/src/embedding/vector-codec.test.ts +99 -0
- package/core/src/embedding/vector-codec.ts +60 -0
- package/core/src/embedding/vectors.test.ts +163 -0
- package/core/src/embedding/vectors.ts +135 -0
- package/core/src/expand.ts +11 -3
- package/core/src/indexed-fields.test.ts +9 -3
- package/core/src/indexed-fields.ts +9 -1
- package/core/src/lede.test.ts +96 -0
- package/core/src/mcp-semantic-search.test.ts +160 -0
- package/core/src/mcp.ts +405 -10
- package/core/src/notes.semantic-search.test.ts +131 -0
- package/core/src/notes.ts +319 -7
- package/core/src/query-warnings.ts +40 -0
- package/core/src/schema-defaults.ts +85 -1
- package/core/src/schema-v27-note-vectors.test.ts +178 -0
- package/core/src/schema.ts +81 -1
- 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 +125 -0
- package/core/src/seed-packs.test.ts +117 -1
- package/core/src/seed-packs.ts +217 -1
- package/core/src/store.semantic-search.test.ts +236 -0
- package/core/src/store.ts +162 -5
- package/core/src/tag-schemas.ts +27 -12
- package/core/src/types.ts +63 -1
- package/core/src/vault-projection.ts +48 -0
- package/package.json +6 -1
- package/src/add-pack.test.ts +32 -0
- package/src/attachment-tickets.test.ts +350 -0
- package/src/attachment-tickets.ts +264 -0
- package/src/auth.ts +8 -2
- package/src/cli.ts +5 -1
- package/src/contract-errors.test.ts +2 -1
- package/src/contract-honest-queries.test.ts +88 -0
- package/src/contract-search.test.ts +41 -0
- package/src/embedding/capability.test.ts +33 -0
- package/src/embedding/capability.ts +34 -0
- package/src/embedding/external-api.test.ts +154 -0
- package/src/embedding/external-api.ts +144 -0
- package/src/embedding/onnx-transformers.test.ts +113 -0
- package/src/embedding/onnx-transformers.ts +141 -0
- package/src/embedding/select.test.ts +99 -0
- package/src/embedding/select.ts +92 -0
- package/src/embedding-worker.test.ts +300 -0
- package/src/embedding-worker.ts +226 -0
- package/src/mcp-http.ts +13 -1
- package/src/mcp-tools.ts +49 -14
- package/src/onboarding-seed.test.ts +64 -0
- package/src/routes.ts +173 -92
- package/src/routing.ts +17 -0
- package/src/scribe-discovery.test.ts +76 -1
- package/src/scribe-discovery.ts +28 -9
- package/src/semantic-search-routes.test.ts +161 -0
- package/src/server.ts +21 -2
- package/src/vault-embeddings-capability.test.ts +82 -0
- package/src/vault-store-embedding-wiring.test.ts +69 -0
- package/src/vault-store.ts +53 -2
- package/src/vault.test.ts +62 -13
|
@@ -0,0 +1,190 @@
|
|
|
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
|
+
describe("leading frontmatter block (misuse path — direct create with raw frontmatter)", () => {
|
|
88
|
+
it("skips a closed frontmatter block and derives the first line of the DOCUMENT", () => {
|
|
89
|
+
expect(computeDisplayTitle("---\ntitle: X\n---\n# Real Title")).toBe("Real Title");
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it("skips a closed frontmatter block with multiple fields + blank lines after", () => {
|
|
93
|
+
expect(
|
|
94
|
+
computeDisplayTitle("---\ntitle: X\ntags: [a, b]\n---\n\n\nReal Title\nbody"),
|
|
95
|
+
).toBe("Real Title");
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it("falls back to treating an UNTERMINATED opening `---` as ordinary content", () => {
|
|
99
|
+
// No closing fence anywhere in the content — a note whose real first
|
|
100
|
+
// line is literally "---" (e.g. a markdown horizontal rule opener)
|
|
101
|
+
// must not be mangled.
|
|
102
|
+
expect(computeDisplayTitle("---\nnot frontmatter, just a rule\nmore text")).toBe("---");
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
it("returns null when content is EXACTLY a closed frontmatter block (nothing after)", () => {
|
|
106
|
+
expect(computeDisplayTitle("---\ntitle: X\n---\n")).toBeNull();
|
|
107
|
+
expect(computeDisplayTitle("---\ntitle: X\n---")).toBeNull();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("returns null when only blank lines follow a closed frontmatter block", () => {
|
|
111
|
+
expect(computeDisplayTitle("---\ntitle: X\n---\n\n \n")).toBeNull();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
it("does not treat a closing fence found past the bounded scan window as a frontmatter close", () => {
|
|
115
|
+
// Opening `---` with the closing fence past FRONTMATTER_SCAN_LINES
|
|
116
|
+
// (100): the scan gives up and falls back to ordinary-content
|
|
117
|
+
// behavior, deriving from line 0.
|
|
118
|
+
const filler = Array.from({ length: 150 }, (_, i) => `line ${i}`).join("\n");
|
|
119
|
+
expect(computeDisplayTitle(`---\n${filler}\n---\nReal Title`)).toBe("---");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("normal content with no leading `---` is byte-identical to prior behavior", () => {
|
|
123
|
+
expect(computeDisplayTitle("# Grocery List\nmilk, eggs")).toBe("Grocery List");
|
|
124
|
+
expect(computeDisplayTitle("Just a note\nbody text")).toBe("Just a note");
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
describe("toNoteIndex — displayTitle wiring", () => {
|
|
130
|
+
const baseNote: Note = {
|
|
131
|
+
id: "n1",
|
|
132
|
+
content: "# Real Title\nbody",
|
|
133
|
+
createdAt: new Date().toISOString(),
|
|
134
|
+
tags: [],
|
|
135
|
+
metadata: {},
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
it("carries the computed display title onto the lean shape", () => {
|
|
139
|
+
const index = toNoteIndex(baseNote);
|
|
140
|
+
expect(index.displayTitle).toBe("Real Title");
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
it("is null when content is empty", () => {
|
|
144
|
+
const index = toNoteIndex({ ...baseNote, content: "" });
|
|
145
|
+
expect(index.displayTitle).toBeNull();
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("is null when content is missing entirely (defensive — matches preview's ?? \"\" fallback)", () => {
|
|
149
|
+
const { content: _drop, ...rest } = baseNote;
|
|
150
|
+
const index = toNoteIndex(rest as Note);
|
|
151
|
+
expect(index.displayTitle).toBeNull();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
let store: SqliteStore;
|
|
156
|
+
let db: Database;
|
|
157
|
+
|
|
158
|
+
beforeEach(() => {
|
|
159
|
+
db = new Database(":memory:");
|
|
160
|
+
store = new SqliteStore(db);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
describe("displayTitle — list-shape presence (MCP)", () => {
|
|
164
|
+
it("query-notes list mode (include_content default false) carries displayTitle", async () => {
|
|
165
|
+
await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
|
|
166
|
+
const tools = generateMcpTools(store);
|
|
167
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
168
|
+
const result = await queryNotes.execute({}) as any[];
|
|
169
|
+
expect(result).toHaveLength(1);
|
|
170
|
+
expect(result[0].displayTitle).toBe("Meeting Notes");
|
|
171
|
+
expect(result[0].content).toBeUndefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("query-notes include_content=true does NOT carry displayTitle (full Note shape, unchanged)", async () => {
|
|
175
|
+
await store.createNote("# Meeting Notes\nagenda: budget", { path: "a" });
|
|
176
|
+
const tools = generateMcpTools(store);
|
|
177
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
178
|
+
const result = await queryNotes.execute({ include_content: true }) as any[];
|
|
179
|
+
expect(result[0].content).toBeTruthy();
|
|
180
|
+
expect(result[0].displayTitle).toBeUndefined();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("displayTitle is null on the lean shape for an empty note", async () => {
|
|
184
|
+
await store.createNote("", { path: "empty" });
|
|
185
|
+
const tools = generateMcpTools(store);
|
|
186
|
+
const queryNotes = tools.find((t) => t.name === "query-notes")!;
|
|
187
|
+
const result = await queryNotes.execute({}) as any[];
|
|
188
|
+
expect(result[0].displayTitle).toBeNull();
|
|
189
|
+
});
|
|
190
|
+
});
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { chunkNoteContent, DEFAULT_TARGET_CHARS, DEFAULT_MIN_CHARS } from "./chunker.js";
|
|
3
|
+
|
|
4
|
+
describe("chunkNoteContent", () => {
|
|
5
|
+
it("returns a single empty chunk for empty content", () => {
|
|
6
|
+
expect(chunkNoteContent("")).toEqual([{ ix: 0, text: "" }]);
|
|
7
|
+
expect(chunkNoteContent(" \n\n ")).toEqual([{ ix: 0, text: "" }]);
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("degenerate case: a short note is a single whole-note chunk", () => {
|
|
11
|
+
const content = "A short morning-pages note about coffee and focus.";
|
|
12
|
+
const chunks = chunkNoteContent(content);
|
|
13
|
+
expect(chunks).toEqual([{ ix: 0, text: content }]);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("a note exactly at the target size is still a single chunk", () => {
|
|
17
|
+
const content = "x".repeat(DEFAULT_TARGET_CHARS);
|
|
18
|
+
const chunks = chunkNoteContent(content);
|
|
19
|
+
expect(chunks.length).toBe(1);
|
|
20
|
+
expect(chunks[0].ix).toBe(0);
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
it("splits a long note on markdown headings, keeping the heading with its section", () => {
|
|
24
|
+
const section = (n: number) => "word ".repeat(Math.ceil(DEFAULT_TARGET_CHARS / 5)) + `end${n}`;
|
|
25
|
+
const content = [
|
|
26
|
+
`# Heading One`,
|
|
27
|
+
section(1),
|
|
28
|
+
`## Heading Two`,
|
|
29
|
+
section(2),
|
|
30
|
+
`### Heading Three`,
|
|
31
|
+
section(3),
|
|
32
|
+
].join("\n\n");
|
|
33
|
+
|
|
34
|
+
const chunks = chunkNoteContent(content);
|
|
35
|
+
expect(chunks.length).toBe(3);
|
|
36
|
+
expect(chunks[0].text.startsWith("# Heading One")).toBe(true);
|
|
37
|
+
expect(chunks[1].text.startsWith("## Heading Two")).toBe(true);
|
|
38
|
+
expect(chunks[2].text.startsWith("### Heading Three")).toBe(true);
|
|
39
|
+
// Sequential 0-based ix.
|
|
40
|
+
expect(chunks.map((c) => c.ix)).toEqual([0, 1, 2]);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it("packs paragraphs within an over-long headingless section up to the target size", () => {
|
|
44
|
+
const para = "sentence ".repeat(30); // a few hundred chars
|
|
45
|
+
const paras = Array.from({ length: 20 }, (_, i) => `${para}${i}`);
|
|
46
|
+
const content = paras.join("\n\n");
|
|
47
|
+
expect(content.length).toBeGreaterThan(DEFAULT_TARGET_CHARS * 2);
|
|
48
|
+
|
|
49
|
+
const chunks = chunkNoteContent(content);
|
|
50
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
51
|
+
// No chunk should wildly exceed the target (a single paragraph can
|
|
52
|
+
// exceed it, but these paragraphs are all well under target size).
|
|
53
|
+
for (const c of chunks) {
|
|
54
|
+
expect(c.text.length).toBeLessThanOrEqual(DEFAULT_TARGET_CHARS + para.length);
|
|
55
|
+
}
|
|
56
|
+
// Every paragraph's marker text survives somewhere in the chunk set.
|
|
57
|
+
for (let i = 0; i < paras.length; i++) {
|
|
58
|
+
expect(chunks.some((c) => c.text.includes(`${para}${i}`))).toBe(true);
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it("a single oversized paragraph becomes its own chunk rather than being hard-truncated", () => {
|
|
63
|
+
const hugeParagraph = "word ".repeat(Math.ceil((DEFAULT_TARGET_CHARS * 2) / 5));
|
|
64
|
+
const content = `intro paragraph here\n\n${hugeParagraph}\n\nclosing paragraph here`;
|
|
65
|
+
const chunks = chunkNoteContent(content);
|
|
66
|
+
const hugeChunk = chunks.find((c) => c.text.includes(hugeParagraph.slice(0, 20)));
|
|
67
|
+
expect(hugeChunk).toBeDefined();
|
|
68
|
+
// Not truncated: the full paragraph text is present verbatim.
|
|
69
|
+
expect(hugeChunk!.text).toContain(hugeParagraph.trim());
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
it("merges a tiny trailing fragment into the previous chunk instead of leaving it alone", () => {
|
|
73
|
+
const bigSection = (n: number) => "word ".repeat(Math.ceil(DEFAULT_TARGET_CHARS / 5)) + `end${n}`;
|
|
74
|
+
const tinyTail = "ok"; // far under DEFAULT_MIN_CHARS
|
|
75
|
+
const content = [`# One`, bigSection(1), `# Two`, bigSection(2), `# Three`, tinyTail].join("\n\n");
|
|
76
|
+
|
|
77
|
+
const chunks = chunkNoteContent(content);
|
|
78
|
+
// The tiny "# Three\n\nok" section merges into the preceding chunk —
|
|
79
|
+
// it must NOT appear as its own separate chunk.
|
|
80
|
+
expect(chunks.some((c) => c.text.trim() === "# Three\n\nok" || c.text.trim() === "ok")).toBe(false);
|
|
81
|
+
// But its content is still present, folded into the last chunk.
|
|
82
|
+
expect(chunks[chunks.length - 1].text).toContain("ok");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it("respects custom targetChars/minChars options", () => {
|
|
86
|
+
const content = Array.from({ length: 10 }, (_, i) => `paragraph number ${i} `.repeat(5)).join("\n\n");
|
|
87
|
+
const chunks = chunkNoteContent(content, { targetChars: 100, minChars: 20 });
|
|
88
|
+
expect(chunks.length).toBeGreaterThan(1);
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("chunk indices are always sequential starting at 0", () => {
|
|
92
|
+
const section = (n: number) => "word ".repeat(Math.ceil(DEFAULT_TARGET_CHARS / 5)) + `end${n}`;
|
|
93
|
+
const content = Array.from({ length: 5 }, (_, i) => `## Section ${i}\n\n${section(i)}`).join("\n\n");
|
|
94
|
+
const chunks = chunkNoteContent(content);
|
|
95
|
+
expect(chunks.map((c) => c.ix)).toEqual(chunks.map((_, i) => i));
|
|
96
|
+
});
|
|
97
|
+
});
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-section chunking for semantic search (V2, ratified in-scope by Aaron
|
|
3
|
+
* per P0's finding — see `SEMANTIC-MVP-PLAN.md`/`RESULTS.md` §6: whole-note
|
|
4
|
+
* embedding dilutes long, multi-topic journal entries, and that dilution —
|
|
5
|
+
* not truncation — was the dominant miss pattern on Aaron's real corpus).
|
|
6
|
+
*
|
|
7
|
+
* Pure function, no I/O, no model dependency. Splits note content into
|
|
8
|
+
* chunks targeting ~400–500 tokens, using a `chars/4` approximation
|
|
9
|
+
* (documented, not exact — an exact tokenizer count would require a
|
|
10
|
+
* model-specific tokenizer in core, which core's dependency-purity rule
|
|
11
|
+
* forbids; the approximation only shapes chunk BOUNDARIES, not what gets
|
|
12
|
+
* embedded, so being off by a few dozen tokens costs nothing but a
|
|
13
|
+
* slightly-off chunk size). Splits on markdown headings first, then
|
|
14
|
+
* paragraphs within an over-long section, then merges chunks that end up
|
|
15
|
+
* too small into a neighbor so recall isn't fragmented by tiny slivers.
|
|
16
|
+
*
|
|
17
|
+
* Degenerate case: a note whose whole content already fits under the
|
|
18
|
+
* target size is returned as a single chunk (`ix: 0`) — this is the same
|
|
19
|
+
* shape a v1 whole-note embedding would produce, so a short note costs
|
|
20
|
+
* nothing extra.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
/** One chunk of a note's content, ready to embed. */
|
|
24
|
+
export interface Chunk {
|
|
25
|
+
/** 0-based chunk index within the note — the `note_vectors.chunk_ix` key. */
|
|
26
|
+
ix: number;
|
|
27
|
+
/** The chunk's text, ready to hand to `EmbeddingProvider.embed()`. */
|
|
28
|
+
text: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** `chars/4` is the documented, approximate chars-per-token ratio used to size chunks — see module doc. */
|
|
32
|
+
const CHARS_PER_TOKEN_APPROX = 4;
|
|
33
|
+
|
|
34
|
+
/** Middle of the plan's ~400–500 token target band. */
|
|
35
|
+
const TARGET_TOKENS = 450;
|
|
36
|
+
|
|
37
|
+
/** Default target chunk size in characters (~450 tokens). */
|
|
38
|
+
export const DEFAULT_TARGET_CHARS = TARGET_TOKENS * CHARS_PER_TOKEN_APPROX;
|
|
39
|
+
|
|
40
|
+
/** Default minimum chunk size before it gets merged into a neighbor (~1/4 of target). */
|
|
41
|
+
export const DEFAULT_MIN_CHARS = Math.floor(DEFAULT_TARGET_CHARS / 4);
|
|
42
|
+
|
|
43
|
+
export interface ChunkOpts {
|
|
44
|
+
/** Target chunk size in characters. Default `DEFAULT_TARGET_CHARS` (~1800 chars ≈ 450 tokens). */
|
|
45
|
+
targetChars?: number;
|
|
46
|
+
/** Chunks below this size get merged into the previous chunk. Default `DEFAULT_MIN_CHARS`. */
|
|
47
|
+
minChars?: number;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Split note content into embedding-ready chunks.
|
|
52
|
+
*
|
|
53
|
+
* Empty/whitespace-only content returns a single degenerate chunk with
|
|
54
|
+
* empty text (`ix: 0`) — callers (the staleness gate, the embed-on-write
|
|
55
|
+
* hook) treat an empty chunk as "nothing to embed" rather than special-
|
|
56
|
+
* casing zero chunks.
|
|
57
|
+
*/
|
|
58
|
+
export function chunkNoteContent(content: string, opts: ChunkOpts = {}): Chunk[] {
|
|
59
|
+
const targetChars = opts.targetChars ?? DEFAULT_TARGET_CHARS;
|
|
60
|
+
const minChars = opts.minChars ?? DEFAULT_MIN_CHARS;
|
|
61
|
+
|
|
62
|
+
const trimmed = content.trim();
|
|
63
|
+
if (trimmed.length === 0) return [{ ix: 0, text: "" }];
|
|
64
|
+
// Degenerate case (the common one — most notes are short): whole note
|
|
65
|
+
// fits in one chunk, byte-identical in spirit to v1's whole-note embed.
|
|
66
|
+
if (trimmed.length <= targetChars) return [{ ix: 0, text: trimmed }];
|
|
67
|
+
|
|
68
|
+
const sections = splitOnHeadings(trimmed);
|
|
69
|
+
const rawChunks: string[] = [];
|
|
70
|
+
for (const section of sections) {
|
|
71
|
+
if (section.length <= targetChars) {
|
|
72
|
+
rawChunks.push(section);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
rawChunks.push(...packParagraphs(section, targetChars));
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
const merged = mergeSmallChunks(rawChunks, minChars);
|
|
79
|
+
return merged.map((text, ix) => ({ ix, text }));
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Split on markdown ATX headings (`#` through `######`), keeping each
|
|
84
|
+
* heading attached to the section that follows it (so a chunk boundary
|
|
85
|
+
* never separates a heading from its own content). Text before the first
|
|
86
|
+
* heading (or a note with no headings at all) is its own leading section.
|
|
87
|
+
*/
|
|
88
|
+
function splitOnHeadings(text: string): string[] {
|
|
89
|
+
const lines = text.split("\n");
|
|
90
|
+
const sections: string[] = [];
|
|
91
|
+
let buf: string[] = [];
|
|
92
|
+
for (const line of lines) {
|
|
93
|
+
if (/^#{1,6}\s/.test(line) && buf.length > 0) {
|
|
94
|
+
sections.push(buf.join("\n").trim());
|
|
95
|
+
buf = [line];
|
|
96
|
+
} else {
|
|
97
|
+
buf.push(line);
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
if (buf.length > 0) sections.push(buf.join("\n").trim());
|
|
101
|
+
return sections.filter((s) => s.length > 0);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Split a section on blank-line-delimited paragraphs. */
|
|
105
|
+
function splitOnParagraphs(text: string): string[] {
|
|
106
|
+
return text
|
|
107
|
+
.split(/\n\s*\n+/)
|
|
108
|
+
.map((p) => p.trim())
|
|
109
|
+
.filter((p) => p.length > 0);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Greedily pack a section's paragraphs into chunks up to `targetChars`.
|
|
114
|
+
* A single paragraph that alone exceeds `targetChars` becomes its own
|
|
115
|
+
* oversized chunk — chunking never hard-truncates content; the provider's
|
|
116
|
+
* own model window does that truncation, same as v1's whole-note path.
|
|
117
|
+
*
|
|
118
|
+
* If the section opens with its own markdown heading (the common case —
|
|
119
|
+
* `splitOnHeadings` attaches a heading to the section that follows it),
|
|
120
|
+
* the heading is pulled out and glued onto ONLY the first resulting
|
|
121
|
+
* chunk, rather than treated as just another paragraph competing for the
|
|
122
|
+
* `targetChars` budget. Without this, a section whose heading-plus-first-
|
|
123
|
+
* paragraph landed just over budget could split the heading into its own
|
|
124
|
+
* paragraph-sized "chunk," and — worse — a global small-chunk merge pass
|
|
125
|
+
* has no way to tell that fragment apart from any other tiny chunk, so it
|
|
126
|
+
* could get glued onto the END of the PRECEDING section instead of the
|
|
127
|
+
* body it actually introduces.
|
|
128
|
+
*/
|
|
129
|
+
function packParagraphs(section: string, targetChars: number): string[] {
|
|
130
|
+
const lines = section.split("\n");
|
|
131
|
+
let heading = "";
|
|
132
|
+
let body = section;
|
|
133
|
+
if (/^#{1,6}\s/.test(lines[0] ?? "")) {
|
|
134
|
+
heading = lines[0]!;
|
|
135
|
+
body = lines.slice(1).join("\n").trim();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const paras = splitOnParagraphs(body);
|
|
139
|
+
const chunks: string[] = [];
|
|
140
|
+
let buf = "";
|
|
141
|
+
for (const p of paras) {
|
|
142
|
+
if (buf && buf.length + p.length + 2 > targetChars) {
|
|
143
|
+
chunks.push(buf);
|
|
144
|
+
buf = p;
|
|
145
|
+
} else {
|
|
146
|
+
buf = buf ? `${buf}\n\n${p}` : p;
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if (buf) chunks.push(buf);
|
|
150
|
+
if (chunks.length === 0) chunks.push("");
|
|
151
|
+
if (heading) chunks[0] = `${heading}\n\n${chunks[0]}`.trim();
|
|
152
|
+
return chunks.filter((c) => c.length > 0);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Merge any chunk under `minChars` into the PRECEDING chunk (so a tiny
|
|
157
|
+
* trailing fragment — a one-line closing section, a short final
|
|
158
|
+
* paragraph — doesn't become its own low-signal vector that fragments
|
|
159
|
+
* recall). A small chunk that ends up FIRST (nothing precedes it — e.g. a
|
|
160
|
+
* lone heading line that `packParagraphs` split away from its own
|
|
161
|
+
* content) has no predecessor to fold backward into, so it's folded
|
|
162
|
+
* FORWARD into what's then the second chunk instead — a tiny fragment
|
|
163
|
+
* never survives as its own low-signal vector regardless of which end of
|
|
164
|
+
* the note it lands on.
|
|
165
|
+
*/
|
|
166
|
+
function mergeSmallChunks(chunks: string[], minChars: number): string[] {
|
|
167
|
+
const merged: string[] = [];
|
|
168
|
+
for (const c of chunks) {
|
|
169
|
+
if (merged.length > 0 && c.length < minChars) {
|
|
170
|
+
merged[merged.length - 1] = `${merged[merged.length - 1]!}\n\n${c}`;
|
|
171
|
+
} else {
|
|
172
|
+
merged.push(c);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
if (merged.length > 1 && merged[0]!.length < minChars) {
|
|
176
|
+
merged[1] = `${merged[0]!}\n\n${merged[1]!}`;
|
|
177
|
+
merged.shift();
|
|
178
|
+
}
|
|
179
|
+
return merged;
|
|
180
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Embedding provider seam (semantic-search MVP, V2).
|
|
3
|
+
*
|
|
4
|
+
* An `EmbeddingProvider` is the single, runtime-agnostic contract for
|
|
5
|
+
* turning text into a dense vector. It exists so the semantic-search scan
|
|
6
|
+
* doesn't hard-code a model or an HTTP call — the store resolves a
|
|
7
|
+
* provider and calls `embed()`, and the provider owns HOW text becomes a
|
|
8
|
+
* vector.
|
|
9
|
+
*
|
|
10
|
+
* ## Why this lives in core (not src)
|
|
11
|
+
*
|
|
12
|
+
* The interface must be importable from BOTH runtimes without dragging in
|
|
13
|
+
* Bun- or Node-only APIs, or any model library:
|
|
14
|
+
*
|
|
15
|
+
* - the Bun vault (`src/embedding/onnx-transformers.ts` — the bundled
|
|
16
|
+
* floor provider — and `src/embedding/external-api.ts` — the
|
|
17
|
+
* OpenAI-compatible config upgrade);
|
|
18
|
+
* - the cloud vault (a future Cloudflare Workers-AI provider implemented
|
|
19
|
+
* against THIS interface, mirroring `transcription/workers-ai.ts`).
|
|
20
|
+
*
|
|
21
|
+
* So it is pure types + one dependency-free error class — no `fetch`, no
|
|
22
|
+
* ONNX/transformers import, no Node-only builtin. Core has no package
|
|
23
|
+
* `exports` map, so consumers import the subpath directly
|
|
24
|
+
* (`../core/src/embedding/provider.ts`), the same convention as
|
|
25
|
+
* `core/src/transcription/provider.ts`, which this file mirrors verbatim.
|
|
26
|
+
*
|
|
27
|
+
* ## The fold (context)
|
|
28
|
+
*
|
|
29
|
+
* Ratified 2026-07-16 (Aaron: GO on the semantic-search MVP, per
|
|
30
|
+
* `SEMANTIC-MVP-PLAN.md` — P0's real-corpus eval cleared the "keep
|
|
31
|
+
* building" bar). This seam is the shared contract both doors implement;
|
|
32
|
+
* see `core/src/embedding/chunker.ts` for the per-note chunking this feeds,
|
|
33
|
+
* and `core/src/notes.ts:semanticSearchNotes` for the consumer.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
/** Batch-first input — a provider embeds many texts in one call (backfill). */
|
|
37
|
+
export interface EmbedInput {
|
|
38
|
+
texts: string[];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/** What a provider returns for a batch: one vector per input text, same order. */
|
|
42
|
+
export interface EmbedResult {
|
|
43
|
+
/** One vector per `EmbedInput.texts` entry, same order/length. */
|
|
44
|
+
vectors: Float32Array[];
|
|
45
|
+
/** The model identifier that produced these vectors (recorded per-row in `note_vectors`). */
|
|
46
|
+
model: string;
|
|
47
|
+
/** Vector dimensionality — every vector in `vectors` has this length. */
|
|
48
|
+
dims: number;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Provider readiness probe result. */
|
|
52
|
+
export interface ProviderAvailability {
|
|
53
|
+
/** True when the provider can service `embed()` calls right now. */
|
|
54
|
+
ok: boolean;
|
|
55
|
+
/** Human-readable cause when `ok` is false (e.g. "no embedding provider configured"). */
|
|
56
|
+
reason?: string;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The embedding contract. Implementations own the text→vector step; the
|
|
61
|
+
* store owns resolving which provider is active, chunking, freshness
|
|
62
|
+
* gating, and the cosine scan — none of which belong here.
|
|
63
|
+
*/
|
|
64
|
+
export interface EmbeddingProvider {
|
|
65
|
+
/** Stable provider identifier, surfaced on the capability flag (e.g. "onnx-transformers", "external-api", "workers-ai"). */
|
|
66
|
+
readonly name: string;
|
|
67
|
+
/** Model identifier this provider produces vectors with (e.g. "bge-small-en-v1.5"). Recorded on every `note_vectors` row so a model change is detectable. */
|
|
68
|
+
readonly model: string;
|
|
69
|
+
/** Vector dimensionality this provider's model produces. */
|
|
70
|
+
readonly dims: number;
|
|
71
|
+
/** Turn a batch of texts into vectors (or throw — see `EmbeddingError` for the retry contract). */
|
|
72
|
+
embed(input: EmbedInput): Promise<EmbedResult>;
|
|
73
|
+
/**
|
|
74
|
+
* Cheap, side-effect-free readiness check. MUST NOT perform an actual
|
|
75
|
+
* embedding or a network round-trip unless that is genuinely cheap — it
|
|
76
|
+
* gates the `embeddings` capability flag on the vault landing.
|
|
77
|
+
*/
|
|
78
|
+
available(): Promise<ProviderAvailability>;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Structured, provider-agnostic embedding failure.
|
|
83
|
+
*
|
|
84
|
+
* Mirrors `TranscriptionError` exactly (see
|
|
85
|
+
* `core/src/transcription/provider.ts`): `retriable` decides whether the
|
|
86
|
+
* caller retries with backoff (a transient upstream failure) or gives up
|
|
87
|
+
* immediately (a config problem the operator must fix). `code` is a
|
|
88
|
+
* stable machine string (e.g. `missing_provider`) so callers branch on it
|
|
89
|
+
* instead of regex-matching message text.
|
|
90
|
+
*/
|
|
91
|
+
export class EmbeddingError extends Error {
|
|
92
|
+
/** Stable machine code (e.g. "missing_provider"), when the provider supplied one. */
|
|
93
|
+
readonly code?: string;
|
|
94
|
+
/** Upstream HTTP status, when the failure came from an HTTP provider. */
|
|
95
|
+
readonly httpStatus?: number;
|
|
96
|
+
/** Whether retrying the same input could plausibly succeed. */
|
|
97
|
+
readonly retriable: boolean;
|
|
98
|
+
constructor(
|
|
99
|
+
message: string,
|
|
100
|
+
opts: { code?: string; httpStatus?: number; retriable: boolean },
|
|
101
|
+
) {
|
|
102
|
+
super(message);
|
|
103
|
+
this.name = "EmbeddingError";
|
|
104
|
+
this.code = opts.code;
|
|
105
|
+
this.httpStatus = opts.httpStatus;
|
|
106
|
+
this.retriable = opts.retriable;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { describe, it, expect } from "bun:test";
|
|
2
|
+
import { planStaleness, contentHash, type ExistingVectorRow } from "./staleness.js";
|
|
3
|
+
import type { Chunk } from "./chunker.js";
|
|
4
|
+
|
|
5
|
+
const MODEL = "bge-small-en-v1.5";
|
|
6
|
+
|
|
7
|
+
function chunk(ix: number, text: string): Chunk {
|
|
8
|
+
return { ix, text };
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function row(ix: number, text: string, model = MODEL): ExistingVectorRow {
|
|
12
|
+
return { chunk_ix: ix, model, content_hash: contentHash(text) };
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
describe("contentHash", () => {
|
|
16
|
+
it("is deterministic", () => {
|
|
17
|
+
expect(contentHash("hello")).toBe(contentHash("hello"));
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("differs for different content", () => {
|
|
21
|
+
expect(contentHash("hello")).not.toBe(contentHash("hello!"));
|
|
22
|
+
});
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
describe("planStaleness", () => {
|
|
26
|
+
it("a brand-new note (no existing rows) is entirely stale", () => {
|
|
27
|
+
const chunks = [chunk(0, "some content")];
|
|
28
|
+
const plan = planStaleness([], chunks, MODEL);
|
|
29
|
+
expect(plan.stale).toEqual(chunks);
|
|
30
|
+
expect(plan.obsoleteIxs).toEqual([]);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("no-op edit: identical content + same model produces zero stale chunks", () => {
|
|
34
|
+
const text = "unchanged content";
|
|
35
|
+
const chunks = [chunk(0, text)];
|
|
36
|
+
const existing = [row(0, text)];
|
|
37
|
+
const plan = planStaleness(existing, chunks, MODEL);
|
|
38
|
+
expect(plan.stale).toEqual([]);
|
|
39
|
+
expect(plan.obsoleteIxs).toEqual([]);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("content change on one chunk marks only that chunk stale", () => {
|
|
43
|
+
const chunks = [chunk(0, "old text"), chunk(1, "second chunk unchanged")];
|
|
44
|
+
const existing = [row(0, "old text"), row(1, "second chunk unchanged")];
|
|
45
|
+
// Mutate chunk 0's text after the existing row was hashed — simulates
|
|
46
|
+
// an edit to that section only.
|
|
47
|
+
chunks[0] = chunk(0, "NEW text");
|
|
48
|
+
const plan = planStaleness(existing, chunks, MODEL);
|
|
49
|
+
expect(plan.stale).toEqual([chunk(0, "NEW text")]);
|
|
50
|
+
expect(plan.obsoleteIxs).toEqual([]);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("a note that shrank (fewer chunks) marks the dropped chunk_ix as obsolete", () => {
|
|
54
|
+
const existing = [row(0, "a"), row(1, "b"), row(2, "c")];
|
|
55
|
+
const chunks = [chunk(0, "a"), chunk(1, "b")]; // chunk 2 no longer exists
|
|
56
|
+
const plan = planStaleness(existing, chunks, MODEL);
|
|
57
|
+
expect(plan.stale).toEqual([]);
|
|
58
|
+
expect(plan.obsoleteIxs).toEqual([2]);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("a note that grew (more chunks) marks the new chunk_ix as stale, not obsolete", () => {
|
|
62
|
+
const existing = [row(0, "a")];
|
|
63
|
+
const chunks = [chunk(0, "a"), chunk(1, "new second chunk")];
|
|
64
|
+
const plan = planStaleness(existing, chunks, MODEL);
|
|
65
|
+
expect(plan.stale).toEqual([chunk(1, "new second chunk")]);
|
|
66
|
+
expect(plan.obsoleteIxs).toEqual([]);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it("a model change marks every existing row obsolete and every current chunk stale (full sweep)", () => {
|
|
70
|
+
const existing = [row(0, "a", "old-model"), row(1, "b", "old-model")];
|
|
71
|
+
const chunks = [chunk(0, "a"), chunk(1, "b")];
|
|
72
|
+
const plan = planStaleness(existing, chunks, "new-model");
|
|
73
|
+
expect(plan.stale).toEqual(chunks);
|
|
74
|
+
expect(plan.obsoleteIxs).toEqual([0, 1]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it("composes shrink + edit + grow in one plan", () => {
|
|
78
|
+
const existing = [row(0, "a"), row(1, "b-old"), row(2, "c")]; // 3 will drop, 1 will change
|
|
79
|
+
const chunks = [chunk(0, "a"), chunk(1, "b-new"), chunk(3, "d")]; // note: ix 3, not 2 — a full re-chunk
|
|
80
|
+
const plan = planStaleness(existing, chunks, MODEL);
|
|
81
|
+
expect(plan.stale.map((c) => c.ix)).toEqual([1, 3]);
|
|
82
|
+
// ix 1 still exists in the current chunk set (its content just
|
|
83
|
+
// changed), so it's STALE, not obsolete — only ix 2 (dropped entirely)
|
|
84
|
+
// is obsolete.
|
|
85
|
+
expect(plan.obsoleteIxs).toEqual([2]);
|
|
86
|
+
});
|
|
87
|
+
});
|