mcp-memory-bucket 0.8.4 → 0.8.7

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.
@@ -33,7 +33,7 @@
33
33
  z-index: 2147483000;
34
34
  }
35
35
  </style>
36
- <script type="module" crossorigin src="/assets/index-_w_b_MQl.js"></script>
36
+ <script type="module" crossorigin src="/assets/index-y6XKwUMi.js"></script>
37
37
  </head>
38
38
  <body>
39
39
  <mem-bucket-app></mem-bucket-app>
@@ -162,6 +162,51 @@ export function sanitizeFolderName(raw) {
162
162
  .replace(/^-+|-+$/g, '')
163
163
  .slice(0, 64);
164
164
  }
165
+ /**
166
+ * Rewrites a REMOTE source's stored `folderPath` in the config file after it was renamed/moved on
167
+ * folderfoo — matched by the source's `name` (mem-bucket's own local identifier, immutable across
168
+ * a folderfoo-side rename), not by the old `folderPath` (which is exactly what just changed and so
169
+ * can no longer be used to find the entry). No-op if `name` isn't found or isn't a remote entry.
170
+ *
171
+ * `oldFolderPath`/`newFolderPath` describe the RENAMED FOLDER ITSELF, which may be an ancestor of
172
+ * (not necessarily equal to) the source's own `folderPath` — folderfoo's rename is recursive
173
+ * (renaming `work` also moves everything under `work/`), so a source registered at
174
+ * `work/project-x` must become `work2/project-x` when `work` itself is renamed to `work2`, not
175
+ * just when the source's exact path matches. Only rewrites when the source's folderPath is the
176
+ * renamed folder itself or nested under it — an unrelated source elsewhere in the tree is untouched.
177
+ */
178
+ export function updateRemoteFolderPath(config, kind, name, oldFolderPath, newFolderPath) {
179
+ const current = readConfigFile(config.configPath);
180
+ const key = kind === 'skill' ? 'skill_sources' : 'memory_sources';
181
+ const existing = current[key];
182
+ if (!existing)
183
+ return;
184
+ let changed = false;
185
+ const next = existing.map((entry) => {
186
+ if (typeof entry === 'string' || entry.name !== name || !isRemoteEntry(entry))
187
+ return entry;
188
+ const rebased = rebaseFolderPath(entry.remote.folderPath, oldFolderPath, newFolderPath);
189
+ if (rebased === entry.remote.folderPath)
190
+ return entry;
191
+ changed = true;
192
+ return { ...entry, remote: { ...entry.remote, folderPath: rebased } };
193
+ });
194
+ if (!changed)
195
+ return;
196
+ fs.writeFileSync(config.configPath, JSON.stringify({ ...current, [key]: next }, null, 2) + '\n');
197
+ }
198
+ /**
199
+ * Rewrites `path` to reflect `oldPrefix` having been renamed to `newPrefix` — returns `path`
200
+ * unchanged if it isn't `oldPrefix` itself or nested under it. Shared by updateRemoteFolderPath
201
+ * (config file) and the repository-layer equivalent that repoints the in-memory RemoteFolder[].
202
+ */
203
+ export function rebaseFolderPath(path, oldPrefix, newPrefix) {
204
+ if (path === oldPrefix)
205
+ return newPrefix;
206
+ if (oldPrefix && path.startsWith(`${oldPrefix}/`))
207
+ return `${newPrefix}${path.slice(oldPrefix.length)}`;
208
+ return path;
209
+ }
165
210
  /**
166
211
  * Removes a named folder from the config file by name. Matches both explicit
167
212
  * {name, path} entries and bare-string entries (via their derived name).
@@ -9,8 +9,9 @@ import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, memorySyncS
9
9
  import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
10
10
  import { applyBodyEdits } from '../shared/body-edits.js';
11
11
  import { attachmentsDirFor } from '../attachments/storage.js';
12
+ import { rebaseFolderPath } from '../config.js';
12
13
  import { normalizeKey } from '../types.js';
13
- import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath } from '../remote/folderfoo-client.js';
14
+ import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
14
15
  import { isFolderVisible } from '../remote/identity.js';
15
16
  /** Uppercases and strips everything but letters/digits — used to compare keys that differ only in
16
17
  * punctuation/whitespace formatting (e.g. `RMXS-15` and `RMXS15` strip to the same `RMXS15`). */
@@ -149,9 +150,25 @@ export class MemoryRepository {
149
150
  fs.rmSync(removedRemote.mirrorDir, { recursive: true, force: true });
150
151
  }
151
152
  }
