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.
@@ -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
- defaultSourceDir;
26
+ roots;
26
27
  syncSpec;
27
- constructor(db, defaultSourceDir) {
28
+ watcher;
29
+ constructor(db, roots) {
28
30
  this.db = db;
29
- this.defaultSourceDir = defaultSourceDir;
30
- this.syncSpec = memorySyncSpec([defaultSourceDir]);
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 filePath = resolveWithinBase(this.defaultSourceDir, input.folder, `${id}.md`);
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
  }
@@ -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 source directory under the given key. ${AUTHORING_SKILL_HINT}`, {
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 source dir'),
27
- }, async ({ key, key_type, doc_type, description, body, tags, related_to, folder }) => {
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
- }, async ({ summary, key, description, tags }) => {
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
  }
@@ -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.skillSources]);
30
- const memorySpec = memorySyncSpec(config.memorySources);
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.skillSources[0]);
36
- const memoryRepo = new MemoryRepository(db, config.memorySources[0]);
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 sources: ${config.skillSources.join(', ')}`);
81
- console.error(`[memory-bucket] memory sources: ${config.memorySources.join(', ')}`);
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 source dir'),
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
- sourceDir;
21
+ roots;
21
22
  syncSpec;
22
- constructor(db, sourceDir) {
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.sourceDir = sourceDir;
25
- this.syncSpec = skillSyncSpec([sourceDir]);
27
+ this.roots = roots;
28
+ this.syncSpec = skillSyncSpec(roots);
26
29
  }
27
- list(query) {
28
- const rows = this.db
29
- .prepare(`SELECT id, description, owner, status, tags, trigger_phrases FROM skills`)
30
- .all();
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 <sourceDir>/[folder/]<name>/SKILL.md — folder-per-skill, per the
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 skillDir = resolveWithinBase(this.sourceDir, folder, frontmatter.name);
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
  }
@@ -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
- 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.', { query: z.string().optional() }, async ({ query }) => {
7
- const items = repo.list(query);
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 <sourceDir>/[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}`, {
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 source dir, e.g. "frontend"'),
32
- }, async ({ name, description, body, license, compatibility, owner, status, tags, trigger_phrases, extends: extendsId, folder }) => {
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) {
@@ -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();
@@ -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 cols = [...spec.columns, 'source_path', 'body', 'mtime_ms'];
65
- const values = [...spec.columns.map((c) => row[c]), filePath, parsed.body, parsed.mtimeMs];
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 dir of spec.sources) {
86
- if (!fs.existsSync(dir))
99
+ for (const root of spec.sources) {
100
+ if (!fs.existsSync(root.path))
87
101
  continue;
88
- for (const file of walkMarkdownFiles(dir)) {
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))