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.
@@ -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 };
@@ -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
- return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
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 };
@@ -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 };
@@ -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
- return { content: [{ type: 'text', text: JSON.stringify(doc, null, 2) }] };
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.1",
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",