mcp-memory-bucket 0.7.4 → 0.8.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 +20 -0
- package/dist/client/assets/{index-Cr5jNKWL.js → index-BfU_uQyK.js} +397 -262
- package/dist/client/index.html +7 -1
- package/dist/src/attachments/repository.js +22 -22
- package/dist/src/attachments/tools.js +12 -12
- package/dist/src/config.js +70 -3
- package/dist/src/memory/repository.js +142 -39
- package/dist/src/memory/tools.js +32 -13
- package/dist/src/remote/credentials.js +31 -0
- package/dist/src/remote/folderfoo-client.js +149 -0
- package/dist/src/remote/remote-sync.js +176 -0
- package/dist/src/server.js +18 -3
- package/dist/src/shared/body-edits.js +87 -0
- package/dist/src/shared/relocate-tool.js +2 -2
- package/dist/src/shared/relocate.js +9 -5
- package/dist/src/skills/repository.js +141 -47
- package/dist/src/skills/tools.js +34 -15
- package/dist/src/store/sync.js +19 -2
- package/dist/src/web/routes.js +220 -25
- package/package.json +1 -1
|
@@ -37,7 +37,7 @@ export function inferMemoryFrontmatter(filePath) {
|
|
|
37
37
|
return null; // nothing left to distinguish this doc — ambiguous
|
|
38
38
|
return { key, key_type: 'ticket', doc_type: docType, description };
|
|
39
39
|
}
|
|
40
|
-
export function relocate(opts, skillRepo, memoryRepo) {
|
|
40
|
+
export async function relocate(opts, skillRepo, memoryRepo) {
|
|
41
41
|
if (!fs.existsSync(opts.path) || !fs.statSync(opts.path).isFile()) {
|
|
42
42
|
return { moved: false, reason: `file not found or not readable: ${opts.path}` };
|
|
43
43
|
}
|
|
@@ -55,7 +55,7 @@ export function relocate(opts, skillRepo, memoryRepo) {
|
|
|
55
55
|
if (!description) {
|
|
56
56
|
return { moved: false, reason: 'skill description cannot be inferred from a filename — provide overrides.description (what it does and when to use it)' };
|
|
57
57
|
}
|
|
58
|
-
const doc = skillRepo.create({
|
|
58
|
+
const doc = await skillRepo.create({
|
|
59
59
|
name,
|
|
60
60
|
description,
|
|
61
61
|
owner: null,
|
|
@@ -84,7 +84,7 @@ export function relocate(opts, skillRepo, memoryRepo) {
|
|
|
84
84
|
if (already) {
|
|
85
85
|
return { moved: false, reason: `already relocated as memory doc "${already.id}"`, id: already.id, target: 'memory' };
|
|
86
86
|
}
|
|
87
|
-
const doc = memoryRepo.create({
|
|
87
|
+
const doc = await memoryRepo.create({
|
|
88
88
|
key,
|
|
89
89
|
key_type: keyType,
|
|
90
90
|
doc_type: docType,
|
|
@@ -104,8 +104,12 @@ export function relocate(opts, skillRepo, memoryRepo) {
|
|
|
104
104
|
* RelocateResult per path (in order) so one bad/ambiguous file doesn't abort
|
|
105
105
|
* the rest of the batch — same "no guess, no partial write" behavior per file.
|
|
106
106
|
*/
|
|
107
|
-
export function relocateMany(entries, skillRepo, memoryRepo) {
|
|
108
|
-
|
|
107
|
+
export async function relocateMany(entries, skillRepo, memoryRepo) {
|
|
108
|
+
const results = [];
|
|
109
|
+
for (const entry of entries) {
|
|
110
|
+
results.push({ path: entry.path, ...(await relocate(entry, skillRepo, memoryRepo)) });
|
|
111
|
+
}
|
|
112
|
+
return results;
|
|
109
113
|
}
|
|
110
114
|
function slugFromFilename(filePath) {
|
|
111
115
|
return path
|
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
+
import matter from 'gray-matter';
|
|
3
4
|
import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
4
5
|
import { assertValidSkillName } from '../store/skill-name.js';
|
|
5
6
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
6
7
|
import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec } from '../store/sync.js';
|
|
7
8
|
import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
|
|
9
|
+
import { applyBodyEdits } from '../shared/body-edits.js';
|
|
10
|
+
import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath } from '../remote/folderfoo-client.js';
|
|
8
11
|
function rowToDoc(row) {
|
|
9
12
|
return {
|
|
10
13
|
name: row.id,
|
|
@@ -24,14 +27,31 @@ function rowToDoc(row) {
|
|
|
24
27
|
export class SkillRepository {
|
|
25
28
|
db;
|
|
26
29
|
folders;
|
|
30
|
+
remoteFolders;
|
|
31
|
+
credentialsBaseDir;
|
|
27
32
|
syncSpec;
|
|
28
33
|
watcher;
|
|
29
34
|
/** `folders[0]` is always the builtin skills dir — never exposed for create()/removal. */
|
|
30
|
-
constructor(db, folders) {
|
|
35
|
+
constructor(db, folders, remoteFolders = [], credentialsBaseDir) {
|
|
31
36
|
this.db = db;
|
|
32
37
|
this.folders = folders;
|
|
38
|
+
this.remoteFolders = remoteFolders;
|
|
39
|
+
this.credentialsBaseDir = credentialsBaseDir;
|
|
33
40
|
this.syncSpec = skillSyncSpec(folders);
|
|
34
41
|
}
|
|
42
|
+
/** The RemoteFolder a NamedFolder name resolves to, or undefined for a local (non-remote) folder. */
|
|
43
|
+
remoteFor(folderName) {
|
|
44
|
+
return this.remoteFolders.find((f) => f.name === folderName);
|
|
45
|
+
}
|
|
46
|
+
/** Pushes the just-written SKILL.md at filePath to folderfoo, if targetFolderName resolves to a remote source. No-op for a local folder. See get()'s comment on the folderPath/name split for a skill's directory-per-skill layout. */
|
|
47
|
+
async pushToRemoteIfNeeded(targetFolderName, filePath) {
|
|
48
|
+
const remote = this.remoteFor(targetFolderName);
|
|
49
|
+
if (!remote || !this.credentialsBaseDir)
|
|
50
|
+
return;
|
|
51
|
+
const skillDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(filePath)));
|
|
52
|
+
const fileContents = fs.readFileSync(filePath, 'utf-8');
|
|
53
|
+
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', fileContents);
|
|
54
|
+
}
|
|
35
55
|
/** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
|
|
36
56
|
setWatcher(watcher) {
|
|
37
57
|
this.watcher = watcher;
|
|
@@ -40,6 +60,14 @@ export class SkillRepository {
|
|
|
40
60
|
listFolders() {
|
|
41
61
|
return this.folders.slice(1);
|
|
42
62
|
}
|
|
63
|
+
/** Full RemoteFolder records (server/tenantId/folderPath/mirrorDir) for every connected remote source — for matching an incoming folderfoo-file-open address back to a configured source. */
|
|
64
|
+
listRemoteFolders() {
|
|
65
|
+
return [...this.remoteFolders];
|
|
66
|
+
}
|
|
67
|
+
/** Same as listFolders(), but tags each entry with whether it's a remote (folderfoo) source — for the web UI's folder list, e.g. to render remote folders in a distinct color. */
|
|
68
|
+
listFoldersWithRemoteInfo() {
|
|
69
|
+
return this.listFolders().map((f) => ({ ...f, remote: !!this.remoteFor(f.name) }));
|
|
70
|
+
}
|
|
43
71
|
resolveFolder(folderName) {
|
|
44
72
|
const userFolders = this.listFolders();
|
|
45
73
|
if (folderName) {
|
|
@@ -56,6 +84,24 @@ export class SkillRepository {
|
|
|
56
84
|
}
|
|
57
85
|
throw new Error(`multiple skill folders configured — specify folder: one of ${userFolders.map((f) => f.name).join(', ')}`);
|
|
58
86
|
}
|
|
87
|
+
/**
|
|
88
|
+
* Registers a new REMOTE (folderfoo) folder: creates its local mirror
|
|
89
|
+
* directory, registers it exactly like a local addFolder (so it starts
|
|
90
|
+
* watching/scanning immediately — empty at first, since nothing's been
|
|
91
|
+
* pulled from folderfoo yet), and records the folderfoo coordinates so
|
|
92
|
+
* get()/create()/update() know to treat this folder as remote. Does NOT
|
|
93
|
+
* perform the initial pull itself — the caller (the web route) does one
|
|
94
|
+
* immediate poll right after this returns, so content shows up without
|
|
95
|
+
* waiting for the first interval tick.
|
|
96
|
+
*/
|
|
97
|
+
registerRemoteFolder(remote) {
|
|
98
|
+
if (this.folders.some((f) => f.name === remote.name)) {
|
|
99
|
+
throw new Error(`a skill folder named "${remote.name}" already exists`);
|
|
100
|
+
}
|
|
101
|
+
fs.mkdirSync(remote.mirrorDir, { recursive: true });
|
|
102
|
+
this.remoteFolders.push(remote);
|
|
103
|
+
this.addFolder({ name: remote.name, path: remote.mirrorDir });
|
|
104
|
+
}
|
|
59
105
|
/** Registers a new folder: appends it, scans it once, and starts watching it live. */
|
|
60
106
|
addFolder(folder) {
|
|
61
107
|
if (this.folders.some((f) => f.name === folder.name)) {
|
|
@@ -154,13 +200,39 @@ export class SkillRepository {
|
|
|
154
200
|
throw new SearchQueryError(query, err);
|
|
155
201
|
}
|
|
156
202
|
}
|
|
157
|
-
|
|
203
|
+
/**
|
|
204
|
+
* For a skill in a LOCAL folder, returns the cached row unchanged (identical behavior to before
|
|
205
|
+
* remote sources existed). For a skill in a REMOTE folder, the cached `body` is only ever a
|
|
206
|
+
* poll-interval-stale mirror snapshot — per the settled design, `get` always fetches the current
|
|
207
|
+
* body live from folderfoo instead of trusting it.
|
|
208
|
+
*/
|
|
209
|
+
async get(name) {
|
|
158
210
|
const row = this.db.prepare(`SELECT * FROM skills WHERE id = ?`).get(name);
|
|
159
|
-
|
|
211
|
+
if (!row)
|
|
212
|
+
return null;
|
|
213
|
+
const doc = rowToDoc(row);
|
|
214
|
+
const remote = this.remoteFor(doc.folder);
|
|
215
|
+
if (!remote || !this.credentialsBaseDir)
|
|
216
|
+
return doc;
|
|
217
|
+
// A skill's SKILL.md sits INSIDE a directory named after the skill
|
|
218
|
+
// (<mirrorDir>/[subfolder/]<name>/SKILL.md, per agentskills.io), unlike
|
|
219
|
+
// a memory doc's flat <id>.md - so the skill's own directory is part of
|
|
220
|
+
// the folderPath sent to folderfoo, and "SKILL" (no extension - the
|
|
221
|
+
// server stores opaque names) is the file name within it.
|
|
222
|
+
const skillDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(doc.source_path)));
|
|
223
|
+
// readRemoteFile returns the RAW file bytes folderfoo stored (whole
|
|
224
|
+
// SKILL.md, frontmatter included) - must parse it the same way a local
|
|
225
|
+
// file read would, or the next update() nests a fresh frontmatter
|
|
226
|
+
// block around this already-frontmattered blob (see memory/
|
|
227
|
+
// repository.ts's get() for the confirmed real-world corruption this
|
|
228
|
+
// caused).
|
|
229
|
+
const liveBody = matter(await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL')).content.trim();
|
|
230
|
+
return { ...doc, body: liveBody };
|
|
160
231
|
}
|
|
161
232
|
/** Fetches many skills by name in one call — e.g. hydrating full bodies for a batch of search() hits. Missing names are simply absent from the result, not errors. */
|
|
162
|
-
bulkGet(names) {
|
|
163
|
-
|
|
233
|
+
async bulkGet(names) {
|
|
234
|
+
const docs = await Promise.all(names.map((name) => this.get(name)));
|
|
235
|
+
return docs.filter((doc) => doc !== null);
|
|
164
236
|
}
|
|
165
237
|
/**
|
|
166
238
|
* Creates <folder>/[subfolder/]<name>/SKILL.md — folder-per-skill, per the
|
|
@@ -168,9 +240,9 @@ export class SkillRepository {
|
|
|
168
240
|
* `folder` selects which configured skill folder to write into; required only
|
|
169
241
|
* when more than one user folder is configured.
|
|
170
242
|
*/
|
|
171
|
-
create(frontmatter, body, subfolder, folder) {
|
|
243
|
+
async create(frontmatter, body, subfolder, folder) {
|
|
172
244
|
assertValidSkillName(frontmatter.name);
|
|
173
|
-
if (this.get(frontmatter.name)) {
|
|
245
|
+
if (await this.get(frontmatter.name)) {
|
|
174
246
|
throw new Error(`skill with name "${frontmatter.name}" already exists`);
|
|
175
247
|
}
|
|
176
248
|
const targetFolder = this.resolveFolder(folder);
|
|
@@ -198,6 +270,9 @@ export class SkillRepository {
|
|
|
198
270
|
folder: targetFolder.name,
|
|
199
271
|
};
|
|
200
272
|
writeMarkdownFile(filePath, stripSourcePath(fm), body);
|
|
273
|
+
// Live/synchronous per the settled design — a folderfoo rejection here throws before upsertFile
|
|
274
|
+
// indexes the mirror as if the skill were successfully saved remotely.
|
|
275
|
+
await this.pushToRemoteIfNeeded(targetFolder.name, filePath);
|
|
201
276
|
upsertFile(this.db, this.syncSpec, filePath);
|
|
202
277
|
return { ...fm, body, paused: false };
|
|
203
278
|
}
|
|
@@ -206,23 +281,25 @@ export class SkillRepository {
|
|
|
206
281
|
* args. Returns per-name results so one bad entry (duplicate name, invalid
|
|
207
282
|
* name, existing directory) doesn't abort the rest of the batch.
|
|
208
283
|
*/
|
|
209
|
-
bulkCreate(entries) {
|
|
210
|
-
|
|
284
|
+
async bulkCreate(entries) {
|
|
285
|
+
const results = [];
|
|
286
|
+
for (const entry of entries) {
|
|
211
287
|
try {
|
|
212
|
-
this.create(entry.frontmatter, entry.body, entry.subfolder, entry.folder);
|
|
213
|
-
|
|
288
|
+
await this.create(entry.frontmatter, entry.body, entry.subfolder, entry.folder);
|
|
289
|
+
results.push({ name: entry.frontmatter.name, ok: true });
|
|
214
290
|
}
|
|
215
291
|
catch (err) {
|
|
216
|
-
|
|
292
|
+
results.push({ name: entry.frontmatter.name, ok: false, error: err.message });
|
|
217
293
|
}
|
|
218
|
-
}
|
|
294
|
+
}
|
|
295
|
+
return results;
|
|
219
296
|
}
|
|
220
297
|
/** Name of the always-present, non-removable builtin folder (folders[0]) — never user content, never deprecatable. */
|
|
221
298
|
isBuiltin(doc) {
|
|
222
299
|
return doc.folder === this.folders[0]?.name;
|
|
223
300
|
}
|
|
224
|
-
update(name, frontmatter, body) {
|
|
225
|
-
const existing = this.get(name);
|
|
301
|
+
async update(name, frontmatter, body, bodyEdits) {
|
|
302
|
+
const existing = await this.get(name);
|
|
226
303
|
if (!existing)
|
|
227
304
|
throw new Error(`skill with name "${name}" not found`);
|
|
228
305
|
// `paused` is local-cache-only and must never reach writeMarkdownFile — split it off of
|
|
@@ -243,23 +320,31 @@ export class SkillRepository {
|
|
|
243
320
|
extends: frontmatter?.extends !== undefined ? frontmatter.extends : existing.metadata.extends,
|
|
244
321
|
},
|
|
245
322
|
};
|
|
246
|
-
const newBody = body ?? existing.body;
|
|
323
|
+
const newBody = bodyEdits ? applyBodyEdits(existing.body, bodyEdits).body : (body ?? existing.body);
|
|
247
324
|
writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
|
|
325
|
+
await this.pushToRemoteIfNeeded(existing.folder, existing.source_path);
|
|
248
326
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
249
327
|
return { ...merged, body: newBody, paused: existingPaused };
|
|
250
328
|
}
|
|
251
329
|
/**
|
|
252
330
|
* Renames a skill: moves <sourceDir>/[subfolder/]<oldName>/ to .../<newName>/ (keeping any
|
|
253
331
|
* scripts/references/assets alongside SKILL.md) and updates the `name` frontmatter field to match.
|
|
332
|
+
*
|
|
333
|
+
* Remote-folder note: pushes the renamed skill's content to folderfoo at its NEW path (a plain
|
|
334
|
+
* write, via pushToRemoteIfNeeded), but does not delete the OLD path on folderfoo — a true
|
|
335
|
+
* remote rename would need a folderfoo folder-rename call scoped to one skill's directory, which
|
|
336
|
+
* is out of scope for this pass (the settled design's write scope covers create/update/
|
|
337
|
+
* attachment writes, not rename). Renaming a remote-sourced skill currently leaves a stale copy
|
|
338
|
+
* under the old name on folderfoo; flagged here rather than silently incomplete.
|
|
254
339
|
*/
|
|
255
|
-
rename(name, newName) {
|
|
340
|
+
async rename(name, newName) {
|
|
256
341
|
assertValidSkillName(newName);
|
|
257
|
-
const existing = this.get(name);
|
|
342
|
+
const existing = await this.get(name);
|
|
258
343
|
if (!existing)
|
|
259
344
|
throw new Error(`skill with name "${name}" not found`);
|
|
260
345
|
if (newName === name)
|
|
261
346
|
return existing;
|
|
262
|
-
if (this.get(newName)) {
|
|
347
|
+
if (await this.get(newName)) {
|
|
263
348
|
throw new Error(`skill with name "${newName}" already exists`);
|
|
264
349
|
}
|
|
265
350
|
const oldDir = path.dirname(existing.source_path);
|
|
@@ -274,6 +359,7 @@ export class SkillRepository {
|
|
|
274
359
|
const { paused: _existingPaused, ...existingForFile } = existing;
|
|
275
360
|
const merged = { ...existingForFile, name: newName };
|
|
276
361
|
writeMarkdownFile(newFilePath, stripSourcePath(merged), existing.body);
|
|
362
|
+
await this.pushToRemoteIfNeeded(existing.folder, newFilePath);
|
|
277
363
|
removeFile(this.db, 'skills', existing.source_path);
|
|
278
364
|
upsertFile(this.db, this.syncSpec, newFilePath);
|
|
279
365
|
return { ...merged, body: existing.body, paused: false };
|
|
@@ -286,10 +372,11 @@ export class SkillRepository {
|
|
|
286
372
|
* Returns per-name results so partial failures (e.g. an unknown name) don't
|
|
287
373
|
* abort the rest of the batch.
|
|
288
374
|
*/
|
|
289
|
-
bulkUpdate(names, changes) {
|
|
290
|
-
|
|
375
|
+
async bulkUpdate(names, changes) {
|
|
376
|
+
const results = [];
|
|
377
|
+
for (const name of names) {
|
|
291
378
|
try {
|
|
292
|
-
const existing = this.get(name);
|
|
379
|
+
const existing = await this.get(name);
|
|
293
380
|
if (!existing)
|
|
294
381
|
throw new Error(`skill with name "${name}" not found`);
|
|
295
382
|
const builtin = this.isBuiltin(existing);
|
|
@@ -298,19 +385,20 @@ export class SkillRepository {
|
|
|
298
385
|
tags = Array.from(new Set([...tags, ...changes.add_tags]));
|
|
299
386
|
if (changes.remove_tags?.length)
|
|
300
387
|
tags = tags.filter((t) => !changes.remove_tags.includes(t));
|
|
301
|
-
this.update(name, {
|
|
388
|
+
await this.update(name, {
|
|
302
389
|
tags,
|
|
303
390
|
owner: changes.owner,
|
|
304
391
|
status: changes.status,
|
|
305
392
|
extends: changes.extends,
|
|
306
393
|
...(changes.deprecated !== undefined && !builtin ? { deprecated: changes.deprecated } : {}),
|
|
307
394
|
});
|
|
308
|
-
|
|
395
|
+
results.push({ name, ok: true });
|
|
309
396
|
}
|
|
310
397
|
catch (err) {
|
|
311
|
-
|
|
398
|
+
results.push({ name, ok: false, error: err.message });
|
|
312
399
|
}
|
|
313
|
-
}
|
|
400
|
+
}
|
|
401
|
+
return results;
|
|
314
402
|
}
|
|
315
403
|
/**
|
|
316
404
|
* Pauses/resumes skills by name — a local-only toggle stored directly in this cache file's
|
|
@@ -320,41 +408,45 @@ export class SkillRepository {
|
|
|
320
408
|
* follow the skill to another machine's cache, survive a rename, or survive the cache file
|
|
321
409
|
* being deleted. Returns per-name results so one bad name doesn't abort the rest of the batch.
|
|
322
410
|
*/
|
|
323
|
-
setPaused(names, paused) {
|
|
324
|
-
|
|
411
|
+
async setPaused(names, paused) {
|
|
412
|
+
const results = [];
|
|
413
|
+
for (const name of names) {
|
|
325
414
|
try {
|
|
326
|
-
const existing = this.get(name);
|
|
415
|
+
const existing = await this.get(name);
|
|
327
416
|
if (!existing)
|
|
328
417
|
throw new Error(`skill with name "${name}" not found`);
|
|
329
418
|
if (this.isBuiltin(existing))
|
|
330
419
|
throw new Error(`skill "${name}" is builtin and cannot be paused`);
|
|
331
420
|
this.db.prepare(`UPDATE skills SET paused = ? WHERE id = ?`).run(paused ? 1 : 0, name);
|
|
332
|
-
|
|
421
|
+
results.push({ name, ok: true });
|
|
333
422
|
}
|
|
334
423
|
catch (err) {
|
|
335
|
-
|
|
424
|
+
results.push({ name, ok: false, error: err.message });
|
|
336
425
|
}
|
|
337
|
-
}
|
|
426
|
+
}
|
|
427
|
+
return results;
|
|
338
428
|
}
|
|
339
429
|
/**
|
|
340
430
|
* Renames many skills at once — each entry is a {name, new_name} pair, same
|
|
341
431
|
* semantics as rename(). Returns per-entry results so one bad pair (unknown
|
|
342
432
|
* name, name collision) doesn't abort the rest of the batch.
|
|
343
433
|
*/
|
|
344
|
-
bulkRename(entries) {
|
|
345
|
-
|
|
434
|
+
async bulkRename(entries) {
|
|
435
|
+
const results = [];
|
|
436
|
+
for (const { name, new_name } of entries) {
|
|
346
437
|
try {
|
|
347
|
-
this.rename(name, new_name);
|
|
348
|
-
|
|
438
|
+
await this.rename(name, new_name);
|
|
439
|
+
results.push({ name, new_name, ok: true });
|
|
349
440
|
}
|
|
350
441
|
catch (err) {
|
|
351
|
-
|
|
442
|
+
results.push({ name, new_name, ok: false, error: err.message });
|
|
352
443
|
}
|
|
353
|
-
}
|
|
444
|
+
}
|
|
445
|
+
return results;
|
|
354
446
|
}
|
|
355
|
-
/** Removes the whole skill directory, including any scripts/references/assets alongside SKILL.md. */
|
|
356
|
-
delete(name) {
|
|
357
|
-
const existing = this.get(name);
|
|
447
|
+
/** Removes the whole skill directory, including any scripts/references/assets alongside SKILL.md. Remote-folder note: does not delete the corresponding content on folderfoo — deletion isn't in the settled design's remote write scope (create/update/attachment writes only), same gap flagged on rename(). */
|
|
448
|
+
async delete(name) {
|
|
449
|
+
const existing = await this.get(name);
|
|
358
450
|
if (!existing)
|
|
359
451
|
throw new Error(`skill with name "${name}" not found`);
|
|
360
452
|
if (this.isBuiltin(existing))
|
|
@@ -368,16 +460,18 @@ export class SkillRepository {
|
|
|
368
460
|
* via search()/list(). Returns per-name results so one bad name doesn't
|
|
369
461
|
* abort the rest of the batch.
|
|
370
462
|
*/
|
|
371
|
-
bulkDelete(names) {
|
|
372
|
-
|
|
463
|
+
async bulkDelete(names) {
|
|
464
|
+
const results = [];
|
|
465
|
+
for (const name of names) {
|
|
373
466
|
try {
|
|
374
|
-
this.delete(name);
|
|
375
|
-
|
|
467
|
+
await this.delete(name);
|
|
468
|
+
results.push({ name, ok: true });
|
|
376
469
|
}
|
|
377
470
|
catch (err) {
|
|
378
|
-
|
|
471
|
+
results.push({ name, ok: false, error: err.message });
|
|
379
472
|
}
|
|
380
|
-
}
|
|
473
|
+
}
|
|
474
|
+
return results;
|
|
381
475
|
}
|
|
382
476
|
}
|
|
383
477
|
function stripSourcePath(fm) {
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { statusSchema } from '../shared/status.js';
|
|
3
|
+
import { bodyEditsSchema, applyBodyEdits, formatBodyEditsDiff } from '../shared/body-edits.js';
|
|
3
4
|
const SKILL_STATUS_DEFAULTS = ['stable', 'beta', 'unreviewed'];
|
|
4
5
|
const SKILL_NAME_DESCRIPTION = 'stable id, must be 1-64 chars, lowercase letters/numbers/hyphens only, no leading/trailing/consecutive hyphens — this becomes the skill\'s folder name (agentskills.io spec requirement)';
|
|
5
6
|
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.";
|
|
@@ -47,18 +48,18 @@ export function registerSkillTools(mcp, repo) {
|
|
|
47
48
|
extends: z.string().nullable().optional(),
|
|
48
49
|
deprecated: z.boolean().optional().describe('marks skills as deprecated (or un-deprecates when false) — independent of status'),
|
|
49
50
|
}, async ({ names, add_tags, remove_tags, owner, status, extends: extendsId, deprecated }) => {
|
|
50
|
-
const results = repo.bulkUpdate(names, { add_tags, remove_tags, owner, status, extends: extendsId, deprecated });
|
|
51
|
+
const results = await repo.bulkUpdate(names, { add_tags, remove_tags, owner, status, extends: extendsId, deprecated });
|
|
51
52
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
52
53
|
});
|
|
53
54
|
mcp.tool('skill_get', 'Fetches a single skill by name, including its full markdown body.', { name: z.string() }, async ({ name }) => {
|
|
54
|
-
const doc = repo.get(name);
|
|
55
|
+
const doc = await repo.get(name);
|
|
55
56
|
if (!doc) {
|
|
56
57
|
return { content: [{ type: 'text', text: `No skill found with name "${name}"` }], isError: true };
|
|
57
58
|
}
|
|
58
59
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
59
60
|
});
|
|
60
61
|
mcp.tool('skill_bulk_get', 'Fetches many skills by name in one call, including full markdown bodies — e.g. hydrating a batch of skill_search hits. Missing names are simply omitted from the result, not errors.', { names: z.array(z.string()).min(1) }, async ({ names }) => {
|
|
61
|
-
const docs = repo.bulkGet(names);
|
|
62
|
+
const docs = await repo.bulkGet(names);
|
|
62
63
|
return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
|
|
63
64
|
});
|
|
64
65
|
mcp.tool('skill_create', `Creates a new skill as <folder>/[subfolder/]<name>/SKILL.md, per the agentskills.io open standard — a folder containing SKILL.md, optionally alongside scripts/references/assets subfolders you create separately on disk. ${AUTHORING_SKILL_HINT}`, {
|
|
@@ -79,7 +80,7 @@ export function registerSkillTools(mcp, repo) {
|
|
|
79
80
|
...(multiFolder ? { folder: z.string().describe(`which configured skill folder to write into: ${folderNames}`) } : {}),
|
|
80
81
|
}, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId, subfolder, folder }) => {
|
|
81
82
|
try {
|
|
82
|
-
const doc = repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body, subfolder, folder);
|
|
83
|
+
const doc = await repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body, subfolder, folder);
|
|
83
84
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
84
85
|
}
|
|
85
86
|
catch (err) {
|
|
@@ -101,7 +102,7 @@ export function registerSkillTools(mcp, repo) {
|
|
|
101
102
|
...(multiFolder ? { folder: z.string().describe(`which configured skill folder to write into: ${folderNames}`) } : {}),
|
|
102
103
|
});
|
|
103
104
|
mcp.tool('skill_bulk_create', `Creates many skills in one call — each entry is the same shape as skill_create's args. Returns per-name success/failure so one bad entry (duplicate name, invalid name, existing directory) doesn't abort the rest of the batch. ${AUTHORING_SKILL_HINT}`, { entries: z.array(skillEntrySchema).min(1) }, async ({ entries }) => {
|
|
104
|
-
const results = repo.bulkCreate(entries.map((e) => ({
|
|
105
|
+
const results = await repo.bulkCreate(entries.map((e) => ({
|
|
105
106
|
frontmatter: {
|
|
106
107
|
name: e.name,
|
|
107
108
|
description: e.description,
|
|
@@ -119,10 +120,11 @@ export function registerSkillTools(mcp, repo) {
|
|
|
119
120
|
})));
|
|
120
121
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
121
122
|
});
|
|
122
|
-
mcp.tool('skill_update', `Edits an existing skill in place — frontmatter fields and/or body. Only provided fields change. Use skill_rename to change the name/folder. ${AUTHORING_SKILL_HINT}`, {
|
|
123
|
+
mcp.tool('skill_update', `Edits an existing skill in place — frontmatter fields and/or body. Only provided fields change. Use skill_rename to change the name/folder. For a body change smaller than the whole document, prefer body_edits over body: it patches via find/replace instead of requiring you to reproduce the entire body, which saves tokens and avoids accidentally dropping untouched content on a large skill. When body_edits is used, the response includes a \`diff\` field (compact -/+ per-edit summary) — show it to the user so they can see what changed, similar to a code diff view. The response never includes the full body (even on a full-body replacement) to avoid echoing back a potentially large doc — call skill_get(name) if you need the fresh full body. ${AUTHORING_SKILL_HINT}`, {
|
|
123
124
|
name: z.string(),
|
|
124
125
|
description: z.string().max(1024).optional(),
|
|
125
|
-
body: z.string().optional(),
|
|
126
|
+
body: z.string().optional().describe('full body replacement — omit in favor of body_edits when only part of the doc is changing'),
|
|
127
|
+
body_edits: bodyEditsSchema.optional(),
|
|
126
128
|
license: z.string().optional(),
|
|
127
129
|
compatibility: z.string().max(500).optional(),
|
|
128
130
|
owner: z.string().optional(),
|
|
@@ -131,10 +133,27 @@ export function registerSkillTools(mcp, repo) {
|
|
|
131
133
|
trigger_phrases: z.array(z.string()).optional(),
|
|
132
134
|
extends: z.string().optional(),
|
|
133
135
|
deprecated: z.boolean().optional().describe('marks the skill as deprecated (or un-deprecates when false) — independent of status'),
|
|
134
|
-
}, async ({ name, body, ...frontmatterFields }) => {
|
|
136
|
+
}, async ({ name, body, body_edits, ...frontmatterFields }) => {
|
|
137
|
+
if (body !== undefined && body_edits !== undefined) {
|
|
138
|
+
return { content: [{ type: 'text', text: 'Pass either body or body_edits, not both.' }], isError: true };
|
|
139
|
+
}
|
|
135
140
|
try {
|
|
136
|
-
|
|
137
|
-
|
|
141
|
+
let diff;
|
|
142
|
+
if (body_edits) {
|
|
143
|
+
const existing = await repo.get(name);
|
|
144
|
+
if (!existing)
|
|
145
|
+
throw new Error(`skill with name "${name}" not found`);
|
|
146
|
+
const { body: patchedBody, applied } = applyBodyEdits(existing.body, body_edits);
|
|
147
|
+
diff = formatBodyEditsDiff(applied);
|
|
148
|
+
body = patchedBody;
|
|
149
|
+
}
|
|
150
|
+
const doc = await repo.update(name, frontmatterFields, body);
|
|
151
|
+
// Body is omitted from the response: the caller either just sent it (full replacement),
|
|
152
|
+
// already has it, or has `diff` — echoing a potentially large body back is pure waste.
|
|
153
|
+
// Fetch skill_get(name) if the fresh full body is actually needed.
|
|
154
|
+
const { body: _omitted, ...docWithoutBody } = doc;
|
|
155
|
+
const result = diff ? { ...docWithoutBody, diff } : docWithoutBody;
|
|
156
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
138
157
|
}
|
|
139
158
|
catch (err) {
|
|
140
159
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
@@ -145,7 +164,7 @@ export function registerSkillTools(mcp, repo) {
|
|
|
145
164
|
new_name: z.string().describe(SKILL_NAME_DESCRIPTION),
|
|
146
165
|
}, async ({ name, new_name }) => {
|
|
147
166
|
try {
|
|
148
|
-
const doc = repo.rename(name, new_name);
|
|
167
|
+
const doc = await repo.rename(name, new_name);
|
|
149
168
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
150
169
|
}
|
|
151
170
|
catch (err) {
|
|
@@ -157,19 +176,19 @@ export function registerSkillTools(mcp, repo) {
|
|
|
157
176
|
.array(z.object({ name: z.string().describe('current skill name'), new_name: z.string().describe(SKILL_NAME_DESCRIPTION) }))
|
|
158
177
|
.min(1),
|
|
159
178
|
}, async ({ entries }) => {
|
|
160
|
-
const results = repo.bulkRename(entries);
|
|
179
|
+
const results = await repo.bulkRename(entries);
|
|
161
180
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
162
181
|
});
|
|
163
182
|
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.', {
|
|
164
183
|
names: z.array(z.string()).min(1),
|
|
165
184
|
paused: z.boolean().describe('true to pause (hide from skill_list/skill_search), false to resume'),
|
|
166
185
|
}, async ({ names, paused }) => {
|
|
167
|
-
const results = repo.setPaused(names, paused);
|
|
186
|
+
const results = await repo.setPaused(names, paused);
|
|
168
187
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
169
188
|
});
|
|
170
189
|
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 }) => {
|
|
171
190
|
try {
|
|
172
|
-
repo.delete(name);
|
|
191
|
+
await repo.delete(name);
|
|
173
192
|
return { content: [{ type: 'text', text: `Deleted skill "${name}"` }] };
|
|
174
193
|
}
|
|
175
194
|
catch (err) {
|
|
@@ -177,7 +196,7 @@ export function registerSkillTools(mcp, repo) {
|
|
|
177
196
|
}
|
|
178
197
|
});
|
|
179
198
|
mcp.tool('skill_bulk_delete', 'Hard-deletes many skills by name in one call — e.g. cleaning up a batch found via skill_search/skill_list. No tombstone. Returns per-name success/failure so one bad name doesn\'t abort the rest of the batch.', { names: z.array(z.string()).min(1) }, async ({ names }) => {
|
|
180
|
-
const results = repo.bulkDelete(names);
|
|
199
|
+
const results = await repo.bulkDelete(names);
|
|
181
200
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
182
201
|
});
|
|
183
202
|
}
|
package/dist/src/store/sync.js
CHANGED
|
@@ -90,11 +90,11 @@ function folderForFile(sources, filePath) {
|
|
|
90
90
|
return best?.name ?? '';
|
|
91
91
|
}
|
|
92
92
|
export function upsertFile(db, spec, filePath) {
|
|
93
|
-
const
|
|
93
|
+
const existingBySourcePath = db
|
|
94
94
|
.prepare(`SELECT mtime_ms FROM ${spec.table} WHERE source_path = ?`)
|
|
95
95
|
.get(filePath);
|
|
96
96
|
const parsed = readMarkdownFile(filePath);
|
|
97
|
-
if (
|
|
97
|
+
if (existingBySourcePath && existingBySourcePath.mtime_ms === parsed.mtimeMs)
|
|
98
98
|
return; // unchanged, skip reprocessing
|
|
99
99
|
const frontmatter = spec.deriveFrontmatter
|
|
100
100
|
? spec.deriveFrontmatter(parsed.frontmatter, filePath, parsed.mtimeMs)
|
|
@@ -104,6 +104,23 @@ export function upsertFile(db, spec, filePath) {
|
|
|
104
104
|
console.error(`[memory-bucket] skipping ${filePath}: missing required id field in frontmatter`);
|
|
105
105
|
return;
|
|
106
106
|
}
|
|
107
|
+
// id/name is the table's real PRIMARY KEY (used as the sole addressing
|
|
108
|
+
// handle across the whole public API - skill_get(name), memory_get(id),
|
|
109
|
+
// etc.), so a DIFFERENT file claiming the same id is a genuine name
|
|
110
|
+
// collision across configured folders/sources, not a re-scan of the same
|
|
111
|
+
// file. The ON CONFLICT(id) below would otherwise silently overwrite
|
|
112
|
+
// whichever row synced first - the first-synced item's row (and its
|
|
113
|
+
// full content) simply vanishes from the cache with no error and no way
|
|
114
|
+
// to address it, since id is the only lookup key this schema has.
|
|
115
|
+
// Refuse the overwrite and warn loudly instead: the first-synced item
|
|
116
|
+
// keeps working, the colliding one is visibly excluded rather than
|
|
117
|
+
// invisibly clobbering it.
|
|
118
|
+
const existingById = db.prepare(`SELECT source_path FROM ${spec.table} WHERE id = ?`).get(id);
|
|
119
|
+
if (existingById && existingById.source_path !== filePath) {
|
|
120
|
+
console.error(`[memory-bucket] SKIPPED indexing ${filePath}: id "${id}" already used by ${existingById.source_path} — ` +
|
|
121
|
+
`names must be unique across every configured folder. Rename one of these two files (or its frontmatter id/name) to resolve the collision.`);
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
107
124
|
const row = spec.toRow(frontmatter, filePath, parsed.mtimeMs);
|
|
108
125
|
const folder = folderForFile(spec.sources, filePath);
|
|
109
126
|
const cols = [...spec.columns, 'source_path', 'folder', 'body', 'mtime_ms'];
|