mcp-memory-bucket 0.5.4 → 0.6.0
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-6BCcM6pL.js → index-PIigsGBG.js} +195 -108
- package/dist/client/index.html +1 -1
- package/dist/src/attachments/repository.js +78 -0
- package/dist/src/attachments/storage.js +67 -0
- package/dist/src/attachments/tools.js +85 -0
- package/dist/src/attachments/types.js +1 -0
- package/dist/src/config.js +3 -0
- package/dist/src/memory/repository.js +42 -2
- package/dist/src/memory/tools.js +12 -1
- package/dist/src/server.js +4 -0
- package/dist/src/skills/builtin/memory-bucket-authoring/SKILL.md +30 -0
- package/dist/src/skills/repository.js +3 -2
- package/dist/src/store/db.js +34 -4
- package/dist/src/store/search.js +25 -2
- package/dist/src/store/sync.js +9 -5
- package/dist/src/web/routes.js +71 -10
- package/package.json +1 -1
package/dist/src/web/routes.js
CHANGED
|
@@ -4,7 +4,11 @@ import path from 'node:path';
|
|
|
4
4
|
import express from 'express';
|
|
5
5
|
import matter from 'gray-matter';
|
|
6
6
|
import { saveFolder, removeFolder as removeFolderFromConfig, sanitizeFolderName } from '../config.js';
|
|
7
|
+
import { stripKey } from '../memory/repository.js';
|
|
7
8
|
import { initialScan } from '../store/sync.js';
|
|
9
|
+
import { sanitizeFtsQuery } from '../store/search.js';
|
|
10
|
+
import { resolveWithinBase } from '../store/safe-path.js';
|
|
11
|
+
import { attachmentsDirFor } from '../attachments/storage.js';
|
|
8
12
|
function asArray(v) {
|
|
9
13
|
if (v === undefined)
|
|
10
14
|
return [];
|
|
@@ -16,7 +20,7 @@ function tagWhereClause(tags) {
|
|
|
16
20
|
const clauses = tags.map(() => `EXISTS (SELECT 1 FROM json_each(tags) WHERE value = ?)`);
|
|
17
21
|
return { clause: ` AND ${clauses.join(' AND ')}`, params: tags };
|
|
18
22
|
}
|
|
19
|
-
function queryEntries(db, req) {
|
|
23
|
+
function queryEntries(db, memoryRepo, req) {
|
|
20
24
|
const type = req.query.type ?? 'all';
|
|
21
25
|
const tags = asArray(req.query.tag);
|
|
22
26
|
const statuses = asArray(req.query.status);
|
|
@@ -31,10 +35,15 @@ function queryEntries(db, req) {
|
|
|
31
35
|
const paused = pausedParam === '0' || pausedParam === '1' ? pausedParam : undefined;
|
|
32
36
|
const dateFrom = req.query.date_from?.trim() || undefined;
|
|
33
37
|
const dateTo = req.query.date_to?.trim() || undefined;
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
// If the typed query matches an existing key (ignoring punctuation/case), short-circuit straight
|
|
39
|
+
// to "every doc under this key" for memory_docs — this bypasses FTS/bm25 ranking entirely, so a
|
|
40
|
+
// doc whose body never repeats the literal key text (e.g. a session summary) still shows up.
|
|
41
|
+
// Skills have no `key` concept, so they still go through the normal FTS path below when q is set.
|
|
42
|
+
const keyMatch = q ? memoryRepo.suggestKeys(q, 1).find((m) => stripKey(m.key) === stripKey(q)) : undefined;
|
|
43
|
+
// Always compute FTS matches when q is set, even on a keyMatch — skills still need this to stay
|
|
44
|
+
// filtered by q (they have no key concept, so keyMatch only bypasses filtering for memory_docs below).
|
|
45
|
+
const matchedIds = q ? matchSearch(db, q) : null;
|
|
46
|
+
if (q && !keyMatch && matchedIds && matchedIds.skills.size === 0 && matchedIds.memory_docs.size === 0) {
|
|
38
47
|
return [];
|
|
39
48
|
}
|
|
40
49
|
const dateIds = dateFrom || dateTo ? matchDateRange(db, dateFrom, dateTo) : null;
|
|
@@ -43,10 +52,10 @@ function queryEntries(db, req) {
|
|
|
43
52
|
}
|
|
44
53
|
const results = [];
|
|
45
54
|
if (type === 'skill' || type === 'all') {
|
|
46
|
-
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], folders, deprecated, paused }, intersectIds(matchedIds?.skills, dateIds?.skills)));
|
|
55
|
+
results.push(...queryTable(db, 'skills', { tags, statuses, owners, docTypes: [], keyTypes: [], folders, keys: [], deprecated, paused }, intersectIds(matchedIds?.skills, dateIds?.skills)));
|
|
47
56
|
}
|
|
48
57
|
if (type === 'memory' || type === 'all') {
|
|
49
|
-
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, folders, deprecated, paused }, intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
|
|
58
|
+
results.push(...queryTable(db, 'memory_docs', { tags, statuses, owners: [], docTypes, keyTypes, folders, deprecated, paused, keys: keyMatch ? [keyMatch.key] : [] }, keyMatch ? undefined : intersectIds(matchedIds?.memory_docs, dateIds?.memory_docs)));
|
|
50
59
|
}
|
|
51
60
|
const sort = req.query.sort ?? 'mtime_desc';
|
|
52
61
|
results.sort((a, b) => {
|
|
@@ -91,6 +100,10 @@ function queryTable(db, table, filters, restrictToIds) {
|
|
|
91
100
|
where += ` AND key_type IN (${filters.keyTypes.map(() => '?').join(', ')})`;
|
|
92
101
|
params.push(...filters.keyTypes);
|
|
93
102
|
}
|
|
103
|
+
if (table === 'memory_docs' && filters.keys.length > 0) {
|
|
104
|
+
where += ` AND key IN (${filters.keys.map(() => '?').join(', ')})`;
|
|
105
|
+
params.push(...filters.keys);
|
|
106
|
+
}
|
|
94
107
|
if (filters.folders.length > 0) {
|
|
95
108
|
where += ` AND folder IN (${filters.folders.map(() => '?').join(', ')})`;
|
|
96
109
|
params.push(...filters.folders);
|
|
@@ -186,7 +199,7 @@ function matchSearch(db, q) {
|
|
|
186
199
|
try {
|
|
187
200
|
rows = db
|
|
188
201
|
.prepare(`SELECT ref_table, ref_id FROM search_index WHERE search_index MATCH ? ORDER BY rank`)
|
|
189
|
-
.all(q);
|
|
202
|
+
.all(sanitizeFtsQuery(q));
|
|
190
203
|
}
|
|
191
204
|
catch {
|
|
192
205
|
// Bad FTS5 query syntax (e.g. a bare quote) — treat as no matches rather than 500ing.
|
|
@@ -271,7 +284,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
271
284
|
}
|
|
272
285
|
});
|
|
273
286
|
router.get('/api/entries', (req, res) => {
|
|
274
|
-
res.json(queryEntries(db, req));
|
|
287
|
+
res.json(queryEntries(db, memoryRepo, req));
|
|
275
288
|
});
|
|
276
289
|
router.get('/api/entries/:table/:id', (req, res) => {
|
|
277
290
|
const { table, id } = req.params;
|
|
@@ -286,6 +299,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
286
299
|
}
|
|
287
300
|
const tags = JSON.parse(row.tags);
|
|
288
301
|
const trigger_phrases = row.trigger_phrases ? JSON.parse(row.trigger_phrases) : undefined;
|
|
302
|
+
const attachments = row.attachments ? JSON.parse(row.attachments) : undefined;
|
|
289
303
|
// A memory doc's cache row always looks fully populated even for a bare file (deriveFrontmatter's
|
|
290
304
|
// fallback backfills id/key/etc — see sync.ts), so whether it actually has an authored
|
|
291
305
|
// frontmatter block has to be checked on disk. This drives "Add frontmatter" (memory only —
|
|
@@ -305,7 +319,46 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
305
319
|
// file unreadable/missing — treat as having frontmatter so the UI doesn't offer to "add"
|
|
306
320
|
// one for a doc it can't actually reach; the existing edit/delete-doc paths will 404 instead.
|
|
307
321
|
}
|
|
308
|
-
res.json({ ...row, tags, trigger_phrases, has_frontmatter, raw_file });
|
|
322
|
+
res.json({ ...row, tags, trigger_phrases, attachments, has_frontmatter, raw_file });
|
|
323
|
+
});
|
|
324
|
+
// Serves a single attachment file for a doc. Unauthenticated web-facing surface, so the
|
|
325
|
+
// filename from the URL is validated with resolveWithinBase (same containment check
|
|
326
|
+
// attachments/storage.ts uses when writing) to rule out path traversal before touching disk.
|
|
327
|
+
router.get('/api/entries/:table/:id/attachments/:filename', (req, res) => {
|
|
328
|
+
const { table, id, filename } = req.params;
|
|
329
|
+
if (table !== 'skills' && table !== 'memory_docs') {
|
|
330
|
+
res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
|
|
331
|
+
return;
|
|
332
|
+
}
|
|
333
|
+
if (!filename) {
|
|
334
|
+
res.status(400).json({ error: 'filename is required' });
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
const row = db.prepare(`SELECT source_path FROM ${table} WHERE id = ?`).get(id);
|
|
338
|
+
if (!row) {
|
|
339
|
+
res.status(404).json({ error: 'not found' });
|
|
340
|
+
return;
|
|
341
|
+
}
|
|
342
|
+
const dir = attachmentsDirFor(row.source_path, table === 'skills' ? 'skill' : 'memory');
|
|
343
|
+
let filePath;
|
|
344
|
+
try {
|
|
345
|
+
filePath = resolveWithinBase(dir, undefined, filename);
|
|
346
|
+
}
|
|
347
|
+
catch (err) {
|
|
348
|
+
res.status(400).json({ error: err.message });
|
|
349
|
+
return;
|
|
350
|
+
}
|
|
351
|
+
// Force a non-executable Content-Type and force-download disposition so a maliciously
|
|
352
|
+
// named attachment (e.g. "notes.html" with an embedded <script>) can never render inline
|
|
353
|
+
// in the browser and execute in this origin — Express/sendFile would otherwise infer
|
|
354
|
+
// Content-Type from the file extension.
|
|
355
|
+
res.setHeader('Content-Type', 'application/octet-stream');
|
|
356
|
+
res.setHeader('Content-Disposition', `attachment; filename="${encodeURIComponent(filename)}"`);
|
|
357
|
+
res.sendFile(filePath, (err) => {
|
|
358
|
+
if (err && !res.headersSent) {
|
|
359
|
+
res.status(404).json({ error: 'attachment not found' });
|
|
360
|
+
}
|
|
361
|
+
});
|
|
309
362
|
});
|
|
310
363
|
router.patch('/api/entries/:table/:id/deprecated', (req, res) => {
|
|
311
364
|
const { table, id } = req.params;
|
|
@@ -491,6 +544,14 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
491
544
|
const type = req.query.type ?? 'all';
|
|
492
545
|
res.json(buildFacets(db, type));
|
|
493
546
|
});
|
|
547
|
+
router.get('/api/keys/suggest', (req, res) => {
|
|
548
|
+
const q = req.query.q?.trim();
|
|
549
|
+
if (!q) {
|
|
550
|
+
res.json([]);
|
|
551
|
+
return;
|
|
552
|
+
}
|
|
553
|
+
res.json(memoryRepo.suggestKeys(q, 8));
|
|
554
|
+
});
|
|
494
555
|
router.get('/api/health', (_req, res) => {
|
|
495
556
|
res.json(buildHealth(db));
|
|
496
557
|
});
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.0",
|
|
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": {
|