153
+ /**
154
+ * Repoints a registered remote source's `folderPath` in place after it was renamed/moved on
155
+ * folderfoo — see SkillRepository's identical method for the full rationale (kept in sync with
156
+ * that one; this file has its own remoteFolders array, not shared state).
157
+ */
158
+ updateRemoteFolderPath(name, renamedFolderPath, newFolderPath) {
159
+ const remote = this.remoteFolders.find((f) => f.name === name);
160
+ if (!remote)
161
+ return;
162
+ remote.folderPath = rebaseFolderPath(remote.folderPath, renamedFolderPath, newFolderPath);
163
+ }
152
164
  /**
153
165
  * Exact-match lookup by normalized key, per V0 (no fuzzy matching).
154
166
  * `includePaused` defaults to false: paused docs are hidden from discovery (see setPaused).
167
+ * `folder`, when passed, restricts to docs in that one folder — memory_docs.id never collides
168
+ * across folders (it bakes in a random UUID suffix), but a human-facing `key` can legitimately
169
+ * repeat in different folders, so any caller treating "same key+description" as "the same doc"
170
+ * (e.g. relocate's already-relocated check) needs this to avoid a false match against an
171
+ * unrelated doc that just happens to share a key/description in a different folder.
155
172
  */
156
173
  getByKey(key, docType, opts = {}) {
157
174
  const normalized = normalizeKey(key);
@@ -161,6 +178,10 @@ export class MemoryRepository {
161
178
  conditions.push('doc_type = ?');
162
179
  params.push(docType);
163
180
  }
181
+ if (opts.folder) {
182
+ conditions.push('folder = ?');
183
+ params.push(opts.folder);
184
+ }
164
185
  if (!opts.includePaused) {
165
186
  conditions.push('paused = 0');
166
187
  }
@@ -339,11 +360,24 @@ export class MemoryRepository {
339
360
  // gets writeMarkdownFile's own formatting verbatim, not a hand-reconstructed string.
340
361
  const remote = this.remoteFor(targetFolder.name);
341
362
  if (remote && this.credentialsBaseDir) {
342
- const relPath = path.relative(remote.mirrorDir, filePath).replace(/\.md$/, '');
343
- const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
344
- const name = path.basename(relPath);
345
- const fileContents = fs.readFileSync(filePath, 'utf-8');
346
- await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
363
+ try {
364
+ // Confirms the remote folder still exists before writing — folderfoo's own save endpoint
365
+ // would otherwise silently recreate a deleted folder rather than failing (see
366
+ // assertRemoteFolderExists's doc comment).
367
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolder.name);
368
+ const relPath = path.relative(remote.mirrorDir, filePath).replace(/\.md$/, '');
369
+ const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
370
+ const name = path.basename(relPath);
371
+ const fileContents = fs.readFileSync(filePath, 'utf-8');
372
+ await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
373
+ }
374
+ catch (err) {
375
+ // Remote push failed after the local mirror file was already written — remove the orphaned
376
+ // local file synchronously rather than leaving it for the next poll tick's
377
+ // reconcileDeletions to eventually clean up, so a failed create leaves zero trace immediately.
378
+ fs.unlinkSync(filePath);
379
+ throw err;
380
+ }
347
381
  }
348
382
  upsertFile(this.db, this.syncSpec, filePath);
349
383
  return { ...fm, body: input.body, paused: false };
@@ -383,6 +417,7 @@ export class MemoryRepository {
383
417
  writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
384
418
  const remote = this.remoteFor(existing.folder);
385
419
  if (remote && this.credentialsBaseDir) {
420
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
386
421
  const relPath = path.relative(remote.mirrorDir, existing.source_path).replace(/\.md$/, '');
387
422
  const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
388
423
  const name = path.basename(relPath);
@@ -104,6 +104,31 @@ export async function getLastChanged(server, baseDir, tenantId, folderPath) {
104
104
  export async function listFolders(server, baseDir, tenantId) {
105
105
  return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
106
106
  }
107
+ /** Thrown when a write targets a remote folder that no longer exists on folderfoo (deleted server-side since the last connect/UI-open sync). */
108
+ export class RemoteFolderGoneError extends Error {
109
+ constructor(folderName) {
110
+ super(`remote folder "${folderName}" no longer exists on folderfoo — reconnect or remove this source`);
111
+ this.name = 'RemoteFolderGoneError';
112
+ }
113
+ }
114
+ /**
115
+ * Confirms `folderPath` still exists on `server` before a write proceeds. folderfoo's own
116
+ * POST /save/:filename can never fail for a gone folder path — it unconditionally
117
+ * `mkdirSync(userDir, { recursive: true })`s before writing, silently recreating (un-deleting) the
118
+ * folder rather than erroring — so this is the only way to catch "the folder was deleted since we
119
+ * last checked" before a write silently resurrects it. Per the settled design, a write into a
120
+ * confirmed-gone folder should fail loudly (RemoteFolderGoneError), not silently recreate the
121
+ * folder — the user may have deleted it deliberately. `folderPath === ''` (a source's own root) is
122
+ * never gone (the user's account root always exists), so this only ever checks a non-root path.
123
+ */
124
+ export async function assertRemoteFolderExists(server, baseDir, tenantId, folderPath, folderName) {
125
+ if (!folderPath)
126
+ return;
127
+ const folders = await listFolders(server, baseDir, tenantId);
128
+ if (!folders.some((f) => f.path === folderPath)) {
129
+ throw new RemoteFolderGoneError(folderName);
130
+ }
131
+ }
107
132
  export async function getChangedSince(server, baseDir, tenantId, folderPath, since) {
108
133
  return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since) })}`, {
109
134
  headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
@@ -64,6 +64,14 @@ const attachmentRepo = new AttachmentRepository(memoryRepo, skillRepo);
64
64
  // same upsertFile/removeFile path a local file-watch event would.
65
65
  const remoteSkillPoller = config.remoteSkillFolders.length > 0 ? startRemotePolling(db, skillSpec, config.remoteSkillFolders, config.baseDir) : undefined;
66
66
  const remoteMemoryPoller = config.remoteMemoryFolders.length > 0 ? startRemotePolling(db, memorySpec, config.remoteMemoryFolders, config.baseDir) : undefined;
67
+ // Forces one immediate poll of every remote source at process start, instead
68
+ // of leaving the cache to show whatever was last synced until the first
69
+ // fixed interval tick (up to POLL_INTERVAL_MS later) - this is effectively
70
+ // "login" for a background server: a fresh process (laptop wake, editor
71
+ // reopened) is exactly when another device's writes are most likely to be
72
+ // unseen locally yet.
73
+ remoteSkillPoller?.resyncAll().catch((err) => console.error('[memory-bucket] initial remote skill resync failed:', err));
74
+ remoteMemoryPoller?.resyncAll().catch((err) => console.error('[memory-bucket] initial remote memory resync failed:', err));
67
75
  if (config.skillFolders.length === 0 && config.memoryFolders.length === 0) {
68
76
  console.error(`[memory-bucket] no folders configured — open http://localhost:${PORT} to add one`);
69
77
  }
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { saveRoot, removeRoot as removeRootFromConfig, sanitizeRootName } from '../config.js';
5
+ import { initialScan } from '../store/sync.js';
6
+ const KIND = z.enum(['skill', 'memory']);
7
+ export function registerBucketRootTools(mcp, config, skillRepo, memoryRepo, db, skillSpec, memorySpec) {
8
+ mcp.tool('bucket_list_roots', 'Lists the configured skill and memory roots (the named source directories skills/memory docs live under, e.g. "super-skills", "demo-skills", "builtin") — use this to see what roots exist before passing a `root` argument to a create/list/search tool, or before adding/removing one.', {}, async () => {
9
+ const skill = skillRepo.listRoots().map((r) => ({ ...r, kind: 'skill' }));
10
+ const memory = memoryRepo.listRoots().map((r) => ({ ...r, kind: 'memory' }));
11
+ return { content: [{ type: 'text', text: JSON.stringify({ skill, memory }, null, 2) }] };
12
+ });
13
+ mcp.tool('bucket_create_root', 'Registers a new skill or memory root: an existing absolute directory path becomes a new named source that skill_create/memory_create can target via `root`. Scans it once and starts watching it live — never creates the directory itself, it must already exist.', {
14
+ kind: KIND,
15
+ path: z.string().describe('absolute path to an existing directory'),
16
+ name: z.string().optional().describe('name for the root; defaults to a sanitized version of the directory\'s basename'),
17
+ }, async ({ kind, path: dirPath, name }) => {
18
+ try {
19
+ if (!path.isAbsolute(dirPath)) {
20
+ return { content: [{ type: 'text', text: 'path must be an absolute directory path' }], isError: true };
21
+ }
22
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
23
+ return { content: [{ type: 'text', text: `not a directory: ${dirPath}` }], isError: true };
24
+ }
25
+ const rootName = sanitizeRootName(name || path.basename(dirPath));
26
+ if (!rootName) {
27
+ return { content: [{ type: 'text', text: 'could not derive a valid root name — provide one explicitly' }], isError: true };
28
+ }
29
+ const repo = kind === 'skill' ? skillRepo : memoryRepo;
30
+ repo.addRoot({ name: rootName, path: dirPath });
31
+ saveRoot(config, kind, { name: rootName, path: dirPath });
32
+ return { content: [{ type: 'text', text: JSON.stringify({ name: rootName, path: dirPath, kind }, null, 2) }] };
33
+ }
34
+ catch (err) {
35
+ return { content: [{ type: 'text', text: err.message }], isError: true };
36
+ }
37
+ });
38
+ mcp.tool('bucket_delete_root', 'Unregisters a skill or memory root by name: stops watching it and drops its cached entries from the index. Never touches files on disk — the directory and its contents are left in place.', { kind: KIND, name: z.string() }, async ({ kind, name }) => {
39
+ try {
40
+ const repo = kind === 'skill' ? skillRepo : memoryRepo;
41
+ repo.removeRoot(name);
42
+ removeRootFromConfig(config, kind, name);
43
+ return { content: [{ type: 'text', text: `Removed ${kind} root "${name}"` }] };
44
+ }
45
+ catch (err) {
46
+ return { content: [{ type: 'text', text: err.message }], isError: true };
47
+ }
48
+ });
49
+ mcp.tool('bucket_rebuild_cache', 'EMERGENCY USE ONLY. Wipes the entire SQLite cache (all skills, memory docs, the full-text search index, and the date index) and rebuilds it from scratch by rescanning every configured root from disk. Source markdown files on disk are never touched — this only affects the derived cache, which is always safe to discard and regenerate. Use this only when other tools return results that contradict what you can see in the actual files (e.g. stale search hits, a doc that clearly exists on disk but skill_get/memory_get can\'t find, or search_by_date returning wrong dates) and a normal create/update/relocate call hasn\'t resolved it — this is a last resort, not a routine maintenance step. Takes a moment to complete on a large root; nothing else should be called until it returns.', {}, async () => {
50
+ try {
51
+ db.exec(`DELETE FROM skills; DELETE FROM memory_docs; DELETE FROM search_index; DELETE FROM doc_dates;`);
52
+ initialScan(db, skillSpec);
53
+ initialScan(db, memorySpec);
54
+ const skillCount = db.prepare(`SELECT COUNT(*) AS n FROM skills`).get().n;
55
+ const memoryCount = db.prepare(`SELECT COUNT(*) AS n FROM memory_docs`).get().n;
56
+ return {
57
+ content: [
58
+ { type: 'text', text: `Cache rebuilt from disk: ${skillCount} skill(s), ${memoryCount} memory doc(s) reindexed.` },
59
+ ],
60
+ };
61
+ }
62
+ catch (err) {
63
+ return { content: [{ type: 'text', text: err.message }], isError: true };
64
+ }
65
+ });
66
+ }
@@ -64,6 +64,14 @@ export async function relocate(opts, skillRepo, memoryRepo) {
64
64
  trigger_phrases: [],
65
65
  extends: null,
66
66
  }, body, opts.overrides?.subfolder, opts.overrides?.folder);
