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.
- package/README.md +47 -0
- package/dist/src/memory/repository.js +8 -10
- package/dist/src/memory/tools.js +1 -1
- package/dist/src/server.js +16 -3
- package/dist/src/shared/bucket-folder-tool.js +2 -0
- package/dist/src/shared/search-tool.js +29 -2
- package/dist/src/skills/repository.js +8 -10
- package/dist/src/skills/tools.js +1 -1
- package/dist/src/store/chunker.js +112 -0
- package/dist/src/store/chunks.js +125 -0
- package/dist/src/store/db.js +39 -6
- package/dist/src/store/embeddings.js +138 -0
- package/dist/src/store/recall.js +228 -0
- package/dist/src/store/search.js +67 -29
- package/dist/src/store/spelling.js +54 -0
- package/dist/src/store/sync.js +8 -1
- package/dist/src/web/routes.js +31 -10
- package/package.json +5 -2
|
@@ -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
|
+
}
|
package/dist/src/store/search.js
CHANGED
|
@@ -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
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
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
|
+
}
|
package/dist/src/store/sync.js
CHANGED
|
@@ -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 ?? ''),
|
|
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) {
|