mcp-memory-bucket 0.10.10 → 0.10.11
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.
|
@@ -10,7 +10,7 @@ import { applyBodyEdits } from '../shared/body-edits.js';
|
|
|
10
10
|
import { attachmentsDirFor } from '../attachments/storage.js';
|
|
11
11
|
import { rebaseFolderPath } from '../config.js';
|
|
12
12
|
import { normalizeKey } from '../types.js';
|
|
13
|
-
import { readFile as readRemoteFile, writeFile as writeRemoteFile, writeBinaryFile as writeRemoteBinaryFile, renameFile as renameRemoteFile, trashFile as trashRemoteFile, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
|
|
13
|
+
import { readFile as readRemoteFile, writeFile as writeRemoteFile, writeBinaryFile as writeRemoteBinaryFile, renameFile as renameRemoteFile, trashFile as trashRemoteFile, joinRemoteFolderPath, assertRemoteFolderExists, FolderfooRequestError } from '../remote/folderfoo-client.js';
|
|
14
14
|
import { isFolderVisible } from '../remote/identity.js';
|
|
15
15
|
import { writeRemoteThenLocal } from '../remote/write-order.js';
|
|
16
16
|
/** Uppercases and strips everything but letters/digits — used to compare keys that differ only in
|
|
@@ -169,6 +169,18 @@ export class MemoryRepository {
|
|
|
169
169
|
}
|
|
170
170
|
throw new Error(`multiple memory folders configured — specify folder: one of ${this.folders.map((f) => f.name).join(', ')}`);
|
|
171
171
|
}
|
|
172
|
+
/**
|
|
173
|
+
* Validates a caller-supplied `folder` filter (getByKey/getByFilenameContains/search) against the
|
|
174
|
+
* configured folder list, throwing the same "unknown memory folder" error resolveFolder() uses for
|
|
175
|
+
* writes. Without this, an unrecognized folder name (typo, stale name from a since-removed folder,
|
|
176
|
+
* or an outright made-up one) silently matched zero rows via `folder = ?` in SQL instead of erroring
|
|
177
|
+
* — indistinguishable from "folder is real but has no matches".
|
|
178
|
+
*/
|
|
179
|
+
assertKnownFolder(folderName) {
|
|
180
|
+
if (!this.folders.some((f) => f.name === folderName)) {
|
|
181
|
+
throw new Error(`unknown memory folder "${folderName}" — valid folders: ${this.folders.map((f) => f.name).join(', ') || '(none configured)'}`);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
172
184
|
/**
|
|
173
185
|
* Registers a new REMOTE (folderfoo) folder: creates its local mirror
|
|
174
186
|
* directory, registers it exactly like a local addFolder (so it starts
|
|
@@ -245,6 +257,7 @@ export class MemoryRepository {
|
|
|
245
257
|
params.push(docType);
|
|
246
258
|
}
|
|
247
259
|
if (opts.folder) {
|
|
260
|
+
this.assertKnownFolder(opts.folder);
|
|
248
261
|
conditions.push('folder = ?');
|
|
249
262
|
params.push(opts.folder);
|
|
250
263
|
}
|
|
@@ -281,6 +294,7 @@ export class MemoryRepository {
|
|
|
281
294
|
params.push(docType);
|
|
282
295
|
}
|
|
283
296
|
if (opts.folder) {
|
|
297
|
+
this.assertKnownFolder(opts.folder);
|
|
284
298
|
conditions.push('folder = ?');
|
|
285
299
|
params.push(opts.folder);
|
|
286
300
|
}
|
|
@@ -317,6 +331,7 @@ export class MemoryRepository {
|
|
|
317
331
|
params.push(status);
|
|
318
332
|
}
|
|
319
333
|
if (folder) {
|
|
334
|
+
this.assertKnownFolder(folder);
|
|
320
335
|
conditions.push('m.folder = ?');
|
|
321
336
|
params.push(folder);
|
|
322
337
|
}
|
|
@@ -382,7 +397,21 @@ export class MemoryRepository {
|
|
|
382
397
|
// blob, nesting one level deeper on every single edit (confirmed via a
|
|
383
398
|
// real corrupted doc: 3 levels of self-nested frontmatter+body after 3
|
|
384
399
|
// edits). Must parse it exactly like a local file read would.
|
|
385
|
-
|
|
400
|
+
let raw;
|
|
401
|
+
try {
|
|
402
|
+
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name);
|
|
403
|
+
}
|
|
404
|
+
catch (err) {
|
|
405
|
+
// Also try the LEGACY extensionless remote name — a doc pushed before the
|
|
406
|
+
// extension-preserving fix still sits on folderfoo under its bare id (no ".md") and always
|
|
407
|
+
// will, until it's renamed/re-saved (same tolerance reconcileDeletions already applies via
|
|
408
|
+
// remoteFilename.toLocal/toRemote — see remote-sync.ts). Only retry on a 404, never for a
|
|
409
|
+
// FolderfooAuthError or any other failure, which must surface as-is.
|
|
410
|
+
if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !name.endsWith('.md'))
|
|
411
|
+
throw err;
|
|
412
|
+
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name.slice(0, -3));
|
|
413
|
+
}
|
|
414
|
+
const liveBody = matter(raw).content.trim();
|
|
386
415
|
return { ...doc, body: liveBody };
|
|
387
416
|
}
|
|
388
417
|
/** Fetches many memory docs by (folder, filename) in one call — e.g. hydrating full bodies for a batch of search() hits. Missing docs are simply absent from the result, not errors. */
|
|
@@ -391,9 +420,16 @@ export class MemoryRepository {
|
|
|
391
420
|
return docs.filter((doc) => doc !== null);
|
|
392
421
|
}
|
|
393
422
|
listKeys(keyPrefix) {
|
|
423
|
+
const conditions = ['paused = 0'];
|
|
424
|
+
const params = [];
|
|
425
|
+
const hidden = this.hiddenFolderNames();
|
|
426
|
+
if (hidden.length > 0) {
|
|
427
|
+
conditions.push(`folder NOT IN (${hidden.map(() => '?').join(', ')})`);
|
|
428
|
+
params.push(...hidden);
|
|
429
|
+
}
|
|
394
430
|
const rows = this.db
|
|
395
|
-
.prepare(`SELECT key, COUNT(*) as doc_count FROM memory_docs GROUP BY key ORDER BY key`)
|
|
396
|
-
.all();
|
|
431
|
+
.prepare(`SELECT key, COUNT(*) as doc_count FROM memory_docs WHERE ${conditions.join(' AND ')} GROUP BY key ORDER BY key`)
|
|
432
|
+
.all(...params);
|
|
397
433
|
const prefix = keyPrefix ? normalizeKey(keyPrefix) : undefined;
|
|
398
434
|
return rows
|
|
399
435
|
.filter((r) => !prefix || r.key.startsWith(prefix))
|
|
@@ -647,7 +683,19 @@ export class MemoryRepository {
|
|
|
647
683
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(oldRelPath));
|
|
648
684
|
const oldName = path.basename(oldRelPath);
|
|
649
685
|
const newName = path.basename(newPath);
|
|
650
|
-
|
|
686
|
+
try {
|
|
687
|
+
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName);
|
|
688
|
+
}
|
|
689
|
+
catch (err) {
|
|
690
|
+
// Same legacy-extensionless fallback as get() above: a doc pushed before the
|
|
691
|
+
// extension-preserving fix still sits on folderfoo under its bare id (no ".md"), so
|
|
692
|
+
// renaming by the .md-suffixed name 404s even though the doc reads back fine via get()'s
|
|
693
|
+
// own fallback. Retry once against the bare name — only on a 404, never for any other
|
|
694
|
+
// failure, which must surface as-is.
|
|
695
|
+
if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !oldName.endsWith('.md'))
|
|
696
|
+
throw err;
|
|
697
|
+
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName.slice(0, -3), newName);
|
|
698
|
+
}
|
|
651
699
|
}, () => {
|
|
652
700
|
fs.renameSync(existing.source_path, newPath);
|
|
653
701
|
if (fs.existsSync(oldAttachmentsWrapper)) {
|
|
@@ -6,6 +6,15 @@ export class FolderfooAuthError extends Error {
|
|
|
6
6
|
this.name = 'FolderfooAuthError';
|
|
7
7
|
}
|
|
8
8
|
}
|
|
9
|
+
/** Thrown for any non-ok, non-401 folderfoo response — carries the HTTP status so callers can distinguish e.g. a 404 (file genuinely absent) from other failures without string-matching the message. */
|
|
10
|
+
export class FolderfooRequestError extends Error {
|
|
11
|
+
status;
|
|
12
|
+
constructor(status, message) {
|
|
13
|
+
super(`folderfoo request failed (${status}): ${message}`);
|
|
14
|
+
this.status = status;
|
|
15
|
+
this.name = 'FolderfooRequestError';
|
|
16
|
+
}
|
|
17
|
+
}
|
|
9
18
|
export async function login(server, username, password) {
|
|
10
19
|
const res = await fetch(`${server}/login`, {
|
|
11
20
|
method: 'POST',
|
|
@@ -79,7 +88,7 @@ async function withAuth(server, baseDir, call, parse) {
|
|
|
79
88
|
}
|
|
80
89
|
if (!res.ok) {
|
|
81
90
|
const body = await res.json().catch(() => ({}));
|
|
82
|
-
throw new
|
|
91
|
+
throw new FolderfooRequestError(res.status, body.error ?? res.statusText);
|
|
83
92
|
}
|
|
84
93
|
return parse(res);
|
|
85
94
|
}
|
|
@@ -236,7 +236,17 @@ export function startRemotePolling(db, spec, remoteFolders, credentialsBaseDir,
|
|
|
236
236
|
// above this module (server.ts, once MemoryRepository/SkillRepository/AttachmentRepository all
|
|
237
237
|
// exist) run its own post-sync work without remote-sync.ts needing to depend on those repos
|
|
238
238
|
// itself — see healAttachmentsAfterSync's doc comment for what server.ts actually wires in here.
|
|
239
|
-
onSynced
|
|
239
|
+
onSynced,
|
|
240
|
+
// True when `folder` should actually be reached over the network right now — server.ts wires this
|
|
241
|
+
// to isFolderVisible(folder, identity.current()). A folder connected under a DIFFERENT mode/user
|
|
242
|
+
// than the one currently logged in (e.g. a `dev` folder left over from a previous
|
|
243
|
+
// --folderfoo-mode session, while this run is `cloud`) is skipped entirely here, not just hidden
|
|
244
|
+
// from tools afterward — without this, every interval tick, the startup resyncAll, and the web
|
|
245
|
+
// UI's "resync all" button would all still try to reach that folder's server and throw a raw
|
|
246
|
+
// fetch/ECONNREFUSED (or worse, succeed against a DIFFERENT server that happens to be listening on
|
|
247
|
+
// that host:port right now) for a source nothing can currently see anyway. Defaults to "always
|
|
248
|
+
// visible" so existing callers/tests that don't care about identity keep working unchanged.
|
|
249
|
+
isVisible = () => true) {
|
|
240
250
|
const byName = new Map(remoteFolders.map((f) => [f.name, f]));
|
|
241
251
|
async function pollAndNotify(folder, options) {
|
|
242
252
|
await pollOne(db, spec, folder, credentialsBaseDir, options);
|
|
@@ -244,6 +254,8 @@ onSynced) {
|
|
|
244
254
|
}
|
|
245
255
|
const interval = setInterval(() => {
|
|
246
256
|
for (const folder of remoteFolders) {
|
|
257
|
+
if (!isVisible(folder))
|
|
258
|
+
continue;
|
|
247
259
|
pollAndNotify(folder).catch((err) => console.error(`[memory-bucket] remote poll failed for ${folder.name}:`, err));
|
|
248
260
|
}
|
|
249
261
|
}, POLL_INTERVAL_MS);
|
|
@@ -256,10 +268,14 @@ onSynced) {
|
|
|
256
268
|
const folder = byName.get(folderName);
|
|
257
269
|
if (!folder)
|
|
258
270
|
throw new Error(`no remote source configured with name "${folderName}"`);
|
|
271
|
+
if (!isVisible(folder))
|
|
272
|
+
throw new Error(`remote source "${folderName}" is not visible under the current login — reconnect or log in as the identity it was connected under`);
|
|
259
273
|
await pollAndNotify(folder, { force: true });
|
|
260
274
|
},
|
|
261
275
|
resyncAll: async () => {
|
|
262
276
|
for (const folder of remoteFolders) {
|
|
277
|
+
if (!isVisible(folder))
|
|
278
|
+
continue;
|
|
263
279
|
await pollAndNotify(folder, { force: true });
|
|
264
280
|
}
|
|
265
281
|
},
|
package/dist/src/server.js
CHANGED
|
@@ -86,23 +86,31 @@ const attachmentRepo = new AttachmentRepository(memoryRepo, skillRepo, db);
|
|
|
86
86
|
// comment for why this lives here (needs memoryRepo/skillRepo, which remote-sync.ts itself doesn't
|
|
87
87
|
// depend on) rather than inside pollOne/reconcileDeletions.
|
|
88
88
|
//
|
|
89
|
-
//
|
|
90
|
-
//
|
|
91
|
-
//
|
|
92
|
-
//
|
|
93
|
-
// now, so without this check, repairUnlistedInFolder would sweep a currently-invisible folder's
|
|
94
|
-
// docs, call skillRepo.get()/memoryRepo.get() on them, and get a spurious "not found" per doc —
|
|
95
|
-
// not a real bug, just this visibility rule surfacing as a scary per-doc error instead of a no-op.
|
|
89
|
+
// startRemotePolling below only ever calls onSynced for a folder that passed its own isVisible
|
|
90
|
+
// check first (isRemoteFolderVisible), so this never runs for a folder connected under a
|
|
91
|
+
// different identity than the one currently logged in — repairUnlistedInFolder can safely call
|
|
92
|
+
// skillRepo.get()/memoryRepo.get() without a spurious "not found" from a currently-invisible doc.
|
|
96
93
|
function onAttachmentSync(table, folder) {
|
|
97
|
-
if (!isFolderVisible(folder, identity.current()))
|
|
98
|
-
return;
|
|
99
94
|
attachmentRepo.repairUnlistedInFolder(table, folder.name).catch((err) => console.error(`[memory-bucket] failed to repair attachments for folder "${folder.name}":`, err));
|
|
100
95
|
}
|
|
101
|
-
|
|
102
|
-
|
|
96
|
+
// folderfoo integration off means nobody can ever be logged in this process (identity.mode stays
|
|
97
|
+
// 'off'), so every remote folder is permanently invisible per isFolderVisible/matchesCurrentIdentity
|
|
98
|
+
// above regardless of polling - polling them anyway just hits a folderfoo host that was never meant
|
|
99
|
+
// to be reachable this run (e.g. a `dev` host from a previous --folderfoo-mode session) and throws a
|
|
100
|
+
// raw fetch/ECONNREFUSED error on every resync, since pollOne only swallows FolderfooAuthError.
|
|
101
|
+
// Gates the actual network poll, not just the post-sync attachment repair (onAttachmentSync
|
|
102
|
+
// above already had this check, but only for its own callback — pollOne itself ran regardless).
|
|
103
|
+
// A folder connected under a different --folderfoo-mode/identity than the one currently logged in
|
|
104
|
+
// (e.g. a leftover `dev` source from a prior session while this run is `cloud`) is invisible to
|
|
105
|
+
// every tool already; without this it was still polled every interval tick and on every
|
|
106
|
+
// resyncAll, hitting whatever host:port it points at (often nothing, hence the raw
|
|
107
|
+
// fetch/ECONNREFUSED) for a source nothing can currently see anyway.
|
|
108
|
+
const isRemoteFolderVisible = (folder) => isFolderVisible(folder, identity.current());
|
|
109
|
+
const remoteSkillPoller = config.folderfooMode !== 'off' && config.remoteSkillFolders.length > 0
|
|
110
|
+
? startRemotePolling(db, skillSpec, config.remoteSkillFolders, config.baseDir, (folder) => onAttachmentSync('skills', folder), isRemoteFolderVisible)
|
|
103
111
|
: undefined;
|
|
104
|
-
const remoteMemoryPoller = config.remoteMemoryFolders.length > 0
|
|
105
|
-
? startRemotePolling(db, memorySpec, config.remoteMemoryFolders, config.baseDir, (folder) => onAttachmentSync('memory_docs', folder))
|
|
112
|
+
const remoteMemoryPoller = config.folderfooMode !== 'off' && config.remoteMemoryFolders.length > 0
|
|
113
|
+
? startRemotePolling(db, memorySpec, config.remoteMemoryFolders, config.baseDir, (folder) => onAttachmentSync('memory_docs', folder), isRemoteFolderVisible)
|
|
106
114
|
: undefined;
|
|
107
115
|
// Forces one immediate poll of every remote source at process start, instead
|
|
108
116
|
// of leaving the cache to show whatever was last synced until the first
|
|
@@ -169,8 +177,9 @@ app.listen(PORT, () => {
|
|
|
169
177
|
console.error(`[memory-bucket] skill folders: ${config.skillFolders.map((f) => `${f.name}=${f.path}`).join(', ') || '(none)'}`);
|
|
170
178
|
console.error(`[memory-bucket] memory folders: ${config.memoryFolders.map((f) => `${f.name}=${f.path}`).join(', ') || '(none)'}`);
|
|
171
179
|
if (config.remoteSkillFolders.length > 0 || config.remoteMemoryFolders.length > 0) {
|
|
172
|
-
|
|
173
|
-
console.error(`[memory-bucket] remote (folderfoo)
|
|
180
|
+
const suffix = config.folderfooMode === 'off' ? ' (folderfoo integration off — not synced, ignored)' : '';
|
|
181
|
+
console.error(`[memory-bucket] remote (folderfoo) skill folders${suffix}: ${config.remoteSkillFolders.map((f) => `${f.name}@${f.server}`).join(', ') || '(none)'}`);
|
|
182
|
+
console.error(`[memory-bucket] remote (folderfoo) memory folders${suffix}: ${config.remoteMemoryFolders.map((f) => `${f.name}@${f.server}`).join(', ') || '(none)'}`);
|
|
174
183
|
}
|
|
175
184
|
});
|
|
176
185
|
process.on('SIGINT', () => {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.11",
|
|
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": {
|