mcp-memory-bucket 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +20 -0
- package/dist/client/assets/{index-Cr5jNKWL.js → index-BfU_uQyK.js} +397 -262
- package/dist/client/index.html +7 -1
- package/dist/src/attachments/repository.js +22 -22
- package/dist/src/attachments/tools.js +12 -12
- package/dist/src/config.js +70 -3
- package/dist/src/memory/repository.js +142 -39
- package/dist/src/memory/tools.js +32 -13
- package/dist/src/remote/credentials.js +31 -0
- package/dist/src/remote/folderfoo-client.js +149 -0
- package/dist/src/remote/remote-sync.js +176 -0
- package/dist/src/server.js +18 -3
- package/dist/src/shared/body-edits.js +87 -0
- package/dist/src/shared/relocate-tool.js +2 -2
- package/dist/src/shared/relocate.js +9 -5
- package/dist/src/skills/repository.js +141 -47
- package/dist/src/skills/tools.js +34 -15
- package/dist/src/store/sync.js +19 -2
- package/dist/src/web/routes.js +220 -25
- package/package.json +1 -1
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'];
|
|
@@ -19,7 +20,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
19
20
|
return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
|
|
20
21
|
});
|
|
21
22
|
mcp.tool('memory_bulk_get', 'Fetches many memory docs by id in one call, including full markdown bodies — e.g. hydrating a batch of memory_search hits (which return ids, not keys). Missing ids are simply omitted from the result, not errors.', { ids: z.array(z.string()).min(1) }, async ({ ids }) => {
|
|
22
|
-
const docs = repo.bulkGet(ids);
|
|
23
|
+
const docs = await repo.bulkGet(ids);
|
|
23
24
|
return { content: [{ type: 'text', text: JSON.stringify(docs, null, 2) }] };
|
|
24
25
|
});
|
|
25
26
|
mcp.tool('memory_list', 'Browses available memory keys (optionally filtered by a prefix) without needing to know the exact key upfront. Returns each key with its doc count.', { key_prefix: z.string().optional() }, async ({ key_prefix }) => {
|
|
@@ -52,7 +53,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
52
53
|
related_to: z.string().nullable().optional(),
|
|
53
54
|
deprecated: z.boolean().optional().describe('marks docs as deprecated (or un-deprecates when false) — independent of status'),
|
|
54
55
|
}, async ({ ids, add_tags, remove_tags, status, related_to, deprecated }) => {
|
|
55
|
-
const results = repo.bulkUpdate(ids, { add_tags, remove_tags, status, related_to, deprecated });
|
|
56
|
+
const results = await repo.bulkUpdate(ids, { add_tags, remove_tags, status, related_to, deprecated });
|
|
56
57
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
57
58
|
});
|
|
58
59
|
mcp.tool('memory_create', `Writes a new memory doc (plan, spec, SQL, testing notes, discovery, etc.) into the memory folder under the given key. ${AUTHORING_SKILL_HINT}`, {
|
|
@@ -73,7 +74,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
73
74
|
const nearDuplicate = repo
|
|
74
75
|
.suggestKeys(key, 3)
|
|
75
76
|
.find((m) => stripKey(m.key) === strippedNew && m.key !== normalized);
|
|
76
|
-
const doc = repo.create({ key, key_type, doc_type, description, body, tags, status, related_to, subfolder, folder });
|
|
77
|
+
const doc = await repo.create({ key, key_type, doc_type, description, body, tags, status, related_to, subfolder, folder });
|
|
77
78
|
const result = { ...doc };
|
|
78
79
|
if (nearDuplicate) {
|
|
79
80
|
result.key_warning = `A similarly-formatted key "${nearDuplicate.key}" already exists with ${nearDuplicate.docCount} doc(s) — did you mean to use that key instead of "${normalized}"? This doc was still created under "${normalized}".`;
|
|
@@ -97,24 +98,42 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
97
98
|
...(multiFolder ? { folder: z.string().describe(`which configured memory folder to write into: ${folderNames}`) } : {}),
|
|
98
99
|
});
|
|
99
100
|
mcp.tool('memory_bulk_create', `Writes many memory docs in one call — each entry is the same shape as memory_create's args. Returns per-key success/failure (with the new id on success) so one bad entry doesn't abort the rest of the batch. ${AUTHORING_SKILL_HINT}`, { entries: z.array(memoryEntrySchema).min(1) }, async ({ entries }) => {
|
|
100
|
-
const results = repo.bulkCreate(entries);
|
|
101
|
+
const results = await 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 {
|
|
116
|
-
|
|
117
|
-
|
|
121
|
+
let diff;
|
|
122
|
+
if (body_edits) {
|
|
123
|
+
const existing = await 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
|
+
}
|
|
130
|
+
const doc = await repo.update(id, frontmatterFields, body);
|
|
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 };
|
|
@@ -124,12 +143,12 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
124
143
|
ids: z.array(z.string()).min(1),
|
|
125
144
|
paused: z.boolean().describe('true to pause (hide from memory_get/memory_search), false to resume'),
|
|
126
145
|
}, async ({ ids, paused }) => {
|
|
127
|
-
const results = repo.setPaused(ids, paused);
|
|
146
|
+
const results = await repo.setPaused(ids, paused);
|
|
128
147
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
129
148
|
});
|
|
130
149
|
mcp.tool('memory_delete', 'Hard-deletes a memory doc by id — removes the markdown file, no tombstone.', { id: z.string() }, async ({ id }) => {
|
|
131
150
|
try {
|
|
132
|
-
repo.delete(id);
|
|
151
|
+
await repo.delete(id);
|
|
133
152
|
return { content: [{ type: 'text', text: `Deleted memory doc "${id}"` }] };
|
|
134
153
|
}
|
|
135
154
|
catch (err) {
|
|
@@ -137,7 +156,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
137
156
|
}
|
|
138
157
|
});
|
|
139
158
|
mcp.tool('memory_bulk_delete', 'Hard-deletes many memory docs by id in one call — e.g. cleaning up a batch of abandoned docs found via memory_search. No tombstone. Returns per-id success/failure so one bad id doesn\'t abort the rest of the batch.', { ids: z.array(z.string()).min(1) }, async ({ ids }) => {
|
|
140
|
-
const results = repo.bulkDelete(ids);
|
|
159
|
+
const results = await repo.bulkDelete(ids);
|
|
141
160
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
142
161
|
});
|
|
143
162
|
mcp.tool('memory_save_session', `Saves a summary of the current chat session as a memory doc (doc_type "session-summary"). Pass a summary, not a raw transcript. If key or description are omitted, ask the user for them rather than guessing. ${AUTHORING_SKILL_HINT}`, {
|
|
@@ -160,7 +179,7 @@ export function registerMemoryTools(mcp, repo) {
|
|
|
160
179
|
};
|
|
161
180
|
}
|
|
162
181
|
try {
|
|
163
|
-
const doc = repo.create({
|
|
182
|
+
const doc = await repo.create({
|
|
164
183
|
key,
|
|
165
184
|
key_type: /^[A-Z]+-\d+$/i.test(key) ? 'ticket' : 'freeform',
|
|
166
185
|
doc_type: 'session-summary',
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
function credentialsPath(baseDir) {
|
|
4
|
+
return path.join(baseDir, '.memory-bucket-credentials.json');
|
|
5
|
+
}
|
|
6
|
+
function readStore(baseDir) {
|
|
7
|
+
const file = credentialsPath(baseDir);
|
|
8
|
+
if (!fs.existsSync(file))
|
|
9
|
+
return {};
|
|
10
|
+
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
11
|
+
}
|
|
12
|
+
function writeStore(baseDir, store) {
|
|
13
|
+
// 0600: this file holds a live JWT, so it shouldn't be group/world-
|
|
14
|
+
// readable the way a config file might reasonably be.
|
|
15
|
+
fs.writeFileSync(credentialsPath(baseDir), JSON.stringify(store, null, 2) + '\n', { mode: 0o600 });
|
|
16
|
+
}
|
|
17
|
+
export function getCredential(baseDir, server) {
|
|
18
|
+
return readStore(baseDir)[server];
|
|
19
|
+
}
|
|
20
|
+
export function setCredential(baseDir, server, jwt) {
|
|
21
|
+
const store = readStore(baseDir);
|
|
22
|
+
store[server] = { jwt };
|
|
23
|
+
writeStore(baseDir, store);
|
|
24
|
+
}
|
|
25
|
+
export function clearCredential(baseDir, server) {
|
|
26
|
+
const store = readStore(baseDir);
|
|
27
|
+
if (!(server in store))
|
|
28
|
+
return;
|
|
29
|
+
delete store[server];
|
|
30
|
+
writeStore(baseDir, store);
|
|
31
|
+
}
|
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { getCredential, setCredential, clearCredential } from './credentials.js';
|
|
2
|
+
/** Thrown when a folderfoo call fails auth (expired/invalid token, refresh also failed). Callers should surface this as "reconnect via the web UI", not retry silently. */
|
|
3
|
+
export class FolderfooAuthError extends Error {
|
|
4
|
+
constructor(server) {
|
|
5
|
+
super(`folderfoo session expired for ${server} - reconnect via the web UI`);
|
|
6
|
+
this.name = 'FolderfooAuthError';
|
|
7
|
+
}
|
|
8
|
+
}
|
|
9
|
+
export async function login(server, username, password) {
|
|
10
|
+
const res = await fetch(`${server}/login`, {
|
|
11
|
+
method: 'POST',
|
|
12
|
+
headers: { 'content-type': 'application/json' },
|
|
13
|
+
body: JSON.stringify({ username, password }),
|
|
14
|
+
});
|
|
15
|
+
if (!res.ok) {
|
|
16
|
+
const body = await res.json().catch(() => ({}));
|
|
17
|
+
throw new Error(`folderfoo login failed: ${body.error ?? res.statusText}`);
|
|
18
|
+
}
|
|
19
|
+
const { token } = (await res.json());
|
|
20
|
+
return { jwt: token };
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Reissues a fresh token from the current one - folderfoo's POST /refresh
|
|
24
|
+
* accepts even an expired bearer token (verified server-side with
|
|
25
|
+
* ignoreExpiration: true) and reissues a new one as long as the original
|
|
26
|
+
* login is within its age window (origIat-based). There is no separate
|
|
27
|
+
* refresh-token concept to pass here - see credentials.ts's doc comment.
|
|
28
|
+
*/
|
|
29
|
+
async function refresh(server, jwt) {
|
|
30
|
+
const res = await fetch(`${server}/refresh`, {
|
|
31
|
+
method: 'POST',
|
|
32
|
+
headers: { authorization: `Bearer ${jwt}` },
|
|
33
|
+
});
|
|
34
|
+
if (!res.ok)
|
|
35
|
+
throw new FolderfooAuthError(server);
|
|
36
|
+
const { token } = (await res.json());
|
|
37
|
+
return token;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolves a usable, current JWT for `server`: returns the stored one, or
|
|
41
|
+
* throws FolderfooAuthError if none is stored (the caller must direct the
|
|
42
|
+
* user through the web UI's login flow - this module never prompts for
|
|
43
|
+
* credentials itself). Does NOT proactively refresh; callers hit this once
|
|
44
|
+
* per call and rely on withAuth's 401-retry-once instead, since a token
|
|
45
|
+
* that still works needs no refresh round trip.
|
|
46
|
+
*/
|
|
47
|
+
function requireCredential(server, baseDir) {
|
|
48
|
+
const credential = getCredential(baseDir, server);
|
|
49
|
+
if (!credential)
|
|
50
|
+
throw new FolderfooAuthError(server);
|
|
51
|
+
return credential.jwt;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Wraps one folderfoo call: on a 401, attempts exactly one refresh-and-
|
|
55
|
+
* retry (persisting the refreshed token so subsequent calls reuse it),
|
|
56
|
+
* and on a second 401 (or a refresh failure) clears the stored credential
|
|
57
|
+
* and throws FolderfooAuthError - per the settled design, a failure here
|
|
58
|
+
* must surface loudly, never fall back to stale cached data or queue
|
|
59
|
+
* silently.
|
|
60
|
+
*/
|
|
61
|
+
async function withAuth(server, baseDir, call, parse) {
|
|
62
|
+
const jwt = requireCredential(server, baseDir);
|
|
63
|
+
let res = await call(jwt);
|
|
64
|
+
if (res.status === 401) {
|
|
65
|
+
let refreshed;
|
|
66
|
+
try {
|
|
67
|
+
refreshed = await refresh(server, jwt);
|
|
68
|
+
}
|
|
69
|
+
catch {
|
|
70
|
+
clearCredential(baseDir, server);
|
|
71
|
+
throw new FolderfooAuthError(server);
|
|
72
|
+
}
|
|
73
|
+
setCredential(baseDir, server, refreshed);
|
|
74
|
+
res = await call(refreshed);
|
|
75
|
+
if (res.status === 401) {
|
|
76
|
+
clearCredential(baseDir, server);
|
|
77
|
+
throw new FolderfooAuthError(server);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
if (!res.ok) {
|
|
81
|
+
const body = await res.json().catch(() => ({}));
|
|
82
|
+
throw new Error(`folderfoo request failed (${res.status}): ${body.error ?? res.statusText}`);
|
|
83
|
+
}
|
|
84
|
+
return parse(res);
|
|
85
|
+
}
|
|
86
|
+
function folderQuery(tenantId, folderPath, extra) {
|
|
87
|
+
const params = new URLSearchParams({ folderPath, ...extra });
|
|
88
|
+
return params.toString();
|
|
89
|
+
}
|
|
90
|
+
export async function getLastChanged(server, baseDir, tenantId, folderPath) {
|
|
91
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/last-changed?${folderQuery(tenantId, folderPath)}`, {
|
|
92
|
+
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
93
|
+
}), async (res) => (await res.json()).lastChanged);
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Lists the caller's own folderfoo folders (flat list of full paths, each
|
|
97
|
+
* with a createdAt timestamp - folderfoo's own GET /folders response
|
|
98
|
+
* shape) via folderfoo's existing GET /folders — used by the "connect a
|
|
99
|
+
* folderfoo folder" UI to offer a picker instead of requiring the user to
|
|
100
|
+
* type a raw folder path. Own folders only (no owner param) - browsing
|
|
101
|
+
* INTO a shared folder someone else owns is a separate, not-yet-exposed
|
|
102
|
+
* flow.
|
|
103
|
+
*/
|
|
104
|
+
export async function listFolders(server, baseDir, tenantId) {
|
|
105
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
|
|
106
|
+
}
|
|
107
|
+
export async function getChangedSince(server, baseDir, tenantId, folderPath, since) {
|
|
108
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since) })}`, {
|
|
109
|
+
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
110
|
+
}), async (res) => (await res.json()).files);
|
|
111
|
+
}
|
|
112
|
+
// folders.js's parseFolderFilenameParam (folderfoo's server) is ambiguous
|
|
113
|
+
// for a bare single-segment own-folder path: "work:name" parses as
|
|
114
|
+
// owner="work", not folderPath="work" (a folder path is only
|
|
115
|
+
// unambiguously recognized when it contains "/"). The always-safe form is
|
|
116
|
+
// the explicit-empty-owner 3-part grammar, ":folder/path:name" - folderfoo
|
|
117
|
+
// itself uses this same form in its own sharing tests for exactly this
|
|
118
|
+
// reason. Always use it here rather than only for the single-segment case,
|
|
119
|
+
// so this client doesn't need to special-case folder depth.
|
|
120
|
+
function filenameParam(folderPath, name) {
|
|
121
|
+
return folderPath ? `:${encodeURIComponent(folderPath)}:${name}` : name;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Joins a RemoteFolder's own folderPath (its actual location on folderfoo,
|
|
125
|
+
* e.g. "memz") with a path relative to the LOCAL mirror directory (e.g. a
|
|
126
|
+
* subfolder the user created inside that mirror, or "." for the mirror's
|
|
127
|
+
* own root). Repository call sites must always route through this rather
|
|
128
|
+
* than passing the mirror-relative path alone - a bug where every remote
|
|
129
|
+
* write dropped the RemoteFolder's own folderPath entirely (writing to the
|
|
130
|
+
* user's folderfoo ROOT instead of the connected folder) shipped once
|
|
131
|
+
* already because each of 4 call sites recomputed this by hand.
|
|
132
|
+
*/
|
|
133
|
+
export function joinRemoteFolderPath(remoteFolderPath, mirrorRelativeDir) {
|
|
134
|
+
if (mirrorRelativeDir === '.' || mirrorRelativeDir === '')
|
|
135
|
+
return remoteFolderPath;
|
|
136
|
+
return remoteFolderPath ? `${remoteFolderPath}/${mirrorRelativeDir}` : mirrorRelativeDir;
|
|
137
|
+
}
|
|
138
|
+
/** Reads one file's raw content via GET /data/:filename. */
|
|
139
|
+
export async function readFile(server, baseDir, tenantId, folderPath, name) {
|
|
140
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/data/${filenameParam(folderPath, name)}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.text());
|
|
141
|
+
}
|
|
142
|
+
/** Writes one file's raw content via POST /save/:filename. */
|
|
143
|
+
export async function writeFile(server, baseDir, tenantId, folderPath, name, content) {
|
|
144
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name)}`, {
|
|
145
|
+
method: 'POST',
|
|
146
|
+
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'text/markdown' },
|
|
147
|
+
body: content,
|
|
148
|
+
}), async () => undefined);
|
|
149
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { upsertFile, removeFile, walkMarkdownFiles } from '../store/sync.js';
|
|
4
|
+
import { getLastChanged, getChangedSince, readFile, FolderfooAuthError } from './folderfoo-client.js';
|
|
5
|
+
// Fixed for every remote source in v1 - no per-source tuning knob, per the
|
|
6
|
+
// settled design (Q15 in the grilling session).
|
|
7
|
+
const POLL_INTERVAL_MS = 60_000;
|
|
8
|
+
/**
|
|
9
|
+
* Local, per-source last-synced watermark - NOT the sqlite cache itself
|
|
10
|
+
* (that's a completely separate concept, mtime_ms per cached row). This is
|
|
11
|
+
* a small sidecar file recording "the last folderfoo last-changed value
|
|
12
|
+
* this poller has successfully synced up through", so a restart doesn't
|
|
13
|
+
* re-pull everything from since=0.
|
|
14
|
+
*/
|
|
15
|
+
function watermarkPath(mirrorDir) {
|
|
16
|
+
return path.join(mirrorDir, '.last-synced');
|
|
17
|
+
}
|
|
18
|
+
function readLocalWatermark(mirrorDir) {
|
|
19
|
+
try {
|
|
20
|
+
return Number(fs.readFileSync(watermarkPath(mirrorDir), 'utf-8')) || 0;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return 0;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
function writeLocalWatermark(mirrorDir, value) {
|
|
27
|
+
fs.mkdirSync(mirrorDir, { recursive: true });
|
|
28
|
+
fs.writeFileSync(watermarkPath(mirrorDir), String(value));
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* folderfoo's GET /folders/changed-since (and GET /folders in general)
|
|
32
|
+
* report folderPath ABSOLUTE FROM THE TENANT ROOT (server.js's walk()
|
|
33
|
+
* seeds its relPath accumulator with the queried folderPath itself) - e.g.
|
|
34
|
+
* querying folderPath=memz for a root-level file returns folderPath:
|
|
35
|
+
* "memz", not "". That's a DIFFERENT coordinate system than mirror-
|
|
36
|
+
* relative (where the mirror root already IS memz, so a root-level file's
|
|
37
|
+
* mirror-relative dir is ""). Joining a tenant-root-absolute folderPath
|
|
38
|
+
* directly onto folder.mirrorDir double-nests it (mirrorDir/memz/...
|
|
39
|
+
* instead of mirrorDir/...) - this strips remote.folderPath back off to
|
|
40
|
+
* recover the true mirror-relative subpath. Every caller that turns a
|
|
41
|
+
* changed-since response entry into a local mirror path must go through
|
|
42
|
+
* this, not path.join the raw folderPath directly.
|
|
43
|
+
*/
|
|
44
|
+
function toMirrorRelativeDir(remoteFolderPath, absoluteFolderPath) {
|
|
45
|
+
if (!absoluteFolderPath)
|
|
46
|
+
return '';
|
|
47
|
+
if (absoluteFolderPath === remoteFolderPath)
|
|
48
|
+
return '';
|
|
49
|
+
if (remoteFolderPath && absoluteFolderPath.startsWith(`${remoteFolderPath}/`)) {
|
|
50
|
+
return absoluteFolderPath.slice(remoteFolderPath.length + 1);
|
|
51
|
+
}
|
|
52
|
+
// Shouldn't happen (changed-since was queried with remoteFolderPath, so
|
|
53
|
+
// every entry should be that path or nested under it) - fall back to the
|
|
54
|
+
// raw value rather than silently losing the file, but this indicates a
|
|
55
|
+
// mismatch worth knowing about.
|
|
56
|
+
console.error(`[memory-bucket] unexpected folderPath "${absoluteFolderPath}" from folderfoo outside queried folder "${remoteFolderPath}" - using it as-is`);
|
|
57
|
+
return absoluteFolderPath;
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Reconciles deletions: getChangedSince only reports additions/
|
|
61
|
+
* modifications (files with mtime > since), never "this file no longer
|
|
62
|
+
* exists" - there's no tombstone protocol on the folderfoo side (see the
|
|
63
|
+
* implementation plan's flagged gap). Resolved here via a full-listing
|
|
64
|
+
* comparison instead: ask folderfoo for every file under the folder
|
|
65
|
+
* (changed-since?since=0, which is exactly "everything"), diff that
|
|
66
|
+
* against what's currently mirrored on disk, and remove any mirror file
|
|
67
|
+
* that's no longer in the remote listing. Run once per poll tick
|
|
68
|
+
* alongside the incremental pull, not in place of it - the incremental
|
|
69
|
+
* pull still avoids re-fetching unchanged file CONTENT, this only avoids
|
|
70
|
+
* missing deletions.
|
|
71
|
+
*/
|
|
72
|
+
async function reconcileDeletions(db, spec, folder, credentialsBaseDir) {
|
|
73
|
+
const remoteFiles = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, 0);
|
|
74
|
+
const remoteRelPaths = new Set(remoteFiles.map((f) => {
|
|
75
|
+
const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, f.folderPath);
|
|
76
|
+
return mirrorRelativeDir ? path.join(mirrorRelativeDir, f.name) : f.name;
|
|
77
|
+
}));
|
|
78
|
+
if (!fs.existsSync(folder.mirrorDir))
|
|
79
|
+
return;
|
|
80
|
+
for (const mirrorFilePath of walkMarkdownFiles(folder.mirrorDir)) {
|
|
81
|
+
const relPath = path.relative(folder.mirrorDir, mirrorFilePath);
|
|
82
|
+
// remoteFiles' entries are keyed by folderfoo's own filename grammar
|
|
83
|
+
// (no .md suffix - folderfoo doesn't know about markdown, it stores
|
|
84
|
+
// opaque names), while the mirror stores real .md files so upsertFile/
|
|
85
|
+
// matchesFile work unchanged - strip the extension before comparing.
|
|
86
|
+
const relPathNoExt = relPath.endsWith('.md') ? relPath.slice(0, -3) : relPath;
|
|
87
|
+
if (!remoteRelPaths.has(relPathNoExt)) {
|
|
88
|
+
fs.unlinkSync(mirrorFilePath);
|
|
89
|
+
removeFile(db, spec.table, mirrorFilePath);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
async function pullFile(db, spec, folder, credentialsBaseDir, changedFile) {
|
|
94
|
+
// changedFile.folderPath is tenant-root-absolute (folderfoo's own
|
|
95
|
+
// convention - see toMirrorRelativeDir's doc comment), so it's the
|
|
96
|
+
// correct value to pass straight through to readFile (folderfoo expects
|
|
97
|
+
// that same absolute form for GET /data/:filename), but it must be
|
|
98
|
+
// converted to mirror-relative before joining onto folder.mirrorDir.
|
|
99
|
+
const content = await readFile(folder.server, credentialsBaseDir, folder.tenantId, changedFile.folderPath, changedFile.name);
|
|
100
|
+
const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, changedFile.folderPath);
|
|
101
|
+
const relPath = mirrorRelativeDir ? path.join(mirrorRelativeDir, changedFile.name) : changedFile.name;
|
|
102
|
+
const mirrorPath = path.join(folder.mirrorDir, `${relPath}.md`);
|
|
103
|
+
fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
|
|
104
|
+
fs.writeFileSync(mirrorPath, content);
|
|
105
|
+
// Preserves the remote mtime on the mirror file so upsertFile's own
|
|
106
|
+
// mtime-based skip check (existing.mtime_ms === parsed.mtimeMs) stays
|
|
107
|
+
// meaningful on a later poll tick, rather than every mirrored file
|
|
108
|
+
// always reporting "just now" and getting needlessly reprocessed.
|
|
109
|
+
const mtimeDate = new Date(changedFile.mtime);
|
|
110
|
+
fs.utimesSync(mirrorPath, mtimeDate, mtimeDate);
|
|
111
|
+
if (spec.matchesFile(mirrorPath))
|
|
112
|
+
upsertFile(db, spec, mirrorPath);
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* credentialsBaseDir is the memory-bucket PROCESS's own base directory
|
|
116
|
+
* (where memory-bucket.config.json and .memory-bucket-credentials.json
|
|
117
|
+
* live) - deliberately NOT folder.mirrorDir, which is just this one
|
|
118
|
+
* source's local content cache. All remote sources share one credentials
|
|
119
|
+
* file at credentialsBaseDir, keyed by server URL (see credentials.ts).
|
|
120
|
+
*/
|
|
121
|
+
export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}) {
|
|
122
|
+
try {
|
|
123
|
+
const lastChanged = await getLastChanged(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath);
|
|
124
|
+
const localWatermark = readLocalWatermark(folder.mirrorDir);
|
|
125
|
+
// force (manual resync, or rebuild-cache) always does real work,
|
|
126
|
+
// including reconcileDeletions - a user explicitly asking to resync
|
|
127
|
+
// shouldn't silently no-op just because folderfoo's watermark happens
|
|
128
|
+
// to equal what we last saw; the whole point of a manual resync is to
|
|
129
|
+
// re-verify against the live state, not trust the cheap check alone.
|
|
130
|
+
if (!options.force && lastChanged <= localWatermark)
|
|
131
|
+
return; // cheap path: nothing changed since our last sync, no listing call
|
|
132
|
+
const changed = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, localWatermark);
|
|
133
|
+
for (const file of changed) {
|
|
134
|
+
await pullFile(db, spec, folder, credentialsBaseDir, file);
|
|
135
|
+
}
|
|
136
|
+
await reconcileDeletions(db, spec, folder, credentialsBaseDir);
|
|
137
|
+
writeLocalWatermark(folder.mirrorDir, lastChanged);
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
if (err instanceof FolderfooAuthError) {
|
|
141
|
+
// Per the settled design: fail loudly per-call, but a poll tick
|
|
142
|
+
// failing auth shouldn't crash the whole poller loop - the next
|
|
143
|
+
// tick tries again (and any live skill_get/memory_get call against
|
|
144
|
+
// this source will surface its own FolderfooAuthError directly to
|
|
145
|
+
// the caller regardless of poller state).
|
|
146
|
+
console.error(`[memory-bucket] ${err.message}`);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
throw err;
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
export function startRemotePolling(db, spec, remoteFolders, credentialsBaseDir) {
|
|
153
|
+
const byName = new Map(remoteFolders.map((f) => [f.name, f]));
|
|
154
|
+
const interval = setInterval(() => {
|
|
155
|
+
for (const folder of remoteFolders) {
|
|
156
|
+
pollOne(db, spec, folder, credentialsBaseDir).catch((err) => console.error(`[memory-bucket] remote poll failed for ${folder.name}:`, err));
|
|
157
|
+
}
|
|
158
|
+
}, POLL_INTERVAL_MS);
|
|
159
|
+
// Node's default keep-alive behavior would hold the process open just for
|
|
160
|
+
// this interval - unref so remote polling never blocks a clean process exit.
|
|
161
|
+
interval.unref();
|
|
162
|
+
return {
|
|
163
|
+
stop: () => clearInterval(interval),
|
|
164
|
+
resyncNow: async (folderName) => {
|
|
165
|
+
const folder = byName.get(folderName);
|
|
166
|
+
if (!folder)
|
|
167
|
+
throw new Error(`no remote source configured with name "${folderName}"`);
|
|
168
|
+
await pollOne(db, spec, folder, credentialsBaseDir, { force: true });
|
|
169
|
+
},
|
|
170
|
+
resyncAll: async () => {
|
|
171
|
+
for (const folder of remoteFolders) {
|
|
172
|
+
await pollOne(db, spec, folder, credentialsBaseDir, { force: true });
|
|
173
|
+
}
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
package/dist/src/server.js
CHANGED
|
@@ -19,6 +19,7 @@ import { buildWebRouter } from './web/routes.js';
|
|
|
19
19
|
import { registerUiTool } from './web/ui-tool.js';
|
|
20
20
|
import { registerMemoryChannelTools } from './channels/tools.js';
|
|
21
21
|
import { startChannelSweep } from './channels/store.js';
|
|
22
|
+
import { startRemotePolling } from './remote/remote-sync.js';
|
|
22
23
|
// server.ts is rebuilt from `buildMcpServer()` on every /mcp request (see below),
|
|
23
24
|
// so tool schemas (which conditionally include `folder` based on folder count) always
|
|
24
25
|
// reflect the current folders — no restart needed after an add/remove-folder call.
|
|
@@ -41,11 +42,17 @@ initialScan(db, skillSpec);
|
|
|
41
42
|
initialScan(db, memorySpec);
|
|
42
43
|
const skillWatcher = watchSources(db, skillSpec);
|
|
43
44
|
const memoryWatcher = watchSources(db, memorySpec);
|
|
44
|
-
const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders]);
|
|
45
|
-
const memoryRepo = new MemoryRepository(db, config.memoryFolders);
|
|
45
|
+
const skillRepo = new SkillRepository(db, [{ name: 'builtin', path: builtinSkillsDir }, ...config.skillFolders], config.remoteSkillFolders, config.baseDir);
|
|
46
|
+
const memoryRepo = new MemoryRepository(db, config.memoryFolders, config.remoteMemoryFolders, config.baseDir);
|
|
46
47
|
skillRepo.setWatcher(skillWatcher);
|
|
47
48
|
memoryRepo.setWatcher(memoryWatcher);
|
|
48
49
|
const attachmentRepo = new AttachmentRepository(memoryRepo, skillRepo);
|
|
50
|
+
// Remote sources have no filesystem to watch (chokidar doesn't apply) - a
|
|
51
|
+
// fixed-interval poller stands in for watchSources above, pulling changes
|
|
52
|
+
// into each remote source's local mirror directory and reusing the exact
|
|
53
|
+
// same upsertFile/removeFile path a local file-watch event would.
|
|
54
|
+
const remoteSkillPoller = config.remoteSkillFolders.length > 0 ? startRemotePolling(db, skillSpec, config.remoteSkillFolders, config.baseDir) : undefined;
|
|
55
|
+
const remoteMemoryPoller = config.remoteMemoryFolders.length > 0 ? startRemotePolling(db, memorySpec, config.remoteMemoryFolders, config.baseDir) : undefined;
|
|
49
56
|
if (config.skillFolders.length === 0 && config.memoryFolders.length === 0) {
|
|
50
57
|
console.error(`[memory-bucket] no folders configured — open http://localhost:${PORT} to add one`);
|
|
51
58
|
}
|
|
@@ -70,7 +77,7 @@ function buildMcpServer() {
|
|
|
70
77
|
startChannelSweep((name) => console.error(`[memory-bucket] sweeping idle memory channel: ${name}`));
|
|
71
78
|
const app = express();
|
|
72
79
|
app.use(express.json());
|
|
73
|
-
app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec));
|
|
80
|
+
app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, { skill: remoteSkillPoller, memory: remoteMemoryPoller }));
|
|
74
81
|
app.use(express.static(path.join(packageRoot, 'dist', 'client')));
|
|
75
82
|
app.post('/mcp', async (req, res) => {
|
|
76
83
|
const server = buildMcpServer();
|
|
@@ -98,10 +105,18 @@ app.delete('/mcp', methodNotAllowed);
|
|
|
98
105
|
app.listen(PORT, () => {
|
|
99
106
|
console.error(`[memory-bucket] MCP server listening on http://localhost:${PORT}/mcp`);
|
|
100
107
|
console.error(`[memory-bucket] UI available at http://localhost:${PORT}`);
|
|
108
|
+
console.error(`[memory-bucket] folderfoo integration: ${config.folderfooMode}${config.folderfooHost ? ` (${config.folderfooHost})` : ''}` +
|
|
109
|
+
(config.folderfooMode === 'off' ? ' — pass --folderfoo-mode dev|cloud (or set FOLDERFOO_MODE) to enable' : ''));
|
|
101
110
|
console.error(`[memory-bucket] skill folders: ${config.skillFolders.map((f) => `${f.name}=${f.path}`).join(', ') || '(none)'}`);
|
|
102
111
|
console.error(`[memory-bucket] memory folders: ${config.memoryFolders.map((f) => `${f.name}=${f.path}`).join(', ') || '(none)'}`);
|
|
112
|
+
if (config.remoteSkillFolders.length > 0 || config.remoteMemoryFolders.length > 0) {
|
|
113
|
+
console.error(`[memory-bucket] remote (folderfoo) skill folders: ${config.remoteSkillFolders.map((f) => `${f.name}@${f.server}`).join(', ') || '(none)'}`);
|
|
114
|
+
console.error(`[memory-bucket] remote (folderfoo) memory folders: ${config.remoteMemoryFolders.map((f) => `${f.name}@${f.server}`).join(', ') || '(none)'}`);
|
|
115
|
+
}
|
|
103
116
|
});
|
|
104
117
|
process.on('SIGINT', () => {
|
|
118
|
+
remoteSkillPoller?.stop();
|
|
119
|
+
remoteMemoryPoller?.stop();
|
|
105
120
|
db.close();
|
|
106
121
|
process.exit(0);
|
|
107
122
|
});
|
|
@@ -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
|
+
}
|
|
@@ -26,7 +26,7 @@ export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
|
|
|
26
26
|
keep_original: z.boolean().optional().describe('if true, copies instead of moving (default: move)'),
|
|
27
27
|
overrides: overridesSchema,
|
|
28
28
|
}, async (opts) => {
|
|
29
|
-
const result = relocate(opts, skillRepo, memoryRepo);
|
|
29
|
+
const result = await relocate(opts, skillRepo, memoryRepo);
|
|
30
30
|
return {
|
|
31
31
|
content: [{ type: 'text', text: JSON.stringify(result, null, 2) }],
|
|
32
32
|
isError: !result.moved,
|
|
@@ -42,7 +42,7 @@ export function registerRelocateTool(mcp, skillRepo, memoryRepo) {
|
|
|
42
42
|
}))
|
|
43
43
|
.min(1),
|
|
44
44
|
}, async ({ entries }) => {
|
|
45
|
-
const results = relocateMany(entries, skillRepo, memoryRepo);
|
|
45
|
+
const results = await relocateMany(entries, skillRepo, memoryRepo);
|
|
46
46
|
return { content: [{ type: 'text', text: JSON.stringify(results, null, 2) }] };
|
|
47
47
|
});
|
|
48
48
|
}
|