mcp-memory-bucket 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +40 -14
- package/dist/client/assets/index-CXe_noum.js +344 -0
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +77 -10
- package/dist/src/memory/repository.js +51 -7
- package/dist/src/memory/tools.js +11 -5
- package/dist/src/server.js +17 -9
- package/dist/src/shared/relocate-tool.js +2 -1
- package/dist/src/shared/relocate.js +2 -1
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +24 -0
- package/dist/src/skills/repository.js +67 -13
- package/dist/src/skills/tools.js +12 -6
- package/dist/src/store/db.js +11 -0
- package/dist/src/store/sync.js +43 -10
- package/dist/src/web/routes.js +119 -7
- package/package.json +2 -2
- package/dist/client/assets/index-CxhInmjj.js +0 -150
|
@@ -4,7 +4,7 @@ import { randomUUID } from 'node:crypto';
|
|
|
4
4
|
import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
5
5
|
import { slugify } from '../store/slug.js';
|
|
6
6
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
7
|
-
import { upsertFile, removeFile, memorySyncSpec } from '../store/sync.js';
|
|
7
|
+
import { upsertFile, removeFile, scanSingleRoot, unregisterRoot, memorySyncSpec } from '../store/sync.js';
|
|
8
8
|
import { normalizeKey } from '../types.js';
|
|
9
9
|
function rowToDoc(row) {
|
|
10
10
|
return {
|
|
@@ -17,17 +17,59 @@ function rowToDoc(row) {
|
|
|
17
17
|
status: row.status,
|
|
18
18
|
related_to: row.related_to,
|
|
19
19
|
source_path: row.source_path,
|
|
20
|
+
root: row.root,
|
|
20
21
|
body: row.body,
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
export class MemoryRepository {
|
|
24
25
|
db;
|
|
25
|
-
|
|
26
|
+
roots;
|
|
26
27
|
syncSpec;
|
|
27
|
-
|
|
28
|
+
watcher;
|
|
29
|
+
constructor(db, roots) {
|
|
28
30
|
this.db = db;
|
|
29
|
-
this.
|
|
30
|
-
this.syncSpec = memorySyncSpec(
|
|
31
|
+
this.roots = roots;
|
|
32
|
+
this.syncSpec = memorySyncSpec(roots);
|
|
33
|
+
}
|
|
34
|
+
/** Attaches the live chokidar watcher so addRoot/removeRoot can mutate it without a restart. */
|
|
35
|
+
setWatcher(watcher) {
|
|
36
|
+
this.watcher = watcher;
|
|
37
|
+
}
|
|
38
|
+
listRoots() {
|
|
39
|
+
return [...this.roots];
|
|
40
|
+
}
|
|
41
|
+
resolveRoot(rootName) {
|
|
42
|
+
if (rootName) {
|
|
43
|
+
const found = this.roots.find((r) => r.name === rootName);
|
|
44
|
+
if (!found) {
|
|
45
|
+
throw new Error(`unknown memory root "${rootName}" — valid roots: ${this.roots.map((r) => r.name).join(', ') || '(none configured)'}`);
|
|
46
|
+
}
|
|
47
|
+
return found;
|
|
48
|
+
}
|
|
49
|
+
if (this.roots.length === 1)
|
|
50
|
+
return this.roots[0];
|
|
51
|
+
if (this.roots.length === 0) {
|
|
52
|
+
throw new Error('no memory root configured — add one first (see bucket_open_ui)');
|
|
53
|
+
}
|
|
54
|
+
throw new Error(`multiple memory roots configured — specify root: one of ${this.roots.map((r) => r.name).join(', ')}`);
|
|
55
|
+
}
|
|
56
|
+
/** Registers a new root: appends it, scans it once, and starts watching it live. */
|
|
57
|
+
addRoot(root) {
|
|
58
|
+
if (this.roots.some((r) => r.name === root.name)) {
|
|
59
|
+
throw new Error(`a memory root named "${root.name}" already exists`);
|
|
60
|
+
}
|
|
61
|
+
this.roots.push(root);
|
|
62
|
+
scanSingleRoot(this.db, this.syncSpec, root.path);
|
|
63
|
+
this.watcher?.add(root.path);
|
|
64
|
+
}
|
|
65
|
+
/** Unregisters a root: stops watching it and drops its cached rows. Never touches files on disk. */
|
|
66
|
+
removeRoot(name) {
|
|
67
|
+
const idx = this.roots.findIndex((r) => r.name === name);
|
|
68
|
+
if (idx === -1)
|
|
69
|
+
throw new Error(`memory root "${name}" not found`);
|
|
70
|
+
const [removed] = this.roots.splice(idx, 1);
|
|
71
|
+
this.watcher?.unwatch(removed.path);
|
|
72
|
+
unregisterRoot(this.db, 'memory_docs', name);
|
|
31
73
|
}
|
|
32
74
|
/** Exact-match lookup by normalized key, per V0 (no fuzzy matching). */
|
|
33
75
|
getByKey(key, docType) {
|
|
@@ -53,7 +95,8 @@ export class MemoryRepository {
|
|
|
53
95
|
create(input) {
|
|
54
96
|
const normalizedKey = normalizeKey(input.key);
|
|
55
97
|
const id = `${slugify(normalizedKey)}-${slugify(input.description)}-${randomUUID().slice(0, 8)}`;
|
|
56
|
-
const
|
|
98
|
+
const targetRoot = this.resolveRoot(input.root);
|
|
99
|
+
const filePath = resolveWithinBase(targetRoot.path, input.folder, `${id}.md`);
|
|
57
100
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
58
101
|
const fm = {
|
|
59
102
|
id,
|
|
@@ -65,6 +108,7 @@ export class MemoryRepository {
|
|
|
65
108
|
status: 'active',
|
|
66
109
|
related_to: input.related_to ?? null,
|
|
67
110
|
source_path: filePath,
|
|
111
|
+
root: targetRoot.name,
|
|
68
112
|
};
|
|
69
113
|
writeMarkdownFile(filePath, stripSourcePath(fm), input.body);
|
|
70
114
|
upsertFile(this.db, this.syncSpec, filePath);
|
|
@@ -94,6 +138,6 @@ export class MemoryRepository {
|
|
|
94
138
|
}
|
|
95
139
|
}
|
|
96
140
|
function stripSourcePath(fm) {
|
|
97
|
-
const { source_path: _sp, ...rest } = fm;
|
|
141
|
+
const { source_path: _sp, root: _root, ...rest } = fm;
|
|
98
142
|
return rest;
|
|
99
143
|
}
|
package/dist/src/memory/tools.js
CHANGED
|
@@ -4,6 +4,9 @@ const MEMORY_KEY_TYPES = ['ticket', 'freeform'];
|
|
|
4
4
|
const MEMORY_STATUS = ['active', 'shipped', 'abandoned'];
|
|
5
5
|
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.";
|
|
6
6
|
export function registerMemoryTools(mcp, repo) {
|
|
7
|
+
const roots = repo.listRoots();
|
|
8
|
+
const multiRoot = roots.length > 1;
|
|
9
|
+
const rootNames = roots.map((r) => r.name).join(', ');
|
|
7
10
|
mcp.tool('memory_get', 'Exact-match lookup of memory docs (plan/spec/sql/etc.) by normalized key — a ticket ID or a free-form name like "Spot Chart Design". Returns every doc under that key, or only the matching doc_type if provided.', {
|
|
8
11
|
key: z.string(),
|
|
9
12
|
doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
|
|
@@ -15,7 +18,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
15
18
|
const keys = repo.listKeys(key_prefix);
|
|
16
19
|
return { content: [{ type: 'text', text: JSON.stringify(keys, null, 2) }] };
|
|
17
20
|
});
|
|
18
|
-
mcp.tool('memory_create', `Writes a new memory doc (plan, spec, SQL, testing notes, discovery, etc.) into the memory
|
|
21
|
+
mcp.tool('memory_create', `Writes a new memory doc (plan, spec, SQL, testing notes, discovery, etc.) into the memory root under the given key. ${AUTHORING_SKILL_HINT}`, {
|
|
19
22
|
key: z.string().describe('lookup handle — ticket ID or free-form name; normalized on write'),
|
|
20
23
|
key_type: z.enum(MEMORY_KEY_TYPES),
|
|
21
24
|
doc_type: z.enum(MEMORY_DOC_TYPES),
|
|
@@ -23,10 +26,11 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
23
26
|
body: z.string(),
|
|
24
27
|
tags: z.array(z.string()).optional(),
|
|
25
28
|
related_to: z.string().optional().describe('id of a related doc, e.g. a spec linking to its plan'),
|
|
26
|
-
folder: z.string().optional().describe('optional subdirectory under the memory
|
|
27
|
-
|
|
29
|
+
folder: z.string().optional().describe('optional subdirectory under the memory root'),
|
|
30
|
+
...(multiRoot ? { root: z.string().describe(`which configured memory root to write into: ${rootNames}`) } : {}),
|
|
31
|
+
}, async ({ key, key_type, doc_type, description, body, tags, related_to, folder, root }) => {
|
|
28
32
|
try {
|
|
29
|
-
const doc = repo.create({ key, key_type, doc_type, description, body, tags, related_to, folder });
|
|
33
|
+
const doc = repo.create({ key, key_type, doc_type, description, body, tags, related_to, folder, root });
|
|
30
34
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
31
35
|
}
|
|
32
36
|
catch (err) {
|
|
@@ -66,7 +70,8 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
66
70
|
key: z.string().optional(),
|
|
67
71
|
description: z.string().optional(),
|
|
68
72
|
tags: z.array(z.string()).optional(),
|
|
69
|
-
|
|
73
|
+
...(multiRoot ? { root: z.string().optional().describe(`which configured memory root to write into: ${rootNames}`) } : {}),
|
|
74
|
+
}, async ({ summary, key, description, tags, root }) => {
|
|
70
75
|
if (!key || !description) {
|
|
71
76
|
const missing = [!key && 'key', !description && 'description'].filter(Boolean).join(' and ');
|
|
72
77
|
return {
|
|
@@ -87,6 +92,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
87
92
|
description,
|
|
88
93
|
body: summary,
|
|
89
94
|
tags,
|
|
95
|
+
root,
|
|
90
96
|
});
|
|
91
97
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
92
98
|
}
|
package/dist/src/server.js
CHANGED
|
@@ -13,6 +13,9 @@ import { registerMemoryTools } from './memory/tools.js';
|
|
|
13
13
|
import { registerRelocateTool } from './shared/relocate-tool.js';
|
|
14
14
|
import { buildWebRouter } from './web/routes.js';
|
|
15
15
|
import { registerUiTool } from './web/ui-tool.js';
|
|
16
|
+
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
17
|
+
// so tool schemas (which conditionally include `root` based on root count) always
|
|
18
|
+
// reflect the current roots — no restart needed after an add/remove-root call.
|
|
16
19
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
17
20
|
// __dirname is <pkg>/src when run via tsx (dev/test) and <pkg>/dist/src once
|
|
18
21
|
// built — either way dist/client (the Vite output) sits one level above the
|
|
@@ -26,14 +29,19 @@ const builtinSkillsDir = path.join(__dirname, 'skills', 'builtin');
|
|
|
26
29
|
const PORT = process.env.PORT ? Number(process.env.PORT) : 8767;
|
|
27
30
|
const config = loadConfig();
|
|
28
31
|
const db = openCache(config.cacheDbPath);
|
|
29
|
-
const skillSpec = skillSyncSpec([builtinSkillsDir, ...config.
|
|
30
|
-
const memorySpec = memorySyncSpec(config.
|
|
32
|
+
const skillSpec = skillSyncSpec([{ name: 'builtin', path: builtinSkillsDir }, ...config.skillRoots]);
|
|
33
|
+
const memorySpec = memorySyncSpec(config.memoryRoots);
|
|
31
34
|
initialScan(db, skillSpec);
|
|
32
35
|
initialScan(db, memorySpec);
|
|
33
|
-
watchSources(db, skillSpec);
|
|
34
|
-
watchSources(db, memorySpec);
|
|
35
|
-
const skillRepo = new SkillRepository(db, config.
|
|
36
|
-
const memoryRepo = new MemoryRepository(db, config.
|
|
36
|
+
const skillWatcher = watchSources(db, skillSpec);
|
|
37
|
+
const memoryWatcher = watchSources(db, memorySpec);
|
|
38
|
+
const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillRoots]);
|
|
39
|
+
const memoryRepo = new MemoryRepository(db, config.memoryRoots);
|
|
40
|
+
skillRepo.setWatcher(skillWatcher);
|
|
41
|
+
memoryRepo.setWatcher(memoryWatcher);
|
|
42
|
+
if (config.skillRoots.length === 0 && config.memoryRoots.length === 0) {
|
|
43
|
+
console.error(`[memory-bucket] no roots configured — open http://localhost:${PORT} to add one`);
|
|
44
|
+
}
|
|
37
45
|
// If the user refers to "mem bucket", "mem bucket mcp", "memory bucket", or
|
|
38
46
|
// "skill bucket" (its working-title predecessor) in conversation, they mean
|
|
39
47
|
// this server — surfaced both in serverInfo.description and instructions so
|
|
@@ -50,7 +58,7 @@ function buildMcpServer() {
|
|
|
50
58
|
}
|
|
51
59
|
const app = express();
|
|
52
60
|
app.use(express.json());
|
|
53
|
-
app.use(buildWebRouter(db));
|
|
61
|
+
app.use(buildWebRouter(db, config, skillRepo, memoryRepo));
|
|
54
62
|
app.use(express.static(path.join(packageRoot, 'dist', 'client')));
|
|
55
63
|
app.post('/mcp', async (req, res) => {
|
|
56
64
|
const server = buildMcpServer();
|
|
@@ -77,8 +85,8 @@ app.get('/mcp', methodNotAllowed);
|
|
|
77
85
|
app.delete('/mcp', methodNotAllowed);
|
|
78
86
|
app.listen(PORT, () => {
|
|
79
87
|
console.error(`[memory-bucket] MCP server listening on http://localhost:${PORT}/mcp`);
|
|
80
|
-
console.error(`[memory-bucket] skill
|
|
81
|
-
console.error(`[memory-bucket] memory
|
|
88
|
+
console.error(`[memory-bucket] skill roots: ${config.skillRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
|
|
89
|
+
console.error(`[memory-bucket] memory roots: ${config.memoryRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
|
|
82
90
|
});
|
|
83
91
|
process.on('SIGINT', () => {
|
|
84
92
|
db.close();
|
|
@@ -19,7 +19,8 @@ export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
|
|
|
19
19
|
doc_type: z.enum(['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other']).optional().describe('memory target only'),
|
|
20
20
|
tags: z.array(z.string()).optional(),
|
|
21
21
|
status: z.enum(['stable', 'beta', 'unreviewed', 'active', 'shipped', 'abandoned']).optional(),
|
|
22
|
-
folder: z.string().optional().describe('optional subdirectory under the target
|
|
22
|
+
folder: z.string().optional().describe('optional subdirectory under the target root'),
|
|
23
|
+
root: z.string().optional().describe('which configured root to write into; required if multiple roots exist for the target type'),
|
|
23
24
|
})
|
|
24
25
|
.optional(),
|
|
25
26
|
}, async (opts) => {
|
|
@@ -63,7 +63,7 @@ export function relocate(opts, skillRepo, memoryRepo) {
|
|
|
63
63
|
tags: opts.overrides?.tags ?? [],
|
|
64
64
|
trigger_phrases: [],
|
|
65
65
|
extends: null,
|
|
66
|
-
}, body, opts.overrides?.folder);
|
|
66
|
+
}, body, opts.overrides?.folder, opts.overrides?.root);
|
|
67
67
|
if (!opts.keep_original)
|
|
68
68
|
fs.unlinkSync(opts.path);
|
|
69
69
|
return { moved: true, id: doc.name, target: 'skill' };
|
|
@@ -92,6 +92,7 @@ export function relocate(opts, skillRepo, memoryRepo) {
|
|
|
92
92
|
body,
|
|
93
93
|
tags: opts.overrides?.tags,
|
|
94
94
|
folder: opts.overrides?.folder,
|
|
95
|
+
root: opts.overrides?.root,
|
|
95
96
|
});
|
|
96
97
|
if (!opts.keep_original)
|
|
97
98
|
fs.unlinkSync(opts.path);
|
|
@@ -164,6 +164,30 @@ If the user asks to save the current conversation/session as memory, use
|
|
|
164
164
|
**summary**, not a raw transcript. If `key` or `description` weren't
|
|
165
165
|
given, ask for both before calling it; don't guess a key from context.
|
|
166
166
|
|
|
167
|
+
## Multiple roots
|
|
168
|
+
|
|
169
|
+
A server can be configured with more than one skill root and/or memory
|
|
170
|
+
root at once — e.g. a personal skills folder plus a shared company repo.
|
|
171
|
+
When exactly one root of a given kind is configured, everything above
|
|
172
|
+
works unchanged: `skill_create`/`memory_create`/`relocate` write into it
|
|
173
|
+
with no extra parameter needed.
|
|
174
|
+
|
|
175
|
+
Once **two or more** roots of a kind are configured, `skill_create`,
|
|
176
|
+
`memory_create`, `memory_save_session`, and `relocate`'s `overrides` gain
|
|
177
|
+
a required (or, for `memory_save_session`, optional) `root` parameter —
|
|
178
|
+
its tool description lists the valid root names. Match a root by name
|
|
179
|
+
from what the user said ("save this to my personal skills", "put this in
|
|
180
|
+
the company repo") rather than guessing; if it's unclear which root they
|
|
181
|
+
mean, ask. `skill_list`/`memory_list` also gain an optional `root` filter
|
|
182
|
+
to narrow results to one root once multiple exist.
|
|
183
|
+
|
|
184
|
+
Roots (for both skills and memory) are managed through the web UI
|
|
185
|
+
(`bucket_open_ui`) — add one via its "+ Add root" folder browser, or
|
|
186
|
+
remove one via the ✕ on its chip (this only unregisters it and drops its
|
|
187
|
+
cached rows; it never deletes files on disk). If the server has zero
|
|
188
|
+
roots configured, the UI opens straight into a first-run "add your first
|
|
189
|
+
root" screen instead of the normal search view.
|
|
190
|
+
|
|
167
191
|
## relocate: pulling in an existing file
|
|
168
192
|
|
|
169
193
|
If there's already a local markdown file that should become a skill or
|
|
@@ -3,7 +3,7 @@ import path from 'node:path';
|
|
|
3
3
|
import { writeMarkdownFile } from '../store/markdown-file.js';
|
|
4
4
|
import { assertValidSkillName } from '../store/skill-name.js';
|
|
5
5
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
6
|
-
import { upsertFile, removeFile, skillSyncSpec } from '../store/sync.js';
|
|
6
|
+
import { upsertFile, removeFile, scanSingleRoot, unregisterRoot, skillSyncSpec } from '../store/sync.js';
|
|
7
7
|
function rowToDoc(row) {
|
|
8
8
|
return {
|
|
9
9
|
name: row.id,
|
|
@@ -12,22 +12,71 @@ function rowToDoc(row) {
|
|
|
12
12
|
trigger_phrases: JSON.parse(row.trigger_phrases),
|
|
13
13
|
metadata: { owner: row.owner, status: row.status, extends: row.extends },
|
|
14
14
|
source_path: row.source_path,
|
|
15
|
+
root: row.root,
|
|
15
16
|
body: row.body,
|
|
16
17
|
};
|
|
17
18
|
}
|
|
18
19
|
export class SkillRepository {
|
|
19
20
|
db;
|
|
20
|
-
|
|
21
|
+
roots;
|
|
21
22
|
syncSpec;
|
|
22
|
-
|
|
23
|
+
watcher;
|
|
24
|
+
/** `roots[0]` is always the builtin skills dir — never exposed for create()/removal. */
|
|
25
|
+
constructor(db, roots) {
|
|
23
26
|
this.db = db;
|
|
24
|
-
this.
|
|
25
|
-
this.syncSpec = skillSyncSpec(
|
|
27
|
+
this.roots = roots;
|
|
28
|
+
this.syncSpec = skillSyncSpec(roots);
|
|
26
29
|
}
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
30
|
+
/** Attaches the live chokidar watcher so addRoot/removeRoot can mutate it without a restart. */
|
|
31
|
+
setWatcher(watcher) {
|
|
32
|
+
this.watcher = watcher;
|
|
33
|
+
}
|
|
34
|
+
/** User-addable roots — excludes the always-present builtin skills dir at roots[0]. */
|
|
35
|
+
listRoots() {
|
|
36
|
+
return this.roots.slice(1);
|
|
37
|
+
}
|
|
38
|
+
resolveRoot(rootName) {
|
|
39
|
+
const userRoots = this.listRoots();
|
|
40
|
+
if (rootName) {
|
|
41
|
+
const found = userRoots.find((r) => r.name === rootName);
|
|
42
|
+
if (!found) {
|
|
43
|
+
throw new Error(`unknown skill root "${rootName}" — valid roots: ${userRoots.map((r) => r.name).join(', ') || '(none configured)'}`);
|
|
44
|
+
}
|
|
45
|
+
return found;
|
|
46
|
+
}
|
|
47
|
+
if (userRoots.length === 1)
|
|
48
|
+
return userRoots[0];
|
|
49
|
+
if (userRoots.length === 0) {
|
|
50
|
+
throw new Error('no skill root configured — add one first (see bucket_open_ui)');
|
|
51
|
+
}
|
|
52
|
+
throw new Error(`multiple skill roots configured — specify root: one of ${userRoots.map((r) => r.name).join(', ')}`);
|
|
53
|
+
}
|
|
54
|
+
/** Registers a new root: appends it, scans it once, and starts watching it live. */
|
|
55
|
+
addRoot(root) {
|
|
56
|
+
if (this.roots.some((r) => r.name === root.name)) {
|
|
57
|
+
throw new Error(`a skill root named "${root.name}" already exists`);
|
|
58
|
+
}
|
|
59
|
+
this.roots.push(root);
|
|
60
|
+
scanSingleRoot(this.db, this.syncSpec, root.path);
|
|
61
|
+
this.watcher?.add(root.path);
|
|
62
|
+
}
|
|
63
|
+
/** Unregisters a root: stops watching it and drops its cached rows. Never touches files on disk. */
|
|
64
|
+
removeRoot(name) {
|
|
65
|
+
const idx = this.roots.findIndex((r) => r.name === name);
|
|
66
|
+
if (idx <= 0)
|
|
67
|
+
throw new Error(`skill root "${name}" not found or is not removable`); // index 0 is builtin
|
|
68
|
+
const [removed] = this.roots.splice(idx, 1);
|
|
69
|
+
this.watcher?.unwatch(removed.path);
|
|
70
|
+
unregisterRoot(this.db, 'skills', name);
|
|
71
|
+
}
|
|
72
|
+
list(query, root) {
|
|
73
|
+
const rows = root
|
|
74
|
+
? this.db
|
|
75
|
+
.prepare(`SELECT id, description, owner, status, tags, trigger_phrases, root FROM skills WHERE root = ?`)
|
|
76
|
+
.all(root)
|
|
77
|
+
: this.db
|
|
78
|
+
.prepare(`SELECT id, description, owner, status, tags, trigger_phrases, root FROM skills`)
|
|
79
|
+
.all();
|
|
31
80
|
const needle = query?.trim().toLowerCase();
|
|
32
81
|
const items = rows.map((r) => ({
|
|
33
82
|
name: r.id,
|
|
@@ -36,6 +85,7 @@ export class SkillRepository {
|
|
|
36
85
|
status: r.status,
|
|
37
86
|
tags: JSON.parse(r.tags),
|
|
38
87
|
triggerPhrases: JSON.parse(r.trigger_phrases),
|
|
88
|
+
root: r.root,
|
|
39
89
|
}));
|
|
40
90
|
const filtered = needle
|
|
41
91
|
? items.filter((item) => item.description.toLowerCase().includes(needle) ||
|
|
@@ -49,15 +99,18 @@ export class SkillRepository {
|
|
|
49
99
|
return row ? rowToDoc(row) : null;
|
|
50
100
|
}
|
|
51
101
|
/**
|
|
52
|
-
* Creates <
|
|
102
|
+
* Creates <root>/[folder/]<name>/SKILL.md — folder-per-skill, per the
|
|
53
103
|
* agentskills.io spec (`name` must equal the containing folder's name).
|
|
104
|
+
* `root` selects which configured skill root to write into; required only
|
|
105
|
+
* when more than one user root is configured.
|
|
54
106
|
*/
|
|
55
|
-
create(frontmatter, body, folder) {
|
|
107
|
+
create(frontmatter, body, folder, root) {
|
|
56
108
|
assertValidSkillName(frontmatter.name);
|
|
57
109
|
if (this.get(frontmatter.name)) {
|
|
58
110
|
throw new Error(`skill with name "${frontmatter.name}" already exists`);
|
|
59
111
|
}
|
|
60
|
-
const
|
|
112
|
+
const targetRoot = this.resolveRoot(root);
|
|
113
|
+
const skillDir = resolveWithinBase(targetRoot.path, folder, frontmatter.name);
|
|
61
114
|
if (fs.existsSync(skillDir)) {
|
|
62
115
|
throw new Error(`skill directory already exists at ${skillDir}`);
|
|
63
116
|
}
|
|
@@ -76,6 +129,7 @@ export class SkillRepository {
|
|
|
76
129
|
extends: frontmatter.extends ?? null,
|
|
77
130
|
},
|
|
78
131
|
source_path: filePath,
|
|
132
|
+
root: targetRoot.name,
|
|
79
133
|
};
|
|
80
134
|
writeMarkdownFile(filePath, stripSourcePath(fm), body);
|
|
81
135
|
upsertFile(this.db, this.syncSpec, filePath);
|
|
@@ -139,6 +193,6 @@ export class SkillRepository {
|
|
|
139
193
|
}
|
|
140
194
|
}
|
|
141
195
|
function stripSourcePath(fm) {
|
|
142
|
-
const { source_path: _sp, ...rest } = fm;
|
|
196
|
+
const { source_path: _sp, root: _root, ...rest } = fm;
|
|
143
197
|
return rest;
|
|
144
198
|
}
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -3,8 +3,13 @@ const SKILL_STATUS = ['stable', 'beta', 'unreviewed'];
|
|
|
3
3
|
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)';
|
|
4
4
|
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.";
|
|
5
5
|
export function registerSkillTools(mcp, repo) {
|
|
6
|
-
|
|
7
|
-
|
|
6
|
+
const roots = repo.listRoots();
|
|
7
|
+
const multiRoot = roots.length > 1;
|
|
8
|
+
const rootNames = roots.map((r) => r.name).join(', ');
|
|
9
|
+
mcp.tool('skill_list', 'Lists skills (reusable coding patterns, one SKILL.md per folder per the agentskills.io open standard), optionally filtered by a keyword matched against description/tags/trigger phrases.', multiRoot
|
|
10
|
+
? { query: z.string().optional(), root: z.string().optional().describe(`filter to one root: ${rootNames}`) }
|
|
11
|
+
: { query: z.string().optional() }, async ({ query, root }) => {
|
|
12
|
+
const items = repo.list(query, root);
|
|
8
13
|
return { content: [{ type: 'text', text: JSON.stringify(items, null, 2) }] };
|
|
9
14
|
});
|
|
10
15
|
mcp.tool('skill_get', 'Fetches a single skill by name, including its full markdown body.', { name: z.string() }, async ({ name }) => {
|
|
@@ -14,7 +19,7 @@ export function registerSkillTools(mcp, repo) {
|
|
|
14
19
|
}
|
|
15
20
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
16
21
|
});
|
|
17
|
-
mcp.tool('skill_create', `Creates a new skill as <
|
|
22
|
+
mcp.tool('skill_create', `Creates a new skill as <root>/[folder/]<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}`, {
|
|
18
23
|
name: z.string().describe(SKILL_NAME_DESCRIPTION),
|
|
19
24
|
description: z
|
|
20
25
|
.string()
|
|
@@ -28,10 +33,11 @@ export function registerSkillTools(mcp, repo) {
|
|
|
28
33
|
tags: z.array(z.string()).optional(),
|
|
29
34
|
trigger_phrases: z.array(z.string()).optional(),
|
|
30
35
|
extends: z.string().optional().describe('reserved for a future overlay mechanism — stored in frontmatter.metadata'),
|
|
31
|
-
folder: z.string().optional().describe('optional subdirectory under the skill
|
|
32
|
-
|
|
36
|
+
folder: z.string().optional().describe('optional subdirectory under the skill root, e.g. "frontend"'),
|
|
37
|
+
...(multiRoot ? { root: z.string().describe(`which configured skill root to write into: ${rootNames}`) } : {}),
|
|
38
|
+
}, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId, folder, root }) => {
|
|
33
39
|
try {
|
|
34
|
-
const doc = repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body, folder);
|
|
40
|
+
const doc = repo.create({ name, description, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId }, body, folder, root);
|
|
35
41
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
36
42
|
}
|
|
37
43
|
catch (err) {
|
package/dist/src/store/db.js
CHANGED
|
@@ -12,6 +12,7 @@ export function openCache(dbPath) {
|
|
|
12
12
|
trigger_phrases TEXT NOT NULL, -- JSON array
|
|
13
13
|
extends TEXT,
|
|
14
14
|
source_path TEXT NOT NULL UNIQUE, -- path to SKILL.md
|
|
15
|
+
root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
|
|
15
16
|
body TEXT NOT NULL,
|
|
16
17
|
mtime_ms INTEGER NOT NULL
|
|
17
18
|
);
|
|
@@ -26,6 +27,7 @@ export function openCache(dbPath) {
|
|
|
26
27
|
status TEXT NOT NULL,
|
|
27
28
|
related_to TEXT,
|
|
28
29
|
source_path TEXT NOT NULL UNIQUE,
|
|
30
|
+
root TEXT NOT NULL DEFAULT '', -- name of the configured root this file lives under
|
|
29
31
|
body TEXT NOT NULL,
|
|
30
32
|
mtime_ms INTEGER NOT NULL
|
|
31
33
|
);
|
|
@@ -41,9 +43,18 @@ export function openCache(dbPath) {
|
|
|
41
43
|
tokenize = 'porter unicode61'
|
|
42
44
|
);
|
|
43
45
|
`);
|
|
46
|
+
addRootColumnIfMissing(db, 'skills');
|
|
47
|
+
addRootColumnIfMissing(db, 'memory_docs');
|
|
44
48
|
backfillSearchIndex(db);
|
|
45
49
|
return db;
|
|
46
50
|
}
|
|
51
|
+
/** Migration for cache files created before the `root` column existed. */
|
|
52
|
+
function addRootColumnIfMissing(db, table) {
|
|
53
|
+
const columns = db.prepare(`PRAGMA table_info(${table})`).all();
|
|
54
|
+
if (columns.some((c) => c.name === 'root'))
|
|
55
|
+
return;
|
|
56
|
+
db.exec(`ALTER TABLE ${table} ADD COLUMN root TEXT NOT NULL DEFAULT ''`);
|
|
57
|
+
}
|
|
47
58
|
/** One-time backfill for existing rows the first time search_index is introduced into a cache file. */
|
|
48
59
|
function backfillSearchIndex(db) {
|
|
49
60
|
const { count: indexed } = db.prepare(`SELECT COUNT(*) as count FROM search_index`).get();
|
package/dist/src/store/sync.js
CHANGED
|
@@ -48,6 +48,19 @@ export function memorySyncSpec(sources) {
|
|
|
48
48
|
* synchronously right after their own writes — the watcher's own event for
|
|
49
49
|
* that same write becomes a harmless no-op re-check once mtime matches.
|
|
50
50
|
*/
|
|
51
|
+
/** Which configured root a file lives under, by longest matching path prefix. */
|
|
52
|
+
function rootForFile(sources, filePath) {
|
|
53
|
+
const resolved = path.resolve(filePath);
|
|
54
|
+
let best;
|
|
55
|
+
for (const root of sources) {
|
|
56
|
+
const rootPath = path.resolve(root.path);
|
|
57
|
+
if (resolved === rootPath || resolved.startsWith(rootPath + path.sep)) {
|
|
58
|
+
if (!best || rootPath.length > path.resolve(best.path).length)
|
|
59
|
+
best = root;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return best?.name ?? '';
|
|
63
|
+
}
|
|
51
64
|
export function upsertFile(db, spec, filePath) {
|
|
52
65
|
const existing = db
|
|
53
66
|
.prepare(`SELECT mtime_ms FROM ${spec.table} WHERE source_path = ?`)
|
|
@@ -61,8 +74,9 @@ export function upsertFile(db, spec, filePath) {
|
|
|
61
74
|
return;
|
|
62
75
|
}
|
|
63
76
|
const row = spec.toRow(parsed.frontmatter, filePath);
|
|
64
|
-
const
|
|
65
|
-
const
|
|
77
|
+
const root = rootForFile(spec.sources, filePath);
|
|
78
|
+
const cols = [...spec.columns, 'source_path', 'root', 'body', 'mtime_ms'];
|
|
79
|
+
const values = [...spec.columns.map((c) => row[c]), filePath, root, parsed.body, parsed.mtimeMs];
|
|
66
80
|
const placeholders = cols.map(() => '?').join(', ');
|
|
67
81
|
const updateClause = cols
|
|
68
82
|
.filter((c) => c !== 'id')
|
|
@@ -82,10 +96,10 @@ export function removeFile(db, table, filePath) {
|
|
|
82
96
|
}
|
|
83
97
|
/** Full scan of all configured source dirs — used once at startup before the watcher takes over. */
|
|
84
98
|
export function initialScan(db, spec) {
|
|
85
|
-
for (const
|
|
86
|
-
if (!fs.existsSync(
|
|
99
|
+
for (const root of spec.sources) {
|
|
100
|
+
if (!fs.existsSync(root.path))
|
|
87
101
|
continue;
|
|
88
|
-
for (const file of walkMarkdownFiles(
|
|
102
|
+
for (const file of walkMarkdownFiles(root.path)) {
|
|
89
103
|
if (!spec.matchesFile(file))
|
|
90
104
|
continue;
|
|
91
105
|
try {
|
|
@@ -97,6 +111,29 @@ export function initialScan(db, spec) {
|
|
|
97
111
|
}
|
|
98
112
|
}
|
|
99
113
|
}
|
|
114
|
+
/** Full scan of a single dir — used when a new root is added live, after registering it in spec.sources. */
|
|
115
|
+
export function scanSingleRoot(db, spec, dirPath) {
|
|
116
|
+
if (!fs.existsSync(dirPath))
|
|
117
|
+
return;
|
|
118
|
+
for (const file of walkMarkdownFiles(dirPath)) {
|
|
119
|
+
if (!spec.matchesFile(file))
|
|
120
|
+
continue;
|
|
121
|
+
try {
|
|
122
|
+
upsertFile(db, spec, file);
|
|
123
|
+
}
|
|
124
|
+
catch (err) {
|
|
125
|
+
console.error(`[memory-bucket] failed to index ${file}:`, err);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
/** Drops all cached rows (and search index entries) belonging to a removed root. Never touches files on disk. */
|
|
130
|
+
export function unregisterRoot(db, table, rootName) {
|
|
131
|
+
const rows = db.prepare(`SELECT id FROM ${table} WHERE root = ?`).all(rootName);
|
|
132
|
+
db.prepare(`DELETE FROM ${table} WHERE root = ?`).run(rootName);
|
|
133
|
+
for (const row of rows) {
|
|
134
|
+
db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(table, row.id);
|
|
135
|
+
}
|
|
136
|
+
}
|
|
100
137
|
function* walkMarkdownFiles(dir) {
|
|
101
138
|
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
102
139
|
const full = path.join(dir, entry.name);
|
|
@@ -109,11 +146,7 @@ function* walkMarkdownFiles(dir) {
|
|
|
109
146
|
}
|
|
110
147
|
}
|
|
111
148
|
export function watchSources(db, spec) {
|
|
112
|
-
const watcher = chokidar.watch(spec.sources, {
|
|
113
|
-
ignoreInitial: true,
|
|
114
|
-
persistent: true,
|
|
115
|
-
depth: 10,
|
|
116
|
-
});
|
|
149
|
+
const watcher = chokidar.watch(spec.sources.map((r) => r.path), { ignoreInitial: true, persistent: true, depth: 10 });
|
|
117
150
|
watcher
|
|
118
151
|
.on('add', (filePath) => {
|
|
119
152
|
if (!spec.matchesFile(filePath))
|