mcp-memory-bucket 0.14.0 → 0.14.1
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.
|
@@ -40,7 +40,12 @@ export function blobToVector(blob) {
|
|
|
40
40
|
// Copy: a Buffer's byteOffset into its pool is not guaranteed to be 4-byte aligned.
|
|
41
41
|
return new Float32Array(blob.buffer.slice(blob.byteOffset, blob.byteOffset + blob.byteLength));
|
|
42
42
|
}
|
|
43
|
-
|
|
43
|
+
/**
|
|
44
|
+
* One chunk per model call. A batch is padded to its longest member, and onnxruntime keeps its
|
|
45
|
+
* peak allocation for the life of the process: on a 1,700-chunk corpus, batches of 16 left the
|
|
46
|
+
* server holding 1.6 GB and ran slower (45 ms/chunk) than single inputs do (26 ms/chunk, ~490 MB).
|
|
47
|
+
*/
|
|
48
|
+
const BATCH_SIZE = 1;
|
|
44
49
|
const RETRY_AFTER_FAILURE_MS = 5 * 60_000;
|
|
45
50
|
/**
|
|
46
51
|
* Embeds every chunk that has no vector for the current model, in the background, a batch at a
|
package/dist/src/store/recall.js
CHANGED
|
@@ -20,14 +20,16 @@ export function questionToFtsQuery(question, corrections) {
|
|
|
20
20
|
return terms.size > 0 ? [...terms].map((t) => `"${t}"`).join(' OR ') : null;
|
|
21
21
|
}
|
|
22
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
23
|
const CANDIDATES_PER_LIST = 30;
|
|
28
24
|
const DOC_CANDIDATES = 10;
|
|
29
25
|
/** Reciprocal-rank-fusion constant — the conventional 60; damps how much a rank-1 hit outweighs a rank-5 one. */
|
|
30
26
|
const RRF_K = 60;
|
|
27
|
+
/**
|
|
28
|
+
* The semantic list counts double: two of the three fused lists are keyword-based (chunk and doc
|
|
29
|
+
* BM25), so at equal weight a chunk that is the clear best match by meaning loses to one that
|
|
30
|
+
* merely shares common words with the question in both keyword lists.
|
|
31
|
+
*/
|
|
32
|
+
const SEMANTIC_WEIGHT = 2;
|
|
31
33
|
const MAX_CHUNKS_PER_DOC = 3;
|
|
32
34
|
/** Fused scores below this fraction of the best hit's are dropped rather than used to fill the token budget. */
|
|
33
35
|
const RELATIVE_SCORE_FLOOR = 0.3;
|
|
@@ -72,8 +74,7 @@ const SEMANTIC_MAX_DOCS = 5;
|
|
|
72
74
|
* of recall()'s vector list, for the web UI's result list (which shows docs, not passages).
|
|
73
75
|
*/
|
|
74
76
|
export async function semanticDocMatches(db, embedder, query) {
|
|
75
|
-
const
|
|
76
|
-
const [queryVector] = await embedder.embed([respell(query, corrections)], 'query');
|
|
77
|
+
const [queryVector] = await embedder.embed([query], 'query');
|
|
77
78
|
const hits = vectorSearch(db, embedder.model, queryVector, 50);
|
|
78
79
|
const floor = Math.max(SEMANTIC_MIN_SIMILARITY, (hits[0]?.similarity ?? 0) - SEMANTIC_WINDOW);
|
|
79
80
|
const chunkDoc = db.prepare(`SELECT ref_table, ref_id, ref_folder FROM chunks WHERE id = ?`);
|
|
@@ -98,7 +99,7 @@ const docKey = (c) => `${c.ref_table}\0${c.ref_folder}\0${c.ref_id}`;
|
|
|
98
99
|
export async function recall(db, query, opts = {}) {
|
|
99
100
|
const { maxTokens = 2000, table, hidden, embedder } = opts;
|
|
100
101
|
const fused = new Map();
|
|
101
|
-
const add = (chunkId, rank) => fused.set(chunkId, (fused.get(chunkId) ?? 0) +
|
|
102
|
+
const add = (chunkId, rank, weight = 1) => fused.set(chunkId, (fused.get(chunkId) ?? 0) + weight / (RRF_K + rank));
|
|
102
103
|
const corrections = correctWords(db, query.toLowerCase().match(WORD_RE) ?? []);
|
|
103
104
|
const ftsQuery = questionToFtsQuery(query, corrections);
|
|
104
105
|
const tableFilter = table ? `AND c.ref_table = '${table}'` : '';
|
|
@@ -118,16 +119,16 @@ export async function recall(db, query, opts = {}) {
|
|
|
118
119
|
}
|
|
119
120
|
let mode = 'lexical';
|
|
120
121
|
if (embedder) {
|
|
121
|
-
const [queryVector] = await embedder.embed([
|
|
122
|
+
const [queryVector] = await embedder.embed([query], 'query');
|
|
122
123
|
const semantic = vectorSearch(db, embedder.model, queryVector, CANDIDATES_PER_LIST * (table ? 4 : 1));
|
|
123
124
|
if (semantic.length > 0)
|
|
124
125
|
mode = 'hybrid';
|
|
125
|
-
semantic.forEach((hit, i) => add(hit.id, i + 1));
|
|
126
|
+
semantic.forEach((hit, i) => add(hit.id, i + 1, SEMANTIC_WEIGHT));
|
|
126
127
|
}
|
|
127
128
|
// Doc-level boost: every candidate chunk of a doc-level hit gains that doc's rank; a doc with no
|
|
128
129
|
// candidate chunk yet (the query matched only its name/key/tags) contributes its opening chunk.
|
|
129
130
|
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 chunkById = db.prepare(`SELECT id, ref_table, ref_id, ref_folder, ord, heading, text, hash FROM chunks WHERE id = ?`);
|
|
131
132
|
const rows = new Map();
|
|
132
133
|
for (const id of fused.keys()) {
|
|
133
134
|
const row = chunkById.get(id);
|
|
@@ -169,6 +170,8 @@ export async function recall(db, query, opts = {}) {
|
|
|
169
170
|
.sort((a, b) => b.score - a.score);
|
|
170
171
|
const floor = (ranked[0]?.score ?? 0) * RELATIVE_SCORE_FLOOR;
|
|
171
172
|
const perDoc = new Map();
|
|
173
|
+
// The same skill synced into two folders yields byte-identical passages — return each once.
|
|
174
|
+
const seenText = new Set();
|
|
172
175
|
const chunks = [];
|
|
173
176
|
let budget = maxTokens;
|
|
174
177
|
let truncated = false;
|
|
@@ -176,7 +179,7 @@ export async function recall(db, query, opts = {}) {
|
|
|
176
179
|
if (score < floor)
|
|
177
180
|
break;
|
|
178
181
|
const key = docKey(row);
|
|
179
|
-
if ((perDoc.get(key) ?? 0) >= MAX_CHUNKS_PER_DOC)
|
|
182
|
+
if ((perDoc.get(key) ?? 0) >= MAX_CHUNKS_PER_DOC || seenText.has(row.hash))
|
|
180
183
|
continue;
|
|
181
184
|
let text = row.text;
|
|
182
185
|
const cost = () => HEADER_TOKENS + Math.ceil((row.heading.length + text.length) / CHARS_PER_TOKEN);
|
|
@@ -189,6 +192,7 @@ export async function recall(db, query, opts = {}) {
|
|
|
189
192
|
}
|
|
190
193
|
budget -= cost();
|
|
191
194
|
perDoc.set(key, (perDoc.get(key) ?? 0) + 1);
|
|
195
|
+
seenText.add(row.hash);
|
|
192
196
|
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
197
|
}
|
|
194
198
|
return { mode, chunks, truncated, corrections: Object.fromEntries(corrections) };
|
|
@@ -14,7 +14,9 @@ function loadVocabulary(db) {
|
|
|
14
14
|
* The corpus word a misspelled query word most likely meant, or undefined when the word is fine
|
|
15
15
|
* (it, or a stemmed form of it, occurs somewhere) or nothing is close enough. Short words and
|
|
16
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.
|
|
17
|
+
* usually a different word, not a typo. A candidate must also start with the same letter: typos
|
|
18
|
+
* rarely fall on the first letter, while unrelated real words one edit apart often differ exactly
|
|
19
|
+
* there ("night" is not a misspelled "right"). Ties go to the word found in more chunks.
|
|
18
20
|
*/
|
|
19
21
|
export function suggestCorrection(db, word) {
|
|
20
22
|
const lower = word.toLowerCase();
|
|
@@ -32,7 +34,7 @@ export function suggestCorrection(db, word) {
|
|
|
32
34
|
const maxDistance = lower.length <= 5 ? 1 : 2;
|
|
33
35
|
let best;
|
|
34
36
|
for (const { term, docs } of terms) {
|
|
35
|
-
if (Math.abs(term.length - lower.length) > maxDistance)
|
|
37
|
+
if (term[0] !== lower[0] || Math.abs(term.length - lower.length) > maxDistance)
|
|
36
38
|
continue;
|
|
37
39
|
const d = distance(lower, term);
|
|
38
40
|
if (d > maxDistance)
|
package/dist/src/web/routes.js
CHANGED
|
@@ -7,7 +7,7 @@ import { saveFolder, saveRemoteFolder, removeFolder as removeFolderFromConfig, u
|
|
|
7
7
|
import { stripKey } from '../memory/repository.js';
|
|
8
8
|
import { pruneOrphanChunks } from '../store/chunks.js';
|
|
9
9
|
import { initialScan, walkMarkdownFiles } from '../store/sync.js';
|
|
10
|
-
import { SEARCH_BM25, withOrFallback, hasExplicitSyntax } from '../store/search.js';
|
|
10
|
+
import { SEARCH_BM25, withOrFallback, hasExplicitSyntax, sanitizeFtsQuery } from '../store/search.js';
|
|
11
11
|
import { semanticDocMatches } from '../store/recall.js';
|
|
12
12
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
13
13
|
import { attachmentsDirFor, guessMimeType, ATTACHMENT_MAX_BYTES, listSkillSourceFiles } from '../attachments/storage.js';
|
|
@@ -314,6 +314,7 @@ function matchDateRange(db, from, to) {
|
|
|
314
314
|
* reciprocal rank fusion, so a doc matching in meaning but sharing no keyword still appears; the
|
|
315
315
|
* stored rank is then the negated fused score, keeping "lower is better".
|
|
316
316
|
*/
|
|
317
|
+
const LOOSENED_RESULT_LIMIT = 15;
|
|
317
318
|
function matchSearch(db, q, semantic = []) {
|
|
318
319
|
const skills = new Set();
|
|
319
320
|
const memory_docs = new Set();
|
|
@@ -321,7 +322,16 @@ function matchSearch(db, q, semantic = []) {
|
|
|
321
322
|
let rows;
|
|
322
323
|
try {
|
|
323
324
|
const statement = db.prepare(`SELECT ref_table, ref_id, ref_folder, ${SEARCH_BM25} AS score FROM search_index WHERE search_index MATCH ? ORDER BY score`);
|
|
324
|
-
|
|
325
|
+
const strict = sanitizeFtsQuery(q);
|
|
326
|
+
let loosened = false;
|
|
327
|
+
rows = withOrFallback(db, q, 0, (ftsQuery) => {
|
|
328
|
+
loosened = ftsQuery !== strict;
|
|
329
|
+
return statement.all(ftsQuery);
|
|
330
|
+
});
|
|
331
|
+
// Any-word and typo-corrected matches are a best guess, not what was typed — a query of common
|
|
332
|
+
// words would otherwise list half the bucket. Keep only the strongest.
|
|
333
|
+
if (loosened)
|
|
334
|
+
rows = rows.slice(0, LOOSENED_RESULT_LIMIT);
|
|
325
335
|
}
|
|
326
336
|
catch {
|
|
327
337
|
// Bad FTS5 query syntax (e.g. a bare quote) — treat as no matches rather than 500ing.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.14.
|
|
3
|
+
"version": "0.14.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server exposing skill_* (reusable coding patterns) and memory_* (point-in-time working context) tools over a markdown+frontmatter source, cached into SQLite at runtime, with hybrid keyword + local-embedding passage retrieval.",
|
|
6
6
|
"repository": {
|