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.
@@ -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
- return entries.map((entry) => ({ path: entry.path, ...relocate(entry, skillRepo, memoryRepo) }));
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
- get(name) {
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
- return row ? rowToDoc(row) : null;
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
- return names.map((name) => this.get(name)).filter((doc) => doc !== null);
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
- return entries.map((entry) => {
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
- return { name: entry.frontmatter.name, ok: true };
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
- return { name: entry.frontmatter.name, ok: false, error: err.message };
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
- return names.map((name) => {
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
- return { name, ok: true };
395
+ results.push({ name, ok: true });
309
396
  }
310
397
  catch (err) {
311
- return { name, ok: false, error: err.message };
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
- return names.map((name) => {
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
- return { name, ok: true };
421
+ results.push({ name, ok: true });
333
422
  }
334
423
  catch (err) {
335
- return { name, ok: false, error: err.message };
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
- return entries.map(({ name, new_name }) => {
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
- return { name, new_name, ok: true };
438
+ await this.rename(name, new_name);
439
+ results.push({ name, new_name, ok: true });
349
440
  }
350
441
  catch (err) {
351
- return { name, new_name, ok: false, error: err.message };
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
- return names.map((name) => {
463
+ async bulkDelete(names) {
464
+ const results = [];
465
+ for (const name of names) {
373
466
  try {
374
- this.delete(name);
375
- return { name, ok: true };
467
+ await this.delete(name);
468
+ results.push({ name, ok: true });
376
469
  }
377
470
  catch (err) {
378
- return { name, ok: false, error: err.message };
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) {
@@ -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
- const doc = repo.update(name, frontmatterFields, body);
137
- return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
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
  }
@@ -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 existing = db
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 (existing && existing.mtime_ms === parsed.mtimeMs)
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'];