mcp-memory-bucket 0.13.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.
package/README.md CHANGED
@@ -45,6 +45,53 @@ the outdated ones deprecated" becomes `skill_search("deploy")` to find
45
45
  candidates, then `skill_bulk_update(names, { deprecated: true })` to flag
46
46
  the stale ones in one call — no need to touch each file individually.
47
47
 
48
+ ## Retrieval
49
+
50
+ `bucket_recall` is the retrieval entry point: it takes a plain-language
51
+ question and returns the most relevant *passages* — heading-level sections,
52
+ with their text — packed into a `max_tokens` budget, so an agent gets the
53
+ one section it needs instead of searching, then loading a whole document.
54
+ No LLM is involved anywhere in indexing or retrieval:
55
+
56
+ - **Chunking.** Each doc body is split along its markdown headings (code
57
+ fences kept intact, oversized sections cut at paragraph boundaries) and
58
+ every chunk remembers its heading path.
59
+ - **Keyword ranking.** SQLite FTS5 with BM25, at two levels: per chunk, and
60
+ per doc with column weights so a hit in a skill's name, a memory doc's
61
+ key or filename, tags, or trigger phrases outranks the same word deep in
62
+ a body.
63
+ - **Semantic ranking.** A small sentence-embedding model
64
+ (`Xenova/bge-small-en-v1.5`, ~33 MB) runs locally on CPU via
65
+ transformers.js and embeds each chunk in the background, so a paraphrase
66
+ with no shared keyword ("step back after a mistake" → an undo/redo skill)
67
+ still matches. Only chunks whose text changed are re-embedded.
68
+ - **Fusion.** The three ranked lists are merged by reciprocal rank fusion.
69
+ - **Typo correction.** A query word found nowhere in the corpus is matched
70
+ to the closest word that is (edit distance 1–2, by word length), so
71
+ "tooltp" finds the tooltip skill. `bucket_recall` reports the correction
72
+ in its first line.
73
+
74
+ The model downloads once, on first start, into
75
+ `<base dir>/.memory-bucket-models`; until it has finished (or if the machine
76
+ is offline, or the optional `@huggingface/transformers` dependency failed to
77
+ install) `bucket_recall` answers from keyword ranking alone and says so in
78
+ its first line. Environment variables:
79
+
80
+ | Variable | Effect |
81
+ | --- | --- |
82
+ | `MEMORY_BUCKET_EMBEDDINGS=off` | Never load a model; keyword ranking only. |
83
+ | `MEMORY_BUCKET_EMBED_MODEL` | A different transformers.js feature-extraction model, e.g. `Xenova/multilingual-e5-small` for non-English content. Changing it re-embeds every chunk. |
84
+ | `MEMORY_BUCKET_SEMANTIC_MIN_SIMILARITY` | Cosine similarity a doc needs to appear in the web UI's results on meaning alone (default `0.6`, tuned for the default model — other models score on a different scale). |
85
+
86
+ `skill_search` / `memory_search` / `bucket_search` remain the keyword tools
87
+ that return a list of *docs* (and take metadata filters). Bare words must all
88
+ match; if no doc contains all of them, the query is retried matching any,
89
+ then with typo correction. Explicit syntax (quotes, `AND`/`OR`/`NOT`,
90
+ `prefix*`) is always taken literally.
91
+
92
+ The web UI's search box uses the same keyword search and, for a plain-words
93
+ query, also lists docs that match by meaning alone.
94
+
48
95
  ## Run
49
96
 
50
97
  ### Via npx
@@ -5,7 +5,7 @@ import { writeMarkdownFile, formatMarkdownFile } from '../store/markdown-file.js
5
5
  import { resolveWithinBase } from '../store/safe-path.js';
6
6
  import { uniqueFilename } from '../attachments/storage.js';
7
7
  import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, memorySyncSpec } from '../store/sync.js';
8
- import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
8
+ import { SearchQueryError, SEARCH_BM25, SEARCH_SNIPPET, withOrFallback } from '../store/search.js';
9
9
  import { applyBodyEdits } from '../shared/body-edits.js';
10
10
  import { attachmentsDirFor } from '../attachments/storage.js';
11
11
  import { rebaseFolderPath } from '../config.js';