67
+ // Belt-and-suspenders per the "never lose the source file" requirement: create() already
68
+ // throws (skipping the unlink below) on any failure, so this positive re-fetch is redundant
69
+ // today — but it guards against a future refactor of create()'s error handling silently
70
+ // swallowing a failure instead of throwing, which would otherwise make the unlink below
71
+ // fire despite the target write never actually landing.
72
+ if (!(await skillRepo.get(doc.name, doc.folder))) {
73
+ throw new Error(`relocate: skill "${doc.name}" create() reported success but is not readable back — refusing to delete the source file`);
74
+ }
67
75
  if (!opts.keep_original)
68
76
  fs.unlinkSync(opts.path);
69
77
  return { moved: true, id: doc.name, target: 'skill' };
@@ -80,7 +88,11 @@ export async function relocate(opts, skillRepo, memoryRepo) {
80
88
  reason: `filename does not clearly match the ticket-key naming pattern — ask the user for an explicit key and description, then retry with overrides.key/overrides.description`,
81
89
  };
82
90
  }
83
- const already = memoryRepo.getByKey(key, docType).find((d) => d.description === description);
91
+ // Scoped to the destination folder: a same-key/description doc in a DIFFERENT folder is not
92
+ // "this file already relocated" — memory_docs ids never collide (see getByKey's doc comment),
93
+ // so without this scoping a genuinely new file could be silently refused because an unrelated
94
+ // doc elsewhere happens to share the same key+description.
95
+ const already = memoryRepo.getByKey(key, docType, { folder: opts.overrides?.folder }).find((d) => d.description === description);
84
96
  if (already) {
85
97
  return { moved: false, reason: `already relocated as memory doc "${already.id}"`, id: already.id, target: 'memory' };
86
98
  }
