mcp-memory-bucket 0.2.2 → 0.3.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 -13
- package/dist/client/assets/{index-BxAZ48QK.js → index-DIO48C0V.js} +212 -113
- package/dist/client/index.html +1 -1
- package/dist/src/memory/repository.js +117 -0
- package/dist/src/memory/tools.js +52 -0
- package/dist/src/server.js +3 -1
- package/dist/src/shared/relocate-tool.js +32 -18
- package/dist/src/shared/relocate.js +9 -0
- package/dist/src/shared/search-tool.js +18 -0
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +423 -7
- package/dist/src/skills/repository.js +129 -0
- package/dist/src/skills/tools.js +71 -0
- package/dist/src/store/db.js +21 -8
- package/dist/src/store/search.js +60 -0
- package/dist/src/store/sync.js +6 -2
- package/dist/src/web/routes.js +98 -4
- package/dist/src/web/ui-tool.js +1 -1
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
:root { color-scheme: light dark; }
|
|
8
8
|
body { font-family: system-ui, sans-serif; margin: 0; }
|
|
9
9
|
</style>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-DIO48C0V.js"></script>
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<mem-bucket-app></mem-bucket-app>
|
|
@@ -5,6 +5,7 @@ import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
|
5
5
|
import { slugify } from '../store/slug.js';
|
|
6
6
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
7
7
|
import { upsertFile, removeFile, scanSingleRoot, unregisterRoot, memorySyncSpec } from '../store/sync.js';
|
|
8
|
+
import { SearchQueryError } from '../store/search.js';
|
|
8
9
|
import { normalizeKey } from '../types.js';
|
|
9
10
|
function rowToDoc(row) {
|
|
10
11
|
return {
|
|
@@ -16,6 +17,8 @@ function rowToDoc(row) {
|
|
|
16
17
|
tags: JSON.parse(row.tags),
|
|
17
18
|
status: row.status,
|
|
18
19
|
related_to: row.related_to,
|
|
20
|
+
deprecated: !!row.deprecated,
|
|
21
|
+
created_at: row.created_at ?? undefined,
|
|
19
22
|
source_path: row.source_path,
|
|
20
23
|
root: row.root,
|
|
21
24
|
body: row.body,
|
|
@@ -79,10 +82,58 @@ export class MemoryRepository {
|
|
|
79
82
|
: this.db.prepare(`SELECT * FROM memory_docs WHERE key = ?`).all(normalized);
|
|
80
83
|
return rows.map(rowToDoc);
|
|
81
84
|
}
|
|
85
|
+
/**
|
|
86
|
+
* Full-text search over memory description/body/tags via FTS5 — `query` is
|
|
87
|
+
* raw FTS5 MATCH syntax (AND/OR/NOT, "phrases", prefix*). Ranked by bm25.
|
|
88
|
+
* Optional metadata filters (doc_type/status/root/tag) apply before limit/offset,
|
|
89
|
+
* so pagination stays correct even when filtering narrows the FTS hit set.
|
|
90
|
+
*/
|
|
91
|
+
search(query, opts = {}) {
|
|
92
|
+
const { docType, status, root, tag, limit = 20, offset = 0 } = opts;
|
|
93
|
+
const conditions = [];
|
|
94
|
+
const params = [query];
|
|
95
|
+
if (docType) {
|
|
96
|
+
conditions.push('m.doc_type = ?');
|
|
97
|
+
params.push(docType);
|
|
98
|
+
}
|
|
99
|
+
if (status) {
|
|
100
|
+
conditions.push('m.status = ?');
|
|
101
|
+
params.push(status);
|
|
102
|
+
}
|
|
103
|
+
if (root) {
|
|
104
|
+
conditions.push('m.root = ?');
|
|
105
|
+
params.push(root);
|
|
106
|
+
}
|
|
107
|
+
if (tag) {
|
|
108
|
+
conditions.push('EXISTS (SELECT 1 FROM json_each(m.tags) WHERE value = ?)');
|
|
109
|
+
params.push(tag);
|
|
110
|
+
}
|
|
111
|
+
params.push(limit, offset);
|
|
112
|
+
try {
|
|
113
|
+
const rows = this.db
|
|
114
|
+
.prepare(`SELECT m.id, m.key, m.description, m.doc_type, m.root,
|
|
115
|
+
snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
|
|
116
|
+
-bm25(search_index) AS score
|
|
117
|
+
FROM search_index
|
|
118
|
+
JOIN memory_docs m ON m.id = search_index.ref_id
|
|
119
|
+
WHERE search_index.ref_table = 'memory_docs' AND search_index MATCH ? ${conditions.map((c) => `AND ${c}`).join(' ')}
|
|
120
|
+
ORDER BY bm25(search_index)
|
|
121
|
+
LIMIT ? OFFSET ?`)
|
|
122
|
+
.all(...params);
|
|
123
|
+
return rows;
|
|
124
|
+
}
|
|
125
|
+
catch (err) {
|
|
126
|
+
throw new SearchQueryError(query, err);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
82
129
|
get(id) {
|
|
83
130
|
const row = this.db.prepare(`SELECT * FROM memory_docs WHERE id = ?`).get(id);
|
|
84
131
|
return row ? rowToDoc(row) : null;
|
|
85
132
|
}
|
|
133
|
+
/** Fetches many memory docs by id in one call — e.g. hydrating full bodies for a batch of search() hits. Missing ids are simply absent from the result, not errors. */
|
|
134
|
+
bulkGet(ids) {
|
|
135
|
+
return ids.map((id) => this.get(id)).filter((doc) => doc !== null);
|
|
136
|
+
}
|
|
86
137
|
listKeys(keyPrefix) {
|
|
87
138
|
const rows = this.db
|
|
88
139
|
.prepare(`SELECT key, COUNT(*) as doc_count FROM memory_docs GROUP BY key ORDER BY key`)
|
|
@@ -107,6 +158,8 @@ export class MemoryRepository {
|
|
|
107
158
|
tags: input.tags ?? [],
|
|
108
159
|
status: 'active',
|
|
109
160
|
related_to: input.related_to ?? null,
|
|
161
|
+
deprecated: false,
|
|
162
|
+
created_at: new Date().toISOString(),
|
|
110
163
|
source_path: filePath,
|
|
111
164
|
root: targetRoot.name,
|
|
112
165
|
};
|
|
@@ -114,6 +167,22 @@ export class MemoryRepository {
|
|
|
114
167
|
upsertFile(this.db, this.syncSpec, filePath);
|
|
115
168
|
return { ...fm, body: input.body };
|
|
116
169
|
}
|
|
170
|
+
/**
|
|
171
|
+
* Creates many memory docs in one call — each entry is the same shape as
|
|
172
|
+
* create()'s input. Returns per-key results (with the id filled in on
|
|
173
|
+
* success) so one bad entry doesn't abort the rest of the batch.
|
|
174
|
+
*/
|
|
175
|
+
bulkCreate(entries) {
|
|
176
|
+
return entries.map((entry) => {
|
|
177
|
+
try {
|
|
178
|
+
const doc = this.create(entry);
|
|
179
|
+
return { key: entry.key, ok: true, id: doc.id };
|
|
180
|
+
}
|
|
181
|
+
catch (err) {
|
|
182
|
+
return { key: entry.key, ok: false, error: err.message };
|
|
183
|
+
}
|
|
184
|
+
});
|
|
185
|
+
}
|
|
117
186
|
update(id, frontmatter, body) {
|
|
118
187
|
const existing = this.get(id);
|
|
119
188
|
if (!existing)
|
|
@@ -129,6 +198,38 @@ export class MemoryRepository {
|
|
|
129
198
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
130
199
|
return { ...merged, body: newBody };
|
|
131
200
|
}
|
|
201
|
+
/**
|
|
202
|
+
* Applies the same frontmatter change to many memory docs at once — e.g.
|
|
203
|
+
* add/remove a tag across a batch found via search(), or flip status for a
|
|
204
|
+
* group (e.g. mark a set of docs "shipped"). Tags in `add_tags`/`remove_tags`
|
|
205
|
+
* are merged/subtracted per-doc; `status`/`related_to` overwrite uniformly
|
|
206
|
+
* when provided. Never touches body. Returns per-id results so partial
|
|
207
|
+
* failures (e.g. an unknown id) don't abort the rest of the batch.
|
|
208
|
+
*/
|
|
209
|
+
bulkUpdate(ids, changes) {
|
|
210
|
+
return ids.map((id) => {
|
|
211
|
+
try {
|
|
212
|
+
const existing = this.get(id);
|
|
213
|
+
if (!existing)
|
|
214
|
+
throw new Error(`memory doc with id "${id}" not found`);
|
|
215
|
+
let tags = existing.tags;
|
|
216
|
+
if (changes.add_tags?.length)
|
|
217
|
+
tags = Array.from(new Set([...tags, ...changes.add_tags]));
|
|
218
|
+
if (changes.remove_tags?.length)
|
|
219
|
+
tags = tags.filter((t) => !changes.remove_tags.includes(t));
|
|
220
|
+
this.update(id, {
|
|
221
|
+
tags,
|
|
222
|
+
...(changes.status !== undefined ? { status: changes.status } : {}),
|
|
223
|
+
...(changes.related_to !== undefined ? { related_to: changes.related_to } : {}),
|
|
224
|
+
...(changes.deprecated !== undefined ? { deprecated: changes.deprecated } : {}),
|
|
225
|
+
});
|
|
226
|
+
return { id, ok: true };
|
|
227
|
+
}
|
|
228
|
+
catch (err) {
|
|
229
|
+
return { id, ok: false, error: err.message };
|
|
230
|
+
}
|
|
231
|
+
});
|
|
232
|
+
}
|
|
132
233
|
delete(id) {
|
|
133
234
|
const existing = this.get(id);
|
|
134
235
|
if (!existing)
|
|
@@ -136,6 +237,22 @@ export class MemoryRepository {
|
|
|
136
237
|
fs.unlinkSync(existing.source_path);
|
|
137
238
|
removeFile(this.db, 'memory_docs', existing.source_path);
|
|
138
239
|
}
|
|
240
|
+
/**
|
|
241
|
+
* Deletes many memory docs by id in one call — e.g. cleaning up a batch of
|
|
242
|
+
* abandoned docs found via search(). Returns per-id results so one bad id
|
|
243
|
+
* doesn't abort the rest of the batch.
|
|
244
|
+
*/
|
|
245
|
+
bulkDelete(ids) {
|
|
246
|
+
return ids.map((id) => {
|
|
247
|
+
try {
|
|
248
|
+
this.delete(id);
|
|
249
|
+
return { id, ok: true };
|
|
250
|
+
}
|
|
251
|
+
catch (err) {
|
|
252
|
+
return { id, ok: false, error: err.message };
|
|
253
|
+
}
|
|
254
|
+
});
|
|
255
|
+
}
|
|
139
256
|
}
|
|
140
257
|
function stripSourcePath(fm) {
|
|
141
258
|
const { source_path: _sp, root: _root, ...rest } = fm;
|
package/dist/src/memory/tools.js
CHANGED
|
@@ -14,10 +14,42 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
14
14
|
const docs = repo.getByKey(key, doc_type);
|
|
15
15
|
return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
|
|
16
16
|
});
|
|
17
|
+
mcp.tool('memory_bulk_get', 'Fetches many memory docs by id in one call, including full markdown bodies — e.g. hydrating a batch of memory_search hits (which return ids, not keys). Missing ids are simply omitted from the result, not errors.', { ids: z.array(z.string()).min(1) }, async ({ ids }) => {
|
|
18
|
+
const docs = repo.bulkGet(ids);
|
|
19
|
+
return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
|
|
20
|
+
});
|
|
17
21
|
mcp.tool('memory_list', 'Browses available memory keys (optionally filtered by a prefix) without needing to know the exact key upfront. Returns each key with its doc count.', { key_prefix: z.string().optional() }, async ({ key_prefix }) => {
|
|
18
22
|
const keys = repo.listKeys(key_prefix);
|
|
19
23
|
return { content: [{ type: 'text', text: JSON.stringify(keys, null, 2) }] };
|
|
20
24
|
});
|
|
25
|
+
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.', {
|
|
26
|
+
query: z.string().describe('FTS5 match expression, e.g. `migration AND rollback` or `"blue green"`'),
|
|
27
|
+
doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
|
|
28
|
+
status: z.enum(MEMORY_STATUS).optional(),
|
|
29
|
+
tag: z.string().optional(),
|
|
30
|
+
...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
|
|
31
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
32
|
+
offset: z.number().int().nonnegative().optional(),
|
|
33
|
+
}, async ({ query, doc_type, status, tag, root, limit, offset }) => {
|
|
34
|
+
try {
|
|
35
|
+
const hits = repo.search(query, { docType: doc_type, status, tag, root, limit, offset });
|
|
36
|
+
return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
|
|
37
|
+
}
|
|
38
|
+
catch (err) {
|
|
39
|
+
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
mcp.tool('memory_bulk_update', 'Applies the same frontmatter change to many memory docs at once by id — e.g. add/remove a tag across a batch found via memory_search, or mark a group of docs "shipped". add_tags/remove_tags merge or subtract per-doc; status/related_to overwrite uniformly when provided. Body is never touched. Returns per-id success/failure so one bad id doesn\'t abort the batch.', {
|
|
43
|
+
ids: z.array(z.string()).min(1),
|
|
44
|
+
add_tags: z.array(z.string()).optional(),
|
|
45
|
+
remove_tags: z.array(z.string()).optional(),
|
|
46
|
+
status: z.enum(MEMORY_STATUS).optional(),
|
|
47
|
+
related_to: z.string().nullable().optional(),
|
|
48
|
+
deprecated: z.boolean().optional().describe('marks docs as deprecated (or un-deprecates when false) — independent of status'),
|
|
49
|
+
}, async ({ ids, add_tags, remove_tags, status, related_to, deprecated }) => {
|
|
50
|
+
const results = repo.bulkUpdate(ids, { add_tags, remove_tags, status, related_to, deprecated });
|
|
51
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
52
|
+
});
|
|
21
53
|
mcp.tool('memory_create', `Writes a new memory doc (plan, spec, SQL, testing notes, discovery, etc.) into the memory root under the given key. ${AUTHORING_SKILL_HINT}`, {
|
|
22
54
|
key: z.string().describe('lookup handle — ticket ID or free-form name; normalized on write'),
|
|
23
55
|
key_type: z.enum(MEMORY_KEY_TYPES),
|
|
@@ -37,6 +69,21 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
37
69
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
38
70
|
}
|
|
39
71
|
});
|
|
72
|
+
const memoryEntrySchema = z.object({
|
|
73
|
+
key: z.string().describe('lookup handle — ticket ID or free-form name; normalized on write'),
|
|
74
|
+
key_type: z.enum(MEMORY_KEY_TYPES),
|
|
75
|
+
doc_type: z.enum(MEMORY_DOC_TYPES),
|
|
76
|
+
description: z.string().describe('distinguishes this doc from others sharing the same key'),
|
|
77
|
+
body: z.string(),
|
|
78
|
+
tags: z.array(z.string()).optional(),
|
|
79
|
+
related_to: z.string().optional(),
|
|
80
|
+
folder: z.string().optional(),
|
|
81
|
+
...(multiRoot ? { root: z.string().describe(`which configured memory root to write into: ${rootNames}`) } : {}),
|
|
82
|
+
});
|
|
83
|
+
mcp.tool('memory_bulk_create', `Writes many memory docs in one call — each entry is the same shape as memory_create's args. Returns per-key success/failure (with the new id on success) so one bad entry doesn't abort the rest of the batch. ${AUTHORING_SKILL_HINT}`, { entries: z.array(memoryEntrySchema).min(1) }, async ({ entries }) => {
|
|
84
|
+
const results = repo.bulkCreate(entries);
|
|
85
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
86
|
+
});
|
|
40
87
|
mcp.tool('memory_update', `Edits an existing memory doc in place — frontmatter fields and/or body. Only provided fields change. ${AUTHORING_SKILL_HINT}`, {
|
|
41
88
|
id: z.string(),
|
|
42
89
|
key: z.string().optional(),
|
|
@@ -47,6 +94,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
47
94
|
tags: z.array(z.string()).optional(),
|
|
48
95
|
status: z.enum(MEMORY_STATUS).optional(),
|
|
49
96
|
related_to: z.string().optional(),
|
|
97
|
+
deprecated: z.boolean().optional().describe('marks the doc as deprecated (or un-deprecates when false) — independent of status'),
|
|
50
98
|
}, async ({ id, body, ...frontmatterFields }) => {
|
|
51
99
|
try {
|
|
52
100
|
const doc = repo.update(id, frontmatterFields, body);
|
|
@@ -65,6 +113,10 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
65
113
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
66
114
|
}
|
|
67
115
|
});
|
|
116
|
+
mcp.tool('memory_bulk_delete', 'Hard-deletes many memory docs by id in one call — e.g. cleaning up a batch of abandoned docs found via memory_search. No tombstone. Returns per-id success/failure so one bad id doesn\'t abort the rest of the batch.', { ids: z.array(z.string()).min(1) }, async ({ ids }) => {
|
|
117
|
+
const results = repo.bulkDelete(ids);
|
|
118
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
119
|
+
});
|
|
68
120
|
mcp.tool('memory_save_session', `Saves a summary of the current chat session as a memory doc (doc_type "session-summary"). Pass a summary, not a raw transcript. If key or description are omitted, ask the user for them rather than guessing. ${AUTHORING_SKILL_HINT}`, {
|
|
69
121
|
summary: z.string().describe('a scannable summary of the session, not a raw transcript'),
|
|
70
122
|
key: z.string().optional(),
|
package/dist/src/server.js
CHANGED
|
@@ -11,6 +11,7 @@ import { MemoryRepository } from './memory/repository.js';
|
|
|
11
11
|
import { registerSkillTools } from './skills/tools.js';
|
|
12
12
|
import { registerMemoryTools } from './memory/tools.js';
|
|
13
13
|
import { registerRelocateTool } from './shared/relocate-tool.js';
|
|
14
|
+
import { registerSearchTool } from './shared/search-tool.js';
|
|
14
15
|
import { buildWebRouter } from './web/routes.js';
|
|
15
16
|
import { registerUiTool } from './web/ui-tool.js';
|
|
16
17
|
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
@@ -47,12 +48,13 @@ if (config.skillRoots.length === 0 && config.memoryRoots.length === 0) {
|
|
|
47
48
|
// this server — surfaced both in serverInfo.description and instructions so
|
|
48
49
|
// clients that expose either to the model can make that association.
|
|
49
50
|
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.';
|
|
50
|
-
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
|
|
51
|
+
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 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. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete, 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. 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.`;
|
|
51
52
|
function buildMcpServer() {
|
|
52
53
|
const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
|
|
53
54
|
registerSkillTools(server, skillRepo);
|
|
54
55
|
registerMemoryTools(server, memoryRepo);
|
|
55
56
|
registerRelocateTool(server, skillRepo, memoryRepo);
|
|
57
|
+
registerSearchTool(server, db);
|
|
56
58
|
registerUiTool(server, PORT);
|
|
57
59
|
return server;
|
|
58
60
|
}
|
|
@@ -1,28 +1,29 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
|
-
import { relocate } from './relocate.js';
|
|
2
|
+
import { relocate, relocateMany } from './relocate.js';
|
|
3
3
|
const AUTHORING_SKILL_HINT = "Before your first call in a session, run skill_get(\"memory-bucket-authoring\") to learn the exact frontmatter schema and conventions — don't guess the shape.";
|
|
4
|
+
const overridesSchema = z
|
|
5
|
+
.object({
|
|
6
|
+
name: z.string().optional().describe('skill target only: lowercase, hyphenated, <=64 chars — becomes the skill folder name'),
|
|
7
|
+
description: z
|
|
8
|
+
.string()
|
|
9
|
+
.max(1024)
|
|
10
|
+
.optional()
|
|
11
|
+
.describe('required for skill target (cannot be inferred from a filename — must state what the skill does and when to use it); optional for memory target where it distinguishes this doc from siblings under the same key'),
|
|
12
|
+
key: z.string().optional().describe('memory target only'),
|
|
13
|
+
key_type: z.enum(['ticket', 'freeform']).optional().describe('memory target only'),
|
|
14
|
+
doc_type: z.enum(['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other']).optional().describe('memory target only'),
|
|
15
|
+
tags: z.array(z.string()).optional(),
|
|
16
|
+
status: z.enum(['stable', 'beta', 'unreviewed', 'active', 'shipped', 'abandoned']).optional(),
|
|
17
|
+
folder: z.string().optional().describe('optional subdirectory under the target root'),
|
|
18
|
+
root: z.string().optional().describe('which configured root to write into; required if multiple roots exist for the target type'),
|
|
19
|
+
})
|
|
20
|
+
.optional();
|
|
4
21
|
export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
|
|
5
22
|
mcp.tool('relocate', `Moves an existing local markdown file into the skill or memory source directory, converting it into a properly frontmattered doc. Default is a move (original deleted); pass keep_original to copy instead. On a weak/ambiguous filename match for memory docs, does nothing — no guess, no partial write; ask the user for an explicit key/description and retry with overrides. ${AUTHORING_SKILL_HINT}`, {
|
|
6
23
|
path: z.string().describe('absolute or relative path to the local file to relocate'),
|
|
7
24
|
target: z.enum(['skill', 'memory']),
|
|
8
25
|
keep_original: z.boolean().optional().describe('if true, copies instead of moving (default: move)'),
|
|
9
|
-
overrides:
|
|
10
|
-
.object({
|
|
11
|
-
name: z.string().optional().describe('skill target only: lowercase, hyphenated, <=64 chars — becomes the skill folder name'),
|
|
12
|
-
description: z
|
|
13
|
-
.string()
|
|
14
|
-
.max(1024)
|
|
15
|
-
.optional()
|
|
16
|
-
.describe('required for skill target (cannot be inferred from a filename — must state what the skill does and when to use it); optional for memory target where it distinguishes this doc from siblings under the same key'),
|
|
17
|
-
key: z.string().optional().describe('memory target only'),
|
|
18
|
-
key_type: z.enum(['ticket', 'freeform']).optional().describe('memory target only'),
|
|
19
|
-
doc_type: z.enum(['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other']).optional().describe('memory target only'),
|
|
20
|
-
tags: z.array(z.string()).optional(),
|
|
21
|
-
status: z.enum(['stable', 'beta', 'unreviewed', 'active', 'shipped', 'abandoned']).optional(),
|
|
22
|
-
folder: z.string().optional().describe('optional subdirectory under the target root'),
|
|
23
|
-
root: z.string().optional().describe('which configured root to write into; required if multiple roots exist for the target type'),
|
|
24
|
-
})
|
|
25
|
-
.optional(),
|
|
26
|
+
overrides: overridesSchema,
|
|
26
27
|
}, async (opts) => {
|
|
27
28
|
const result = relocate(opts, skillRepo, memoryRepo);
|
|
28
29
|
return {
|
|
@@ -30,4 +31,17 @@ export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
|
|
|
30
31
|
isError: !result.moved,
|
|
31
32
|
};
|
|
32
33
|
});
|
|
34
|
+
mcp.tool('relocate_bulk', `Relocates many local files in one call — each entry is the same shape as relocate's args. Returns one result per path (in order); a weak/ambiguous filename match for one file does not block the rest of the batch. ${AUTHORING_SKILL_HINT}`, {
|
|
35
|
+
entries: z
|
|
36
|
+
.array(z.object({
|
|
37
|
+
path: z.string().describe('absolute or relative path to the local file to relocate'),
|
|
38
|
+
target: z.enum(['skill', 'memory']),
|
|
39
|
+
keep_original: z.boolean().optional(),
|
|
40
|
+
overrides: overridesSchema,
|
|
41
|
+
}))
|
|
42
|
+
.min(1),
|
|
43
|
+
}, async ({ entries }) => {
|
|
44
|
+
const results = relocateMany(entries, skillRepo, memoryRepo);
|
|
45
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
46
|
+
});
|
|
33
47
|
}
|
|
@@ -98,6 +98,15 @@ export function relocate(opts, skillRepo, memoryRepo) {
|
|
|
98
98
|
fs.unlinkSync(opts.path);
|
|
99
99
|
return { moved: true, id: doc.id, target: 'memory' };
|
|
100
100
|
}
|
|
101
|
+
/**
|
|
102
|
+
* Relocates many files in one call — each entry is the same shape as
|
|
103
|
+
* relocate()'s options, minus `path` which is supplied per-entry. Returns one
|
|
104
|
+
* RelocateResult per path (in order) so one bad/ambiguous file doesn't abort
|
|
105
|
+
* the rest of the batch — same "no guess, no partial write" behavior per file.
|
|
106
|
+
*/
|
|
107
|
+
export function relocateMany(entries, skillRepo, memoryRepo) {
|
|
108
|
+
return entries.map((entry) => ({ path: entry.path, ...relocate(entry, skillRepo, memoryRepo) }));
|
|
109
|
+
}
|
|
101
110
|
function slugFromFilename(filePath) {
|
|
102
111
|
return path
|
|
103
112
|
.basename(filePath, path.extname(filePath))
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { searchCombined, 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.', {
|
|
5
|
+
query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
|
|
6
|
+
limit: z.number().int().positive().max(100).optional(),
|
|
7
|
+
offset: z.number().int().nonnegative().optional(),
|
|
8
|
+
}, async ({ query, limit, offset }) => {
|
|
9
|
+
try {
|
|
10
|
+
const hits = searchCombined(db, query, limit, offset);
|
|
11
|
+
return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
|
|
12
|
+
}
|
|
13
|
+
catch (err) {
|
|
14
|
+
const message = err instanceof SearchQueryError ? err.message : err.message;
|
|
15
|
+
return { content: [{ type: 'text', text: message }], isError: true };
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
}
|