mcp-memory-bucket 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,138 @@
1
+ import { bumpChunksVersion, embedTextFor, setChunksChangedListener } from './chunks.js';
2
+ export const DEFAULT_EMBED_MODEL = 'Xenova/bge-small-en-v1.5';
3
+ const BGE_QUERY_PREFIX = 'Represent this sentence for searching relevant passages: ';
4
+ /**
5
+ * A local sentence-embedding model run in-process through transformers.js (ONNX, CPU) — no LLM and
6
+ * no network after the one-time model download into `cacheDir`. The package is an optional
7
+ * dependency loaded on first use, so a machine where it failed to install only loses the semantic
8
+ * half of bucket_recall rather than failing to start.
9
+ */
10
+ export function createTransformersEmbedder(opts) {
11
+ const model = opts.model ?? DEFAULT_EMBED_MODEL;
12
+ const isBge = /bge/i.test(model);
13
+ let extractor;
14
+ const load = () => {
15
+ extractor ??= (async () => {
16
+ const transformers = (await import('@huggingface/transformers'));
17
+ // The default cache sits inside node_modules, which an `npx` run throws away with the package.
18
+ transformers.env.cacheDir = opts.cacheDir;
19
+ return transformers.pipeline('feature-extraction', model, { dtype: 'q8' });
20
+ })();
21
+ // A failed load (offline first run) must not be cached forever — let the next call retry.
22
+ extractor.catch(() => (extractor = undefined));
23
+ return extractor;
24
+ };
25
+ return {
26
+ model,
27
+ async embed(texts, kind) {
28
+ const run = await load();
29
+ const inputs = kind === 'query' && isBge ? texts.map((t) => BGE_QUERY_PREFIX + t) : texts;
30
+ const output = await run(inputs, { pooling: isBge ? 'cls' : 'mean', normalize: true });
31
+ const dim = output.dims[output.dims.length - 1];
32
+ return texts.map((_, i) => output.data.slice(i * dim, (i + 1) * dim));
33
+ },
34
+ };
35
+ }
36
+ export function vectorToBlob(vector) {
37
+ return Buffer.from(vector.buffer, vector.byteOffset, vector.byteLength);
38
+ }
39
+ export function blobToVector(blob) {
40
+ // Copy: a Buffer's byteOffset into its pool is not guaranteed to be 4-byte aligned.
41
+ return new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength));
42
+ }
43
+ const BATCH_SIZE = 16;
44
+ const RETRY_AFTER_FAILURE_MS = 5 * 60_000;
45
+ /**
46
+ * Embeds every chunk that has no vector for the current model, in the background, a batch at a
47
+ * time. Indexing never waits on it: upsertFile writes chunks synchronously with embedding NULL and
48
+ * this catches up, so lexical search works immediately and semantic search fills in behind it.
49
+ */
50
+ export class EmbeddingIndexer {
51
+ db;
52
+ embedder;
53
+ running = false;
54
+ rerun = false;
55
+ modelLoaded = false;
56
+ lastError;
57
+ retryTimer;
58
+ constructor(db, embedder) {
59
+ this.db = db;
60
+ this.embedder = embedder;
61
+ }
62
+ /** Subscribes to chunk writes and embeds whatever is already pending. */
63
+ start() {
64
+ setChunksChangedListener(() => this.kick());
65
+ this.kick();
66
+ }
67
+ stop() {
68
+ setChunksChangedListener(undefined);
69
+ clearTimeout(this.retryTimer);
70
+ }
71
+ /** True once the model has produced output — i.e. a query can be embedded without a cold load or a download. */
72
+ isReady() {
73
+ return this.modelLoaded;
74
+ }
75
+ status() {
76
+ const { total, embedded } = this.db
77
+ .prepare(`SELECT COUNT(*) AS total, COALESCE(SUM(embedding IS NOT NULL AND embed_model = ?), 0) AS embedded FROM chunks`)
78
+ .get(this.embedder.model);
79
+ return { model: this.embedder.model, embedded, total, error: this.lastError };
80
+ }
81
+ kick() {
82
+ if (this.running) {
83
+ this.rerun = true;
84
+ return;
85
+ }
86
+ this.running = true;
87
+ this.run()
88
+ .then(() => {
89
+ this.lastError = undefined;
90
+ })
91
+ .catch((err) => {
92
+ this.lastError = err.message;
93
+ console.error(`[memory-bucket] embedding indexer stopped (semantic search unavailable, lexical still works): ${this.lastError}`);
94
+ clearTimeout(this.retryTimer);
95
+ this.retryTimer = setTimeout(() => this.kick(), RETRY_AFTER_FAILURE_MS);
96
+ this.retryTimer.unref();
97
+ })
98
+ .finally(() => {
99
+ this.running = false;
100
+ if (this.rerun) {
101
+ this.rerun = false;
102
+ if (!this.lastError)
103
+ this.kick();
104
+ }
105
+ });
106
+ }
107
+ /** Resolves once nothing is pending — for tests and one-shot scripts; the server itself never awaits this. */
108
+ async drain() {
109
+ while (this.running)
110
+ await new Promise((resolve) => setTimeout(resolve, 5));
111
+ if (this.lastError)
112
+ throw new Error(this.lastError);
113
+ }
114
+ async run() {
115
+ const pending = this.db.prepare(`SELECT id, hash, context, heading, text FROM chunks WHERE embedding IS NULL OR embed_model IS NOT ? LIMIT ${BATCH_SIZE}`);
116
+ // Guarded by hash: a chunk rewritten while its batch was in flight must not receive the old text's vector.
117
+ const store = this.db.prepare(`UPDATE chunks SET embedding = ?, embed_model = ? WHERE id = ? AND hash = ?`);
118
+ for (;;) {
119
+ const batch = pending.all(this.embedder.model);
120
+ if (batch.length === 0) {
121
+ // Everything was embedded by an earlier process: load the model anyway, or isReady() stays
122
+ // false and every query falls back to lexical despite a complete index.
123
+ if (!this.modelLoaded) {
124
+ await this.embedder.embed(['warm up'], 'query');
125
+ this.modelLoaded = true;
126
+ }
127
+ return;
128
+ }
129
+ const vectors = await this.embedder.embed(batch.map(embedTextFor), 'passage');
130
+ this.modelLoaded = true;
131
+ this.db.transaction(() => {
132
+ batch.forEach((row, i) => store.run(vectorToBlob(vectors[i]), this.embedder.model, row.id, row.hash));
133
+ })();
134
+ bumpChunksVersion();
135
+ await new Promise((resolve) => setImmediate(resolve));
136
+ }
137
+ }
138
+ }
@@ -0,0 +1,228 @@
1
+ import path from 'node:path';
2
+ import { getChunksVersion } from './chunks.js';
3
+ import { blobToVector } from './embeddings.js';
4
+ import { SEARCH_BM25 } from './search.js';
5
+ import { correctWords } from './spelling.js';
6
+ const STOPWORDS = new Set('a an and are as at be but by can do does for from how i if in into is it my not of on or so that the their then there this to was we what when where which who why will with you your'.split(' '));
7
+ /**
8
+ * A natural-language question as an FTS5 query: its content words OR'd together. OR (not FTS5's
9
+ * default AND) because a question rarely shares every word with its answer — bm25 already ranks a
10
+ * chunk matching more of the words above one matching fewer.
11
+ */
12
+ export function questionToFtsQuery(question, corrections) {
13
+ const words = [...new Set(question.toLowerCase().match(/[\p{L}\p{N}_]+/gu) ?? [])];
14
+ const content = words.filter((w) => !STOPWORDS.has(w));
15
+ const terms = new Set(content.length > 0 ? content : words);
16
+ // A corrected spelling is added, not swapped in: the original may be a real word this corpus simply lacks.
17
+ for (const term of [...terms])
18
+ if (corrections?.has(term))
19
+ terms.add(corrections.get(term));
20
+ return terms.size > 0 ? [...terms].map((t) => `"${t}"`).join(' OR ') : null;
21
+ }
22
+ const WORD_RE = /[\p{L}\p{N}_]+/gu;
23
+ /** `query` with each misspelled word swapped for its correction — what gets embedded, since a typo shifts an embedding toward nothing in particular. */
24
+ function respell(query, corrections) {
25
+ return corrections.size === 0 ? query : query.replace(WORD_RE, (word) => corrections.get(word.toLowerCase()) ?? word);
26
+ }
27
+ const CANDIDATES_PER_LIST = 30;
28
+ const DOC_CANDIDATES = 10;
29
+ /** Reciprocal-rank-fusion constant — the conventional 60; damps how much a rank-1 hit outweighs a rank-5 one. */
30
+ const RRF_K = 60;
31
+ const MAX_CHUNKS_PER_DOC = 3;
32
+ /** Fused scores below this fraction of the best hit's are dropped rather than used to fill the token budget. */
33
+ const RELATIVE_SCORE_FLOOR = 0.3;
34
+ const CHARS_PER_TOKEN = 4;
35
+ const HEADER_TOKENS = 20;
36
+ let vectorCache;
37
+ function loadVectors(db, model) {
38
+ const version = getChunksVersion();
39
+ if (!vectorCache || vectorCache.version !== version || vectorCache.model !== model) {
40
+ const rows = db.prepare(`SELECT id, embedding FROM chunks WHERE embedding IS NOT NULL AND embed_model = ?`).all(model);
41
+ vectorCache = { version, model, ids: rows.map((r) => r.id), vectors: rows.map((r) => blobToVector(r.embedding)) };
42
+ }
43
+ return vectorCache;
44
+ }
45
+ /** Chunks by descending cosine similarity (vectors are unit-length, so a dot product). Brute force — a personal corpus is thousands of chunks, not millions. */
46
+ function vectorSearch(db, model, query, limit) {
47
+ const { ids, vectors } = loadVectors(db, model);
48
+ const scored = [];
49
+ for (let i = 0; i < vectors.length; i++) {
50
+ const vector = vectors[i];
51
+ if (vector.length !== query.length)
52
+ continue;
53
+ let dot = 0;
54
+ for (let d = 0; d < vector.length; d++)
55
+ dot += vector[d] * query[d];
56
+ scored.push({ id: ids[i], similarity: dot });
57
+ }
58
+ return scored.sort((a, b) => b.similarity - a.similarity).slice(0, limit);
59
+ }
60
+ /**
61
+ * Cosine similarity a chunk must reach to count as a semantic match on its own. Rank fusion in
62
+ * recall() needs no such cut-off, but a result LIST does: similarity search always returns its
63
+ * nearest chunks, however unrelated. Tuned for the default bge-small model, where unrelated text
64
+ * scores 0.50–0.58 and a real match 0.60+; override when using another model.
65
+ */
66
+ const SEMANTIC_MIN_SIMILARITY = Number(process.env.MEMORY_BUCKET_SEMANTIC_MIN_SIMILARITY) || 0.6;
67
+ /** Beyond the floor, a doc must also be this close to the best match — keeps one strong hit from dragging in the merely-adjacent. */
68
+ const SEMANTIC_WINDOW = 0.05;
69
+ const SEMANTIC_MAX_DOCS = 5;
70
+ /**
71
+ * Docs whose best chunk is semantically close to `query`, best first — the doc-level counterpart
72
+ * of recall()'s vector list, for the web UI's result list (which shows docs, not passages).
73
+ */
74
+ export async function semanticDocMatches(db, embedder, query) {
75
+ const corrections = correctWords(db, query.toLowerCase().match(WORD_RE) ?? []);
76
+ const [queryVector] = await embedder.embed([respell(query, corrections)], 'query');
77
+ const hits = vectorSearch(db, embedder.model, queryVector, 50);
78
+ const floor = Math.max(SEMANTIC_MIN_SIMILARITY, (hits[0]?.similarity ?? 0) - SEMANTIC_WINDOW);
79
+ const chunkDoc = db.prepare(`SELECT ref_table, ref_id, ref_folder FROM chunks WHERE id = ?`);
80
+ const docs = new Map();
81
+ for (const hit of hits) {
82
+ if (hit.similarity < floor || docs.size >= SEMANTIC_MAX_DOCS)
83
+ break;
84
+ const doc = chunkDoc.get(hit.id);
85
+ if (doc && !docs.has(docKey(doc)))
86
+ docs.set(docKey(doc), { ...doc, similarity: hit.similarity });
87
+ }
88
+ return [...docs.values()];
89
+ }
90
+ const docKey = (c) => `${c.ref_table}\0${c.ref_folder}\0${c.ref_id}`;
91
+ /**
92
+ * Retrieves the passages most relevant to a natural-language query, packed into a token budget.
93
+ * Three ranked lists are fused by reciprocal rank: chunk-level BM25, chunk-level embedding
94
+ * similarity (when an embedder is ready), and doc-level weighted BM25 — the last so a query that
95
+ * names a skill, key, tag or trigger phrase lifts that doc's chunks even when the body never
96
+ * repeats the word. Rank fusion needs no score calibration between the three.
97
+ */
98
+ export async function recall(db, query, opts = {}) {
99
+ const { maxTokens = 2000, table, hidden, embedder } = opts;
100
+ const fused = new Map();
101
+ const add = (chunkId, rank) => fused.set(chunkId, (fused.get(chunkId) ?? 0) + 1 / (RRF_K + rank));
102
+ const corrections = correctWords(db, query.toLowerCase().match(WORD_RE) ?? []);
103
+ const ftsQuery = questionToFtsQuery(query, corrections);
104
+ const tableFilter = table ? `AND c.ref_table = '${table}'` : '';
105
+ let docRanks = [];
106
+ if (ftsQuery) {
107
+ const lexical = db
108
+ .prepare(`SELECT c.id FROM chunk_fts JOIN chunks c ON c.id = chunk_fts.rowid
109
+ WHERE chunk_fts MATCH ? ${tableFilter}
110
+ ORDER BY bm25(chunk_fts, 3.0, 1.0) LIMIT ${CANDIDATES_PER_LIST}`)
111
+ .all(ftsQuery);
112
+ lexical.forEach((row, i) => add(row.id, i + 1));
113
+ docRanks = db
114
+ .prepare(`SELECT ref_table, ref_id, ref_folder FROM search_index
115
+ WHERE search_index MATCH ? ${table ? `AND ref_table = '${table}'` : ''}
116
+ ORDER BY ${SEARCH_BM25} LIMIT ${DOC_CANDIDATES}`)
117
+ .all(ftsQuery);
118
+ }
119
+ let mode = 'lexical';
120
+ if (embedder) {
121
+ const [queryVector] = await embedder.embed([respell(query, corrections)], 'query');
122
+ const semantic = vectorSearch(db, embedder.model, queryVector, CANDIDATES_PER_LIST * (table ? 4 : 1));
123
+ if (semantic.length > 0)
124
+ mode = 'hybrid';
125
+ semantic.forEach((hit, i) => add(hit.id, i + 1));
126
+ }
127
+ // Doc-level boost: every candidate chunk of a doc-level hit gains that doc's rank; a doc with no
128
+ // candidate chunk yet (the query matched only its name/key/tags) contributes its opening chunk.
129
+ const firstChunkOf = db.prepare(`SELECT id FROM chunks WHERE ref_table = ? AND ref_id = ? AND ref_folder = ? ORDER BY ord LIMIT 1`);
130
+ const chunkById = db.prepare(`SELECT id, ref_table, ref_id, ref_folder, ord, heading, text FROM chunks WHERE id = ?`);
131
+ const rows = new Map();
132
+ for (const id of fused.keys()) {
133
+ const row = chunkById.get(id);
134
+ if (row && (!table || row.ref_table === table))
135
+ rows.set(id, row);
136
+ else
137
+ fused.delete(id);
138
+ }
139
+ docRanks.forEach((doc, i) => {
140
+ const key = docKey(doc);
141
+ const members = [...rows.values()].filter((row) => docKey(row) === key);
142
+ if (members.length === 0) {
143
+ const first = firstChunkOf.get(doc.ref_table, doc.ref_id, doc.ref_folder);
144
+ const row = first && chunkById.get(first.id);
145
+ if (row) {
146
+ rows.set(row.id, row);
147
+ members.push(row);
148
+ }
149
+ }
150
+ for (const row of members)
151
+ add(row.id, i + 1);
152
+ });
153
+ // Resolve each candidate's parent doc once — for its public handle, and to drop paused/hidden docs.
154
+ const skillMeta = db.prepare(`SELECT folder, paused FROM skills WHERE id = ? AND folder = ?`);
155
+ const memoryMeta = db.prepare(`SELECT folder, key, paused FROM memory_docs WHERE source_path = ?`);
156
+ const docs = new Map();
157
+ const resolveDoc = (row) => {
158
+ const key = docKey(row);
159
+ if (!docs.has(key)) {
160
+ const meta = (row.ref_table === 'skills' ? skillMeta.get(row.ref_id, row.ref_folder) : memoryMeta.get(row.ref_id));
161
+ const visible = meta && !meta.paused && !(hidden?.[row.ref_table] ?? []).includes(meta.folder);
162
+ docs.set(key, visible ? { id: row.ref_table === 'skills' ? row.ref_id : path.basename(row.ref_id), folder: meta.folder, key: meta.key } : null);
163
+ }
164
+ return docs.get(key);
165
+ };
166
+ const ranked = [...rows.values()]
167
+ .map((row) => ({ row, score: fused.get(row.id) ?? 0, doc: resolveDoc(row) }))
168
+ .filter((c) => c.doc !== null)
169
+ .sort((a, b) => b.score - a.score);
170
+ const floor = (ranked[0]?.score ?? 0) * RELATIVE_SCORE_FLOOR;
171
+ const perDoc = new Map();
172
+ const chunks = [];
173
+ let budget = maxTokens;
174
+ let truncated = false;
175
+ for (const { row, score, doc } of ranked) {
176
+ if (score < floor)
177
+ break;
178
+ const key = docKey(row);
179
+ if ((perDoc.get(key) ?? 0) >= MAX_CHUNKS_PER_DOC)
180
+ continue;
181
+ let text = row.text;
182
+ const cost = () => HEADER_TOKENS + Math.ceil((row.heading.length + text.length) / CHARS_PER_TOKEN);
183
+ if (cost() > budget) {
184
+ truncated = true;
185
+ // Always return something for the best hit, even under a budget smaller than one chunk.
186
+ if (chunks.length > 0)
187
+ continue;
188
+ text = text.slice(0, Math.max(0, (budget - HEADER_TOKENS) * CHARS_PER_TOKEN - row.heading.length)) + '…';
189
+ }
190
+ budget -= cost();
191
+ perDoc.set(key, (perDoc.get(key) ?? 0) + 1);
192
+ chunks.push({ ref_table: row.ref_table, id: doc.id, folder: doc.folder, key: doc.key, heading: row.heading, ord: row.ord, text, score });
193
+ }
194
+ return { mode, chunks, truncated, corrections: Object.fromEntries(corrections) };
195
+ }
196
+ /**
197
+ * Renders a recall result as compact markdown rather than JSON — passages are mostly multi-line
198
+ * text, and JSON-escaping every newline and quote costs tokens for nothing. Chunks of the same doc
199
+ * are grouped (docs in best-hit order) and shown in document order.
200
+ */
201
+ export function formatRecall(result, note) {
202
+ if (result.chunks.length === 0)
203
+ return `No matches.${note ? ` ${note}` : ''}`;
204
+ const groups = new Map();
205
+ for (const chunk of result.chunks) {
206
+ const key = `${chunk.ref_table}\0${chunk.folder}\0${chunk.id}`;
207
+ groups.set(key, [...(groups.get(key) ?? []), chunk]);
208
+ }
209
+ const corrected = Object.entries(result.corrections).map(([from, to]) => `"${from}" read as "${to}"`).join(', ');
210
+ const lines = [
211
+ `${result.mode} search, ${result.chunks.length} passages from ${groups.size} docs${result.truncated ? ' (more matched — raise max_tokens)' : ''}${corrected ? `; ${corrected}` : ''}${note ? `. ${note}` : ''}`,
212
+ ];
213
+ for (const group of groups.values()) {
214
+ const first = group[0];
215
+ const label = first.ref_table === 'skills' ? `skill ${first.id}` : `memory ${first.id}`;
216
+ const details = [first.folder && `folder: ${first.folder}`, first.key && `key: ${first.key}`].filter(Boolean).join(', ');
217
+ lines.push('', `## ${label}${details ? ` (${details})` : ''}`);
218
+ let previousHeading = '';
219
+ for (const chunk of group.sort((a, b) => a.ord - b.ord)) {
220
+ // Consecutive pieces of one oversized section share a heading — print it once.
221
+ if (chunk.heading && chunk.heading !== previousHeading)
222
+ lines.push(`### ${chunk.heading}`);
223
+ previousHeading = chunk.heading;
224
+ lines.push(chunk.text, '');
225
+ }
226
+ }
227
+ return lines.join('\n').trimEnd();
228
+ }
@@ -1,3 +1,4 @@
1
+ import { correctWords } from './spelling.js';
1
2
  /** A quoted FTS5 syntax error, translated into something an agent can act on without seeing raw SQLite internals. */