@@ -94,6 +106,10 @@ export async function relocate(opts, skillRepo, memoryRepo) {
94
106
  subfolder: opts.overrides?.subfolder,
95
107
  folder: opts.overrides?.folder,
96
108
  });
109
+ // Same belt-and-suspenders re-fetch as the skill branch above.
110
+ if (!(await memoryRepo.get(doc.id))) {
111
+ throw new Error(`relocate: memory doc "${doc.id}" create() reported success but is not readable back — refusing to delete the source file`);
112
+ }
97
113
  if (!opts.keep_original)
98
114
  fs.unlinkSync(opts.path);
99
115
  return { moved: true, id: doc.id, target: 'memory' };
@@ -7,7 +7,8 @@ import { resolveWithinBase } from '../store/safe-path.js';
7
7
  import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec } from '../store/sync.js';
8
8
  import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
9
9
  import { applyBodyEdits } from '../shared/body-edits.js';
10
- import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath } from '../remote/folderfoo-client.js';
10
+ import { rebaseFolderPath } from '../config.js';
11
+ import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
11
12
  import { isFolderVisible } from '../remote/identity.js';
12
13
  function rowToDoc(row) {
13
14
  return {
@@ -62,11 +63,20 @@ export class SkillRepository {
62
63
  const identity = this.identity.current();
63
64
  return this.remoteFolders.filter((f) => !isFolderVisible(f, identity)).map((f) => f.name);
64
65
  }
65
- /** 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. */
66
+ /**
67
+ * Pushes the just-written SKILL.md at filePath to folderfoo, if targetFolderName resolves to a
68
+ * remote source. No-op for a local folder. See get()'s comment on the folderPath/name split for
69
+ * a skill's directory-per-skill layout.
70
+ *
71
+ * Confirms the remote folder still exists before writing: folderfoo's own save endpoint would
72
+ * otherwise silently recreate a deleted folder rather than failing (see assertRemoteFolderExists's
73
+ * doc comment) — this turns that into a loud, specific RemoteFolderGoneError instead.
74
+ */
66
75
  async pushToRemoteIfNeeded(targetFolderName, filePath) {
67
76
  const remote = this.remoteFor(targetFolderName);
68
77
  if (!remote || !this.credentialsBaseDir)
69
78
  return;
79
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolderName);
70
80
  const skillDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(filePath)));
