mcp-memory-bucket 0.10.2 → 0.10.4

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.
@@ -33,7 +33,7 @@
33
33
  z-index: 2147483000;
34
34
  }
35
35
  </style>
36
- <script type="module" crossorigin src="/assets/index-DXX4Dusv.js"></script>
36
+ <script type="module" crossorigin src="/assets/index-ARgjrRA4.js"></script>
37
37
  </head>
38
38
  <body>
39
39
  <mem-bucket-app></mem-bucket-app>
@@ -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) {
@@ -32,8 +32,30 @@ const MIME_BY_EXT = {
32
32
  '.pdf': 'application/pdf',
33
33
  '.csv': 'text/csv',
34
34
  };
35
+ // Extensions whose content isn't meaningfully viewable as text, so an unrecognized one among
36
+ // these should stay application/octet-stream rather than fall through to the text/plain default
37
+ // below — a spreadsheet or archive rendered as raw bytes in the inline preview is just noise;
38
+ // better to leave that preview blank and let the download button be the escape hatch.
39
+ const BINARY_EXTENSIONS = new Set([
40
+ '.zip', '.tar', '.gz', '.tgz', '.rar', '.7z',
41
+ '.exe', '.dll', '.so', '.dylib', '.wasm', '.bin', '.class', '.jar',
42
+ '.doc', '.docx', '.xls', '.xlsx', '.ppt', '.pptx',
43
+ '.mp3', '.mp4', '.mov', '.avi', '.wav', '.ogg',
44
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
45
+ '.db', '.sqlite', '.ico',
46
+ ]);
47
+ /**
48
+ * Attachments on skills/memory docs are overwhelmingly source/config/template files (the
49
+ * generate-crud-entity-from-plop-templates skill's .hbs templates are a typical example) —
50
+ * extensions with no dedicated MIME_BY_EXT entry default to text/plain so the inline preview
51
+ * (src/web/routes.ts's /view route) can actually render them, instead of application/octet-stream
52
+ * which browsers show as a blank iframe. Recognized binary formats stay opaque via BINARY_EXTENSIONS.
53
+ */
35
54
  export function guessMimeType(filename) {
36
- return MIME_BY_EXT[path.extname(filename).toLowerCase()] ?? 'application/octet-stream';
55
+ const ext = path.extname(filename).toLowerCase();
56
+ if (MIME_BY_EXT[ext])
57
+ return MIME_BY_EXT[ext];
58
+ return BINARY_EXTENSIONS.has(ext) ? 'application/octet-stream' : 'text/plain';
37
59
  }
38
60
  function uniqueFilename(dir, filename) {
39
61
  // Validate that the filename doesn't escape the directory
@@ -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;
@@ -19,6 +19,43 @@ function asArray(v) {
19
19
  return [];
20
20
  return Array.isArray(v) ? v.map(String) : [String(v)];
21
21
  }
22
+ function isTextishMime(mimeType) {
23
+ return mimeType.startsWith('text/') || mimeType === 'application/json' || mimeType === 'application/xml';
24
+ }
25
+ function escapeHtml(s) {
26
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
27
+ }
28
+ /**
29
+ * Colors mirror public/index.html's --bg-subtle/--fg light-dark() pairs (composited over --bg,
30
+ * since this shell has no ambient page to layer a translucent --bg-subtle over) so a text
31
+ * attachment's inline preview reads as part of the same surface as the detail panel around it,
32
+ * in both the app's manual theme override and the OS-level default.
33
+ */
34
+ function renderTextAttachmentPage(content, theme) {
35
+ return `<!doctype html>
36
+ <html data-theme="${theme}">
37
+ <head>
38
+ <meta charset="utf-8">
39
+ <style>
40
+ :root { color-scheme: light dark; }
41
+ :root[data-theme='light'] { color-scheme: light; }
42
+ :root[data-theme='dark'] { color-scheme: dark; }
43
+ html, body { margin: 0; }
44
+ body { background: light-dark(#f7f7f7, #262626); color: light-dark(#111111, #f0f0f0); }
45
+ pre {
46
+ margin: 0;
47
+ padding: 12px;
48
+ white-space: pre-wrap;
49
+ word-break: break-word;
50
+ font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
51
+ font-size: 12px;
52
+ line-height: 1.5;
53
+ }
54
+ </style>
55
+ </head>
56
+ <body><pre>${escapeHtml(content)}</pre></body>
57
+ </html>`;
58
+ }
22
59
  function tagWhereClause(tags) {
23
60
  if (tags.length === 0)
24
61
  return { clause: '', params: [] };
@@ -450,7 +487,30 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
450
487
  return;
451
488
  }
452
489
  res.setHeader('Content-Security-Policy', "sandbox; default-src 'none'; style-src 'unsafe-inline'; img-src *");
453
- res.setHeader('Content-Type', guessMimeType(filename));
490
+ const mimeType = guessMimeType(filename);
491
+ // The client's detail-panel embeds this route in an <iframe> sized/backgrounded to match its
492
+ // own light/dark theme (see .attachment-frame in detail-panel.ts) — but a plain text/JSON/XML
493
+ // file sent as-is renders through the browser's own raw-text viewer, which is always
494
+ // white-on-black-text regardless of that surrounding theme. Wrapping it in a tiny themed HTML
495
+ // shell (same color-scheme/data-theme pattern as public/index.html, so a manual light/dark
496
+ // pick in the app is honored, not just the OS preference) fixes that mismatch. Images/PDFs/etc.
497
+ // are unaffected — the browser's native viewer for those is used as-is.
498
+ if (isTextishMime(mimeType)) {
499
+ let content;
500
+ try {
501
+ content = fs.readFileSync(filePath, 'utf-8');
502
+ }
503
+ catch {
504
+ res.status(404).json({ error: 'attachment not found' });
505
+ return;
506
+ }
507
+ const theme = req.query.theme === 'light' || req.query.theme === 'dark' ? req.query.theme : '';
508
+ res.setHeader('Content-Type', 'text/html; charset=utf-8');
509
+ res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
510
+ res.send(renderTextAttachmentPage(content, theme));
511
+ return;
512
+ }
513
+ res.setHeader('Content-Type', mimeType);
454
514
  res.setHeader('Content-Disposition', `inline; filename="${encodeURIComponent(filename)}"`);
455
515
  res.sendFile(filePath, (err) => {
456
516
  if (err && !res.headersSent) {
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.4",
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": {