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