71
81
  const fileContents = fs.readFileSync(filePath, 'utf-8');
72
82
  await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', fileContents);
@@ -152,6 +162,24 @@ export class SkillRepository {
152
162
  fs.rmSync(removedRemote.mirrorDir, { recursive: true, force: true });
153
163
  }
154
164
  }
165
+ /**
166
+ * Repoints a registered remote source's `folderPath` in place after it was renamed/moved on
167
+ * folderfoo — e.g. the embedded File Open dialog's `folderfoo-folder-changed` event. Unlike
168
+ * removeFolder, this keeps the source's local mirror directory, cached rows, and content
169
+ * completely untouched: only WHERE ON FOLDERFOO future polls/writes target changes, matching
170
+ * folderfoo's own rename (same content, new path) rather than a delete+reconnect.
171
+ *
172
+ * `renamedFolderPath`/`newFolderPath` describe the folder that was actually renamed on
173
+ * folderfoo, which may be an ancestor of this source's own `folderPath` (folderfoo's rename is
174
+ * recursive) — see config.ts's rebaseFolderPath for the shared prefix-rewrite logic. No-op if
175
+ * this source's folderPath isn't the renamed folder or nested under it.
176
+ */
177
+ updateRemoteFolderPath(name, renamedFolderPath, newFolderPath) {
178
+ const remote = this.remoteFolders.find((f) => f.name === name);
179
+ if (!remote)
180
+ return;
181
+ remote.folderPath = rebaseFolderPath(remote.folderPath, renamedFolderPath, newFolderPath);
182
+ }
155
183
  /** `includePaused` defaults to false: paused skills are hidden from discovery (see setPaused). */
