mcp-memory-bucket 0.1.0 → 0.2.1
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-CU-hoVvq.js +645 -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 +3 -2
- package/dist/client/assets/index-CxhInmjj.js +0 -150
package/dist/client/index.html
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
:root { color-scheme: light dark; }
|
|
8
8
|
body { font-family: system-ui, sans-serif; margin: 0; }
|
|
9
9
|
</style>
|
|
10
|
-
<script type="module" crossorigin src="/assets/index-
|
|
10
|
+
<script type="module" crossorigin src="/assets/index-CU-hoVvq.js"></script>
|
|
11
11
|
</head>
|
|
12
12
|
<body>
|
|
13
13
|
<mem-bucket-app></mem-bucket-app>
|
package/dist/src/config.js
CHANGED
|
@@ -4,20 +4,87 @@ function memoryDirFlag(argv) {
|
|
|
4
4
|
const idx = argv.indexOf('--memory-dir');
|
|
5
5
|
return idx !== -1 ? argv[idx + 1] : undefined;
|
|
6
6
|
}
|
|
7
|
+
function readConfigFile(configPath) {
|
|
8
|
+
if (!fs.existsSync(configPath))
|
|
9
|
+
return {};
|
|
10
|
+
return JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
11
|
+
}
|
|
12
|
+
function nameFromPath(p) {
|
|
13
|
+
return path.basename(p);
|
|
14
|
+
}
|
|
15
|
+
function resolveRoots(entries, baseDir) {
|
|
16
|
+
const roots = entries.map((entry) => {
|
|
17
|
+
if (typeof entry === 'string') {
|
|
18
|
+
return { name: nameFromPath(entry), path: path.resolve(baseDir, entry) };
|
|
19
|
+
}
|
|
20
|
+
return { name: entry.name, path: path.resolve(baseDir, entry.path) };
|
|
21
|
+
});
|
|
22
|
+
// de-dupe by resolved path (e.g. default + explicit config both pointing at ./skills)
|
|
23
|
+
const seen = new Set();
|
|
24
|
+
return roots.filter((r) => (seen.has(r.path) ? false : (seen.add(r.path), true)));
|
|
25
|
+
}
|
|
7
26
|
export function loadConfig(cwd = process.cwd(), argv = process.argv) {
|
|
8
|
-
const configPath = path.join(cwd, 'memory-bucket.config.json');
|
|
9
|
-
const hasConfigFile = fs.existsSync(configPath);
|
|
10
|
-
let overrides = {};
|
|
11
|
-
if (hasConfigFile) {
|
|
12
|
-
overrides = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
|
|
13
|
-
}
|
|
14
27
|
const explicitDir = memoryDirFlag(argv) ?? process.env.MEMORY_BUCKET_DIR;
|
|
15
28
|
const baseDir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
|
|
16
|
-
|
|
17
|
-
|
|
29
|
+
// The config file lives alongside the cache DB in baseDir (which is cwd
|
|
30
|
+
// itself unless --memory-dir/MEMORY_BUCKET_DIR points elsewhere) — this
|
|
31
|
+
// must match where saveRoot/removeRoot write back to.
|
|
32
|
+
const configPath = path.join(baseDir, 'memory-bucket.config.json');
|
|
33
|
+
const overrides = readConfigFile(configPath);
|
|
34
|
+
// Without an explicit config, only default to ./skills or ./docs when that
|
|
35
|
+
// directory already exists on disk — otherwise a genuinely fresh directory
|
|
36
|
+
// should report zero roots and trigger the first-run "add a root" UI,
|
|
37
|
+
// rather than silently registering a directory that was never created.
|
|
38
|
+
const defaultSkillSources = overrides.skill_sources ?? (fs.existsSync(path.resolve(baseDir, './skills')) ? ['./skills'] : []);
|
|
39
|
+
const defaultMemorySources = overrides.memory_sources ?? (fs.existsSync(path.resolve(baseDir, './docs')) ? ['./docs'] : []);
|
|
40
|
+
const skillRoots = resolveRoots(defaultSkillSources, baseDir);
|
|
41
|
+
const memoryRoots = resolveRoots(defaultMemorySources, baseDir);
|
|
18
42
|
return {
|
|
19
|
-
|
|
20
|
-
|
|
43
|
+
skillRoots,
|
|
44
|
+
memoryRoots,
|
|
21
45
|
cacheDbPath: path.join(baseDir, '.memory-bucket-cache.sqlite'),
|
|
46
|
+
configPath,
|
|
47
|
+
baseDir,
|
|
22
48
|
};
|
|
23
49
|
}
|
|
50
|
+
/**
|
|
51
|
+
* Appends a new named root to the config file's skill_sources/memory_sources array,
|
|
52
|
+
* creating the file if absent. Existing entries (bare strings or objects) are left
|
|
53
|
+
* untouched — this only ever appends, never rewrites/normalizes prior entries.
|
|
54
|
+
* Path is stored relative to baseDir when possible, for a portable config file.
|
|
55
|
+
*/
|
|
56
|
+
export function saveRoot(config, kind, root) {
|
|
57
|
+
const current = readConfigFile(config.configPath);
|
|
58
|
+
const key = kind === 'skill' ? 'skill_sources' : 'memory_sources';
|
|
59
|
+
// Only append to whatever's already in the file — never inject the
|
|
60
|
+
// ./skills or ./docs implicit default, since that default is applied at
|
|
61
|
+
// load time (loadConfig), not stored; writing it here would silently turn
|
|
62
|
+
// an implicit default into an explicit, possibly-unwanted config entry.
|
|
63
|
+
const existing = current[key] ?? [];
|
|
64
|
+
const storedPath = path.isAbsolute(root.path) ? path.relative(config.baseDir, root.path) || '.' : root.path;
|
|
65
|
+
const next = {
|
|
66
|
+
...current,
|
|
67
|
+
[key]: [...existing, { name: root.name, path: storedPath }],
|
|
68
|
+
};
|
|
69
|
+
fs.writeFileSync(config.configPath, JSON.stringify(next, null, 2) + '\n');
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Removes a named root from the config file by name. Matches both explicit
|
|
73
|
+
* {name, path} entries and bare-string entries (via their derived name).
|
|
74
|
+
* No-op if the name isn't found (e.g. it only ever existed in-memory).
|
|
75
|
+
*/
|
|
76
|
+
export function removeRoot(config, kind, name) {
|
|
77
|
+
const current = readConfigFile(config.configPath);
|
|
78
|
+
const key = kind === 'skill' ? 'skill_sources' : 'memory_sources';
|
|
79
|
+
const existing = current[key];
|
|
80
|
+
if (!existing)
|
|
81
|
+
return;
|
|
82
|
+
const filtered = existing.filter((entry) => {
|
|
83
|
+
const entryName = typeof entry === 'string' ? nameFromPath(entry) : entry.name;
|
|
84
|
+
return entryName !== name;
|
|
85
|
+
});
|
|
86
|
+
if (filtered.length === existing.length)
|
|
87
|
+
return; // nothing matched, don't rewrite the file
|
|
88
|
+
const next = { ...current, [key]: filtered };
|
|
89
|
+
fs.writeFileSync(config.configPath, JSON.stringify(next, null, 2) + '\n');
|
|
90
|
+
}
|
|
@@ -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();
|