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.
- package/dist/client/assets/{index-_w_b_MQl.js → index-y6XKwUMi.js} +70 -70
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +45 -0
- package/dist/src/memory/repository.js +41 -6
- package/dist/src/remote/folderfoo-client.js +25 -0
- package/dist/src/server.js +8 -0
- package/dist/src/shared/bucket-root-tool.js +66 -0
- package/dist/src/shared/relocate.js +17 -1
- package/dist/src/skills/repository.js +84 -21
- package/dist/src/skills/tools.js +29 -15
- package/dist/src/store/db.js +51 -13
- package/dist/src/store/search.js +9 -6
- package/dist/src/store/sync.js +43 -26
- package/dist/src/web/routes.js +138 -7
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
package/dist/src/config.js
CHANGED
|
@@ -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
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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 },
|
package/dist/src/server.js
CHANGED
|
@@ -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
|
-
|
|
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 {
|
|
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
|
-
/**
|
|
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.
|
|
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
|
-
|
|
318
|
-
|
|
319
|
-
|
|
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
|
-
|
|
392
|
-
|
|
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
|
-
|
|
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))
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -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',
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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',
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
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',
|
|
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) {
|