mcp-memory-bucket 0.4.0 → 0.4.2
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/dist/client/assets/{index-vdbzlLML.js → index-BXCTjiGA.js} +144 -134
- package/dist/client/index.html +1 -1
- package/dist/src/memory/repository.js +50 -9
- package/dist/src/memory/tools.js +15 -6
- package/dist/src/skills/repository.js +56 -14
- package/dist/src/skills/tools.js +22 -7
- package/dist/src/store/db.js +4 -0
- package/dist/src/store/sync.js +3 -0
- package/dist/src/web/routes.js +50 -4
- package/package.json +2 -2
package/dist/client/index.html
CHANGED
|
@@ -27,7 +27,7 @@
|
|
|
27
27
|
:root[data-theme='dark'] { color-scheme: dark; }
|
|
28
28
|
body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--fg); }
|
|
29
29
|
</style>
|
|
30
|
-
<script type="module" crossorigin src="/assets/index-
|
|
30
|
+
<script type="module" crossorigin src="/assets/index-BXCTjiGA.js"></script>
|
|
31
31
|
</head>
|
|
32
32
|
<body>
|
|
33
33
|
<mem-bucket-app></mem-bucket-app>
|
|
@@ -18,6 +18,7 @@ function rowToDoc(row) {
|
|
|
18
18
|
status: row.status,
|
|
19
19
|
related_to: row.related_to,
|
|
20
20
|
deprecated: !!row.deprecated,
|
|
21
|
+
paused: !!row.paused,
|
|
21
22
|
created_at: row.created_at ?? undefined,
|
|
22
23
|
source_path: row.source_path,
|
|
23
24
|
root: row.root,
|
|
@@ -74,12 +75,24 @@ export class MemoryRepository {
|
|
|
74
75
|
this.watcher?.unwatch(removed.path);
|
|
75
76
|
unregisterRoot(this.db, 'memory_docs', name);
|
|
76
77
|
}
|
|
77
|
-
/**
|
|
78
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Exact-match lookup by normalized key, per V0 (no fuzzy matching).
|
|
80
|
+
* `includePaused` defaults to false: paused docs are hidden from discovery (see setPaused).
|
|
81
|
+
*/
|
|
82
|
+
getByKey(key, docType, opts = {}) {
|
|
79
83
|
const normalized = normalizeKey(key);
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
84
|
+
const conditions = ['key = ?'];
|
|
85
|
+
const params = [normalized];
|
|
86
|
+
if (docType) {
|
|
87
|
+
conditions.push('doc_type = ?');
|
|
88
|
+
params.push(docType);
|
|
89
|
+
}
|
|
90
|
+
if (!opts.includePaused) {
|
|
91
|
+
conditions.push('paused = 0');
|
|
92
|
+
}
|
|
93
|
+
const rows = this.db
|
|
94
|
+
.prepare(`SELECT * FROM memory_docs WHERE ${conditions.join(' AND ')}`)
|
|
95
|
+
.all(...params);
|
|
83
96
|
return rows.map(rowToDoc);
|
|
84
97
|
}
|
|
85
98
|
/**
|
|
@@ -89,7 +102,7 @@ export class MemoryRepository {
|
|
|
89
102
|
* so pagination stays correct even when filtering narrows the FTS hit set.
|
|
90
103
|
*/
|
|
91
104
|
search(query, opts = {}) {
|
|
92
|
-
const { docType, status, root, tag, limit = 20, offset = 0 } = opts;
|
|
105
|
+
const { docType, status, root, tag, limit = 20, offset = 0, includePaused = false } = opts;
|
|
93
106
|
const conditions = [];
|
|
94
107
|
const params = [query];
|
|
95
108
|
if (docType) {
|
|
@@ -108,6 +121,9 @@ export class MemoryRepository {
|
|
|
108
121
|
conditions.push('EXISTS (SELECT 1 FROM json_each(m.tags) WHERE value = ?)');
|
|
109
122
|
params.push(tag);
|
|
110
123
|
}
|
|
124
|
+
if (!includePaused) {
|
|
125
|
+
conditions.push('m.paused = 0');
|
|
126
|
+
}
|
|
111
127
|
params.push(limit, offset);
|
|
112
128
|
try {
|
|
113
129
|
const rows = this.db
|
|
@@ -165,7 +181,7 @@ export class MemoryRepository {
|
|
|
165
181
|
};
|
|
166
182
|
writeMarkdownFile(filePath, stripSourcePath(fm), input.body);
|
|
167
183
|
upsertFile(this.db, this.syncSpec, filePath);
|
|
168
|
-
return { ...fm, body: input.body };
|
|
184
|
+
return { ...fm, body: input.body, paused: false };
|
|
169
185
|
}
|
|
170
186
|
/**
|
|
171
187
|
* Creates many memory docs in one call — each entry is the same shape as
|
|
@@ -187,8 +203,11 @@ export class MemoryRepository {
|
|
|
187
203
|
const existing = this.get(id);
|
|
188
204
|
if (!existing)
|
|
189
205
|
throw new Error(`memory doc with id "${id}" not found`);
|
|
206
|
+
// `paused` is local-cache-only and must never reach writeMarkdownFile — split it off of
|
|
207
|
+
// `existing` before spreading the rest into the frontmatter that gets written to disk.
|
|
208
|
+
const { paused: existingPaused, ...existingForFile } = existing;
|
|
190
209
|
const merged = {
|
|
191
|
-
...
|
|
210
|
+
...existingForFile,
|
|
192
211
|
...frontmatter,
|
|
193
212
|
id: existing.id,
|
|
194
213
|
key: frontmatter?.key ? normalizeKey(frontmatter.key) : existing.key,
|
|
@@ -196,7 +215,7 @@ export class MemoryRepository {
|
|
|
196
215
|
const newBody = body ?? existing.body;
|
|
197
216
|
writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
|
|
198
217
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
199
|
-
return { ...merged, body: newBody };
|
|
218
|
+
return { ...merged, body: newBody, paused: existingPaused };
|
|
200
219
|
}
|
|
201
220
|
/**
|
|
202
221
|
* Applies the same frontmatter change to many memory docs at once — e.g.
|
|
@@ -230,6 +249,28 @@ export class MemoryRepository {
|
|
|
230
249
|
}
|
|
231
250
|
});
|
|
232
251
|
}
|
|
252
|
+
/**
|
|
253
|
+
* Pauses/resumes memory docs by id — a local-only toggle stored directly in this cache file's
|
|
254
|
+
* `paused` column, never written to the doc's markdown file and never synced by the file
|
|
255
|
+
* watcher (see the comment on memoryColumns in store/sync.ts). Paused docs are hidden from
|
|
256
|
+
* getByKey()/search() by default but remain fetchable via get()/bulkGet(). Because it's
|
|
257
|
+
* local-only, the flag does not follow the doc to another machine's cache or survive the cache
|
|
258
|
+
* file being deleted. Returns per-id results so one bad id doesn't abort the rest of the batch.
|
|
259
|
+
*/
|
|
260
|
+
setPaused(ids, paused) {
|
|
261
|
+
return ids.map((id) => {
|
|
262
|
+
try {
|
|
263
|
+
const existing = this.get(id);
|
|
264
|
+
if (!existing)
|
|
265
|
+
throw new Error(`memory doc with id "${id}" not found`);
|
|
266
|
+
this.db.prepare(`UPDATE memory_docs SET paused = ? WHERE id = ?`).run(paused ? 1 : 0, id);
|
|
267
|
+
return { id, ok: true };
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
return { id, ok: false, error: err.message };
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
}
|
|
233
274
|
delete(id) {
|
|
234
275
|
const existing = this.get(id);
|
|
235
276
|
if (!existing)
|
package/dist/src/memory/tools.js
CHANGED
|
@@ -7,11 +7,12 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
7
7
|
const roots = repo.listRoots();
|
|
8
8
|
const multiRoot = roots.length > 1;
|
|
9
9
|
const rootNames = roots.map((r) => r.name).join(', ');
|
|
10
|
-
mcp.tool('memory_get', 'Exact-match lookup of memory docs (plan/spec/sql/etc.) by normalized key — a ticket ID or a free-form name like "Spot Chart Design". Returns every doc under that key, or only the matching doc_type if provided.', {
|
|
10
|
+
mcp.tool('memory_get', 'Exact-match lookup of memory docs (plan/spec/sql/etc.) by normalized key — a ticket ID or a free-form name like "Spot Chart Design". Returns every doc under that key, or only the matching doc_type if provided. Paused docs are hidden by default — pass include_paused to see them.', {
|
|
11
11
|
key: z.string(),
|
|
12
12
|
doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
|
|
13
|
-
|
|
14
|
-
|
|
13
|
+
include_paused: z.boolean().optional().describe('include paused docs, which are hidden by default (see memory_set_paused)'),
|
|
14
|
+
}, async ({ key, doc_type, include_paused }) => {
|
|
15
|
+
const docs = repo.getByKey(key, doc_type, { includePaused: include_paused });
|
|
15
16
|
return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
|
|
16
17
|
});
|
|
17
18
|
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 }) => {
|
|
@@ -22,7 +23,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
22
23
|
const keys = repo.listKeys(key_prefix);
|
|
23
24
|
return { content: [{ type: 'text', text: JSON.stringify(keys, null, 2) }] };
|
|
24
25
|
});
|
|
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
|
+
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.', {
|
|
26
27
|
query: z.string().describe('FTS5 match expression, e.g. `migration AND rollback` or `"blue green"`'),
|
|
27
28
|
doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
|
|
28
29
|
status: z.enum(MEMORY_STATUS).optional(),
|
|
@@ -30,9 +31,10 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
30
31
|
...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
|
|
31
32
|
limit: z.number().int().positive().max(100).optional(),
|
|
32
33
|
offset: z.number().int().nonnegative().optional(),
|
|
33
|
-
|
|
34
|
+
include_paused: z.boolean().optional().describe('include paused docs, which are hidden by default (see memory_set_paused)'),
|
|
35
|
+
}, async ({ query, doc_type, status, tag, root, limit, offset, include_paused }) => {
|
|
34
36
|
try {
|
|
35
|
-
const hits = repo.search(query, { docType: doc_type, status, tag, root, limit, offset });
|
|
37
|
+
const hits = repo.search(query, { docType: doc_type, status, tag, root, limit, offset, includePaused: include_paused });
|
|
36
38
|
return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
|
|
37
39
|
}
|
|
38
40
|
catch (err) {
|
|
@@ -104,6 +106,13 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
104
106
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
105
107
|
}
|
|
106
108
|
});
|
|
109
|
+
mcp.tool('memory_set_paused', 'Pauses or resumes memory docs by id — a local-only toggle stored in this machine\'s SQLite cache, never written to the doc\'s markdown file, so it never touches the file on disk and never follows the doc to another machine\'s cache. Paused docs are hidden from memory_get/memory_search by default but stay fully intact and are still fetchable directly via memory_bulk_get — use this to temporarily stop a doc from surfacing during discovery without deprecating it. Returns per-id success/failure so one bad id doesn\'t abort the batch.', {
|
|
110
|
+
ids: z.array(z.string()).min(1),
|
|
111
|
+
paused: z.boolean().describe('true to pause (hide from memory_get/memory_search), false to resume'),
|
|
112
|
+
}, async ({ ids, paused }) => {
|
|
113
|
+
const results = repo.setPaused(ids, paused);
|
|
114
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
115
|
+
});
|
|
107
116
|
mcp.tool('memory_delete', 'Hard-deletes a memory doc by id — removes the markdown file, no tombstone.', { id: z.string() }, async ({ id }) => {
|
|
108
117
|
try {
|
|
109
118
|
repo.delete(id);
|
|
@@ -13,6 +13,7 @@ function rowToDoc(row) {
|
|
|
13
13
|
trigger_phrases: JSON.parse(row.trigger_phrases),
|
|
14
14
|
metadata: { owner: row.owner, status: row.status, extends: row.extends },
|
|
15
15
|
deprecated: !!row.deprecated,
|
|
16
|
+
paused: !!row.paused,
|
|
16
17
|
created_at: row.created_at ?? undefined,
|
|
17
18
|
source_path: row.source_path,
|
|
18
19
|
root: row.root,
|
|
@@ -72,14 +73,21 @@ export class SkillRepository {
|
|
|
72
73
|
this.watcher?.unwatch(removed.path);
|
|
73
74
|
unregisterRoot(this.db, 'skills', name);
|
|
74
75
|
}
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
76
|
+
/** `includePaused` defaults to false: paused skills are hidden from discovery (see setPaused). */
|
|
77
|
+
list(query, root, opts = {}) {
|
|
78
|
+
const conditions = [];
|
|
79
|
+
const params = [];
|
|
80
|
+
if (root) {
|
|
81
|
+
conditions.push('root = ?');
|
|
82
|
+
params.push(root);
|
|
83
|
+
}
|
|
84
|
+
if (!opts.includePaused) {
|
|
85
|
+
conditions.push('paused = 0');
|
|
86
|
+
}
|
|
87
|
+
const where = conditions.length ? ` WHERE ${conditions.join(' AND ')}` : '';
|
|
88
|
+
const rows = this.db
|
|
89
|
+
.prepare(`SELECT id, description, owner, status, tags, trigger_phrases, root, paused FROM skills${where}`)
|
|
90
|
+
.all(...params);
|
|
83
91
|
const needle = query?.trim().toLowerCase();
|
|
84
92
|
const items = rows.map((r) => ({
|
|
85
93
|
name: r.id,
|
|
@@ -89,6 +97,7 @@ export class SkillRepository {
|
|
|
89
97
|
tags: JSON.parse(r.tags),
|
|
90
98
|
triggerPhrases: JSON.parse(r.trigger_phrases),
|
|
91
99
|
root: r.root,
|
|
100
|
+
paused: !!r.paused,
|
|
92
101
|
}));
|
|
93
102
|
const filtered = needle
|
|
94
103
|
? items.filter((item) => item.description.toLowerCase().includes(needle) ||
|
|
@@ -104,7 +113,7 @@ export class SkillRepository {
|
|
|
104
113
|
* so pagination stays correct even when filtering narrows the FTS hit set.
|
|
105
114
|
*/
|
|
106
115
|
search(query, opts = {}) {
|
|
107
|
-
const { root, status, owner, tag, limit = 20, offset = 0 } = opts;
|
|
116
|
+
const { root, status, owner, tag, limit = 20, offset = 0, includePaused = false } = opts;
|
|
108
117
|
const conditions = [];
|
|
109
118
|
const params = [query];
|
|
110
119
|
if (root) {
|
|
@@ -123,6 +132,9 @@ export class SkillRepository {
|
|
|
123
132
|
conditions.push('EXISTS (SELECT 1 FROM json_each(s.tags) WHERE value = ?)');
|
|
124
133
|
params.push(tag);
|
|
125
134
|
}
|
|
135
|
+
if (!includePaused) {
|
|
136
|
+
conditions.push('s.paused = 0');
|
|
137
|
+
}
|
|
126
138
|
params.push(limit, offset);
|
|
127
139
|
try {
|
|
128
140
|
const rows = this.db
|
|
@@ -186,7 +198,7 @@ export class SkillRepository {
|
|
|
186
198
|
};
|
|
187
199
|
writeMarkdownFile(filePath, stripSourcePath(fm), body);
|
|
188
200
|
upsertFile(this.db, this.syncSpec, filePath);
|
|
189
|
-
return { ...fm, body };
|
|
201
|
+
return { ...fm, body, paused: false };
|
|
190
202
|
}
|
|
191
203
|
/**
|
|
192
204
|
* Creates many skills in one call — each entry is the same shape as create()'s
|
|
@@ -212,11 +224,14 @@ export class SkillRepository {
|
|
|
212
224
|
const existing = this.get(name);
|
|
213
225
|
if (!existing)
|
|
214
226
|
throw new Error(`skill with name "${name}" not found`);
|
|
227
|
+
// `paused` is local-cache-only and must never reach writeMarkdownFile — split it off of
|
|
228
|
+
// `existing` before spreading the rest into the frontmatter that gets written to disk.
|
|
229
|
+
const { paused: existingPaused, ...existingForFile } = existing;
|
|
215
230
|
// Builtin skills (e.g. memory-bucket-authoring) are the server's own always-present
|
|
216
231
|
// documentation, not user content — deprecating them would hide guidance every session needs.
|
|
217
232
|
const deprecated = this.isBuiltin(existing) ? existing.deprecated : frontmatter?.deprecated;
|
|
218
233
|
const merged = {
|
|
219
|
-
...
|
|
234
|
+
...existingForFile,
|
|
220
235
|
...frontmatter,
|
|
221
236
|
deprecated,
|
|
222
237
|
name: existing.name, // name is immutable post-creation (it's also the folder name)
|
|
@@ -230,7 +245,7 @@ export class SkillRepository {
|
|
|
230
245
|
const newBody = body ?? existing.body;
|
|
231
246
|
writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
|
|
232
247
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
233
|
-
return { ...merged, body: newBody };
|
|
248
|
+
return { ...merged, body: newBody, paused: existingPaused };
|
|
234
249
|
}
|
|
235
250
|
/**
|
|
236
251
|
* Renames a skill: moves <sourceDir>/[folder/]<oldName>/ to .../<newName>/ (keeping any
|
|
@@ -253,11 +268,14 @@ export class SkillRepository {
|
|
|
253
268
|
}
|
|
254
269
|
fs.renameSync(oldDir, newDir);
|
|
255
270
|
const newFilePath = path.join(newDir, 'SKILL.md');
|
|
256
|
-
|
|
271
|
+
// Rename changes the skill's id, so it becomes a fresh cache row — paused (local-only,
|
|
272
|
+
// keyed by id) does not carry over, same as it wouldn't survive deleting the cache file.
|
|
273
|
+
const { paused: _existingPaused, ...existingForFile } = existing;
|
|
274
|
+
const merged = { ...existingForFile, name: newName };
|
|
257
275
|
writeMarkdownFile(newFilePath, stripSourcePath(merged), existing.body);
|
|
258
276
|
removeFile(this.db, 'skills', existing.source_path);
|
|
259
277
|
upsertFile(this.db, this.syncSpec, newFilePath);
|
|
260
|
-
return { ...merged, body: existing.body };
|
|
278
|
+
return { ...merged, body: existing.body, paused: false };
|
|
261
279
|
}
|
|
262
280
|
/**
|
|
263
281
|
* Applies the same frontmatter change to many skills at once — e.g. add/remove
|
|
@@ -293,6 +311,30 @@ export class SkillRepository {
|
|
|
293
311
|
}
|
|
294
312
|
});
|
|
295
313
|
}
|
|
314
|
+
/**
|
|
315
|
+
* Pauses/resumes skills by name — a local-only toggle stored directly in this cache file's
|
|
316
|
+
* `paused` column, never written to SKILL.md and never synced by the file watcher (see the
|
|
317
|
+
* comment on skillColumns in store/sync.ts). Paused skills are hidden from list()/search() by
|
|
318
|
+
* default but remain fetchable via get()/bulkGet(). Because it's local-only, the flag does not
|
|
319
|
+
* follow the skill to another machine's cache, survive a rename, or survive the cache file
|
|
320
|
+
* being deleted. Returns per-name results so one bad name doesn't abort the rest of the batch.
|
|
321
|
+
*/
|
|
322
|
+
setPaused(names, paused) {
|
|
323
|
+
return names.map((name) => {
|
|
324
|
+
try {
|
|
325
|
+
const existing = this.get(name);
|
|
326
|
+
if (!existing)
|
|
327
|
+
throw new Error(`skill with name "${name}" not found`);
|
|
328
|
+
if (this.isBuiltin(existing))
|
|
329
|
+
throw new Error(`skill "${name}" is builtin and cannot be paused`);
|
|
330
|
+
this.db.prepare(`UPDATE skills SET paused = ? WHERE id = ?`).run(paused ? 1 : 0, name);
|
|
331
|
+
return { name, ok: true };
|
|
332
|
+
}
|
|
333
|
+
catch (err) {
|
|
334
|
+
return { name, ok: false, error: err.message };
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
}
|
|
296
338
|
/**
|
|
297
339
|
* Renames many skills at once — each entry is a {name, new_name} pair, same
|
|
298
340
|
* semantics as rename(). Returns per-entry results so one bad pair (unknown
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -6,23 +6,31 @@ export function registerSkillTools(mcp, repo) {
|
|
|
6
6
|
const roots = repo.listRoots();
|
|
7
7
|
const multiRoot = roots.length > 1;
|
|
8
8
|
const rootNames = roots.map((r) => r.name).join(', ');
|
|
9
|
-
mcp.tool('skill_list', 'Lists skills (reusable coding patterns, one SKILL.md per folder per the agentskills.io open standard), optionally filtered by a keyword matched against description/tags/trigger phrases.', multiRoot
|
|
10
|
-
? {
|
|
11
|
-
|
|
12
|
-
|
|
9
|
+
mcp.tool('skill_list', 'Lists skills (reusable coding patterns, one SKILL.md per folder per the agentskills.io open standard), optionally filtered by a keyword matched against description/tags/trigger phrases. Paused skills are hidden by default — pass include_paused to see them.', multiRoot
|
|
10
|
+
? {
|
|
11
|
+
query: z.string().optional(),
|
|
12
|
+
root: z.string().optional().describe(`filter to one root: ${rootNames}`),
|
|
13
|
+
include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
|
|
14
|
+
}
|
|
15
|
+
: {
|
|
16
|
+
query: z.string().optional(),
|
|
17
|
+
include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
|
|
18
|
+
}, async ({ query, root, include_paused }) => {
|
|
19
|
+
const items = repo.list(query, root, { includePaused: include_paused });
|
|
13
20
|
return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
|
|
14
21
|
});
|
|
15
|
-
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.', {
|
|
22
|
+
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.', {
|
|
16
23
|
query: z.string().describe('FTS5 match expression, e.g. `deploy AND rollback` or `"blue green"`'),
|
|
17
24
|
status: z.enum(SKILL_STATUS).optional(),
|
|
18
25
|
owner: z.string().optional(),
|
|
19
26
|
tag: z.string().optional(),
|
|
20
27
|
limit: z.number().int().positive().max(100).optional(),
|
|
21
28
|
offset: z.number().int().nonnegative().optional(),
|
|
29
|
+
include_paused: z.boolean().optional().describe('include paused skills, which are hidden by default (see skill_set_paused)'),
|
|
22
30
|
...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
|
|
23
|
-
}, async ({ query, status, owner, tag, limit, offset, root }) => {
|
|
31
|
+
}, async ({ query, status, owner, tag, limit, offset, root, include_paused }) => {
|
|
24
32
|
try {
|
|
25
|
-
const hits = repo.search(query, { root, status, owner, tag, limit, offset });
|
|
33
|
+
const hits = repo.search(query, { root, status, owner, tag, limit, offset, includePaused: include_paused });
|
|
26
34
|
return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
|
|
27
35
|
}
|
|
28
36
|
catch (err) {
|
|
@@ -151,6 +159,13 @@ export function registerSkillTools(mcp, repo) {
|
|
|
151
159
|
const results = repo.bulkRename(entries);
|
|
152
160
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
153
161
|
});
|
|
162
|
+
mcp.tool('skill_set_paused', 'Pauses or resumes skills by name — a local-only toggle stored in this machine\'s SQLite cache, never written to SKILL.md, so it never touches the file on disk and never follows the skill to another machine\'s cache. Paused skills are hidden from skill_list/skill_search by default but stay fully intact and are still fetchable directly via skill_get/skill_bulk_get — use this to temporarily stop a skill from being surfaced during discovery without deprecating (retiring) it. Returns per-name success/failure so one bad name doesn\'t abort the batch.', {
|
|
163
|
+
names: z.array(z.string()).min(1),
|
|
164
|
+
paused: z.boolean().describe('true to pause (hide from skill_list/skill_search), false to resume'),
|
|
165
|
+
}, async ({ names, paused }) => {
|
|
166
|
+
const results = repo.setPaused(names, paused);
|
|
167
|
+
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
168
|
+
});
|
|
154
169
|
mcp.tool('skill_delete', 'Hard-deletes a skill by name — removes the whole skill folder (SKILL.md plus any scripts/references/assets), no tombstone.', { name: z.string() }, async ({ name }) => {
|
|
155
170
|
try {
|
|
156
171
|
repo.delete(name);
|
package/dist/src/store/db.js
CHANGED
|
@@ -14,6 +14,7 @@ export function openCache(dbPath) {
|
|
|
14
14
|
source_path TEXT NOT NULL UNIQUE, -- path to SKILL.md
|
|
15
15
|
root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
|
|
16
16
|
deprecated INTEGER NOT NULL DEFAULT 0,
|
|
17
|
+
paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to SKILL.md, cache-file scoped
|
|
17
18
|
created_at TEXT,
|
|
18
19
|
body TEXT NOT NULL,
|
|
19
20
|
mtime_ms INTEGER NOT NULL
|
|
@@ -31,6 +32,7 @@ export function openCache(dbPath) {
|
|
|
31
32
|
source_path TEXT NOT NULL UNIQUE,
|
|
32
33
|
root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
|
|
33
34
|
deprecated INTEGER NOT NULL DEFAULT 0,
|
|
35
|
+
paused INTEGER NOT NULL DEFAULT 0, -- local-only: never synced from/to the doc's markdown file, cache-file scoped
|
|
34
36
|
created_at TEXT,
|
|
35
37
|
body TEXT NOT NULL,
|
|
36
38
|
mtime_ms INTEGER NOT NULL
|
|
@@ -59,11 +61,13 @@ export function openCache(dbPath) {
|
|
|
59
61
|
ensureColumns(db, 'skills', [
|
|
60
62
|
['root', "TEXT NOT NULL DEFAULT ''"],
|
|
61
63
|
['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
|
|
64
|
+
['paused', 'INTEGER NOT NULL DEFAULT 0'],
|
|
62
65
|
['created_at', 'TEXT'],
|
|
63
66
|
]);
|
|
64
67
|
ensureColumns(db, 'memory_docs', [
|
|
65
68
|
['root', "TEXT NOT NULL DEFAULT ''"],
|
|
66
69
|
['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
|
|
70
|
+
['paused', 'INTEGER NOT NULL DEFAULT 0'],
|
|
67
71
|
['created_at', 'TEXT'],
|
|
68
72
|
]);
|
|
69
73
|
backfillSearchIndex(db);
|
package/dist/src/store/sync.js
CHANGED
|
@@ -4,6 +4,9 @@ import chokidar, {} from 'chokidar';
|
|
|
4
4
|
import { readMarkdownFile } from './markdown-file.js';
|
|
5
5
|
import { flattenTags } from './db.js';
|
|
6
6
|
import { extractDates, toLocalDate } from './date-extract.js';
|
|
7
|
+
// `paused` is deliberately absent from both lists: it's a local-only cache column (see
|
|
8
|
+
// SkillRepository/MemoryRepository#setPaused) that never round-trips through frontmatter, so a
|
|
9
|
+
// file add/change/rescan must never overwrite it via the INSERT/ON CONFLICT UPDATE below.
|
|
7
10
|
const skillColumns = ['id', 'description', 'owner', 'status', 'tags', 'trigger_phrases', 'extends', 'deprecated', 'created_at'];
|
|
8
11
|
const memoryColumns = ['id', 'key', 'key_type', 'description', 'doc_type', 'tags', 'status', 'related_to', 'deprecated', 'created_at'];
|
|
9
12
|
export function skillSyncSpec(sources) {
|
package/dist/src/web/routes.js
CHANGED
|
@@ -25,6 +25,8 @@ function queryEntries(db, req) {
|
|
|
25
25
|
const q = req.query.q?.trim();
|
|
26
26
|
const deprecatedParam = req.query.deprecated;
|
|
27
27
|
const deprecated = deprecatedParam === '0' || deprecatedParam === '1' ? deprecatedParam : undefined;
|
|
28
|
+
const pausedParam = req.query.paused;
|
|
29
|
+
const paused = pausedParam === '0' || pausedParam === '1' ? pausedParam : undefined;
|
|
28
30
|
const dateFrom = req.query.date_from?.trim() || undefined;
|
|
29
31
|
const dateTo = req.query.date_to?.trim() || undefined;
|
|
30
32
|
const matchedIds = q
|
|
@@ -39,10 +41,10 @@ function queryEntries(db, req) {
|
|
|
39
41
|
}
|
|
40
42
|
const results = [];
|
|
41
43
|
if (type === 'skill' || type === 'all') {
|
|
42
|
-
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated }, intersectIds(matchedIds?.skills, dateIds?.skills)));
|
|
44
|
+
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], roots, deprecated, paused }, intersectIds(matchedIds?.skills, dateIds?.skills)));
|
|
43
45
|
}
|
|
44
46
|
if (type === 'memory' || type === 'all') {
|
|
45
|
-
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
|
|
47
|
+
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, roots, deprecated, paused }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
|
|
46
48
|
}
|
|
47
49
|
const sort = req.query.sort ?? 'mtime_desc';
|
|
48
50
|
results.sort((a, b) => {
|
|
@@ -95,13 +97,17 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
95
97
|
where += ` AND deprecated = ?`;
|
|
96
98
|
params.push(filters.deprecated === '1' ? 1 : 0);
|
|
97
99
|
}
|
|
100
|
+
if (filters.paused !== undefined) {
|
|
101
|
+
where += ` AND paused = ?`;
|
|
102
|
+
params.push(filters.paused === '1' ? 1 : 0);
|
|
103
|
+
}
|
|
98
104
|
if (restrictToIds) {
|
|
99
105
|
where += ` AND id IN (${[...restrictToIds].map(() => '?').join(', ')})`;
|
|
100
106
|
params.push(...restrictToIds);
|
|
101
107
|
}
|
|
102
108
|
if (table === 'skills') {
|
|
103
109
|
const rows = db
|
|
104
|
-
.prepare(`SELECT id, description, owner, status, tags, root, mtime_ms, deprecated, created_at FROM skills WHERE ${where}`)
|
|
110
|
+
.prepare(`SELECT id, description, owner, status, tags, root, mtime_ms, deprecated, paused, created_at FROM skills WHERE ${where}`)
|
|
105
111
|
.all(...params);
|
|
106
112
|
return rows.map((r) => ({
|
|
107
113
|
_table: 'skills',
|
|
@@ -116,11 +122,12 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
116
122
|
root: r.root,
|
|
117
123
|
mtime_ms: r.mtime_ms,
|
|
118
124
|
deprecated: !!r.deprecated,
|
|
125
|
+
paused: !!r.paused,
|
|
119
126
|
created_at: r.created_at,
|
|
120
127
|
}));
|
|
121
128
|
}
|
|
122
129
|
const rows = db
|
|
123
|
-
.prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms, deprecated, created_at FROM memory_docs WHERE ${where}`)
|
|
130
|
+
.prepare(`SELECT id, key, description, doc_type, key_type, status, tags, root, mtime_ms, deprecated, paused, created_at FROM memory_docs WHERE ${where}`)
|
|
124
131
|
.all(...params);
|
|
125
132
|
return rows.map((r) => ({
|
|
126
133
|
_table: 'memory_docs',
|
|
@@ -135,6 +142,7 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
135
142
|
root: r.root,
|
|
136
143
|
mtime_ms: r.mtime_ms,
|
|
137
144
|
deprecated: !!r.deprecated,
|
|
145
|
+
paused: !!r.paused,
|
|
138
146
|
created_at: r.created_at,
|
|
139
147
|
}));
|
|
140
148
|
}
|
|
@@ -305,6 +313,44 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo) {
|
|
|
305
313
|
const results = table === 'skills' ? skillRepo.bulkUpdate(ids, { deprecated }) : memoryRepo.bulkUpdate(ids, { deprecated });
|
|
306
314
|
res.json({ results });
|
|
307
315
|
});
|
|
316
|
+
// `paused` is a local-only cache toggle (see SkillRepository/MemoryRepository#setPaused) — it
|
|
317
|
+
// never touches the source file, so this goes through setPaused, not update()/bulkUpdate().
|
|
318
|
+
router.patch('/api/entries/:table/:id/paused', (req, res) => {
|
|
319
|
+
const { table, id } = req.params;
|
|
320
|
+
const { paused } = req.body;
|
|
321
|
+
if (table !== 'skills' && table !== 'memory_docs') {
|
|
322
|
+
res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
|
|
323
|
+
return;
|
|
324
|
+
}
|
|
325
|
+
if (!id) {
|
|
326
|
+
res.status(400).json({ error: 'id is required' });
|
|
327
|
+
return;
|
|
328
|
+
}
|
|
329
|
+
if (typeof paused !== 'boolean') {
|
|
330
|
+
res.status(400).json({ error: 'body must be { paused: boolean }' });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
const [result] = table === 'skills' ? skillRepo.setPaused([id], paused) : memoryRepo.setPaused([id], paused);
|
|
334
|
+
if (!result?.ok) {
|
|
335
|
+
res.status(404).json({ error: result?.error ?? 'not found' });
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
res.json({ id, paused });
|
|
339
|
+
});
|
|
340
|
+
router.post('/api/entries/:table/bulk/paused', (req, res) => {
|
|
341
|
+
const { table } = req.params;
|
|
342
|
+
const { ids, paused } = req.body;
|
|
343
|
+
if (table !== 'skills' && table !== 'memory_docs') {
|
|
344
|
+
res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
|
|
345
|
+
return;
|
|
346
|
+
}
|
|
347
|
+
if (!Array.isArray(ids) || ids.length === 0 || typeof paused !== 'boolean') {
|
|
348
|
+
res.status(400).json({ error: 'body must be { ids: string[], paused: boolean }' });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
const results = table === 'skills' ? skillRepo.setPaused(ids, paused) : memoryRepo.setPaused(ids, paused);
|
|
352
|
+
res.json({ results });
|
|
353
|
+
});
|
|
308
354
|
router.delete('/api/entries/:table/:id', (req, res) => {
|
|
309
355
|
const { table, id } = req.params;
|
|
310
356
|
if (table !== 'skills' && table !== 'memory_docs') {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server exposing skill_* (reusable coding patterns) and memory_* (point-in-time working context) tools over a markdown+frontmatter source, cached into SQLite at runtime.",
|
|
6
6
|
"repository": {
|
|
@@ -29,7 +29,7 @@
|
|
|
29
29
|
"copy:builtin-skills": "mkdir -p dist/src/skills/builtin && cp -r src/skills/builtin/. dist/src/skills/builtin/",
|
|
30
30
|
"prepublishOnly": "npm run build",
|
|
31
31
|
"start": "tsx src/server.ts",
|
|
32
|
-
"start_in_folder": "tsx src/server.ts --memory-dir",
|
|
32
|
+
"start_in_folder": "vite build && tsx src/server.ts --memory-dir",
|
|
33
33
|
"typecheck": "tsc --noEmit -p tsconfig.server.json && tsc --noEmit -p tsconfig.client.json",
|
|
34
34
|
"test": "node --import tsx --test test/**/*.test.ts"
|
|
35
35
|
},
|