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.
@@ -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
- const matchedIds = q
35
- ? matchSearch(db, q)
36
- : null;
37
- if (q && matchedIds && matchedIds.skills.size === 0 && matchedIds.memory_docs.size === 0) {
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.5.4",
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": {