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.
- package/dist/client/assets/{index-DXX4Dusv.js → index-ARgjrRA4.js} +235 -178
- package/dist/client/index.html +1 -1
- package/dist/src/attachments/repository.js +29 -0
- package/dist/src/attachments/storage.js +23 -1
- package/dist/src/memory/repository.js +19 -1
- package/dist/src/remote/folderfoo-client.js +22 -0
- package/dist/src/skills/repository.js +19 -1
- package/dist/src/web/routes.js +61 -1
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -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
|
-
|
|
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;
|
package/dist/src/web/routes.js
CHANGED
|
@@ -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, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
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
|
-
|
|
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.
|
|
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": {
|