@@ -401,7 +401,7 @@ export class MemoryRepository {
401
401
  search(query, opts = {}) {
402
402
  const { docType, status, folder, tag, limit = 20, offset = 0, includePaused = false } = opts;
403
403
  const conditions = [];
404
- const params = [sanitizeFtsQuery(query)];
404
+ const params = [];
405
405
  if (docType) {
406
406
  conditions.push('m.doc_type = ?');
407
407
  params.push(docType);
@@ -427,23 +427,21 @@ export class MemoryRepository {
427
427
  conditions.push(`m.folder NOT IN (${hidden.map(() => '?').join(', ')})`);
428
428
  params.push(...hidden);
429
429
  }
430
- params.push(limit, offset);
431
430
  try {
432
431
  // shared_items is LEFT JOINed on m.source_path (== mirror_path for an item-level share —
433
432
  // see remote/shared-items.ts) to surface owner/role on any hit that came from a share.
434
- const rows = this.db
435
- .prepare(`SELECT m.source_path, m.key, m.description, m.doc_type, m.folder,
436
- snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
437
- -bm25(search_index) AS score,
433
+ const statement = this.db.prepare(`SELECT m.source_path, m.key, m.description, m.doc_type, m.folder,
434
+ ${SEARCH_SNIPPET} AS snippet,
435
+ -${SEARCH_BM25} AS score,
438
436
  si.owner AS shared_owner,
439
437
  si.role AS shared_role
440
438
  FROM search_index
441
439
  JOIN memory_docs m ON m.source_path = search_index.ref_id
442
440
  LEFT JOIN shared_items si ON si.status = 'active' AND si.mirror_path = m.source_path
443
441
  WHERE search_index.ref_table = 'memory_docs' AND search_index MATCH ? ${conditions.map((c) => `AND ${c}`).join(' ')}
444
- ORDER BY bm25(search_index)
445
- LIMIT ? OFFSET ?`)
446
- .all(...params);
442
+ ORDER BY ${SEARCH_BM25}
443
+ LIMIT ? OFFSET ?`);
444
+ const rows = withOrFallback(this.db, query, offset, (ftsQuery, probe) => statement.all(ftsQuery, ...params, ...(probe ? [1, 0] : [limit, offset])));
447
445
  return rows.map(({ source_path, shared_owner, shared_role, ...rest }) => ({
448
446
  ...rest,
449
447
  filename: path.basename(source_path),
@@ -33,7 +33,7 @@ export function registerMemoryTools(mcp, repo) {
33
33
  const keys = repo.listKeys(key_prefix);
34
34
  return { content: [{ type: 'text', text: JSON.stringify(keys, null, 2) }] };
35
35
  });
36
- mcp.tool('memory_search', 'Full-text search over memory doc description/body/tags (grep/find-like, ranked by relevance) — unlike memory_get\'s exact-key lookup, this searches by content across all keys. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". Can be combined with doc_type/status/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call memory_get(key) or fetch by id for that. Paused docs are hidden by default — pass include_paused to see them.', {
36
+ mcp.tool('memory_search', 'Full-text search over memory doc description/body/tags (grep/find-like, ranked by relevance) — unlike memory_get\'s exact-key lookup, this searches by content across all keys. `query` is SQLite FTS5 MATCH syntax: bare words (all must match; falls back to any-word when nothing matches all), "exact phrases", prefix* wildcards, AND/OR/NOT. Can be combined with doc_type/status/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call memory_get(key) or fetch by id for that. Paused docs are hidden by default — pass include_paused to see them.', {
37
37
  query: z.string().describe('FTS5 match expression, e.g. `migration AND rollback` or `"blue green"`'),
38
38
  doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
39
39
  status: statusSchema(MEMORY_STATUS_DEFAULTS).optional(),
@@ -14,6 +14,7 @@ import { registerSkillTools } from './skills/tools.js';
14
14
  import { registerMemoryTools } from './memory/tools.js';
15
15
  import { registerRelocateTool } from './shared/relocate-tool.js';
16
16
  import { registerSearchTool } from './shared/search-tool.js';
17
+ import { EmbeddingIndexer, createTransformersEmbedder } from './store/embeddings.js';
17
18
  import { registerBucketFolderTools } from './shared/bucket-folder-tool.js';
18
19
  import { registerBucketShareTools } from './shared/bucket-share-tool.js';
19
20
  import { syncSharedItemsIndex } from './remote/shared-items.js';
@@ -113,6 +114,14 @@ const memoryWatcher = watchSources(db, memorySpec);
113
114
  skillRepo.setWatcher(skillWatcher);
114
115
  memoryRepo.setWatcher(memoryWatcher);
115
116
  const attachmentRepo = new AttachmentRepository(memoryRepo, skillRepo, db);
117
+ // Semantic half of bucket_recall: a local embedding model (no LLM, no network after its one-time
118
+ // download into baseDir) fills in chunk vectors in the background. Started after the scans above so
119
+ // its first pass sees every chunk; anything indexed later wakes it through the chunk-write listener.
120
+ // MEMORY_BUCKET_EMBEDDINGS=off keeps retrieval purely lexical.
121
+ const embeddingIndexer = process.env.MEMORY_BUCKET_EMBEDDINGS === 'off'
122
+ ? undefined
123
+ : new EmbeddingIndexer(db, createTransformersEmbedder({ cacheDir: path.join(config.baseDir, '.memory-bucket-models'), model: process.env.MEMORY_BUCKET_EMBED_MODEL }));
124
+ embeddingIndexer?.start();
116
125
  // Remote sources have no filesystem to watch (chokidar doesn't apply) - a
117
126
  // fixed-interval poller stands in for watchSources above, pulling changes
118
127
  // into each remote source's local mirror directory and reusing the exact
@@ -174,13 +183,16 @@ if (config.skillFolders.length === 0 && config.memoryFolders.length === 0) {
174
183
  // this server — surfaced both in serverInfo.description and instructions so
175
184
  // clients that expose either to the model can make that association.
176
185
  const SERVER_DESCRIPTION = 'Also known as "memory bucket", "mem bucket", or "skill bucket" — if the user refers to this server by any of those names, they mean this one.';
177
- const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_folder tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Use bucket_list_folders to see what named source directories (folders) are configured before passing a folder argument elsewhere, and bucket_create_folder/bucket_delete_folder to register or unregister one (bucket_delete_folder works for both local and remote folders). bucket_list_remote_folders/bucket_connect_remote_folder connect a folderfoo folder as a new source, once the user is logged in via the web UI. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape. Also exposes memory_channel_read/memory_channel_post/list_memory_channels — a SEPARATE, ephemeral in-memory layer for live cross-agent coordination (a shared scratchpad/discussion channel by name), never written to disk and never indexed by memory_search/bucket_search; do not confuse these with the persisted memory_* docs above.`;
186
+ const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_folder tools. Use bucket_recall to retrieve relevant passages for a natural-language question (hybrid keyword + semantic, token-budgeted) — the default way to look something up. Use skill_search/memory_search/bucket_search for keyword search returning a list of docs — the first two when filtering by metadata, bucket_search when you don't know which bucket something landed in. Use bucket_list_folders to see what named source directories (folders) are configured before passing a folder argument elsewhere, and bucket_create_folder/bucket_delete_folder to register or unregister one (bucket_delete_folder works for both local and remote folders). bucket_list_remote_folders/bucket_connect_remote_folder connect a folderfoo folder as a new source, once the user is logged in via the web UI. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape. Also exposes memory_channel_read/memory_channel_post/list_memory_channels — a SEPARATE, ephemeral in-memory layer for live cross-agent coordination (a shared scratchpad/discussion channel by name), never written to disk and never indexed by memory_search/bucket_search; do not confuse these with the persisted memory_* docs above.`;
178
187
  function buildMcpServer() {
179
188
  const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
180
189
  registerSkillTools(server, skillRepo);
181
190
  registerMemoryTools(server, memoryRepo);
182
191
  registerRelocateTool(server, skillRepo, memoryRepo);
183
- registerSearchTool(server, db);
192
+ registerSearchTool(server, db, {
193
+ hiddenFolders: () => ({ skills: skillRepo.hiddenFolderNames(), memory_docs: memoryRepo.hiddenFolderNames() }),
194
+ indexer: embeddingIndexer,
195
+ });
184
196
  registerBucketFolderTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec, identity);
185
197
  registerBucketShareTools(server, config, skillRepo, memoryRepo, db, identity);
186
198
  registerAttachmentTools(server, attachmentRepo);
@@ -191,7 +203,7 @@ function buildMcpServer() {
191
203
  startChannelSweep((name) => console.error(`[memory-bucket] sweeping idle memory channel: ${name}`));
192
204
  const app = express();
193
205
  app.use(express.json());
194
- app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, identity, { skill: remoteSkillPoller, memory: remoteMemoryPoller }, attachmentRepo));
206
+ app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, identity, { skill: remoteSkillPoller, memory: remoteMemoryPoller }, attachmentRepo, embeddingIndexer));
195
207
  app.use(express.static(path.join(packageRoot, 'dist', 'client')));
196
208
  app.post('/mcp', async (req, res) => {
197
209
  const server = buildMcpServer();
@@ -219,6 +231,7 @@ app.delete('/mcp', methodNotAllowed);
219
231
  app.listen(PORT, () => {
220
232
  console.error(`[memory-bucket] MCP server listening on http://localhost:${PORT}/mcp`);
221
233
  console.error(`[memory-bucket] UI available at http://localhost:${PORT}`);
234
+ console.error(`[memory-bucket] semantic search: ${embeddingIndexer ? embeddingIndexer.embedder.model : 'off (MEMORY_BUCKET_EMBEDDINGS=off)'}`);
222
235
  console.error(`[memory-bucket] folderfoo integration: ${config.folderfooMode}${config.folderfooHost ? ` (${config.folderfooHost})` : ''}` +
223
236
  (config.folderfooMode === 'off' ? ' — pass --folderfoo-mode dev|cloud (or set FOLDERFOO_MODE) to enable' : ''));
224
237
  console.error(`[memory-bucket] skill folders: ${config.skillFolders.map((f) => `${f.name}=${f.path}`).join(', ') || '(none)'}`);
@@ -2,6 +2,7 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { z } from 'zod';
4
4
  import { saveFolder, saveRemoteFolder, removeFolder as removeFolderFromConfig, sanitizeFolderName, mirrorDirFor, } from '../config.js';
5
+ import { pruneOrphanChunks } from '../store/chunks.js';
5
6
  import { initialScan } from '../store/sync.js';
6
7
  import { listFolders as listFolderfooFolders } from '../remote/folderfoo-client.js';
7
8
  import { pollOne } from '../remote/remote-sync.js';
@@ -135,6 +136,7 @@ export function registerBucketFolderTools(mcp, config, skillRepo, memoryRepo, db
135
136
  initialScan(db, skillSpec);
136
137
  initialScan(db, memorySpec);
137
138
  syncSharedItemsIndex(db, skillSpec, memorySpec, identity.current().username);
139
+ pruneOrphanChunks(db); // chunks survive the wipe so unchanged embeddings carry over — see pruneOrphanChunks
138
140
  const skillCount = db.prepare(`SELECT COUNT(*) AS n FROM skills`).get().n;
139
141
  const memoryCount = db.prepare(`SELECT COUNT(*) AS n FROM memory_docs`).get().n;
140
142
  return {
@@ -1,7 +1,34 @@
1
1
  import { z } from 'zod';
2
2
  import { searchCombined, searchByDate, SearchQueryError } from '../store/search.js';
3
- export function registerSearchTool(mcp, db) {
4
- mcp.tool('bucket_search', 'Full-text search across BOTH skills and memory docs in one ranked list — use this when you don\'t know (or don\'t care) which bucket something landed in. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". For filtering by doc_type/status/tag, use skill_search or memory_search instead. Returns ranked hits with a highlighted snippet, not full body — call skill_get/memory_get on a hit for that.', {
3
+ import { recall, formatRecall } from '../store/recall.js';
4
+ export function registerSearchTool(mcp, db, deps) {
5
+ mcp.tool('bucket_recall', 'Answers "what do I have about X" in one call: takes a plain natural-language question (no query syntax) and returns the most relevant PASSAGES — heading-level sections with their full text — from skills and memory docs, ranked by hybrid keyword + semantic similarity and packed into max_tokens. Prefer this over *_search followed by *_get when you want content rather than a list of docs; use skill_get/memory_get only when a returned passage shows you need its whole document. Paused docs are excluded.', {
6
+ query: z.string().describe('a question or topic in plain words, e.g. `how do I position a tooltip so it flips at the viewport edge`'),
7
+ max_tokens: z.number().int().min(200).max(20000).optional().describe('budget for returned passage text, default 2000'),
8
+ bucket: z.enum(['skills', 'memory_docs']).optional().describe('restrict to one bucket; omit to search both'),
9
+ }, async ({ query, max_tokens, bucket }) => {
10
+ try {
11
+ const { indexer } = deps;
12
+ const result = await recall(db, query, {
13
+ maxTokens: max_tokens,
14
+ table: bucket,
15
+ hidden: deps.hiddenFolders(),
16
+ embedder: indexer?.isReady() ? indexer.embedder : undefined,
17
+ });
18
+ let note;
19
+ if (indexer && result.mode === 'lexical') {
20
+ const status = indexer.status();
21
+ note = status.error
22
+ ? `Semantic index unavailable (${status.error}) — keyword matches only.`
23
+ : `Semantic index still building (${status.embedded}/${status.total} passages) — keyword matches only for now.`;
24
+ }
25
+ return { content: [{ type: 'text', text: formatRecall(result, note) }] };
26
+ }
27
+ catch (err) {
28
+ return { content: [{ type: 'text', text: err.message }], isError: true };
29
+ }
30
+ });
31
+ mcp.tool('bucket_search', 'Keyword search across BOTH skills and memory docs in one ranked list of DOCS (for passages, use bucket_recall) — use this to locate a doc when you don\'t know which bucket it landed in. `query` is SQLite FTS5 MATCH syntax: bare words (all must match; falls back to any-word when nothing matches all), "exact phrases", prefix* wildcards, AND/OR/NOT. For filtering by doc_type/status/tag, use skill_search or memory_search instead. Returns ranked hits with a highlighted snippet, not full body — call skill_get/memory_get on a hit for that.', {
5
32
  query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
6
33
  limit: z.number().int().positive().max(100).optional(),
7
34
  offset: z.number().int().nonnegative().optional(),
@@ -5,7 +5,7 @@ import { writeMarkdownFile, formatMarkdownFile } from '../store/markdown-file.js
5
5
  import { assertValidSkillName } from '../store/skill-name.js';
6
6
  import { resolveWithinBase } from '../store/safe-path.js';
7
7
  import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec, walkSkillSiblingFiles } from '../store/sync.js';
8
- import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
8
+ import { SearchQueryError, SEARCH_BM25, SEARCH_SNIPPET, withOrFallback } from '../store/search.js';
9
9
  import { applyBodyEdits } from '../shared/body-edits.js';
10
10
  import { rebaseFolderPath } from '../config.js';
11
11
  import { readFile as readRemoteFile, writeFile as writeRemoteFile, writeBinaryFile as writeRemoteBinaryFile, trashFile as trashRemoteFile, trashFolder as trashRemoteFolder, renameFolder as renameRemoteFolder, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
@@ -339,7 +339,7 @@ export class SkillRepository {
339
339
  search(query, opts = {}) {
340
340
  const { folder, status, owner, tag, group, limit = 20, offset = 0, includePaused = false } = opts;
341
341
  const conditions = [];
342
- const params = [sanitizeFtsQuery(query)];
342
+ const params = [];
343
343
  if (folder) {
344
344
  conditions.push('s.folder = ?');
345
345
  params.push(folder);
@@ -368,23 +368,21 @@ export class SkillRepository {
368
368
  conditions.push(`s.folder NOT IN (${hidden.map(() => '?').join(', ')})`);
369
369
  params.push(...hidden);
370
370
  }
371
- params.push(limit, offset);
372
371
  try {
373
372
  // shared_items is LEFT JOINed on s.source_path (== mirror_path for an item-level share — see
374
373
  // remote/shared-items.ts) — same reasoning as MemoryRepository.search's identical join.
375
- const rows = this.db
376
- .prepare(`SELECT s.id AS name, s.description, s.folder,
377
- snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
378
- -bm25(search_index) AS score,
374
+ const statement = this.db.prepare(`SELECT s.id AS name, s.description, s.folder,
375
+ ${SEARCH_SNIPPET} AS snippet,
376
+ -${SEARCH_BM25} AS score,
379
377
  si.owner AS shared_owner,
380
378
  si.role AS shared_role
381
379
  FROM search_index
382
380
  JOIN skills s ON s.id = search_index.ref_id AND s.folder = search_index.ref_folder
383
381
  LEFT JOIN shared_items si ON si.status = 'active' AND si.mirror_path = s.source_path
384
382
  WHERE search_index.ref_table = 'skills' AND search_index MATCH ? ${conditions.map((c) => `AND ${c}`).join(' ')}
385
- ORDER BY bm25(search_index)
386
- LIMIT ? OFFSET ?`)
387
- .all(...params);
383
+ ORDER BY ${SEARCH_BM25}
384
+ LIMIT ? OFFSET ?`);
385
+ const rows = withOrFallback(this.db, query, offset, (ftsQuery, probe) => statement.all(ftsQuery, ...params, ...(probe ? [1, 0] : [limit, offset])));
388
386
  return rows.map(({ shared_owner, shared_role, ...rest }) => ({
389
387
  ...rest,
390
388
  shared_owner: shared_owner ?? undefined,
@@ -23,7 +23,7 @@ export function registerSkillTools(mcp, repo) {
23
23
  const items = repo.list(query, folder, { includePaused: include_paused, group });
24
24
  return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
25
25
  });
26
- mcp.tool('skill_search', 'Full-text search over skill description/body/tags (grep/find-like, ranked by relevance) — unlike skill_list\'s substring metadata filter, this searches the full markdown body. `query` is raw SQLite FTS5 MATCH syntax: bare words, "exact phrases", prefix* wildcards, AND/OR/NOT boolean operators; hyphenated/punctuated terms must be quoted, e.g. "blue-green". Can be combined with status/owner/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call skill_get on a hit\'s name for that. Paused skills are hidden by default — pass include_paused to see them.', {
26
+ mcp.tool('skill_search', 'Full-text search over skill description/body/tags (grep/find-like, ranked by relevance) — unlike skill_list\'s substring metadata filter, this searches the full markdown body. `query` is SQLite FTS5 MATCH syntax: bare words (all must match; falls back to any-word when nothing matches all), "exact phrases", prefix* wildcards, AND/OR/NOT. Can be combined with status/owner/tag filters. Returns ranked hits with a highlighted snippet, not the full body — call skill_get on a hit\'s name for that. Paused skills are hidden by default — pass include_paused to see them.', {
27
27
  query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
28
28
  status: statusSchema(SKILL_STATUS_DEFAULTS).optional(),
29
29
  owner: z.string().optional(),
@@ -0,0 +1,112 @@
1
+ /** Upper bound per chunk — sized so heading + context + text stays inside a small embedding model's 512-token window. */
2
+ export const MAX_CHUNK_CHARS = 1400;
3
+ /** Sections shorter than this fold into the chunk before them rather than standing alone as a near-empty hit. */
4
+ const MIN_CHUNK_CHARS = 200;
5
+ const HEADING_RE = /^(#{1,6})\s+(.+?)\s*#*\s*$/;
6
+ const FENCE_RE = /^\s*(```|~~~)/;
7
+ /**
8
+ * Splits a markdown body into retrieval-sized chunks along its heading structure, so a search hit
9
+ * can return the one relevant section instead of the whole document. Fenced code blocks are never
10
+ * split on a `#` line inside them, and an oversized section is cut at paragraph boundaries (never
11
+ * mid-fence) rather than at an arbitrary character offset.
12
+ */
13
+ export function chunkMarkdown(body) {
14
+ const sections = [{ level: 0, title: '', path: '', lines: [] }];
15
+ const stack = [];
16
+ let fence = null;
17
+ for (const line of body.split(/\r?\n/)) {
18
+ const fenceMatch = FENCE_RE.exec(line);
19
+ if (fenceMatch) {
20
+ if (!fence)
21
+ fence = fenceMatch[1];
22
+ else if (fenceMatch[1] === fence)
23
+ fence = null;
24
+ }
25
+ const heading = fence ? null : HEADING_RE.exec(line);
26
+ if (heading) {
27
+ const level = heading[1].length;
28
+ while (stack.length > 0 && stack[stack.length - 1].level >= level)
29
+ stack.pop();
30
+ stack.push({ level, title: heading[2] });
31
+ sections.push({ level, title: heading[2], path: stack.map((s) => s.title).join(' › '), lines: [] });
32
+ }
33
+ else {
34
+ sections[sections.length - 1].lines.push(line);
35
+ }
36
+ }
37
+ const chunks = [];
38
+ for (const section of sections) {
39
+ const text = section.lines.join('\n').trim();
40
+ if (!text)
41
+ continue;
42
+ const previous = chunks[chunks.length - 1];
43
+ if (previous && text.length < MIN_CHUNK_CHARS && previous.text.length + text.length <= MAX_CHUNK_CHARS) {
44
+ // Keep the folded section's own heading inline — its text no longer has a heading path of its own.
45
+ previous.text += `\n\n${'#'.repeat(section.level)} ${section.title}\n${text}`;
46
+ continue;
47
+ }
48
+ for (const piece of splitOversized(text))
49
+ chunks.push({ heading: section.path, text: piece });
50
+ }
51
+ return chunks.map((chunk, ord) => ({ ord, ...chunk }));
52
+ }
53
+ /** Greedily packs paragraphs up to MAX_CHUNK_CHARS; a single paragraph over the limit is cut by lines. */
54
+ function splitOversized(text) {
55
+ if (text.length <= MAX_CHUNK_CHARS)
56
+ return [text];
57
+ const pieces = [];
58
+ let current = '';
59
+ const flush = () => {
60
+ if (current.trim())
61
+ pieces.push(current.trim());
62
+ current = '';
63
+ };
64
+ for (const paragraph of splitParagraphs(text)) {
65
+ if (paragraph.length > MAX_CHUNK_CHARS) {
66
+ flush();
67
+ for (const line of paragraph.split('\n')) {
68
+ if (current.length + line.length + 1 > MAX_CHUNK_CHARS)
69
+ flush();
70
+ // A single line longer than the limit (minified JSON, a long URL list) still has to be cut somewhere.
71
+ for (let i = 0; i < line.length; i += MAX_CHUNK_CHARS) {
72
+ current += (current ? '\n' : '') + line.slice(i, i + MAX_CHUNK_CHARS);
73
+ if (line.length > MAX_CHUNK_CHARS)
74
+ flush();
75
+ }
76
+ }
77
+ flush();
78
+ continue;
79
+ }
80
+ if (current.length + paragraph.length + 2 > MAX_CHUNK_CHARS)
81
+ flush();
82
+ current += (current ? '\n\n' : '') + paragraph;
83
+ }
84
+ flush();
85
+ return pieces;
86
+ }
87
+ /** Blank-line-separated paragraphs, treating a fenced code block (blank lines included) as one paragraph. */
88
+ function splitParagraphs(text) {
89
+ const paragraphs = [];
90
+ let current = [];
91
+ let fence = null;
92
+ for (const line of text.split('\n')) {
93
+ const fenceMatch = FENCE_RE.exec(line);
94
+ if (fenceMatch) {
95
+ if (!fence)
96
+ fence = fenceMatch[1];
97
+ else if (fenceMatch[1] === fence)
98
+ fence = null;
99
+ }
100
+ if (!fence && line.trim() === '') {
101
+ if (current.length > 0)
102
+ paragraphs.push(current.join('\n'));
103
+ current = [];
104
+ }
105
+ else {
106
+ current.push(line);
107
+ }
108
+ }
109
+ if (current.length > 0)
110
+ paragraphs.push(current.join('\n'));
111
+ return paragraphs;
112
+ }
@@ -0,0 +1,125 @@
1
+ import crypto from 'node:crypto';
2
+ import { chunkMarkdown } from './chunker.js';
3
+ import { flattenTags } from './db.js';
4
+ /**
5
+ * Bumped on every write to `chunks` (content or embedding) — lets the in-memory vector cache in
6
+ * recall.ts know when to reload, and wakes the embedding indexer via the listener below.
7
+ */
8
+ let chunksVersion = 0;
9
+ let onChunksChanged;
10
+ export function getChunksVersion() {
11
+ return chunksVersion;
12
+ }
13
+ export function bumpChunksVersion() {
14
+ chunksVersion++;
15
+ }
16
+ /** Registers the single callback fired whenever chunk CONTENT changes (not on embedding writes) — see EmbeddingIndexer.start. */
17
+ export function setChunksChangedListener(listener) {
18
+ onChunksChanged = listener;
19
+ }
20
+ /** The text an embedding is computed from: doc-level context + heading path + chunk text, so a chunk stays meaningful out of its document. */
21
+ export function embedTextFor(chunk) {
22
+ return [chunk.context, chunk.heading, chunk.text].filter(Boolean).join('\n');
23
+ }
24
+ /** Doc-level line prepended to every chunk's embedding input — name/key, description, and a skill's trigger phrases. */
25
+ export function chunkContext(table, row) {
26
+ const label = table === 'skills' ? row.id : row.key;
27
+ const triggers = table === 'skills' ? flattenTags(String(row.trigger_phrases ?? '[]')) : '';
28
+ return [`${label ?? ''}: ${row.description ?? ''}`, triggers].filter(Boolean).join(' — ');
29
+ }
30
+ /**
31
+ * Replaces one doc's chunks (and their chunk_fts rows) with a fresh split of `body`. An embedding
32
+ * is carried over to any new chunk whose embedded text is byte-identical to an old one, so editing
33
+ * one section of a long doc re-embeds only that section. A doc with an empty body still gets one
34
+ * chunk (its description) so it stays reachable by semantic search.
35
+ */
36
+ export function writeChunks(db, table, id, refFolder, context, description, body) {
37
+ const old = db
38
+ .prepare(`SELECT id, hash, embedding, embed_model FROM chunks WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`)
39
+ .all(table, id, refFolder);
40
+ const reusable = new Map(old.filter((o) => o.embedding).map((o) => [o.hash, o]));
41
+ deleteChunkRows(db, old.map((o) => o.id));
42
+ let chunks = chunkMarkdown(body);
43
+ if (chunks.length === 0)
44
+ chunks = [{ ord: 0, heading: '', text: description }];
45
+ const insertChunk = db.prepare(`INSERT INTO chunks (ref_table, ref_id, ref_folder, ord, context, heading, text, hash, embedding, embed_model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`);
46
+ const insertFts = db.prepare(`INSERT INTO chunk_fts (rowid, heading, body) VALUES (?, ?, ?)`);
47
+ const insertTerms = db.prepare(`INSERT INTO term_index (rowid, text) VALUES (?, ?)`);
48
+ for (const chunk of chunks) {
49
+ const hash = crypto.createHash('sha1').update(embedTextFor({ context, ...chunk })).digest('hex');
50
+ const kept = reusable.get(hash);
51
+ const { lastInsertRowid } = insertChunk.run(table, id, refFolder, chunk.ord, context, chunk.heading, chunk.text, hash, kept?.embedding ?? null, kept?.embed_model ?? null);
52
+ insertFts.run(lastInsertRowid, chunk.heading, chunk.text);
53
+ insertTerms.run(lastInsertRowid, embedTextFor({ context, ...chunk }));
54
+ }
55
+ chunksVersion++;
56
+ onChunksChanged?.();
57
+ }
58
+ export function deleteChunks(db, table, id, refFolder) {
59
+ const rows = db.prepare(`SELECT id FROM chunks WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).all(table, id, refFolder);
60
+ deleteChunkRows(db, rows.map((r) => r.id));
61
+ chunksVersion++;
62
+ }
63
+ function deleteChunkRows(db, ids) {
64
+ const deleteFts = db.prepare(`DELETE FROM chunk_fts WHERE rowid = ?`);
65
+ const deleteChunk = db.prepare(`DELETE FROM chunks WHERE id = ?`);
66
+ const deleteTerms = db.prepare(`DELETE FROM term_index WHERE rowid = ?`);
67
+ for (const id of ids) {
68
+ deleteFts.run(id);
69
+ deleteTerms.run(id);
70
+ deleteChunk.run(id);
71
+ }
72
+ }
73
+ /**
74
+ * Drops chunks whose parent doc row is gone. A cache rebuild wipes skills/memory_docs and rescans
75
+ * but deliberately leaves `chunks` alone so writeChunks can carry every unchanged embedding over —
76
+ * this then removes whatever the rescan did not re-claim.
77
+ */
78
+ export function pruneOrphanChunks(db) {
79
+ const orphans = db
80
+ .prepare(`SELECT c.id FROM chunks c WHERE
81
+ (c.ref_table = 'skills' AND NOT EXISTS (SELECT 1 FROM skills s WHERE s.id = c.ref_id AND s.folder = c.ref_folder))
82
+ OR (c.ref_table = 'memory_docs' AND NOT EXISTS (SELECT 1 FROM memory_docs m WHERE m.source_path = c.ref_id))`)
83
+ .all();
84
+ if (orphans.length === 0)
85
+ return;
86
+ deleteChunkRows(db, orphans.map((o) => o.id));
87
+ chunksVersion++;
88
+ }
89
+ /**
90
+ * Chunks every cached doc that has none yet — cache files created before `chunks` existed, whose
91
+ * rows upsertFile's mtime check would otherwise skip forever. Safe to call every startup.
92
+ */
93
+ export function backfillChunks(db) {
94
+ const skills = db
95
+ .prepare(`SELECT id, folder, description, trigger_phrases, body FROM skills s
96
+ WHERE NOT EXISTS (SELECT 1 FROM chunks c WHERE c.ref_table = 'skills' AND c.ref_id = s.id AND c.ref_folder = s.folder)`)
97
+ .all();
98
+ const memoryDocs = db
99
+ .prepare(`SELECT source_path, key, description, body FROM memory_docs m
100
+ WHERE NOT EXISTS (SELECT 1 FROM chunks c WHERE c.ref_table = 'memory_docs' AND c.ref_id = m.source_path)`)
101
+ .all();
102
+ if (skills.length === 0 && memoryDocs.length === 0)
103
+ return;
104
+ db.transaction(() => {
105
+ for (const row of skills)
106
+ writeChunks(db, 'skills', row.id, row.folder, chunkContext('skills', row), row.description, row.body);
107
+ for (const row of memoryDocs)
108
+ writeChunks(db, 'memory_docs', row.source_path, '', chunkContext('memory_docs', row), row.description, row.body);
109
+ })();
110
+ }
111
+ /** Fills term_index for cache files whose chunks were written before it existed. Safe to call every startup. */
112
+ export function backfillTermIndex(db) {
113
+ const { n } = db.prepare(`SELECT COUNT(*) AS n FROM term_index`).get();
114
+ if (n > 0)
115
+ return;
116
+ const rows = db.prepare(`SELECT id, context, heading, text FROM chunks`).all();
117
+ if (rows.length === 0)
118
+ return;
119
+ const insert = db.prepare(`INSERT INTO term_index (rowid, text) VALUES (?, ?)`);
120
+ db.transaction(() => {
121
+ for (const row of rows)
122
+ insert.run(row.id, embedTextFor(row));
123
+ })();
124
+ chunksVersion++;
125
+ }
@@ -1,4 +1,5 @@
1
1
  import Database from 'better-sqlite3';
2
+ import { backfillChunks, backfillTermIndex } from './chunks.js';
2
3
  export function openCache(dbPath) {
3
4
  const db = new Database(dbPath);
4
5
  db.pragma('journal_mode = WAL');
@@ -53,10 +54,39 @@ export function openCache(dbPath) {
53
54
  body,
54
55
  tags,
55
56
  key, -- memory docs' key; also holds a skill's group (skills never had a key, so this is safe to share)
56
- filename,
57
+ filename, -- memory docs' on-disk filename; for a skill, its name (its file is always SKILL.md)
58
+ triggers, -- a skill's trigger_phrases; '' for memory docs
57
59
  tokenize = 'porter unicode61'
58
60
  );
59
61
 
62
+ -- Heading-level pieces of each doc's body (see chunker.ts) — the retrieval unit for bucket_recall.
63
+ -- embedding is a unit-length Float32 vector, NULL until the background indexer reaches the row
64
+ -- (or forever, when embeddings are off); embed_model records which model produced it.
65
+ CREATE TABLE IF NOT EXISTS chunks (
66
+ id INTEGER PRIMARY KEY,
67
+ ref_table TEXT NOT NULL,
68
+ ref_id TEXT NOT NULL,
69
+ ref_folder TEXT NOT NULL DEFAULT '',
70
+ ord INTEGER NOT NULL,
71
+ context TEXT NOT NULL,
72
+ heading TEXT NOT NULL,
73
+ text TEXT NOT NULL,
74
+ hash TEXT NOT NULL,
75
+ embedding BLOB,
76
+ embed_model TEXT
77
+ );
78
+
79
+ CREATE INDEX IF NOT EXISTS idx_chunks_ref ON chunks(ref_table, ref_id, ref_folder);
80
+
81
+ -- rowid == chunks.id
82
+ CREATE VIRTUAL TABLE IF NOT EXISTS chunk_fts USING fts5(heading, body, tokenize = 'porter unicode61');
83
+
84
+ -- Unstemmed words per chunk (rowid == chunks.id), kept only so term_vocab can list the corpus
85
+ -- vocabulary for typo correction (see spelling.ts) — the stemmed indexes above hold word stems
86
+ -- ("migrat"), which a misspelling cannot be compared against. Stores no text and no positions.
87
+ CREATE VIRTUAL TABLE IF NOT EXISTS term_index USING fts5(text, content = '', contentless_delete = 1, detail = none, tokenize = 'unicode61');
88
+ CREATE VIRTUAL TABLE IF NOT EXISTS term_vocab USING fts5vocab(term_index, 'row');
89
+
60
90
  CREATE TABLE IF NOT EXISTS doc_dates (
61
91
  ref_table TEXT NOT NULL,
62
92
  ref_id TEXT NOT NULL,
@@ -115,6 +145,8 @@ export function openCache(dbPath) {
115
145
  ]);
116
146
  ensureSearchIndexHasKeyColumn(db);
117
147
  backfillSearchIndex(db);
148
+ backfillChunks(db);
149
+ backfillTermIndex(db);
118
150
  return db;
119
151
  }
120
152
  /**
@@ -133,7 +165,7 @@ export function openCache(dbPath) {
133
165
  */
134
166
  function ensureSearchIndexHasKeyColumn(db) {
135
167
  const cols = db.prepare(`PRAGMA table_info(search_index)`).all();
136
- if (cols.length === 0 || (cols.some((c) => c.name === 'key') && cols.some((c) => c.name === 'ref_folder') && cols.some((c) => c.name === 'filename')))
168
+ if (cols.length === 0 || ['key', 'ref_folder', 'filename', 'triggers'].every((name) => cols.some((c) => c.name === name)))
137
169
  return; // fresh table, or already migrated
138
170
  db.exec(`DROP TABLE search_index`);
139
171
  db.exec(`
@@ -146,6 +178,7 @@ function ensureSearchIndexHasKeyColumn(db) {
146
178
  tags,
147
179
  key,
148
180
  filename,
181
+ triggers,
149
182
  tokenize = 'porter unicode61'
150
183
  );
151
184
  `);
@@ -209,18 +242,18 @@ function backfillSearchIndex(db) {
209
242
  const { count: indexed } = db.prepare(`SELECT COUNT(*) as count FROM search_index`).get();
210
243
  if (indexed > 0)
211
244
  return;
212
- const skillRows = db.prepare(`SELECT id, folder, description, body, tags, skill_group FROM skills`).all();
245
+ const skillRows = db.prepare(`SELECT id, folder, description, body, tags, skill_group, trigger_phrases FROM skills`).all();
213
246
  const memoryRows = db.prepare(`SELECT source_path AS id, key, description, body, tags FROM memory_docs`).all();
214
247
  if (skillRows.length === 0 && memoryRows.length === 0)
215
248
  return;
216
- const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, ref_folder, description, body, tags, key, filename) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`);
249
+ const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, ref_folder, description, body, tags, key, filename, triggers) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`);
217
250
  const insertAll = db.transaction(() => {
218
251
  for (const row of skillRows) {
219
- insert.run('skills', row.id, row.folder, row.description, row.body, flattenTags(row.tags), row.skill_group ?? '', '');
252
+ insert.run('skills', row.id, row.folder, row.description, row.body, flattenTags(row.tags), row.skill_group ?? '', row.id, flattenTags(row.trigger_phrases));
220
253
  }
221
254
  for (const row of memoryRows) {
222
255
  // row.id is source_path (see the SELECT above) — filename is its basename.
223
- insert.run('memory_docs', row.id, '', row.description, row.body, flattenTags(row.tags), row.key, row.id.split('/').pop() ?? row.id);
256
+ insert.run('memory_docs', row.id, '', row.description, row.body, flattenTags(row.tags), row.key, row.id.split('/').pop() ?? row.id, '');
224
257
  }
225
258
  });
226
259
  insertAll();