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
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,
|
|
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 = [
|
|
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
|
|
435
|
-
|
|
436
|
-
|
|
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
|
|
445
|
-
LIMIT ? OFFSET ?`)
|
|
446
|
-
|
|
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),
|
package/dist/src/memory/tools.js
CHANGED
|
@@ -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
|
|
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(),
|
package/dist/src/server.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
4
|
-
|
|
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,
|
|
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 = [
|
|
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
|
|
376
|
-
|
|
377
|
-
|
|
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
|
|
386
|
-
LIMIT ? OFFSET ?`)
|
|
387
|
-
|
|
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,
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/dist/src/store/db.js
CHANGED
|
@@ -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 ||
|
|
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();
|