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
- const liveBody = matter(await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name)).content.trim();
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
- await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName);
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 Error(`folderfoo request failed (${res.status}): ${body.error ?? res.statusText}`);
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
  },
@@ -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
- // Skipped entirely for a folder connected under a DIFFERENT identity than the one currently logged
90
- // in (or connected while nobody was logged in) same visibility rule memoryRepo.get()/
91
- // skillRepo.get() already enforce (isFolderVisible). The poller itself has no notion of "current
92
- // identity" and keeps polling every configured remote source regardless of who's logged in right
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
- const remoteSkillPoller = config.remoteSkillFolders.length > 0
102
- ? startRemotePolling(db, skillSpec, config.remoteSkillFolders, config.baseDir, (folder) => onAttachmentSync('skills', folder))
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
- console.error(`[memory-bucket] remote (folderfoo) skill folders: ${config.remoteSkillFolders.map((f) => `${f.name}@${f.server}`).join(', ') || '(none)'}`);
173
- console.error(`[memory-bucket] remote (folderfoo) memory folders: ${config.remoteMemoryFolders.map((f) => `${f.name}@${f.server}`).join(', ') || '(none)'}`);
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.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": {