156
184
  list(query, folder, opts = {}) {
157
185
  const conditions = [];
@@ -231,7 +259,7 @@ export class SkillRepository {
231
259
  snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
232
260
  -bm25(search_index) AS score
233
261
  FROM search_index
234
- JOIN skills s ON s.id = search_index.ref_id
262
+ JOIN skills s ON s.id = search_index.ref_id AND s.folder = search_index.ref_folder
235
263
  WHERE search_index.ref_table = 'skills' AND search_index MATCH ? ${conditions.map((c) => `AND ${c}`).join(' ')}
236
264
  ORDER BY bm25(search_index)
237
265
  LIMIT ? OFFSET ?`)
@@ -242,14 +270,34 @@ export class SkillRepository {
242
270
  throw new SearchQueryError(query, err);
243
271
  }
244
272
  }
273
+ /**
274
+ * Names are unique PER FOLDER, not globally (skills table key is (folder, id)) — so a bare `name`
275
+ * can match more than one row across configured folders. When `folder` is omitted and the name is
276
+ * unambiguous (unique across every configured folder, or only one folder configured), resolution
277
+ * works exactly as before. When it's genuinely ambiguous, this throws a disambiguation error
278
+ * listing the matching folders rather than silently picking one — per the settled multi-folder
279
+ * design (see FOLDERFOO-MULTI-FOLDER-SUPPORT's "Name collisions across sources" section).
280
+ */
281
+ resolveRow(name, folder) {
282
+ if (folder) {
283
+ return this.db.prepare(`SELECT * FROM skills WHERE folder = ? AND id = ?`).get(folder, name) ?? null;
284
+ }
285
+ const rows = this.db.prepare(`SELECT * FROM skills WHERE id = ?`).all(name);
286
+ if (rows.length === 0)
287
+ return null;
288
+ if (rows.length === 1)
289
+ return rows[0];
290
+ const folders = rows.map((r) => r.folder).join(', ');
291
+ throw new Error(`skill "${name}" exists in multiple folders (${folders}) — specify folder to disambiguate`);
292
+ }
245
293
  /**
246
294
  * For a skill in a LOCAL folder, returns the cached row unchanged (identical behavior to before
247
295
  * remote sources existed). For a skill in a REMOTE folder, the cached `body` is only ever a
248
296
  * poll-interval-stale mirror snapshot — per the settled design, `get` always fetches the current
249
297
  * body live from folderfoo instead of trusting it.
250
298
  */
251
- async get(name) {
252
- const row = this.db.prepare(`SELECT * FROM skills WHERE id = ?`).get(name);
299
+ async get(name, folder) {
300
+ const row = this.resolveRow(name, folder);
253
301
  if (!row)
254
302
  return null;
255
303
  const doc = rowToDoc(row);
@@ -282,14 +330,16 @@ export class SkillRepository {
282
330
  * Creates <folder>/[subfolder/]<name>/SKILL.md — folder-per-skill, per the
283
331
  * agentskills.io spec (`name` must equal the containing folder's name).
284
332
  * `folder` selects which configured skill folder to write into; required only
285
- * when more than one user folder is configured.
333
+ * when more than one user folder is configured. Name uniqueness is checked only
334
+ * WITHIN that target folder — the same name existing in a different configured
335
+ * folder is not a collision (skills key on (folder, id), not on id alone).
286
336
  */
287
337
  async create(frontmatter, body, subfolder, folder) {
288
338
  assertValidSkillName(frontmatter.name);
289
- if (await this.get(frontmatter.name)) {
290
- throw new Error(`skill with name "${frontmatter.name}" already exists`);
291
- }
292
339
  const targetFolder = this.resolveFolder(folder);
340
+ if (await this.get(frontmatter.name, targetFolder.name)) {
341
+ throw new Error(`skill with name "${frontmatter.name}" already exists in folder "${targetFolder.name}"`);
342
+ }
293
343
  const skillDir = resolveWithinBase(targetFolder.path, subfolder, frontmatter.name);
294
344
  if (fs.existsSync(skillDir)) {
295
345
  throw new Error(`skill directory already exists at ${skillDir}`);
@@ -314,9 +364,18 @@ export class SkillRepository {
314
364
  folder: targetFolder.name,
315
365
  };
316
366
  writeMarkdownFile(filePath, stripSourcePath(fm), body);
317
- // Live/synchronous per the settled design — a folderfoo rejection here throws before upsertFile
318
- // indexes the mirror as if the skill were successfully saved remotely.
319
- await this.pushToRemoteIfNeeded(targetFolder.name, filePath);
367
+ try {
368
+ // Live/synchronous per the settled design — a folderfoo rejection here throws before upsertFile
369
+ // indexes the mirror as if the skill were successfully saved remotely.
370
+ await this.pushToRemoteIfNeeded(targetFolder.name, filePath);
371
+ }
372
+ catch (err) {
373
+ // Remote push failed after the local mirror was already written — remove the orphaned local
374
+ // dir synchronously rather than leaving it for the next poll tick's reconcileDeletions to
375
+ // eventually clean up, so a failed create leaves zero trace immediately.
376
+ fs.rmSync(skillDir, { recursive: true, force: true });
377
+ throw err;
378
+ }
320
379
  upsertFile(this.db, this.syncSpec, filePath);
321
380
  return { ...fm, body, paused: false };
322
381
  }
@@ -342,8 +401,8 @@ export class SkillRepository {
342
401
  isBuiltin(doc) {
343
402
  return doc.folder === this.folders[0]?.name;
344
403
  }
345
- async update(name, frontmatter, body, bodyEdits) {
346
- const existing = await this.get(name);
404
+ async update(name, frontmatter, body, bodyEdits, folder) {
405
+ const existing = await this.get(name, folder);
347
406
  if (!existing)
348
407
  throw new Error(`skill with name "${name}" not found`);
349
408
  // `paused` is local-cache-only and must never reach writeMarkdownFile — split it off of
@@ -381,15 +440,17 @@ export class SkillRepository {
381
440
  * attachment writes, not rename). Renaming a remote-sourced skill currently leaves a stale copy
382
441
  * under the old name on folderfoo; flagged here rather than silently incomplete.
383
442
  */
384
- async rename(name, newName) {
443
+ async rename(name, newName, folder) {
385
444
  assertValidSkillName(newName);
386
- const existing = await this.get(name);
445
+ const existing = await this.get(name, folder);
387
446
  if (!existing)
388
447
  throw new Error(`skill with name "${name}" not found`);
389
448
  if (newName === name)
390
449
  return existing;
391
- if (await this.get(newName)) {
392
- throw new Error(`skill with name "${newName}" already exists`);
450
+ // The rename target's collision check is scoped to the SAME folder the existing skill lives
451
+ // in — a rename never moves a skill to a different folder, so only that folder's names matter.
452
+ if (await this.get(newName, existing.folder)) {
453
+ throw new Error(`skill with name "${newName}" already exists in folder "${existing.folder}"`);
393
454
  }
394
455
  const oldDir = path.dirname(existing.source_path);
395
456
  const newDir = path.join(path.dirname(oldDir), newName);
@@ -461,7 +522,9 @@ export class SkillRepository {
461
522
  throw new Error(`skill with name "${name}" not found`);
462
523
  if (this.isBuiltin(existing))
463
524
  throw new Error(`skill "${name}" is builtin and cannot be paused`);
464
- this.db.prepare(`UPDATE skills SET paused = ? WHERE id = ?`).run(paused ? 1 : 0, name);
525
+ // Scoped to the resolved row's own folder — a bare `WHERE id = ?` would flip paused on
526
+ // EVERY folder's same-named skill at once now that names are only unique per folder.
527
+ this.db.prepare(`UPDATE skills SET paused = ? WHERE folder = ? AND id = ?`).run(paused ? 1 : 0, existing.folder, name);
465
528
  results.push({ name, ok: true });
466
529
  }
467
530
  catch (err) {
@@ -489,8 +552,8 @@ export class SkillRepository {
489
552
  return results;
490
553
  }
491
554
  /** 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(). */
492
- async delete(name) {
493
- const existing = await this.get(name);
555
+ async delete(name, folder) {
556
+ const existing = await this.get(name, folder);
494
557
  if (!existing)
495
558
  throw new Error(`skill with name "${name}" not found`);
496
559
  if (this.isBuiltin(existing))
@@ -51,12 +51,17 @@ export function registerSkillTools(mcp, repo) {
51
51
  const results = await repo.bulkUpdate(names, { add_tags, remove_tags, owner, status, extends: extendsId, deprecated });
52
52
  return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
53
53
  });
54
- mcp.tool('skill_get', 'Fetches a single skill by name, including its full markdown body.', { name: z.string() }, async ({ name }) => {
55
- const doc = await repo.get(name);
56
- if (!doc) {
57
- return { content: [{ type: 'text', text: `No skill found with name "${name}"` }], isError: true };
54
+ mcp.tool('skill_get', `Fetches a single skill by name, including its full markdown body.${multiFolder ? ` Names are unique per folder, not globally — if "${folderNames}" has more than one skill sharing this name, pass folder to disambiguate; omitting it errors out when the name is ambiguous instead of guessing.` : ''}`, multiFolder ? { name: z.string(), folder: z.string().optional().describe(`disambiguates when the name exists in more than one folder: ${folderNames}`) } : { name: z.string() }, async ({ name, folder }) => {
55
+ try {
56
+ const doc = await repo.get(name, folder);
57
+ if (!doc) {
58
+ return { content: [{ type: 'text', text: `No skill found with name "${name}"` }], isError: true };
59
+ }
60
+ return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
61
+ }
62
+ catch (err) {
63
+ return { content: [{ type: 'text', text: err.message }], isError: true };
58
64
  }
59
- return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
60
65
  });
61
66
  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 }) => {
62
67
  const docs = await repo.bulkGet(names);
@@ -133,21 +138,22 @@ export function registerSkillTools(mcp, repo) {
133
138
  trigger_phrases: z.array(z.string()).optional(),
134
139
  extends: z.string().optional(),
135
140
  deprecated: z.boolean().optional().describe('marks the skill as deprecated (or un-deprecates when false) — independent of status'),
136
- }, async ({ name, body, body_edits, ...frontmatterFields }) => {
141
+ ...(multiFolder ? { folder: z.string().optional().describe(`disambiguates when the name exists in more than one folder: ${folderNames}`) } : {}),
142
+ }, async ({ name, body, body_edits, folder, ...frontmatterFields }) => {
137
143
  if (body !== undefined && body_edits !== undefined) {
138
144
  return { content: [{ type: 'text', text: 'Pass either body or body_edits, not both.' }], isError: true };
139
145
  }
140
146
  try {
141
147
  let diff;
142
148
  if (body_edits) {
143
- const existing = await repo.get(name);
149
+ const existing = await repo.get(name, folder);
144
150
  if (!existing)
145
151
  throw new Error(`skill with name "${name}" not found`);
146
152
  const { body: patchedBody, applied } = applyBodyEdits(existing.body, body_edits);
147
153
  diff = formatBodyEditsDiff(applied);
148
154
  body = patchedBody;
149
155
  }
150
- const doc = await repo.update(name, frontmatterFields, body);
156
+ const doc = await repo.update(name, frontmatterFields, body, undefined, folder);
151
157
  // Body is omitted from the response: the caller either just sent it (full replacement),
152
158
  // already has it, or has `diff` — echoing a potentially large body back is pure waste.
153
159
  // Fetch skill_get(name) if the fresh full body is actually needed.
@@ -159,12 +165,18 @@ export function registerSkillTools(mcp, repo) {
159
165
  return { content: [{ type: 'text', text: err.message }], isError: true };
160
166
  }
161
167
  });
162
- mcp.tool('skill_rename', 'Renames a skill: moves its folder to the new name and updates the frontmatter `name` field to match, preserving any scripts/references/assets alongside SKILL.md.', {
163
- name: z.string().describe('current skill name'),
164
- new_name: z.string().describe(SKILL_NAME_DESCRIPTION),
165
- }, async ({ name, new_name }) => {
168
+ mcp.tool('skill_rename', `Renames a skill: moves its folder to the new name and updates the frontmatter \`name\` field to match, preserving any scripts/references/assets alongside SKILL.md.${multiFolder ? ` If the current name exists in more than one folder, pass folder to disambiguate which one to rename.` : ''}`, multiFolder
169
+ ? {
170
+ name: z.string().describe('current skill name'),
171
+ new_name: z.string().describe(SKILL_NAME_DESCRIPTION),
172
+ folder: z.string().optional().describe(`disambiguates when the current name exists in more than one folder: ${folderNames}`),
173
+ }
174
+ : {
175
+ name: z.string().describe('current skill name'),
176
+ new_name: z.string().describe(SKILL_NAME_DESCRIPTION),
177
+ }, async ({ name, new_name, folder }) => {
166
178
  try {
167
- const doc = await repo.rename(name, new_name);
179
+ const doc = await repo.rename(name, new_name, folder);
168
180
  return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
169
181
  }
170
182
  catch (err) {
@@ -186,9 +198,11 @@ export function registerSkillTools(mcp, repo) {
186
198
  const results = await repo.setPaused(names, paused);
187
199
  return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
188
200
  });
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 }) => {
201
+ mcp.tool('skill_delete', `Hard-deletes a skill by name — removes the whole skill folder (SKILL.md plus any scripts/references/assets), no tombstone.${multiFolder ? ` If the name exists in more than one folder, pass folder to disambiguate which one to delete.` : ''}`, multiFolder
202
+ ? { name: z.string(), folder: z.string().optional().describe(`disambiguates when the name exists in more than one folder: ${folderNames}`) }
203
+ : { name: z.string() }, async ({ name, folder }) => {
190
204
  try {
191
- await repo.delete(name);
205
+ await repo.delete(name, folder);
192
206
  return { content: [{ type: 'text', text: `Deleted skill "${name}"` }] };
193
207
  }
194
208
  catch (err) {