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 };
|
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 };
|
|
@@ -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": {
|