2
3
  export class SearchQueryError extends Error {
3
4
  constructor(query, cause) {
@@ -28,27 +29,66 @@ export function sanitizeFtsQuery(query) {
28
29
  })
29
30
  .join(' ');
30
31
  }
32
+ /**
33
+ * bm25() over search_index with per-column weights, in column order (ref_table, ref_id, ref_folder,
34
+ * description, body, tags, key, filename, triggers) — a hit in a doc's name/key/trigger phrases
35
+ * says far more about what the doc IS than the same word appearing somewhere in a long body.
36
+ */
37
+ export const SEARCH_BM25 = 'bm25(search_index, 0, 0, 0, 4.0, 1.0, 3.0, 6.0, 8.0, 6.0)';
38
+ /** Best-matching column's excerpt (-1), not a fixed column — a hit in tags/key alone still shows why it matched. */
39
+ export const SEARCH_SNIPPET = `snippet(search_index, -1, '<<', '>>', '…', 20)`;
40
+ /** True when the caller used FTS5 syntax (quotes, AND/OR/NOT, prefix*, parentheses, column filters) and so meant the query literally. */
41
+ export function hasExplicitSyntax(query) {
42
+ const tokens = query.match(/\S+/g) ?? [];
43
+ return tokens.some((t) => FTS_BOOLEAN_KEYWORDS.has(t.toUpperCase()) || /["*():^]/.test(t));
44
+ }
45
+ /**
46
+ * Runs `run` with the sanitized query and, only when that matches nothing and the query is plain
47
+ * bare words, loosens it in two steps: first the words OR'd instead of FTS5's implicit AND (bm25
48
+ * ranks docs matching more of them first), then with misspelled words replaced by the closest word
49
+ * in the corpus (see spelling.ts). A query using explicit syntax is never loosened.
50
+ */
51
+ export function withOrFallback(db, query, offset, run) {
52
+ const strict = sanitizeFtsQuery(query);
53
+ const rows = run(strict, false);
54
+ if (rows.length > 0 || hasExplicitSyntax(query))
55
+ return rows;
56
+ // An empty page past the first is not "no AND matches" — don't start serving looser rows mid-pagination.
57
+ if (offset > 0 && run(strict, true).length > 0)
58
+ return rows;
59
+ const tokens = query.match(/\S+/g) ?? [];
60
+ if (tokens.length > 1) {
61
+ const relaxed = run(tokens.map((t) => sanitizeFtsQuery(t)).join(' OR '), false);
62
+ if (relaxed.length > 0)
63
+ return relaxed;
64
+ }
65
+ const corrections = correctWords(db, tokens);
66
+ if (corrections.size === 0)
67
+ return rows;
68
+ return run(tokens.map((t) => sanitizeFtsQuery(corrections.get(t) ?? t)).join(' OR '), false);
69
+ }
31
70
  /**
32
71
  * Full-text search over the shared FTS5 index, optionally scoped to one ref_table.
33
72
  * `query` is passed through as raw FTS5 MATCH syntax — supports `AND`/`OR`/`NOT`,
34
73
  * `"exact phrases"`, and `prefix*` — so callers get grep-like power for free.
35
- * Ranked by bm25() (lower is better, so we negate for a "higher is better" score).
74
+ * Ranked by weighted bm25() (lower is better, so we negate for a "higher is better" score); bare
75
+ * words that match nothing together fall back to OR, then to typo correction (see withOrFallback).
36
76
  * Throws SearchQueryError on malformed FTS5 syntax instead of a raw SQLite error.
37
77
  */
38
78
  export function searchIndex(db, query, opts = {}) {
39
79
  const { table, limit = 20, offset = 0 } = opts;
40
- const sanitized = sanitizeFtsQuery(query);
41
80
  try {
42
- const rows = db
43
- .prepare(`SELECT ref_table, ref_id,
44
- snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
45
- -bm25(search_index) AS score
46
- FROM search_index
47
- WHERE search_index MATCH ? ${table ? 'AND ref_table = ?' : ''}
48
- ORDER BY bm25(search_index)
49
- LIMIT ? OFFSET ?`)
50
- .all(...(table ? [sanitized, table, limit, offset] : [sanitized, limit, offset]));
51
- return rows;
81
+ const statement = db.prepare(`SELECT ref_table, ref_id,
82
+ ${SEARCH_SNIPPET} AS snippet,
83
+ -${SEARCH_BM25} AS score
84
+ FROM search_index
85
+ WHERE search_index MATCH ? ${table ? 'AND ref_table = ?' : ''}
86
+ ORDER BY ${SEARCH_BM25}
87
+ LIMIT ? OFFSET ?`);
88
+ return withOrFallback(db, query, offset, (ftsQuery, probe) => {
89
+ const page = probe ? [1, 0] : [limit, offset];
90
+ return statement.all(...(table ? [ftsQuery, table, ...page] : [ftsQuery, ...page]));
91
+ });
52
92
  }
53
93
  catch (err) {
54
94
  throw new SearchQueryError(query, err);
@@ -123,24 +163,22 @@ export function searchByDate(db, start, end, opts = {}) {
123
163
  * per table); use skill_search/memory_search directly when filtering by those.
124
164
  */
125
165
  export function searchCombined(db, query, limit = 20, offset = 0) {
126
- const sanitized = sanitizeFtsQuery(query);
127
166
  try {
128
- const rows = db
129
- .prepare(`SELECT ref_table, ref_id AS id,
130
- COALESCE(s.description, m.description) AS description,
131
- COALESCE(s.folder, m.folder) AS folder,
132
- snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
133
- -bm25(search_index) AS score,
134
- si.owner AS shared_owner,
135
- si.role AS shared_role
136
- FROM search_index
137
- LEFT JOIN skills s ON search_index.ref_table = 'skills' AND s.id = search_index.ref_id AND s.folder = search_index.ref_folder
138
- LEFT JOIN memory_docs m ON search_index.ref_table = 'memory_docs' AND m.source_path = search_index.ref_id
139
- LEFT JOIN shared_items si ON si.status = 'active' AND si.mirror_path = COALESCE(s.source_path, m.source_path)
140
- WHERE search_index MATCH ?
141
- ORDER BY bm25(search_index)
142
- LIMIT ? OFFSET ?`)
143
- .all(sanitized, limit, offset);
167
+ const statement = db.prepare(`SELECT ref_table, ref_id AS id,
168
+ COALESCE(s.description, m.description) AS description,
169
+ COALESCE(s.folder, m.folder) AS folder,
170
+ ${SEARCH_SNIPPET} AS snippet,
171
+ -${SEARCH_BM25} AS score,
172
+ si.owner AS shared_owner,
173
+ si.role AS shared_role
174
+ FROM search_index
175
+ LEFT JOIN skills s ON search_index.ref_table = 'skills' AND s.id = search_index.ref_id AND s.folder = search_index.ref_folder
176
+ LEFT JOIN memory_docs m ON search_index.ref_table = 'memory_docs' AND m.source_path = search_index.ref_id
177
+ LEFT JOIN shared_items si ON si.status = 'active' AND si.mirror_path = COALESCE(s.source_path, m.source_path)
178
+ WHERE search_index MATCH ?
179
+ ORDER BY ${SEARCH_BM25}
180
+ LIMIT ? OFFSET ?`);
181
+ const rows = withOrFallback(db, query, offset, (ftsQuery, probe) => statement.all(ftsQuery, ...(probe ? [1, 0] : [limit, offset])));
144
182
  return rows.map((row) => ({
145
183
  ...row,
146
184
  shared_owner: row.shared_owner ?? undefined,
@@ -0,0 +1,54 @@
1
+ import { distance } from 'fastest-levenshtein';
2
+ import { getChunksVersion } from './chunks.js';
3
+ let vocabulary;
4
+ /** Every distinct (unstemmed) word in the corpus with how many chunks contain it — from term_vocab, reloaded when chunks change. */
5
+ function loadVocabulary(db) {
6
+ const version = getChunksVersion();
7
+ if (!vocabulary || vocabulary.version !== version || vocabulary.db !== db) {
8
+ const terms = db.prepare(`SELECT term, doc AS docs FROM term_vocab`).all();
9
+ vocabulary = { version, db, terms, known: new Set(terms.map((t) => t.term)) };
10
+ }
11
+ return vocabulary;
12
+ }
13
+ /**
14
+ * The corpus word a misspelled query word most likely meant, or undefined when the word is fine
15
+ * (it, or a stemmed form of it, occurs somewhere) or nothing is close enough. Short words and
16
+ * anything with a digit are left alone — an edit away from a 3-letter word or a ticket number is
17
+ * usually a different word, not a typo. Ties go to the word found in more chunks.
18
+ */
19
+ export function suggestCorrection(db, word) {
20
+ const lower = word.toLowerCase();
21
+ if (lower.length < 4 || !/^\p{L}+$/u.test(lower))
22
+ return undefined;
23
+ const { terms, known } = loadVocabulary(db);
24
+ if (known.has(lower))
25
+ return undefined;
26
+ // Not in the unstemmed vocabulary, but the porter-stemmed indexes may still match it ("deploying" → "deploy").
27
+ const phrase = `"${lower}"`;
28
+ if (db.prepare(`SELECT 1 FROM chunk_fts WHERE chunk_fts MATCH ? LIMIT 1`).get(phrase))
29
+ return undefined;
30
+ if (db.prepare(`SELECT 1 FROM search_index WHERE search_index MATCH ? LIMIT 1`).get(phrase))
31
+ return undefined;
32
+ const maxDistance = lower.length <= 5 ? 1 : 2;
33
+ let best;
34
+ for (const { term, docs } of terms) {
35
+ if (Math.abs(term.length - lower.length) > maxDistance)
36
+ continue;
37
+ const d = distance(lower, term);
38
+ if (d > maxDistance)
39
+ continue;
40
+ if (!best || d < best.distance || (d === best.distance && docs > best.docs))
41
+ best = { term, docs, distance: d };
42
+ }
43
+ return best?.term;
44
+ }
45
+ /** Misspelled word → correction, for each of `words` that has one. */
46
+ export function correctWords(db, words) {
47
+ const corrections = new Map();
48
+ for (const word of words) {
49
+ const correction = suggestCorrection(db, word);
50
+ if (correction)
51
+ corrections.set(word, correction);
52
+ }
53
+ return corrections;
54
+ }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
  import chokidar, {} from 'chokidar';
4
4
  import { readMarkdownFile } from './markdown-file.js';
5
5
  import { flattenTags } from './db.js';
6
+ import { writeChunks, deleteChunks, chunkContext } from './chunks.js';
6
7
  import { extractDates, toLocalDate } from './date-extract.js';
7
8
  import { slugify } from './slug.js';
8
9
  import { normalizeKey } from '../types.js';
@@ -228,7 +229,11 @@ folderOverride) {
228
229
  // construction), so '' (unscoped) is safe there.
229
230
  const refFolder = scopedById ? folder : '';
230
231
  db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(spec.table, id, refFolder);
231
- db.prepare(`INSERT INTO search_index (ref_table, ref_id, ref_folder, description, body, tags, key, filename) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`).run(spec.table, id, refFolder, String(row.description ?? ''), parsed.body, flattenTags(String(row.tags ?? '[]')), String(row.key ?? row.skill_group ?? ''), path.basename(filePath));
232
+ db.prepare(`INSERT INTO search_index (ref_table, ref_id, ref_folder, description, body, tags, key, filename, triggers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`).run(spec.table, id, refFolder, String(row.description ?? ''), parsed.body, flattenTags(String(row.tags ?? '[]')), String(row.key ?? row.skill_group ?? ''),
233
+ // A skill's file is always SKILL.md, so its name goes here instead — otherwise the name is
234
+ // only searchable when the description or body happens to repeat it.
235
+ scopedById ? id : path.basename(filePath), flattenTags(String(row.trigger_phrases ?? '[]')));
236
+ writeChunks(db, spec.table, id, refFolder, chunkContext(spec.table, row), String(row.description ?? ''), parsed.body);
232
237
  db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(spec.table, id, refFolder);
233
238
  const dates = new Set(extractDates(parsed.body));
234
239
  // created_at is a UTC instant; convert to the OS-local calendar date so it
@@ -255,6 +260,7 @@ export function removeFile(db, table, filePath) {
255
260
  const refFolder = table === 'skills' ? existing.folder : '';
256
261
  db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(table, existing.id, refFolder);
257
262
  db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(table, existing.id, refFolder);
263
+ deleteChunks(db, table, existing.id, refFolder);
258
264
  }
259
265
  }
260
266
  /** Full scan of all configured source dirs — used once at startup before the watcher takes over. */
@@ -296,6 +302,7 @@ export function unregisterFolder(db, table, folderName) {
296
302
  db.prepare(`DELETE FROM ${table} WHERE folder = ?`).run(folderName);
297
303
  for (const row of rows) {
298
304
  db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(table, row.id);
305
+ deleteChunks(db, table, row.id, table === 'skills' ? folderName : '');
299
306
  }
300
307
  }
301
308
  export function* walkMarkdownFiles(dir) {