mcp-memory-bucket 0.10.2 → 0.10.3

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.
@@ -23,10 +23,34 @@ export class AttachmentRepository {
23
23
  await this.skillRepo.update(docIdOrName, { attachments });
24
24
  }
25
25
  }
26
+ /**
27
+ * Pushes an attachment's binary content to folderfoo if the doc lives in a remote folder —
28
+ * no-op for a local folder (both repos' pushAttachmentIfNeeded already guard on that). Callers
29
+ * must roll back their own local file write if this throws, same as skill/memory create() rolls
30
+ * back its own file on a failed remote push.
31
+ */
32
+ async pushAttachmentToRemoteIfNeeded(kind, folder, filePath, mimeType) {
33
+ if (kind === 'memory') {
34
+ await this.memoryRepo.pushAttachmentIfNeeded(folder, filePath, mimeType);
35
+ }
36
+ else {
37
+ await this.skillRepo.pushAttachmentIfNeeded(folder, filePath, mimeType);
38
+ }
39
+ }
26
40
  async add(kind, docIdOrName, filename, data) {
27
41
  const doc = await this.getDoc(kind, docIdOrName);
28
42
  const dir = attachmentsDirFor(doc.source_path, kind);
29
43
  const entry = writeAttachmentFile(dir, filename, data);
44
+ try {
45
+ await this.pushAttachmentToRemoteIfNeeded(kind, doc.folder, path.join(dir, entry.filename), entry.mime_type);
46
+ }
47
+ catch (err) {
48
+ // Mirrors create()'s rollback: remote push failed after the local mirror file was already
49
+ // written — remove the orphaned local file rather than leaving it registered as if it were
50
+ // already synced remotely.
51
+ fs.rmSync(path.join(dir, entry.filename), { force: true });
52
+ throw err;
53
+ }
30
54
  const existing = doc.attachments ?? [];
31
55
  await this.saveAttachmentsList(kind, docIdOrName, [...existing, entry]);
32
56
  return entry;
@@ -44,6 +68,7 @@ export class AttachmentRepository {
44
68
  const safePath = resolveWithinBase(dir, undefined, filename);
45
69
  fs.rmSync(safePath, { force: true });
46
70
  const written = writeAttachmentFile(dir, filename, data);
71
+ await this.pushAttachmentToRemoteIfNeeded(kind, doc.folder, path.join(dir, written.filename), written.mime_type);
47
72
  // Full replace: preserve position, overwrite metadata for this filename entry.
48
73
  const next = (doc.attachments ?? []).map((a) => (a.filename === filename ? written : a));
49
74
  await this.saveAttachmentsList(kind, docIdOrName, next);
@@ -54,6 +79,10 @@ export class AttachmentRepository {
54
79
  const dir = attachmentsDirFor(doc.source_path, kind);
55
80
  const safePath = resolveWithinBase(dir, undefined, filename);
56
81
  fs.rmSync(safePath, { force: true });
82
+ // Does NOT delete the corresponding file on folderfoo — there is no delete endpoint in
83
+ // folderfoo-client.ts, matching the same already-flagged gap on skill/memory doc rename/delete
84
+ // (see SkillRepository.rename()/remove() doc comments). A removed attachment leaves a stale
85
+ // copy on the remote source until folderfoo itself gains a delete API.
57
86
  const next = (doc.attachments ?? []).filter((a) => a.filename !== filename);
58
87
  await this.saveAttachmentsList(kind, docIdOrName, next);
59
88
  if (listAttachmentFiles(dir).length === 0) {
@@ -11,7 +11,7 @@ import { applyBodyEdits } from '../shared/body-edits.js';
11
11
  import { attachmentsDirFor } from '../attachments/storage.js';
12
12
  import { rebaseFolderPath } from '../config.js';
13
13
  import { normalizeKey } from '../types.js';
14
- import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
14
+ import { readFile as readRemoteFile, writeFile as writeRemoteFile, writeBinaryFile as writeRemoteBinaryFile, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
15
15
  import { isFolderVisible } from '../remote/identity.js';
16
16
  /** Uppercases and strips everything but letters/digits — used to compare keys that differ only in
17
17
  * punctuation/whitespace formatting (e.g. `RMXS-15` and `RMXS15` strip to the same `RMXS15`). */
@@ -73,6 +73,24 @@ export class MemoryRepository {
73
73
  const identity = this.identity.current();
74
74
  return this.remoteFolders.filter((f) => !isFolderVisible(f, identity)).map((f) => f.name);
75
75
  }
76
+ /**
77
+ * Pushes one attachment file's raw binary content to folderfoo, if `folderName` resolves to a
78
+ * remote source — the attachment counterpart to create()/update()'s inline remote-write blocks.
79
+ * Pushed under the attachment's own actual filename (unlike the doc's own .md file, which is
80
+ * pushed under its id with the .md suffix stripped). No-op for a local folder. Called by
81
+ * AttachmentRepository after it writes an attachment to the local mirror — callers must roll
82
+ * back the local write on a thrown error here, exactly like create() already rolls back its own
83
+ * .md file on a failed push.
84
+ */
85
+ async pushAttachmentIfNeeded(folderName, attachmentFilePath, mimeType) {
86
+ const remote = this.remoteFor(folderName);
87
+ if (!remote || !this.credentialsBaseDir)
88
+ return;
89
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
90
+ const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
91
+ const data = fs.readFileSync(attachmentFilePath);
92
+ await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType);
93
+ }
76
94
  /** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
77
95
  setWatcher(watcher) {
78
96
  this.watcher = watcher;
@@ -172,3 +172,25 @@ export async function writeFile(server, baseDir, tenantId, folderPath, name, con
172
172
  body: content,
173
173
  }), async () => undefined);
174
174
  }
175
+ /**
176
+ * Writes one file's raw BINARY content via POST /save/:filename — the attachment-file counterpart
177
+ * to writeFile's markdown-string upload. Same endpoint, same auth/retry wrapper, just a Buffer body
178
+ * and the attachment's own mime type instead of a fixed text/markdown content-type.
179
+ *
180
+ * NOT YET LIVE-VERIFIED against folderfoo: memory doc ids are deliberately stripped of hyphens
181
+ * before ever reaching writeFile's `name` param, per a confirmed-in-production finding that
182
+ * folderfoo's save endpoint silently drops non-[0-9a-zA-Z_] characters from the final filename
183
+ * segment. Attachment filenames routinely contain dots and hyphens (e.g. "entity.java.hbs") that
184
+ * this same stripping would mangle if it applies uniformly here too. This function intentionally
185
+ * does NOT pre-sanitize the name (unlike memory ids) because that behavior hasn't been confirmed
186
+ * for this endpoint/content-type combination from this client alone — verify with a real round-trip
187
+ * (attach a dotted/hyphenated filename to a doc in a remote folder, then check the folderfoo UI
188
+ * shows it unmangled) before relying on this for anything beyond best-effort.
189
+ */
190
+ export async function writeBinaryFile(server, baseDir, tenantId, folderPath, name, data, mimeType) {
191
+ await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name)}`, {
192
+ method: 'POST',
193
+ headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': mimeType },
194
+ body: data,
195
+ }), async () => undefined);
196
+ }
@@ -8,7 +8,7 @@ import { upsertFile, removeFile, scanSingleFolder, unregisterFolder, skillSyncSp
8
8
  import { SearchQueryError, sanitizeFtsQuery } from '../store/search.js';
9
9
  import { applyBodyEdits } from '../shared/body-edits.js';
10
10
  import { rebaseFolderPath } from '../config.js';
11
- import { readFile as readRemoteFile, writeFile as writeRemoteFile, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
11
+ import { readFile as readRemoteFile, writeFile as writeRemoteFile, writeBinaryFile as writeRemoteBinaryFile, joinRemoteFolderPath, assertRemoteFolderExists } from '../remote/folderfoo-client.js';
12
12
  import { isFolderVisible } from '../remote/identity.js';
13
13
  function rowToDoc(row) {
14
14
  return {
@@ -81,6 +81,24 @@ export class SkillRepository {
81
81
  const fileContents = fs.readFileSync(filePath, 'utf-8');
82
82
  await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', fileContents);
83
83
  }
84
+ /**
85
+ * Pushes one attachment file's raw binary content to folderfoo, if `folderName` resolves to a
86
+ * remote source — the attachment counterpart to pushToRemoteIfNeeded. Unlike SKILL.md (always
87
+ * pushed under the fixed name 'SKILL'), an attachment is pushed under its own actual filename,
88
+ * since folderfoo needs to store each attachment as a distinct file. No-op for a local folder,
89
+ * same as pushToRemoteIfNeeded. Called by AttachmentRepository after it writes an attachment to
90
+ * the local mirror — callers must roll back the local write on a thrown error here, exactly like
91
+ * create()/update() already roll back SKILL.md on a failed push.
92
+ */
93
+ async pushAttachmentIfNeeded(folderName, attachmentFilePath, mimeType) {
94
+ const remote = this.remoteFor(folderName);
95
+ if (!remote || !this.credentialsBaseDir)
96
+ return;
97
+ await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
98
+ const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
99
+ const data = fs.readFileSync(attachmentFilePath);
100
+ await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType);
101
+ }
84
102
  /** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
85
103
  setWatcher(watcher) {
86
104
  this.watcher = watcher;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.10.2",
3
+ "version": "0.10.3",
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": {