mcp-memory-bucket 0.7.4 → 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.
@@ -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 };
@@ -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.4",
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": {