mcp-memory-bucket 0.7.1 → 0.7.5
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/dist/src/channels/search.js +40 -0
- package/dist/src/channels/store.js +53 -0
- package/dist/src/channels/tools.js +59 -0
- package/dist/src/memory/repository.js +3 -2
- package/dist/src/memory/tools.js +23 -4
- package/dist/src/server.js +5 -1
- package/dist/src/shared/body-edits.js +87 -0
- package/dist/src/skills/repository.js +3 -2
- package/dist/src/skills/tools.js +23 -4
- package/package.json +2 -1
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { distance } from 'fastest-levenshtein';
|
|
2
|
+
/**
|
|
3
|
+
* Mirrors mcp-tenant-lib's channel-search.ts — duplicated rather than
|
|
4
|
+
* imported since this package has no other dependency on mcp-tenant-lib.
|
|
5
|
+
* See that file for the full design rationale behind the stem/typo split.
|
|
6
|
+
*/
|
|
7
|
+
function words(name) {
|
|
8
|
+
return name.split(/[_-]+/).filter(Boolean);
|
|
9
|
+
}
|
|
10
|
+
function typoScore(a, b) {
|
|
11
|
+
const maxLen = Math.max(a.length, b.length);
|
|
12
|
+
if (maxLen === 0)
|
|
13
|
+
return 1;
|
|
14
|
+
return 1 - distance(a, b) / maxLen;
|
|
15
|
+
}
|
|
16
|
+
function stemScore(a, b) {
|
|
17
|
+
const [shorter, longer] = a.length <= b.length ? [a, b] : [b, a];
|
|
18
|
+
if (shorter.length < 3 || !longer.startsWith(shorter))
|
|
19
|
+
return 0;
|
|
20
|
+
return shorter.length / longer.length;
|
|
21
|
+
}
|
|
22
|
+
function wordScore(query, word) {
|
|
23
|
+
const stem = stemScore(query, word);
|
|
24
|
+
if (Math.abs(query.length - word.length) > 2)
|
|
25
|
+
return stem;
|
|
26
|
+
return Math.max(stem, typoScore(query, word));
|
|
27
|
+
}
|
|
28
|
+
export function scoreChannelMatch(query, candidate) {
|
|
29
|
+
const q = query.toLowerCase();
|
|
30
|
+
const c = candidate.toLowerCase();
|
|
31
|
+
const whole = Math.max(typoScore(q, c), stemScore(q, c));
|
|
32
|
+
const perWord = words(c).map((w) => wordScore(q, w));
|
|
33
|
+
return Math.max(whole, ...perWord, 0);
|
|
34
|
+
}
|
|
35
|
+
export function findChannelMatches(query, candidates, minScore = 0.5) {
|
|
36
|
+
return candidates
|
|
37
|
+
.map((name) => ({ name, score: scoreChannelMatch(query, name) }))
|
|
38
|
+
.filter((m) => m.score >= minScore)
|
|
39
|
+
.sort((a, b) => b.score - a.score);
|
|
40
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL-safe slug rule for agent-chosen channel names: letters, digits,
|
|
3
|
+
* underscore, hyphen only. Mirrors mcp-tenant-lib's isValidChannelName —
|
|
4
|
+
* duplicated rather than imported since this package has no other
|
|
5
|
+
* dependency on mcp-tenant-lib and one shared regex isn't worth adding one.
|
|
6
|
+
*/
|
|
7
|
+
export function isValidChannelName(name) {
|
|
8
|
+
return /^[a-zA-Z0-9_-]+$/.test(name);
|
|
9
|
+
}
|
|
10
|
+
const channels = new Map();
|
|
11
|
+
/** Auto-vivifies on first reference, same as mcp-tenant-lib's getOrCreateTenant. */
|
|
12
|
+
export function getOrCreateChannel(name) {
|
|
13
|
+
let channel = channels.get(name);
|
|
14
|
+
if (!channel) {
|
|
15
|
+
channel = { name, content: '', lastActivityAt: Date.now() };
|
|
16
|
+
channels.set(name, channel);
|
|
17
|
+
}
|
|
18
|
+
return channel;
|
|
19
|
+
}
|
|
20
|
+
export function getChannel(name) {
|
|
21
|
+
return channels.get(name);
|
|
22
|
+
}
|
|
23
|
+
export function listChannels() {
|
|
24
|
+
return [...channels.values()];
|
|
25
|
+
}
|
|
26
|
+
function envMs(name, defaultMs) {
|
|
27
|
+
const raw = process.env[name];
|
|
28
|
+
if (!raw)
|
|
29
|
+
return defaultMs;
|
|
30
|
+
const n = Number(raw);
|
|
31
|
+
return Number.isFinite(n) && n > 0 ? n : defaultMs;
|
|
32
|
+
}
|
|
33
|
+
// Long-lived by design: a cross-agent discussion may span much longer gaps
|
|
34
|
+
// between contributions than a live form-fill session (mcp-tenant-lib's
|
|
35
|
+
// tenants default to a 30-minute idle timeout) — default here is ~24h.
|
|
36
|
+
const MEMORY_CHANNEL_IDLE_TIMEOUT_MS = envMs('MEMORY_CHANNEL_IDLE_TIMEOUT_MS', 24 * 60 * 60 * 1000);
|
|
37
|
+
const MEMORY_CHANNEL_SWEEP_INTERVAL_MS = envMs('MEMORY_CHANNEL_SWEEP_INTERVAL_MS', 30 * 60 * 1000);
|
|
38
|
+
export function startChannelSweep(onSweep) {
|
|
39
|
+
const sweepInterval = setInterval(() => {
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [name, channel] of channels) {
|
|
42
|
+
if (now - channel.lastActivityAt > MEMORY_CHANNEL_IDLE_TIMEOUT_MS) {
|
|
43
|
+
onSweep(name);
|
|
44
|
+
channels.delete(name);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}, MEMORY_CHANNEL_SWEEP_INTERVAL_MS);
|
|
48
|
+
sweepInterval.unref();
|
|
49
|
+
return sweepInterval;
|
|
50
|
+
}
|
|
51
|
+
// Test-only escape hatch, mirroring how mcp-tenant-lib's tests reach into
|
|
52
|
+
// its module-level `tenants` Map directly (see tenant.ts's `tenants` export).
|
|
53
|
+
export { channels };
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
import { getOrCreateChannel, getChannel, listChannels, isValidChannelName } from './store.js';
|
|
3
|
+
import { findChannelMatches } from './search.js';
|
|
4
|
+
export function registerMemoryChannelTools(mcp) {
|
|
5
|
+
mcp.tool('memory_channel_read', 'Reads a memory channel: ephemeral, in-memory-only text shared live between agent sessions on this server ' +
|
|
6
|
+
'— distinct from persisted memory_* docs (never on disk, never indexed by search). Empty content if the ' +
|
|
7
|
+
'channel doesn\'t exist yet — reading never creates one (memory_channel_post does). ' +
|
|
8
|
+
'IMPORTANT: empty is ambiguous ("nothing posted yet" vs "wrong name" — matching is EXACT, no fuzz). Before ' +
|
|
9
|
+
'saying a discussion doesn\'t exist, call memory_channel_find or list_memory_channels to check for a ' +
|
|
10
|
+
'similarly-named channel (e.g. "pets" vs "pet_discussion").', { channel: z.string().describe('Channel name. Letters/digits/underscore/hyphen only.') }, async ({ channel }) => {
|
|
11
|
+
if (!isValidChannelName(channel)) {
|
|
12
|
+
return {
|
|
13
|
+
content: [{ type: 'text', text: `Error: "${channel}" is not a valid channel name — use only letters, digits, underscore, and hyphen.` }],
|
|
14
|
+
isError: true,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
const existing = getChannel(channel);
|
|
18
|
+
return {
|
|
19
|
+
content: [{ type: 'text', text: JSON.stringify({ content: existing?.content ?? '', lastActivityAt: existing?.lastActivityAt ?? null }, null, 2) }],
|
|
20
|
+
};
|
|
21
|
+
});
|
|
22
|
+
mcp.tool('memory_channel_post', 'Writes to a memory channel, replacing its content (last-write-wins) — auto-creates it if missing. ' +
|
|
23
|
+
'Ephemeral, in-memory only: never on disk, never indexed by search, gone on restart or after a long idle ' +
|
|
24
|
+
'period. This ALWAYS replaces content — "append to a discussion" vs "hand over a fresh summary" is your ' +
|
|
25
|
+
'choice, not a mode: to preserve history, read first then post the old content plus your addition ' +
|
|
26
|
+
'concatenated; to hand over cleanly, just post fresh content. Names must match EXACTLY — if continuing an ' +
|
|
27
|
+
'existing discussion, double-check via memory_channel_find/list_memory_channels first, or a typo silently ' +
|
|
28
|
+
'starts an unrelated empty channel instead of erroring.', {
|
|
29
|
+
channel: z.string().describe('Channel name. Letters/digits/underscore/hyphen only.'),
|
|
30
|
+
content: z.string().describe('The full new content of the channel — replaces whatever was there before.'),
|
|
31
|
+
}, async ({ channel, content }) => {
|
|
32
|
+
if (!isValidChannelName(channel)) {
|
|
33
|
+
return {
|
|
34
|
+
content: [{ type: 'text', text: `Error: "${channel}" is not a valid channel name — use only letters, digits, underscore, and hyphen.` }],
|
|
35
|
+
isError: true,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
const ch = getOrCreateChannel(channel);
|
|
39
|
+
ch.content = content;
|
|
40
|
+
ch.lastActivityAt = Date.now();
|
|
41
|
+
return {
|
|
42
|
+
content: [{ type: 'text', text: `Posted ${content.length} character(s) to channel "${channel}".` }],
|
|
43
|
+
};
|
|
44
|
+
});
|
|
45
|
+
mcp.tool('list_memory_channels', 'Lists every memory channel currently live on this server, with when each was last written or read. Use ' +
|
|
46
|
+
'this to discover an existing channel by name before calling memory_channel_read/post on it.', {}, async () => {
|
|
47
|
+
const channels = listChannels().map((c) => ({ name: c.name, lastActivityAt: c.lastActivityAt }));
|
|
48
|
+
return { content: [{ type: 'text', text: JSON.stringify(channels, null, 2) }] };
|
|
49
|
+
});
|
|
50
|
+
mcp.tool('memory_channel_find', 'Fuzzy-searches existing memory channel names for a loose query (e.g. "the pets discussion") instead of ' +
|
|
51
|
+
'guessing at memory_channel_read/post or eyeballing list_memory_channels yourself. Matches whole-name ' +
|
|
52
|
+
'similarity (typos) and per-word similarity on underscore/hyphen-split parts (e.g. "pets" matches ' +
|
|
53
|
+
'"pet_food_memory" via "pet"). Returns ranked {name, score} (0..1, higher better), empty if nothing scores ' +
|
|
54
|
+
'above threshold. Read-only — does not read or create anything. Call memory_channel_read yourself on the ' +
|
|
55
|
+
'right result; if scores are close, ask the user to disambiguate rather than guessing.', { query: z.string().describe('Loose/partial channel name or topic to search for, e.g. "pets".') }, async ({ query }) => {
|
|
56
|
+
const matches = findChannelMatches(query, listChannels().map((c) => c.name));
|
|
57
|
+
return { content: [{ type: 'text', text: JSON.stringify(matches, null, 2) }] };
|
|
58
|
+
});
|
|
59
|
+
}
|
|
@@ -6,6 +6,7 @@ import { slugify } from '../store/slug.js';
|
|
|
6
6
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
7
7
|
import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, memorySyncSpec } from '../store/sync.js';
|
|
8
8
|
import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
|
|
9
|
+
import { applyBodyEdits } from '../shared/body-edits.js';
|
|
9
10
|
import { attachmentsDirFor } from '../attachments/storage.js';
|
|
10
11
|
import { normalizeKey } from '../types.js';
|
|
11
12
|
/** Uppercases and strips everything but letters/digits — used to compare keys that differ only in
|
|
@@ -235,7 +236,7 @@ export class MemoryRepository {
|
|
|
235
236
|
}
|
|
236
237
|
});
|
|
237
238
|
}
|
|
238
|
-
update(id, frontmatter, body) {
|
|
239
|
+
update(id, frontmatter, body, bodyEdits) {
|
|
239
240
|
const existing = this.get(id);
|
|
240
241
|
if (!existing)
|
|
241
242
|
throw new Error(`memory doc with id "${id}" not found`);
|
|
@@ -248,7 +249,7 @@ export class MemoryRepository {
|
|
|
248
249
|
id: existing.id,
|
|
249
250
|
key: frontmatter?.key ? normalizeKey(frontmatter.key) : existing.key,
|
|
250
251
|
};
|
|
251
|
-
const newBody = body ?? existing.body;
|
|
252
|
+
const newBody = bodyEdits ? applyBodyEdits(existing.body, bodyEdits).body : (body ?? existing.body);
|
|
252
253
|
writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
|
|
253
254
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
254
255
|
return { ...merged, body: newBody, paused: existingPaused };
|
package/dist/src/memory/tools.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { stripKey } from './repository.js';
|
|
3
3
|
import { statusSchema } from '../shared/status.js';
|
|
4
|
+
import { bodyEditsSchema, applyBodyEdits, formatBodyEditsDiff } from '../shared/body-edits.js';
|
|
4
5
|
import { normalizeKey } from '../types.js';
|
|
5
6
|
const MEMORY_DOC_TYPES = ['plan', 'spec', 'sql', 'testing-todo', 'discovery', 'session-summary', 'other'];
|
|
6
7
|
const MEMORY_KEY_TYPES = ['ticket', 'freeform'];
|
|
@@ -100,21 +101,39 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
100
101
|
const results = repo.bulkCreate(entries);
|
|
101
102
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
102
103
|
});
|
|
103
|
-
mcp.tool('memory_update', `Edits an existing memory doc in place — frontmatter fields and/or body. Only provided fields change. ${AUTHORING_SKILL_HINT}`, {
|
|
104
|
+
mcp.tool('memory_update', `Edits an existing memory doc in place — frontmatter fields and/or body. Only provided fields change. For a body change smaller than the whole document, prefer body_edits over body: it patches via find/replace instead of requiring you to reproduce the entire body, which saves tokens and avoids accidentally dropping untouched content on a large doc. When body_edits is used, the response includes a \`diff\` field (compact -/+ per-edit summary) — show it to the user so they can see what changed, similar to a code diff view. The response never includes the full body (even on a full-body replacement) to avoid echoing back a potentially large doc — call memory_get(id) if you need the fresh full body. ${AUTHORING_SKILL_HINT}`, {
|
|
104
105
|
id: z.string(),
|
|
105
106
|
key: z.string().optional(),
|
|
106
107
|
key_type: z.enum(MEMORY_KEY_TYPES).optional(),
|
|
107
108
|
doc_type: z.enum(MEMORY_DOC_TYPES).optional(),
|
|
108
109
|
description: z.string().optional(),
|
|
109
|
-
body: z.string().optional(),
|
|
110
|
+
body: z.string().optional().describe('full body replacement — omit in favor of body_edits when only part of the doc is changing'),
|
|
111
|
+
body_edits: bodyEditsSchema.optional(),
|
|
110
112
|
tags: z.array(z.string()).optional(),
|
|
111
113
|
status: statusSchema(MEMORY_STATUS_DEFAULTS).optional(),
|
|
112
114
|
related_to: z.string().optional(),
|
|
113
115
|
deprecated: z.boolean().optional().describe('marks the doc as deprecated (or un-deprecates when false) — independent of status'),
|
|
114
|
-
}, async ({ id, body, ...frontmatterFields }) => {
|
|
116
|
+
}, async ({ id, body, body_edits, ...frontmatterFields }) => {
|
|
117
|
+
if (body !== undefined && body_edits !== undefined) {
|
|
118
|
+
return { content: [{ type: 'text', text: 'Pass either body or body_edits, not both.' }], isError: true };
|
|
119
|
+
}
|
|
115
120
|
try {
|
|
121
|
+
let diff;
|
|
122
|
+
if (body_edits) {
|
|
123
|
+
const existing = repo.get(id);
|
|
124
|
+
if (!existing)
|
|
125
|
+
throw new Error(`memory doc with id "${id}" not found`);
|
|
126
|
+
const { body: patchedBody, applied } = applyBodyEdits(existing.body, body_edits);
|
|
127
|
+
diff = formatBodyEditsDiff(applied);
|
|
128
|
+
body = patchedBody;
|
|
129
|
+
}
|
|
116
130
|
const doc = repo.update(id, frontmatterFields, body);
|
|
117
|
-
|
|
131
|
+
// Body is omitted from the response: the caller either just sent it (full replacement),
|
|
132
|
+
// already has it, or has `diff` — echoing a potentially large body back is pure waste.
|
|
133
|
+
// Fetch memory_get(id) if the fresh full body is actually needed.
|
|
134
|
+
const { body: _omitted, ...docWithoutBody } = doc;
|
|
135
|
+
const result = diff ? { ...docWithoutBody, diff } : docWithoutBody;
|
|
136
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
118
137
|
}
|
|
119
138
|
catch (err) {
|
|
120
139
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
package/dist/src/server.js
CHANGED
|
@@ -17,6 +17,8 @@ import { registerAttachmentTools } from './attachments/tools.js';
|
|
|
17
17
|
import { AttachmentRepository } from './attachments/repository.js';
|
|
18
18
|
import { buildWebRouter } from './web/routes.js';
|
|
19
19
|
import { registerUiTool } from './web/ui-tool.js';
|
|
20
|
+
import { registerMemoryChannelTools } from './channels/tools.js';
|
|
21
|
+
import { startChannelSweep } from './channels/store.js';
|
|
20
22
|
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
21
23
|
// so tool schemas (which conditionally include `folder` based on folder count) always
|
|
22
24
|
// reflect the current folders — no restart needed after an add/remove-folder call.
|
|
@@ -52,7 +54,7 @@ if (config.skillFolders.length === 0 && config.memoryFolders.length === 0) {
|
|
|
52
54
|
// this server — surfaced both in serverInfo.description and instructions so
|
|
53
55
|
// clients that expose either to the model can make that association.
|
|
54
56
|
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.';
|
|
55
|
-
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.`;
|
|
57
|
+
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. Also exposes memory_channel_read/memory_channel_post/list_memory_channels — a SEPARATE, ephemeral in-memory layer for live cross-agent coordination (a shared scratchpad/discussion channel by name), never written to disk and never indexed by memory_search/bucket_search; do not confuse these with the persisted memory_* docs above.`;
|
|
56
58
|
function buildMcpServer() {
|
|
57
59
|
const server = new McpServer({ name: 'memory-bucket', version: '0.1.0', description: SERVER_DESCRIPTION }, { capabilities: {}, instructions: SERVER_INSTRUCTIONS });
|
|
58
60
|
registerSkillTools(server, skillRepo);
|
|
@@ -62,8 +64,10 @@ function buildMcpServer() {
|
|
|
62
64
|
registerBucketFolderTools(server, config, skillRepo, memoryRepo, db, skillSpec, memorySpec);
|
|
63
65
|
registerAttachmentTools(server, attachmentRepo);
|
|
64
66
|
registerUiTool(server, PORT);
|
|
67
|
+
registerMemoryChannelTools(server);
|
|
65
68
|
return server;
|
|
66
69
|
}
|
|
70
|
+
startChannelSweep((name) => console.error(`[memory-bucket] sweeping idle memory channel: ${name}`));
|
|
67
71
|
const app = express();
|
|
68
72
|
app.use(express.json());
|
|
69
73
|
app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec));
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { z } from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Search/replace patch format for editing a doc body without round-tripping the whole
|
|
4
|
+
* thing through the model — same shape as Claude Code's own Edit tool (old_string/new_string).
|
|
5
|
+
* Line-anchored diffs (unified diff, @@ hunk headers) are what this deliberately avoids:
|
|
6
|
+
* weaker models reliably botch line numbers and context-line counts, and a rejected hunk
|
|
7
|
+
* gives no partial credit. Exact-text search/replace has no offsets for the model to get
|
|
8
|
+
* wrong, and the host enforces uniqueness so a bad match fails loudly with context instead
|
|
9
|
+
* of silently patching the wrong spot.
|
|
10
|
+
*/
|
|
11
|
+
export const bodyEditSchema = z.object({
|
|
12
|
+
find: z.string().min(1).describe('exact existing text to locate in the body — must match exactly once unless replace_all is set'),
|
|
13
|
+
replace: z.string().describe('text to substitute in place of the match'),
|
|
14
|
+
replace_all: z.boolean().optional().describe('replace every occurrence instead of requiring exactly one match (default: false)'),
|
|
15
|
+
});
|
|
16
|
+
export const bodyEditsSchema = z
|
|
17
|
+
.array(bodyEditSchema)
|
|
18
|
+
.min(1)
|
|
19
|
+
.describe('Alternative to `body`: apply one or more find/replace patches to the existing body instead of rewriting it wholesale. ' +
|
|
20
|
+
'Applied in order against the current body. Each `find` must match exactly once unless `replace_all` is set. ' +
|
|
21
|
+
'Cannot be combined with `body` in the same call.');
|
|
22
|
+
/**
|
|
23
|
+
* Applies find/replace edits to `body` in order, matching Claude Code's Edit tool semantics:
|
|
24
|
+
* each `find` must appear exactly once unless `replace_all` is set, and a zero/ambiguous match
|
|
25
|
+
* throws immediately (with surrounding context) rather than guessing — so the caller can retry
|
|
26
|
+
* with a more specific `find` in the same turn instead of silently corrupting the doc.
|
|
27
|
+
*
|
|
28
|
+
* Also returns the applied edits (with occurrence counts) so a caller can render a diff of what
|
|
29
|
+
* changed without having to re-fetch and diff the pre-edit body itself.
|
|
30
|
+
*/
|
|
31
|
+
export function applyBodyEdits(body, edits) {
|
|
32
|
+
let result = body;
|
|
33
|
+
const applied = [];
|
|
34
|
+
for (const [i, edit] of edits.entries()) {
|
|
35
|
+
const { find, replace, replace_all } = edit;
|
|
36
|
+
const occurrences = countOccurrences(result, find);
|
|
37
|
+
if (occurrences === 0) {
|
|
38
|
+
throw new Error(`body_edits[${i}]: find text not found in body: ${JSON.stringify(truncate(find))}`);
|
|
39
|
+
}
|
|
40
|
+
if (occurrences > 1 && !replace_all) {
|
|
41
|
+
throw new Error(`body_edits[${i}]: find text matches ${occurrences} times in body — narrow it to a unique match, or set replace_all: true. Text: ${JSON.stringify(truncate(find))}`);
|
|
42
|
+
}
|
|
43
|
+
result = replace_all ? result.split(find).join(replace) : replaceFirst(result, find, replace);
|
|
44
|
+
applied.push({ find, replace, replace_all: !!replace_all, occurrences });
|
|
45
|
+
}
|
|
46
|
+
return { body: result, applied };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Renders applied edits as a compact per-edit diff (`-`/`+` lines, aider/Claude-Code-Edit-tool
|
|
50
|
+
* style) so a calling agent can show the user what changed without re-fetching the old body.
|
|
51
|
+
* Not a true unified diff (no line numbers/hunk headers) — deliberately, since this is for
|
|
52
|
+
* human-readable display, not for feeding back into another patch tool.
|
|
53
|
+
*/
|
|
54
|
+
export function formatBodyEditsDiff(applied) {
|
|
55
|
+
return applied
|
|
56
|
+
.map((edit, i) => {
|
|
57
|
+
const suffix = edit.occurrences > 1 ? ` (${edit.occurrences} occurrences replaced)` : '';
|
|
58
|
+
const removed = edit.find
|
|
59
|
+
.split('\n')
|
|
60
|
+
.map((line) => `-${line}`)
|
|
61
|
+
.join('\n');
|
|
62
|
+
const added = edit.replace
|
|
63
|
+
.split('\n')
|
|
64
|
+
.map((line) => `+${line}`)
|
|
65
|
+
.join('\n');
|
|
66
|
+
return `--- edit ${i + 1}${suffix} ---\n${removed}\n${added}`;
|
|
67
|
+
})
|
|
68
|
+
.join('\n');
|
|
69
|
+
}
|
|
70
|
+
function countOccurrences(haystack, needle) {
|
|
71
|
+
if (needle.length === 0)
|
|
72
|
+
return 0;
|
|
73
|
+
let count = 0;
|
|
74
|
+
let idx = 0;
|
|
75
|
+
while ((idx = haystack.indexOf(needle, idx)) !== -1) {
|
|
76
|
+
count++;
|
|
77
|
+
idx += needle.length;
|
|
78
|
+
}
|
|
79
|
+
return count;
|
|
80
|
+
}
|
|
81
|
+
function replaceFirst(haystack, needle, replacement) {
|
|
82
|
+
const idx = haystack.indexOf(needle);
|
|
83
|
+
return idx === -1 ? haystack : haystack.slice(0, idx) + replacement + haystack.slice(idx + needle.length);
|
|
84
|
+
}
|
|
85
|
+
function truncate(s, max = 120) {
|
|
86
|
+
return s.length > max ? `${s.slice(0, max)}…` : s;
|
|
87
|
+
}
|
|
@@ -5,6 +5,7 @@ import { assertValidSkillName } from '../store/skill-name.js';
|
|
|
5
5
|
import { resolveWithinBase } from '../store/safe-path.js';
|
|
6
6
|
import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSpec } from '../store/sync.js';
|
|
7
7
|
import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
|
|
8
|
+
import { applyBodyEdits } from '../shared/body-edits.js';
|
|
8
9
|
function rowToDoc(row) {
|
|
9
10
|
return {
|
|
10
11
|
name: row.id,
|
|
@@ -221,7 +222,7 @@ export class SkillRepository {
|
|
|
221
222
|
isBuiltin(doc) {
|
|
222
223
|
return doc.folder === this.folders[0]?.name;
|
|
223
224
|
}
|
|
224
|
-
update(name, frontmatter, body) {
|
|
225
|
+
update(name, frontmatter, body, bodyEdits) {
|
|
225
226
|
const existing = this.get(name);
|
|
226
227
|
if (!existing)
|
|
227
228
|
throw new Error(`skill with name "${name}" not found`);
|
|
@@ -243,7 +244,7 @@ export class SkillRepository {
|
|
|
243
244
|
extends: frontmatter?.extends !== undefined ? frontmatter.extends : existing.metadata.extends,
|
|
244
245
|
},
|
|
245
246
|
};
|
|
246
|
-
const newBody = body ?? existing.body;
|
|
247
|
+
const newBody = bodyEdits ? applyBodyEdits(existing.body, bodyEdits).body : (body ?? existing.body);
|
|
247
248
|
writeMarkdownFile(existing.source_path, stripSourcePath(merged), newBody);
|
|
248
249
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
249
250
|
return { ...merged, body: newBody, paused: existingPaused };
|
package/dist/src/skills/tools.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import { statusSchema } from '../shared/status.js';
|
|
3
|
+
import { bodyEditsSchema, applyBodyEdits, formatBodyEditsDiff } from '../shared/body-edits.js';
|
|
3
4
|
const SKILL_STATUS_DEFAULTS = ['stable', 'beta', 'unreviewed'];
|
|
4
5
|
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)';
|
|
5
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.";
|
|
@@ -119,10 +120,11 @@ export function registerSkillTools(mcp, repo) {
|
|
|
119
120
|
})));
|
|
120
121
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
121
122
|
});
|
|
122
|
-
mcp.tool('skill_update', `Edits an existing skill in place — frontmatter fields and/or body. Only provided fields change. Use skill_rename to change the name/folder. ${AUTHORING_SKILL_HINT}`, {
|
|
123
|
+
mcp.tool('skill_update', `Edits an existing skill in place — frontmatter fields and/or body. Only provided fields change. Use skill_rename to change the name/folder. For a body change smaller than the whole document, prefer body_edits over body: it patches via find/replace instead of requiring you to reproduce the entire body, which saves tokens and avoids accidentally dropping untouched content on a large skill. When body_edits is used, the response includes a \`diff\` field (compact -/+ per-edit summary) — show it to the user so they can see what changed, similar to a code diff view. The response never includes the full body (even on a full-body replacement) to avoid echoing back a potentially large doc — call skill_get(name) if you need the fresh full body. ${AUTHORING_SKILL_HINT}`, {
|
|
123
124
|
name: z.string(),
|
|
124
125
|
description: z.string().max(1024).optional(),
|
|
125
|
-
body: z.string().optional(),
|
|
126
|
+
body: z.string().optional().describe('full body replacement — omit in favor of body_edits when only part of the doc is changing'),
|
|
127
|
+
body_edits: bodyEditsSchema.optional(),
|
|
126
128
|
license: z.string().optional(),
|
|
127
129
|
compatibility: z.string().max(500).optional(),
|
|
128
130
|
owner: z.string().optional(),
|
|
@@ -131,10 +133,27 @@ export function registerSkillTools(mcp, repo) {
|
|
|
131
133
|
trigger_phrases: z.array(z.string()).optional(),
|
|
132
134
|
extends: z.string().optional(),
|
|
133
135
|
deprecated: z.boolean().optional().describe('marks the skill as deprecated (or un-deprecates when false) — independent of status'),
|
|
134
|
-
}, async ({ name, body, ...frontmatterFields }) => {
|
|
136
|
+
}, async ({ name, body, body_edits, ...frontmatterFields }) => {
|
|
137
|
+
if (body !== undefined && body_edits !== undefined) {
|
|
138
|
+
return { content: [{ type: 'text', text: 'Pass either body or body_edits, not both.' }], isError: true };
|
|
139
|
+
}
|
|
135
140
|
try {
|
|
141
|
+
let diff;
|
|
142
|
+
if (body_edits) {
|
|
143
|
+
const existing = repo.get(name);
|
|
144
|
+
if (!existing)
|
|
145
|
+
throw new Error(`skill with name "${name}" not found`);
|
|
146
|
+
const { body: patchedBody, applied } = applyBodyEdits(existing.body, body_edits);
|
|
147
|
+
diff = formatBodyEditsDiff(applied);
|
|
148
|
+
body = patchedBody;
|
|
149
|
+
}
|
|
136
150
|
const doc = repo.update(name, frontmatterFields, body);
|
|
137
|
-
|
|
151
|
+
// Body is omitted from the response: the caller either just sent it (full replacement),
|
|
152
|
+
// already has it, or has `diff` — echoing a potentially large body back is pure waste.
|
|
153
|
+
// Fetch skill_get(name) if the fresh full body is actually needed.
|
|
154
|
+
const { body: _omitted, ...docWithoutBody } = doc;
|
|
155
|
+
const result = diff ? { ...docWithoutBody, diff } : docWithoutBody;
|
|
156
|
+
return { content: [{ type: 'text', text: JSON.stringify(result, null, 2) }] };
|
|
138
157
|
}
|
|
139
158
|
catch (err) {
|
|
140
159
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.5",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "MCP server exposing skill_* (reusable coding patterns) and memory_* (point-in-time working context) tools over a markdown+frontmatter source, cached into SQLite at runtime.",
|
|
6
6
|
"repository": {
|
|
@@ -39,6 +39,7 @@
|
|
|
39
39
|
"better-sqlite3": "^11.8.0",
|
|
40
40
|
"chokidar": "^4.0.0",
|
|
41
41
|
"express": "^4.21.0",
|
|
42
|
+
"fastest-levenshtein": "^1.0.16",
|
|
42
43
|
"gray-matter": "^4.0.3",
|
|
43
44
|
"lit": "^3.3.3",
|
|
44
45
|
"marked": "^18.0.10",
|