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.
- package/README.md +21 -21
- package/dist/client/assets/{index-CqgDoHFV.js → index-6BCcM6pL.js} +104 -104
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +17 -17
- package/dist/src/memory/repository.js +41 -41
- package/dist/src/memory/tools.js +16 -16
- package/dist/src/server.js +13 -13
- package/dist/src/shared/bucket-folder-tool.js +66 -0
- package/dist/src/shared/relocate-tool.js +2 -2
- package/dist/src/shared/relocate.js +2 -2
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +28 -28
- package/dist/src/skills/repository.js +58 -58
- package/dist/src/skills/tools.js +18 -18
- package/dist/src/store/db.js +4 -4
- package/dist/src/store/search.js +1 -1
- package/dist/src/store/sync.js +20 -20
- package/dist/src/web/routes.js +33 -33
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -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-
|
|
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>
|
package/dist/src/config.js
CHANGED
|
@@ -12,8 +12,8 @@ function readConfigFile(configPath) {
|
|
|
12
12
|
function nameFromPath(p) {
|
|
13
13
|
return path.basename(p);
|
|
14
14
|
}
|
|
15
|
-
function
|
|
16
|
-
const
|
|
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
|
|
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
|
|
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
|
|
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
|
|
41
|
-
const
|
|
40
|
+
const skillFolders = resolveFolders(defaultSkillSources, baseDir);
|
|
41
|
+
const memoryFolders = resolveFolders(defaultMemorySources, baseDir);
|
|
42
42
|
return {
|
|
43
|
-
|
|
44
|
-
|
|
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
|
|
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
|
|
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(
|
|
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:
|
|
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
|
|
72
|
-
export function
|
|
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
|
|
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
|
|
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,
|
|
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
|
-
|
|
24
|
+
folder: row.folder,
|
|
25
25
|
body: row.body,
|
|
26
26
|
};
|
|
27
27
|
}
|
|
28
28
|
export class MemoryRepository {
|
|
29
29
|
db;
|
|
30
|
-
|
|
30
|
+
folders;
|
|
31
31
|
syncSpec;
|
|
32
32
|
watcher;
|
|
33
|
-
constructor(db,
|
|
33
|
+
constructor(db, folders) {
|
|
34
34
|
this.db = db;
|
|
35
|
-
this.
|
|
36
|
-
this.syncSpec = memorySyncSpec(
|
|
35
|
+
this.folders = folders;
|
|
36
|
+
this.syncSpec = memorySyncSpec(folders);
|
|
37
37
|
}
|
|
38
|
-
/** Attaches the live chokidar watcher so
|
|
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
|
-
|
|
43
|
-
return [...this.
|
|
42
|
+
listFolders() {
|
|
43
|
+
return [...this.folders];
|
|
44
44
|
}
|
|
45
|
-
|
|
46
|
-
if (
|
|
47
|
-
const found = this.
|
|
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
|
|
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.
|
|
54
|
-
return this.
|
|
55
|
-
if (this.
|
|
56
|
-
throw new Error('no memory
|
|
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
|
|
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
|
|
61
|
-
|
|
62
|
-
if (this.
|
|
63
|
-
throw new Error(`a memory
|
|
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.
|
|
66
|
-
|
|
67
|
-
this.watcher?.add(
|
|
65
|
+
this.folders.push(folder);
|
|
66
|
+
scanSingleFolder(this.db, this.syncSpec, folder.path);
|
|
67
|
+
this.watcher?.add(folder.path);
|
|
68
68
|
}
|
|
69
|
-
/** Unregisters a
|
|
70
|
-
|
|
71
|
-
const idx = this.
|
|
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
|
|
74
|
-
const [removed] = this.
|
|
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
|
-
|
|
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/
|
|
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,
|
|
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 (
|
|
117
|
-
conditions.push('m.
|
|
118
|
-
params.push(
|
|
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.
|
|
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
|
|
166
|
-
const filePath = resolveWithinBase(
|
|
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
|
-
|
|
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,
|
|
314
|
+
const { source_path: _sp, folder: _folder, ...rest } = fm;
|
|
315
315
|
return rest;
|
|
316
316
|
}
|
package/dist/src/memory/tools.js
CHANGED
|
@@ -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
|
|
9
|
-
const
|
|
10
|
-
const
|
|
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
|
-
...(
|
|
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,
|
|
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,
|
|
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
|
|
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
|
-
|
|
66
|
-
...(
|
|
67
|
-
}, async ({ key, key_type, doc_type, description, body, tags, status, related_to,
|
|
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,
|
|
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
|
-
|
|
86
|
-
...(
|
|
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
|
-
...(
|
|
138
|
-
}, async ({ summary, key, description, tags,
|
|
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
|
-
|
|
159
|
+
folder,
|
|
160
160
|
});
|
|
161
161
|
return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
|
|
162
162
|
}
|
package/dist/src/server.js
CHANGED
|
@@ -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 {
|
|
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 `
|
|
20
|
-
// reflect the current
|
|
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.
|
|
35
|
-
const memorySpec = memorySyncSpec(config.
|
|
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.
|
|
41
|
-
const memoryRepo = new MemoryRepository(db, config.
|
|
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.
|
|
45
|
-
console.error(`[memory-bucket] no
|
|
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_*
|
|
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
|
-
|
|
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
|
|
94
|
-
console.error(`[memory-bucket] memory
|
|
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
|
-
|
|
19
|
-
|
|
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?.
|
|
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
|
|
314
|
+
## Multiple folders
|
|
315
315
|
|
|
316
316
|
|
|
317
|
-
A server can be configured with more than one skill
|
|
317
|
+
A server can be configured with more than one skill folder and/or memory
|
|
318
318
|
|
|
319
|
-
|
|
319
|
+
folder at once — e.g. a personal skills folder plus a shared company repo.
|
|
320
320
|
|
|
321
|
-
When exactly one
|
|
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**
|
|
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) `
|
|
332
|
+
a required (or, for `memory_save_session`, optional) `folder` parameter —
|
|
333
333
|
|
|
334
|
-
its tool description lists the valid
|
|
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
|
|
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 `
|
|
340
|
+
mean, ask. `skill_list`/`memory_list` also gain an optional `folder` filter
|
|
341
341
|
|
|
342
|
-
to narrow results to one
|
|
342
|
+
to narrow results to one folder once multiple exist.
|
|
343
343
|
|
|
344
344
|
|
|
345
|
-
|
|
345
|
+
Folders (for both skills and memory) are managed through the web UI
|
|
346
346
|
|
|
347
|
-
(`bucket_open_ui`) — add one via its "+ Add
|
|
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
|
-
|
|
353
|
+
folders configured, the UI opens straight into a first-run "add your first
|
|
354
354
|
|
|
355
|
-
|
|
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
|
|
596
|
+
## Multiple folders
|
|
597
597
|
|
|
598
|
-
A server can be configured with more than one skill
|
|
599
|
-
|
|
600
|
-
When exactly one
|
|
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**
|
|
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) `
|
|
607
|
-
its tool description lists the valid
|
|
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
|
|
610
|
-
mean, ask. `skill_list`/`memory_list` also gain an optional `
|
|
611
|
-
to narrow results to one
|
|
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
|
-
|
|
614
|
-
(`bucket_open_ui`) — add one via its "+ Add
|
|
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
|
-
|
|
618
|
-
|
|
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
|
|