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
- const BATCH_SIZE = 16;
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
@@ -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 corrections = correctWords(db, query.toLowerCase().match(WORD_RE) ?? []);
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) + 1 / (RRF_K + rank));
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([respell(query, corrections)], 'query');
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. Ties go to the word found in more chunks.
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)
@@ -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
- rows = withOrFallback(db, q, 0, (ftsQuery) => statement.all(ftsQuery));
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.0",
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": {