mcp-memory-bucket 0.5.3 → 0.5.4

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.
@@ -27,7 +27,7 @@
27
27
  :root[data-theme='dark'] { color-scheme: dark; }
28
28
  body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--fg); }
29
29
  </style>
30
- <script type="module" crossorigin src="/assets/index-CqgDoHFV.js"></script>
30
+ <script type="module" crossorigin src="/assets/index-6BCcM6pL.js"></script>
31
31
  </head>
32
32
  <body>
33
33
  <mem-bucket-app></mem-bucket-app>
@@ -12,8 +12,8 @@ function readConfigFile(configPath) {
12
12
  function nameFromPath(p) {
13
13
  return path.basename(p);
14
14
  }
15
- function resolveRoots(entries, baseDir) {
16
- const roots = entries.map((entry) => {
15
+ function resolveFolders(entries, baseDir) {
16
+ const folders = entries.map((entry) => {
17
17
  if (typeof entry === 'string') {
18
18
  return { name: nameFromPath(entry), path: path.resolve(baseDir, entry) };
19
19
  }
@@ -21,39 +21,39 @@ function resolveRoots(entries, baseDir) {
21
21
  });
22
22
  // de-dupe by resolved path (e.g. default + explicit config both pointing at ./skills)
23
23
  const seen = new Set();
24
- return roots.filter((r) => (seen.has(r.path) ? false : (seen.add(r.path), true)));
24
+ return folders.filter((f) => (seen.has(f.path) ? false : (seen.add(f.path), true)));
25
25
  }
26
26
  export function loadConfig(cwd = process.cwd(), argv = process.argv) {
27
27
  const explicitDir = memoryDirFlag(argv) ?? process.env.MEMORY_BUCKET_DIR;
28
28
  const baseDir = explicitDir ? path.resolve(cwd, explicitDir) : cwd;
29
29
  // The config file lives alongside the cache DB in baseDir (which is cwd
30
30
  // itself unless --memory-dir/MEMORY_BUCKET_DIR points elsewhere) — this
31
- // must match where saveRoot/removeRoot write back to.
31
+ // must match where saveFolder/removeFolder write back to.
32
32
  const configPath = path.join(baseDir, 'memory-bucket.config.json');
33
33
  const overrides = readConfigFile(configPath);
34
34
  // Without an explicit config, only default to ./skills or ./docs when that
35
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,
36
+ // should report zero folders and trigger the first-run "add a folder" UI,
37
37
  // rather than silently registering a directory that was never created.
38
38
  const defaultSkillSources = overrides.skill_sources ?? (fs.existsSync(path.resolve(baseDir, './skills')) ? ['./skills'] : []);
39
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);
40
+ const skillFolders = resolveFolders(defaultSkillSources, baseDir);
41
+ const memoryFolders = resolveFolders(defaultMemorySources, baseDir);
42
42
  return {
43
- skillRoots,
44
- memoryRoots,
43
+ skillFolders,
44
+ memoryFolders,
45
45
  cacheDbPath: path.join(baseDir, '.memory-bucket-cache.sqlite'),
46
46
  configPath,
47
47
  baseDir,
48
48
  };
49
49
  }
50
50
  /**
51
- * Appends a new named root to the config file's skill_sources/memory_sources array,
51
+ * Appends a new named folder to the config file's skill_sources/memory_sources array,
52
52
  * creating the file if absent. Existing entries (bare strings or objects) are left
53
53
  * untouched — this only ever appends, never rewrites/normalizes prior entries.
54
54
  * Path is stored relative to baseDir when possible, for a portable config file.
55
55
  */
56
- export function saveRoot(config, kind, root) {
56
+ export function saveFolder(config, kind, folder) {
57
57
  const current = readConfigFile(config.configPath);
58
58
  const key = kind === 'skill' ? 'skill_sources' : 'memory_sources';
59
59
  // Only append to whatever's already in the file — never inject the
@@ -61,15 +61,15 @@ export function saveRoot(config, kind, root) {
61
61
  // load time (loadConfig), not stored; writing it here would silently turn
62
62
  // an implicit default into an explicit, possibly-unwanted config entry.
63
63
  const existing = current[key] ?? [];
64
- const storedPath = path.isAbsolute(root.path) ? path.relative(config.baseDir, root.path) || '.' : root.path;
64
+ const storedPath = path.isAbsolute(folder.path) ? path.relative(config.baseDir, folder.path) || '.' : folder.path;
65
65
  const next = {
66
66
  ...current,
67
- [key]: [...existing, { name: root.name, path: storedPath }],
67
+ [key]: [...existing, { name: folder.name, path: storedPath }],
68
68
  };
69
69
  fs.writeFileSync(config.configPath, JSON.stringify(next, null, 2) + '\n');
70
70
  }
71
- /** Lowercase-hyphenate a folder-derived root name, same shape as skill names. */
72
- export function sanitizeRootName(raw) {
71
+ /** Lowercase-hyphenate a folder-derived name, same shape as skill names. */
72
+ export function sanitizeFolderName(raw) {
73
73
  return raw
74
74
  .trim()
75
75
  .toLowerCase()
@@ -78,11 +78,11 @@ export function sanitizeRootName(raw) {
78
78
  .slice(0, 64);
79
79
  }
80
80
  /**
81
- * Removes a named root from the config file by name. Matches both explicit
81
+ * Removes a named folder from the config file by name. Matches both explicit
82
82
  * {name, path} entries and bare-string entries (via their derived name).
83
83
  * No-op if the name isn't found (e.g. it only ever existed in-memory).
84
84
  */
85
- export function removeRoot(config, kind, name) {
85
+ export function removeFolder(config, kind, name) {
86
86
  const current = readConfigFile(config.configPath);
87
87
  const key = kind === 'skill' ? 'skill_sources' : 'memory_sources';
88
88
  const existing = current[key];
@@ -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, scanSingleRoot, unregisterRoot, memorySyncSpec } from '../store/sync.js';
7
+ import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, memorySyncSpec } from '../store/sync.js';
8
8
  import { SearchQueryError } from '../store/search.js';
9
9
  import { normalizeKey } from '../types.js';
10
10
  function rowToDoc(row) {
@@ -21,59 +21,59 @@ function rowToDoc(row) {
21
21
  paused: !!row.paused,
22
22
  created_at: row.created_at ?? undefined,
23
23
  source_path: row.source_path,
24
- root: row.root,
24
+ folder: row.folder,
25
25
  body: row.body,
26
26
  };
27
27
  }
28
28
  export class MemoryRepository {
29
29
  db;
30
- roots;
30
+ folders;
31
31
  syncSpec;
32
32
  watcher;
33
- constructor(db, roots) {
33
+ constructor(db, folders) {
34
34
  this.db = db;
35
- this.roots = roots;
36
- this.syncSpec = memorySyncSpec(roots);
35
+ this.folders = folders;
36
+ this.syncSpec = memorySyncSpec(folders);
37
37
  }
38
- /** Attaches the live chokidar watcher so addRoot/removeRoot can mutate it without a restart. */
38
+ /** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
39
39
  setWatcher(watcher) {
40
40
  this.watcher = watcher;
41
41
  }
42
- listRoots() {
43
- return [...this.roots];
42
+ listFolders() {
43
+ return [...this.folders];
44
44
  }
45
- resolveRoot(rootName) {
46
- if (rootName) {
47
- const found = this.roots.find((r) => r.name === rootName);
45
+ resolveFolder(folderName) {
46
+ if (folderName) {
47
+ const found = this.folders.find((f) => f.name === folderName);
48
48
  if (!found) {
49
- throw new Error(`unknown memory root "${rootName}" — valid roots: ${this.roots.map((r) => r.name).join(', ') || '(none configured)'}`);
49
+ throw new Error(`unknown memory folder "${folderName}" — valid folders: ${this.folders.map((f) => f.name).join(', ') || '(none configured)'}`);
50
50
  }
51
51
  return found;
52
52
  }
53
- if (this.roots.length === 1)
54
- return this.roots[0];
55
- if (this.roots.length === 0) {
56
- throw new Error('no memory root configured — add one first (see bucket_open_ui)');
53
+ if (this.folders.length === 1)
54
+ return this.folders[0];
55
+ if (this.folders.length === 0) {
56
+ throw new Error('no memory folder configured — add one first (see bucket_open_ui)');
57
57
  }
58
- throw new Error(`multiple memory roots configured — specify root: one of ${this.roots.map((r) => r.name).join(', ')}`);
58
+ throw new Error(`multiple memory folders configured — specify folder: one of ${this.folders.map((f) => f.name).join(', ')}`);
59
59
  }
60
- /** Registers a new root: appends it, scans it once, and starts watching it live. */
61
- addRoot(root) {
62
- if (this.roots.some((r) => r.name === root.name)) {
63
- throw new Error(`a memory root named "${root.name}" already exists`);
60
+ /** Registers a new folder: appends it, scans it once, and starts watching it live. */
61
+ addFolder(folder) {
62
+ if (this.folders.some((f) => f.name === folder.name)) {
63
+ throw new Error(`a memory folder named "${folder.name}" already exists`);
64
64
  }
65
- this.roots.push(root);
66
- scanSingleRoot(this.db, this.syncSpec, root.path);
67
- this.watcher?.add(root.path);
65
+ this.folders.push(folder);
66
+ scanSingleFolder(this.db, this.syncSpec, folder.path);
67
+ this.watcher?.add(folder.path);
68
68
  }
69
- /** Unregisters a root: stops watching it and drops its cached rows. Never touches files on disk. */
70
- removeRoot(name) {
71
- const idx = this.roots.findIndex((r) => r.name === name);
69
+ /** Unregisters a folder: stops watching it and drops its cached rows. Never touches files on disk. */
70
+ removeFolder(name) {
71
+ const idx = this.folders.findIndex((f) => f.name === name);
72
72
  if (idx === -1)
73
- throw new Error(`memory root "${name}" not found`);
74
- const [removed] = this.roots.splice(idx, 1);
73
+ throw new Error(`memory folder "${name}" not found`);
74
+ const [removed] = this.folders.splice(idx, 1);
75
75
  this.watcher?.unwatch(removed.path);
76
- unregisterRoot(this.db, 'memory_docs', name);
76
+ unregisterFolder(this.db, 'memory_docs', name);
77
77
  }
78
78
  /**
79
79
  * Exact-match lookup by normalized key, per V0 (no fuzzy matching).
@@ -98,11 +98,11 @@ export class MemoryRepository {
98
98
  /**
99
99
  * Full-text search over memory description/body/tags via FTS5 — `query` is
100
100
  * raw FTS5 MATCH syntax (AND/OR/NOT, "phrases", prefix*). Ranked by bm25.
101
- * Optional metadata filters (doc_type/status/root/tag) apply before limit/offset,
101
+ * Optional metadata filters (doc_type/status/folder/tag) apply before limit/offset,
102
102
  * so pagination stays correct even when filtering narrows the FTS hit set.
103
103
  */
104
104
  search(query, opts = {}) {
105
- const { docType, status, root, tag, limit = 20, offset = 0, includePaused = false } = opts;
105
+ const { docType, status, folder, tag, limit = 20, offset = 0, includePaused = false } = opts;
106
106
  const conditions = [];
107
107
  const params = [query];
108
108
  if (docType) {
@@ -113,9 +113,9 @@ export class MemoryRepository {
113
113
  conditions.push('m.status = ?');
114
114
  params.push(status);
115
115
  }
116
- if (root) {
117
- conditions.push('m.root = ?');
118
- params.push(root);
116
+ if (folder) {
117
+ conditions.push('m.folder = ?');
118
+ params.push(folder);
119
119
  }
120
120
  if (tag) {
121
121
  conditions.push('EXISTS (SELECT 1 FROM json_each(m.tags) WHERE value = ?)');
@@ -127,7 +127,7 @@ export class MemoryRepository {
127
127
  params.push(limit, offset);
128
128
  try {
129
129
  const rows = this.db
130
- .prepare(`SELECT m.id, m.key, m.description, m.doc_type, m.root,
130
+ .prepare(`SELECT m.id, m.key, m.description, m.doc_type, m.folder,
131
131
  snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
132
132
  -bm25(search_index) AS score
133
133
  FROM search_index
@@ -162,8 +162,8 @@ export class MemoryRepository {
162
162
  create(input) {
163
163
  const normalizedKey = normalizeKey(input.key);
164
164
  const id = `${slugify(normalizedKey)}-${slugify(input.description)}-${randomUUID().slice(0, 8)}`;
165
- const targetRoot = this.resolveRoot(input.root);
166
- const filePath = resolveWithinBase(targetRoot.path, input.folder, `${id}.md`);
165
+ const targetFolder = this.resolveFolder(input.folder);
166
+ const filePath = resolveWithinBase(targetFolder.path, input.subfolder, `${id}.md`);
167
167
  fs.mkdirSync(path.dirname(filePath), { recursive: true });
168
168
  const fm = {
169
169
  id,
@@ -177,7 +177,7 @@ export class MemoryRepository {
177
177
  deprecated: false,
178
178
  created_at: new Date().toISOString(),
179
179
  source_path: filePath,
180
- root: targetRoot.name,
180
+ folder: targetFolder.name,
181
181
  };
182
182
  writeMarkdownFile(filePath, stripSourcePath(fm), input.body);
183
183
  upsertFile(this.db, this.syncSpec, filePath);
@@ -311,6 +311,6 @@ export class MemoryRepository {
311
311
  }
312
312
  }
313
313
  function stripSourcePath(fm) {
314
- const { source_path: _sp, root: _root, ...rest } = fm;
314
+ const { source_path: _sp, folder: _folder, ...rest } = fm;
315
315
  return rest;
316
316
  }
@@ -5,9 +5,9 @@ const MEMORY_KEY_TYPES = ['ticket', 'freeform'];
5
5
  const MEMORY_STATUS_DEFAULTS = ['active', 'shipped', 'abandoned'];
6
6
  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.";
7
7
  export function registerMemoryTools(mcp, repo) {
8
- const roots = repo.listRoots();
9
- const multiRoot = roots.length > 1;
10
- const rootNames = roots.map((r) => r.name).join(', ');
8
+ const folders = repo.listFolders();
9
+ const multiFolder = folders.length > 1;
10
+ const folderNames = folders.map((f) => f.name).join(', ');
11
11
  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. Paused docs are hidden by default — pass include_paused to see them.', {
12
12
  key: z.string(),
13
13
  doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
@@ -29,13 +29,13 @@ export function registerMemoryTools(mcp, repo) {
29
29
  doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
30
30
  status: statusSchema(MEMORY_STATUS_DEFAULTS).optional(),
31
31
  tag: z.string().optional(),
32
- ...(multiRoot ? { root: z.string().optional().describe(`filter to one root: ${rootNames}`) } : {}),
32
+ ...(multiFolder ? { folder: z.string().optional().describe(`filter to one folder: ${folderNames}`) } : {}),
33
33
  limit: z.number().int().positive().max(100).optional(),
34
34
  offset: z.number().int().nonnegative().optional(),
35
35
  include_paused: z.boolean().optional().describe('include paused docs, which are hidden by default (see memory_set_paused)'),
36
- }, async ({ query, doc_type, status, tag, root, limit, offset, include_paused }) => {
36
+ }, async ({ query, doc_type, status, tag, folder, limit, offset, include_paused }) => {
37
37
  try {
38
- const hits = repo.search(query, { docType: doc_type, status, tag, root, limit, offset, includePaused: include_paused });
38
+ const hits = repo.search(query, { docType: doc_type, status, tag, folder, limit, offset, includePaused: include_paused });
39
39
  return { content: [{ type: 'text', text: JSON.stringify(hits, null, 2) }] };
40
40
  }
41
41
  catch (err) {
@@ -53,7 +53,7 @@ export function registerMemoryTools(mcp, repo) {
53
53
  const results = repo.bulkUpdate(ids, { add_tags, remove_tags, status, related_to, deprecated });
54
54
  return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
55
55
  });
56
- 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}`, {
56
+ mcp.tool('memory_create', `Writes a new memory doc (plan, spec, SQL, testing notes, discovery, etc.) into the memory folder under the given key. ${AUTHORING_SKILL_HINT}`, {
57
57
  key: z.string().describe('lookup handle — ticket ID or free-form name; normalized on write'),
58
58
  key_type: z.enum(MEMORY_KEY_TYPES),
59
59
  doc_type: z.enum(MEMORY_DOC_TYPES),
@@ -62,11 +62,11 @@ export function registerMemoryTools(mcp, repo) {
62
62
  tags: z.array(z.string()).optional(),
63
63
  status: statusSchema(MEMORY_STATUS_DEFAULTS).optional().describe('defaults to "active"'),
64
64
  related_to: z.string().optional().describe('id of a related doc, e.g. a spec linking to its plan'),
65
- folder: z.string().optional().describe('optional subdirectory under the memory root'),
66
- ...(multiRoot ? { root: z.string().describe(`which configured memory root to write into: ${rootNames}`) } : {}),
67
- }, async ({ key, key_type, doc_type, description, body, tags, status, related_to, folder, root }) => {
65
+ subfolder: z.string().optional().describe('optional subdirectory under the memory folder'),
66
+ ...(multiFolder ? { folder: z.string().describe(`which configured memory folder to write into: ${folderNames}`) } : {}),
67
+ }, async ({ key, key_type, doc_type, description, body, tags, status, related_to, subfolder, folder }) => {
68
68
  try {
69
- const doc = repo.create({ key, key_type, doc_type, description, body, tags, status, related_to, folder, root });
69
+ const doc = repo.create({ key, key_type, doc_type, description, body, tags, status, related_to, subfolder, folder });
70
70
  return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
71
71
  }
72
72
  catch (err) {
@@ -82,8 +82,8 @@ export function registerMemoryTools(mcp, repo) {
82
82
  tags: z.array(z.string()).optional(),
83
83
  status: statusSchema(MEMORY_STATUS_DEFAULTS).optional().describe('defaults to "active"'),
84
84
  related_to: z.string().optional(),
85
- folder: z.string().optional(),
86
- ...(multiRoot ? { root: z.string().describe(`which configured memory root to write into: ${rootNames}`) } : {}),
85
+ subfolder: z.string().optional(),
86
+ ...(multiFolder ? { folder: z.string().describe(`which configured memory folder to write into: ${folderNames}`) } : {}),
87
87
  });
88
88
  mcp.tool('memory_bulk_create', `Writes many memory docs in one call — each entry is the same shape as memory_create's args. Returns per-key success/failure (with the new id on success) so one bad entry doesn't abort the rest of the batch. ${AUTHORING_SKILL_HINT}`, { entries: z.array(memoryEntrySchema).min(1) }, async ({ entries }) => {
89
89
  const results = repo.bulkCreate(entries);
@@ -134,8 +134,8 @@ export function registerMemoryTools(mcp, repo) {
134
134
  key: z.string().optional(),
135
135
  description: z.string().optional(),
136
136
  tags: z.array(z.string()).optional(),
137
- ...(multiRoot ? { root: z.string().optional().describe(`which configured memory root to write into: ${rootNames}`) } : {}),
138
- }, async ({ summary, key, description, tags, root }) => {
137
+ ...(multiFolder ? { folder: z.string().optional().describe(`which configured memory folder to write into: ${folderNames}`) } : {}),
138
+ }, async ({ summary, key, description, tags, folder }) => {
139
139
  if (!key || !description) {
140
140
  const missing = [!key && 'key', !description && 'description'].filter(Boolean).join(' and ');
141
141
  return {
@@ -156,7 +156,7 @@ export function registerMemoryTools(mcp, repo) {
156
156
  description,
157
157
  body: summary,
158
158
  tags,
159
- root,
159
+ folder,
160
160
  });
161
161
  return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
162
162
  }
@@ -12,12 +12,12 @@ import { registerSkillTools } from './skills/tools.js';
12
12
  import { registerMemoryTools } from './memory/tools.js';
13
13
  import { registerRelocateTool } from './shared/relocate-tool.js';
14
14
  import { registerSearchTool } from './shared/search-tool.js';
15
- import { registerBucketRootTools } from './shared/bucket-root-tool.js';
15
+ import { registerBucketFolderTools } from './shared/bucket-folder-tool.js';
16
16
  import { buildWebRouter } from './web/routes.js';
17
17
  import { registerUiTool } from './web/ui-tool.js';
18
18
  // server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
19
- // so tool schemas (which conditionally include `root` based on root count) always
20
- // reflect the current roots — no restart needed after an add/remove-root call.
19
+ // so tool schemas (which conditionally include `folder` based on folder count) always
20
+ // reflect the current folders — no restart needed after an add/remove-folder call.
21
21
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
22
22
  // __dirname is <pkg>/src when run via tsx (dev/test) and <pkg>/dist/src once
23
23
  // built — either way dist/client (the Vite output) sits one level above the
@@ -31,32 +31,32 @@ const builtinSkillsDir = path.join(__dirname, 'skills', 'builtin');
31
31
  const PORT = process.env.PORT ? Number(process.env.PORT) : 8767;
32
32
  const config = loadConfig();
33
33
  const db = openCache(config.cacheDbPath);
34
- const skillSpec = skillSyncSpec([{ name: 'builtin', path: builtinSkillsDir }, ...config.skillRoots]);
35
- const memorySpec = memorySyncSpec(config.memoryRoots);
34
+ const skillSpec = skillSyncSpec([{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders]);
35
+ const memorySpec = memorySyncSpec(config.memoryFolders);
36
36
  initialScan(db, skillSpec);
37
37
  initialScan(db, memorySpec);
38
38
  const skillWatcher = watchSources(db, skillSpec);
39
39
  const memoryWatcher = watchSources(db, memorySpec);
40
- const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillRoots]);
41
- const memoryRepo = new MemoryRepository(db, config.memoryRoots);
40
+ const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders]);
41
+ const memoryRepo = new MemoryRepository(db, config.memoryFolders);
42
42
  skillRepo.setWatcher(skillWatcher);
43
43
  memoryRepo.setWatcher(memoryWatcher);
44
- if (config.skillRoots.length === 0 && config.memoryRoots.length === 0) {
45
- console.error(`[memory-bucket] no roots configured — open http://localhost:${PORT} to add one`);
44
+ if (config.skillFolders.length === 0 && config.memoryFolders.length === 0) {
45
+ console.error(`[memory-bucket] no folders configured — open http://localhost:${PORT} to add one`);
46
46
  }
47
47
  // If the user refers to "mem bucket", "mem bucket mcp", "memory bucket", or
48
48
  // "skill bucket" (its working-title predecessor) in conversation, they mean
49
49
  // this server — surfaced both in serverInfo.description and instructions so
50
50
  // clients that expose either to the model can make that association.
51
51
  const SERVER_DESCRIPTION = 'Also known as "memory bucket", "mem bucket", or "skill bucket" — if the user refers to this server by any of those names, they mean this one.';
52
- const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_root tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Use bucket_list_roots to see what named source directories (roots) are configured before passing a root argument elsewhere, and bucket_create_root/bucket_delete_root to register or unregister one. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
52
+ const SERVER_INSTRUCTIONS = `${SERVER_DESCRIPTION} Exposes skill_* (reusable coding patterns, stored as agentskills.io-standard SKILL.md folders) and memory_* (point-in-time working context — plans, specs, SQL, session summaries — looked up by key) tools, plus shared relocate/bucket_search/bucket_*_folder tools. Use skill_search/memory_search/bucket_search for full-text search over body content (not just metadata) — bucket_search when you don't know which bucket something landed in. Use bucket_list_folders to see what named source directories (folders) are configured before passing a folder argument elsewhere, and bucket_create_folder/bucket_delete_folder to register or unregister one. Most operations have a _bulk_ variant (bulk_get/bulk_create/bulk_update/bulk_delete/bulk_rename, relocate_bulk) that take a list and return per-item success/failure — prefer these over looping single calls when acting on more than one item. A memory doc's key can be changed in place via memory_update(id, key: ...) — no separate rename tool needed. Before calling any *_create/*_update/relocate tool, call skill_get("memory-bucket-authoring") first to learn the exact frontmatter schema — don't guess the shape.`;
53
53
  function buildMcpServer() {
54
54
  const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
55
55
  registerSkillTools(server, skillRepo);
56
56
  registerMemoryTools(server, memoryRepo);
57
57
  registerRelocateTool(server, skillRepo, memoryRepo);
58
58
  registerSearchTool(server, db);
59
- registerBucketRootTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec);
59
+ registerBucketFolderTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec);
60
60
  registerUiTool(server, PORT);
61
61
  return server;
62
62
  }
@@ -90,8 +90,8 @@ app.delete('/mcp', methodNotAllowed);
90
90
  app.listen(PORT, () => {
91
91
  console.error(`[memory-bucket] MCP server listening on http://localhost:${PORT}/mcp`);
92
92
  console.error(`[memory-bucket] UI available at http://localhost:${PORT}`);
93
- console.error(`[memory-bucket] skill roots: ${config.skillRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
94
- console.error(`[memory-bucket] memory roots: ${config.memoryRoots.map((r) => `${r.name}=${r.path}`).join(', ') || '(none)'}`);
93
+ console.error(`[memory-bucket] skill folders: ${config.skillFolders.map((f) => `${f.name}=${f.path}`).join(', ') || '(none)'}`);
94
+ console.error(`[memory-bucket] memory folders: ${config.memoryFolders.map((f) => `${f.name}=${f.path}`).join(', ') || '(none)'}`);
95
95
  });
96
96
  process.on('SIGINT', () => {
97
97
  db.close();
@@ -0,0 +1,66 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { z } from 'zod';
4
+ import { saveFolder, removeFolder as removeFolderFromConfig, sanitizeFolderName } from '../config.js';
5
+ import { initialScan } from '../store/sync.js';
6
+ const KIND = z.enum(['skill', 'memory']);
7
+ export function registerBucketFolderTools(mcp, config, skillRepo, memoryRepo, db, skillSpec, memorySpec) {
8
+ mcp.tool('bucket_list_folders', 'Lists the configured skill and memory folders (the named source directories skills/memory docs live under, e.g. "super-skills", "demo-skills", "builtin") — use this to see what folders exist before passing a `folder` argument to a create/list/search tool, or before adding/removing one.', {}, async () => {
9
+ const skill = skillRepo.listFolders().map((f) => ({ ...f, kind: 'skill' }));
10
+ const memory = memoryRepo.listFolders().map((f) => ({ ...f, kind: 'memory' }));
11
+ return { content: [{ type: 'text', text: JSON.stringify({ skill, memory }, null, 2) }] };
12
+ });
13
+ mcp.tool('bucket_create_folder', 'Registers a new skill or memory folder: an existing absolute directory path becomes a new named source that skill_create/memory_create can target via `folder`. Scans it once and starts watching it live — never creates the directory itself, it must already exist.', {
14
+ kind: KIND,
15
+ path: z.string().describe('absolute path to an existing directory'),
16
+ name: z.string().optional().describe('name for the folder; defaults to a sanitized version of the directory\'s basename'),
17
+ }, async ({ kind, path: dirPath, name }) => {
18
+ try {
19
+ if (!path.isAbsolute(dirPath)) {
20
+ return { content: [{ type: 'text', text: 'path must be an absolute directory path' }], isError: true };
21
+ }
22
+ if (!fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory()) {
23
+ return { content: [{ type: 'text', text: `not a directory: ${dirPath}` }], isError: true };
24
+ }
25
+ const folderName = sanitizeFolderName(name || path.basename(dirPath));
26
+ if (!folderName) {
27
+ return { content: [{ type: 'text', text: 'could not derive a valid folder name — provide one explicitly' }], isError: true };
28
+ }
29
+ const repo = kind === 'skill' ? skillRepo : memoryRepo;
30
+ repo.addFolder({ name: folderName, path: dirPath });
31
+ saveFolder(config, kind, { name: folderName, path: dirPath });
32
+ return { content: [{ type: 'text', text: JSON.stringify({ name: folderName, path: dirPath, kind }, null, 2) }] };
33
+ }
34
+ catch (err) {
35
+ return { content: [{ type: 'text', text: err.message }], isError: true };
36
+ }
37
+ });
38
+ mcp.tool('bucket_delete_folder', 'Unregisters a skill or memory folder by name: stops watching it and drops its cached entries from the index. Never touches files on disk — the directory and its contents are left in place.', { kind: KIND, name: z.string() }, async ({ kind, name }) => {
39
+ try {
40
+ const repo = kind === 'skill' ? skillRepo : memoryRepo;
41
+ repo.removeFolder(name);
42
+ removeFolderFromConfig(config, kind, name);
43
+ return { content: [{ type: 'text', text: `Removed ${kind} folder "${name}"` }] };
44
+ }
45
+ catch (err) {
46
+ return { content: [{ type: 'text', text: err.message }], isError: true };
47
+ }
48
+ });
49
+ mcp.tool('bucket_rebuild_cache', 'EMERGENCY USE ONLY. Wipes the entire SQLite cache (all skills, memory docs, the full-text search index, and the date index) and rebuilds it from scratch by rescanning every configured folder from disk. Source markdown files on disk are never touched — this only affects the derived cache, which is always safe to discard and regenerate. Use this only when other tools return results that contradict what you can see in the actual files (e.g. stale search hits, a doc that clearly exists on disk but skill_get/memory_get can\'t find, or search_by_date returning wrong dates) and a normal create/update/relocate call hasn\'t resolved it — this is a last resort, not a routine maintenance step. Takes a moment to complete on a large folder; nothing else should be called until it returns.', {}, async () => {
50
+ try {
51
+ db.exec(`DELETE FROM skills; DELETE FROM memory_docs; DELETE FROM search_index; DELETE FROM doc_dates;`);
52
+ initialScan(db, skillSpec);
53
+ initialScan(db, memorySpec);
54
+ const skillCount = db.prepare(`SELECT COUNT(*) AS n FROM skills`).get().n;
55
+ const memoryCount = db.prepare(`SELECT COUNT(*) AS n FROM memory_docs`).get().n;
56
+ return {
57
+ content: [
58
+ { type: 'text', text: `Cache rebuilt from disk: ${skillCount} skill(s), ${memoryCount} memory doc(s) reindexed.` },
59
+ ],
60
+ };
61
+ }
62
+ catch (err) {
63
+ return { content: [{ type: 'text', text: err.message }], isError: true };
64
+ }
65
+ });
66
+ }
@@ -15,8 +15,8 @@ const overridesSchema = z
15
15
  doc_type: z.enum(['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other']).optional().describe('memory target only'),
16
16
  tags: z.array(z.string()).optional(),
17
17
  status: statusSchema(['stable', 'beta', 'unreviewed', 'active', 'shipped', 'abandoned']).optional(),
18
- folder: z.string().optional().describe('optional subdirectory under the target root'),
19
- root: z.string().optional().describe('which configured root to write into; required if multiple roots exist for the target type'),
18
+ subfolder: z.string().optional().describe('optional subdirectory under the target folder'),
19
+ folder: z.string().optional().describe('which configured folder to write into; required if multiple folders exist for the target type'),
20
20
  })
21
21
  .optional();
22
22
  export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
@@ -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, opts.overrides?.root);
66
+ }, body, opts.overrides?.subfolder, opts.overrides?.folder);
67
67
  if (!opts.keep_original)
68
68
  fs.unlinkSync(opts.path);
69
69
  return { moved: true, id: doc.name, target: 'skill' };
@@ -91,8 +91,8 @@ export function relocate(opts, skillRepo, memoryRepo) {
91
91
  description,
92
92
  body,
93
93
  tags: opts.overrides?.tags,
94
+ subfolder: opts.overrides?.subfolder,
94
95
  folder: opts.overrides?.folder,
95
- root: opts.overrides?.root,
96
96
  });
97
97
  if (!opts.keep_original)
98
98
  fs.unlinkSync(opts.path);
@@ -311,48 +311,48 @@ body: >-
311
311
  - `relocate_bulk` — same idea for `relocate`, one entry per file.
312
312
 
313
313
 
314
- ## Multiple roots
314
+ ## Multiple folders
315
315
 
316
316
 
317
- A server can be configured with more than one skill root and/or memory
317
+ A server can be configured with more than one skill folder and/or memory
318
318
 
319
- root at once — e.g. a personal skills folder plus a shared company repo.
319
+ folder at once — e.g. a personal skills folder plus a shared company repo.
320
320
 
321
- When exactly one root of a given kind is configured, everything above
321
+ When exactly one folder of a given kind is configured, everything above
322
322
 
323
323
  works unchanged: `skill_create`/`memory_create`/`relocate` write into it
324
324
 
325
325
  with no extra parameter needed.
326
326
 
327
327
 
328
- Once **two or more** roots of a kind are configured, `skill_create`,
328
+ Once **two or more** folders of a kind are configured, `skill_create`,
329
329
 
330
330
  `memory_create`, `memory_save_session`, and `relocate`'s `overrides` gain
331
331
 
332
- a required (or, for `memory_save_session`, optional) `root` parameter —
332
+ a required (or, for `memory_save_session`, optional) `folder` parameter —
333
333
 
334
- its tool description lists the valid root names. Match a root by name
334
+ its tool description lists the valid folder names. Match a folder by name
335
335
 
336
336
  from what the user said ("save this to my personal skills", "put this in
337
337
 
338
- the company repo") rather than guessing; if it's unclear which root they
338
+ the company repo") rather than guessing; if it's unclear which folder they
339
339
 
340
- mean, ask. `skill_list`/`memory_list` also gain an optional `root` filter
340
+ mean, ask. `skill_list`/`memory_list` also gain an optional `folder` filter
341
341
 
342
- to narrow results to one root once multiple exist.
342
+ to narrow results to one folder once multiple exist.
343
343
 
344
344
 
345
- Roots (for both skills and memory) are managed through the web UI
345
+ Folders (for both skills and memory) are managed through the web UI
346
346
 
347
- (`bucket_open_ui`) — add one via its "+ Add root" folder browser, or
347
+ (`bucket_open_ui`) — add one via its "+ Add folder" folder browser, or
348
348
 
349
349
  remove one via the ✕ on its chip (this only unregisters it and drops its
350
350
 
351
351
  cached rows; it never deletes files on disk). If the server has zero
352
352
 
353
- roots configured, the UI opens straight into a first-run "add your first
353
+ folders configured, the UI opens straight into a first-run "add your first
354
354
 
355
- root" screen instead of the normal search view.
355
+ folder" screen instead of the normal search view.
356
356
 
357
357
 
358
358
  ## relocate: pulling in an existing file
@@ -593,29 +593,29 @@ calls whenever acting on more than one item, e.g. after a search:
593
593
  name/id in one call.
594
594
  - `relocate_bulk` — same idea for `relocate`, one entry per file.
595
595
 
596
- ## Multiple roots
596
+ ## Multiple folders
597
597
 
598
- A server can be configured with more than one skill root and/or memory
599
- root at once — e.g. a personal skills folder plus a shared company repo.
600
- When exactly one root of a given kind is configured, everything above
598
+ A server can be configured with more than one skill folder and/or memory
599
+ folder at once — e.g. a personal skills folder plus a shared company repo.
600
+ When exactly one folder of a given kind is configured, everything above
601
601
  works unchanged: `skill_create`/`memory_create`/`relocate` write into it
602
602
  with no extra parameter needed.
603
603
 
604
- Once **two or more** roots of a kind are configured, `skill_create`,
604
+ Once **two or more** folders of a kind are configured, `skill_create`,
605
605
  `memory_create`, `memory_save_session`, and `relocate`'s `overrides` gain
606
- a required (or, for `memory_save_session`, optional) `root` parameter —
607
- its tool description lists the valid root names. Match a root by name
606
+ a required (or, for `memory_save_session`, optional) `folder` parameter —
607
+ its tool description lists the valid folder names. Match a folder by name
608
608
  from what the user said ("save this to my personal skills", "put this in
609
- the company repo") rather than guessing; if it's unclear which root they
610
- mean, ask. `skill_list`/`memory_list` also gain an optional `root` filter
611
- to narrow results to one root once multiple exist.
609
+ the company repo") rather than guessing; if it's unclear which folder they
610
+ mean, ask. `skill_list`/`memory_list` also gain an optional `folder` filter
611
+ to narrow results to one folder once multiple exist.
612
612
 
613
- Roots (for both skills and memory) are managed through the web UI
614
- (`bucket_open_ui`) — add one via its "+ Add root" folder browser, or
613
+ Folders (for both skills and memory) are managed through the web UI
614
+ (`bucket_open_ui`) — add one via its "+ Add folder" folder browser, or
615
615
  remove one via the ✕ on its chip (this only unregisters it and drops its
616
616
  cached rows; it never deletes files on disk). If the server has zero
617
- roots configured, the UI opens straight into a first-run "add your first
618
- root" screen instead of the normal search view.
617
+ folders configured, the UI opens straight into a first-run "add your first
618
+ folder" screen instead of the normal search view.
619
619
 
620
620
  ## relocate: pulling in an existing file
621
621