mcp-memory-bucket 0.4.2 → 0.5.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.
@@ -27,7 +27,7 @@
27
27
  :root[data-theme='dark'] { color-scheme: dark; }
28
28
  body { font-family: system-ui, sans-serif; margin: 0; background: var(--bg); color: var(--fg); }
29
29
  </style>
30
- <script type="module" crossorigin src="/assets/index-BXCTjiGA.js"></script>
30
+ <script type="module" crossorigin src="/assets/index-DSHtiYCt.js"></script>
31
31
  </head>
32
32
  <body>
33
33
  <mem-bucket-app></mem-bucket-app>
@@ -278,6 +278,21 @@ export class MemoryRepository {
278
278
  fs.unlinkSync(existing.source_path);
279
279
  removeFile(this.db, 'memory_docs', existing.source_path);
280
280
  }
281
+ /**
282
+ * Strips the doc's frontmatter entirely, leaving a bare markdown file with just the body — the
283
+ * inverse of memory_create, for turning a managed memory doc back into a plain dropped-in file.
284
+ * The file stays at the same path, but loses its `key` (so it drops out of key-based lookup) and
285
+ * its `id` — the upsertFile call below immediately re-derives a fresh one from the filename via
286
+ * deriveFrontmatter's fallback, so the caller should treat this doc as gone under its old id
287
+ * once this returns (look it up by the new filename-derived id/key instead).
288
+ */
289
+ stripFrontmatter(id) {
290
+ const existing = this.get(id);
291
+ if (!existing)
292
+ throw new Error(`memory doc with id "${id}" not found`);
293
+ writeMarkdownFile(existing.source_path, {}, existing.body);
294
+ upsertFile(this.db, this.syncSpec, existing.source_path);
295
+ }
281
296
  /**
282
297
  * Deletes many memory docs by id in one call — e.g. cleaning up a batch of
283
298
  * abandoned docs found via search(). Returns per-id results so one bad id
@@ -62,7 +62,7 @@ function buildMcpServer() {
62
62
  }
63
63
  const app = express();
64
64
  app.use(express.json());
65
- app.use(buildWebRouter(db, config, skillRepo, memoryRepo));
65
+ app.use(buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec));
66
66
  app.use(express.static(path.join(packageRoot, 'dist', 'client')));
67
67
  app.post('/mcp', async (req, res) => {
68
68
  const server = buildMcpServer();
@@ -4,6 +4,8 @@ import chokidar, {} from 'chokidar';
4
4
  import { readMarkdownFile } from './markdown-file.js';
5
5
  import { flattenTags } from './db.js';
6
6
  import { extractDates, toLocalDate } from './date-extract.js';
7
+ import { slugify } from './slug.js';
8
+ import { normalizeKey } from '../types.js';
7
9
  // `paused` is deliberately absent from both lists: it's a local-only cache column (see
8
10
  // SkillRepository/MemoryRepository#setPaused) that never round-trips through frontmatter, so a
9
11
  // file add/change/rescan must never overwrite it via the INSERT/ON CONFLICT UPDATE below.
@@ -16,7 +18,7 @@ export function skillSyncSpec(sources) {
16
18
  matchesFile: (filePath) => path.basename(filePath) === 'SKILL.md',
17
19
  columns: skillColumns,
18
20
  getId: (fm) => fm.name,
19
- toRow: (fm) => ({
21
+ toRow: (fm, _sourcePath, mtimeMs) => ({
20
22
  id: fm.name,
21
23
  description: fm.description,
22
24
  owner: fm.metadata?.owner ?? null,
@@ -25,7 +27,7 @@ export function skillSyncSpec(sources) {
25
27
  trigger_phrases: JSON.stringify(fm.trigger_phrases ?? []),
26
28
  extends: fm.metadata?.extends ?? null,
27
29
  deprecated: fm.deprecated ? 1 : 0,
28
- created_at: fm.created_at ?? null,
30
+ created_at: fm.created_at ?? new Date(mtimeMs).toISOString(),
29
31
  }),
30
32
  };
31
33
  }
@@ -36,6 +38,22 @@ export function memorySyncSpec(sources) {
36
38
  matchesFile: (filePath) => filePath.endsWith('.md'),
37
39
  columns: memoryColumns,
38
40
  getId: (fm) => fm.id,
41
+ deriveFrontmatter: (fm, filePath, mtimeMs) => {
42
+ const basename = path.basename(filePath, '.md');
43
+ const fallbackId = slugify(basename) || 'untitled';
44
+ return {
45
+ ...fm,
46
+ id: fm.id ?? fallbackId,
47
+ key: fm.key ?? normalizeKey(fallbackId),
48
+ key_type: fm.key_type ?? 'freeform',
49
+ description: fm.description ?? basename,
50
+ doc_type: fm.doc_type ?? 'other',
51
+ status: fm.status ?? 'active',
52
+ tags: fm.tags ?? [],
53
+ related_to: fm.related_to ?? null,
54
+ created_at: fm.created_at ?? new Date(mtimeMs).toISOString(),
55
+ };
56
+ },
39
57
  toRow: (fm) => ({
40
58
  id: fm.id,
41
59
  key: fm.key,
@@ -76,12 +94,15 @@ export function upsertFile(db, spec, filePath) {
76
94
  const parsed = readMarkdownFile(filePath);
77
95
  if (existing && existing.mtime_ms === parsed.mtimeMs)
78
96
  return; // unchanged, skip reprocessing
79
- const id = spec.getId(parsed.frontmatter);
97
+ const frontmatter = spec.deriveFrontmatter
98
+ ? spec.deriveFrontmatter(parsed.frontmatter, filePath, parsed.mtimeMs)
99
+ : parsed.frontmatter;
100
+ const id = spec.getId(frontmatter);
80
101
  if (!id) {
81
102
  console.error(`[memory-bucket] skipping ${filePath}: missing required id field in frontmatter`);
82
103
  return;
83
104
  }
84
- const row = spec.toRow(parsed.frontmatter, filePath);
105
+ const row = spec.toRow(frontmatter, filePath, parsed.mtimeMs);
85
106
  const root = rootForFile(spec.sources, filePath);
86
107
  const cols = [...spec.columns, 'source_path', 'root', 'body', 'mtime_ms'];
87
108
  const values = [...spec.columns.map((c) => row[c]), filePath, root, parsed.body, parsed.mtimeMs];
@@ -2,7 +2,9 @@ import fs from 'node:fs';
2
2
  import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import express from 'express';
5
+ import matter from 'gray-matter';
5
6
  import { saveRoot, removeRoot as removeRootFromConfig, sanitizeRootName } from '../config.js';
7
+ import { initialScan } from '../store/sync.js';
6
8
  function asArray(v) {
7
9
  if (v === undefined)
8
10
  return [];
@@ -253,8 +255,21 @@ function buildHealth(db) {
253
255
  .map((m) => m.id);
254
256
  return { danglingExtends, danglingRelatedTo, emptyTriggerPhrases, staleActiveMemoryDocs };
255
257
  }
256
- export function buildWebRouter(db, config, skillRepo, memoryRepo) {
258
+ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec) {
257
259
  const router = express.Router();
260
+ router.post('/api/rebuild-cache', (_req, res) => {
261
+ try {
262
+ db.exec(`DELETE FROM skills; DELETE FROM memory_docs; DELETE FROM search_index; DELETE FROM doc_dates;`);
263
+ initialScan(db, skillSpec);
264
+ initialScan(db, memorySpec);
265
+ const skillCount = db.prepare(`SELECT COUNT(*) AS n FROM skills`).get().n;
266
+ const memoryCount = db.prepare(`SELECT COUNT(*) AS n FROM memory_docs`).get().n;
267
+ res.json({ skillCount, memoryCount });
268
+ }
269
+ catch (err) {
270
+ res.status(500).json({ error: err.message });
271
+ }
272
+ });
258
273
  router.get('/api/entries', (req, res) => {
259
274
  res.json(queryEntries(db, req));
260
275
  });
@@ -271,7 +286,26 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo) {
271
286
  }
272
287
  const tags = JSON.parse(row.tags);
273
288
  const trigger_phrases = row.trigger_phrases ? JSON.parse(row.trigger_phrases) : undefined;
274
- res.json({ ...row, tags, trigger_phrases });
289
+ // A memory doc's cache row always looks fully populated even for a bare file (deriveFrontmatter's
290
+ // fallback backfills id/key/etc — see sync.ts), so whether it actually has an authored
291
+ // frontmatter block has to be checked on disk. This drives "Add frontmatter" (memory only —
292
+ // write real values in for the first time) vs "Edit"/"Delete frontmatter". Skills have no such
293
+ // fallback, so a skills row reaching this route always has real frontmatter; this stays false
294
+ // only in the memory_docs case in practice.
295
+ //
296
+ // `raw_file` is the true on-disk content (frontmatter block + body) for the Raw view — distinct
297
+ // from the cache's `body` column, which is always frontmatter-stripped (see readMarkdownFile).
298
+ let has_frontmatter = true;
299
+ let raw_file;
300
+ try {
301
+ raw_file = fs.readFileSync(row.source_path, 'utf-8');
302
+ has_frontmatter = Object.keys(matter(raw_file).data).length > 0;
303
+ }
304
+ catch {
305
+ // file unreadable/missing — treat as having frontmatter so the UI doesn't offer to "add"
306
+ // one for a doc it can't actually reach; the existing edit/delete-doc paths will 404 instead.
307
+ }
308
+ res.json({ ...row, tags, trigger_phrases, has_frontmatter, raw_file });
275
309
  });
276
310
  router.patch('/api/entries/:table/:id/deprecated', (req, res) => {
277
311
  const { table, id } = req.params;
@@ -313,6 +347,73 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo) {
313
347
  const results = table === 'skills' ? skillRepo.bulkUpdate(ids, { deprecated }) : memoryRepo.bulkUpdate(ids, { deprecated });
314
348
  res.json({ results });
315
349
  });
350
+ // General frontmatter edit — covers both "edit an existing doc's fields" and "add frontmatter
351
+ // to a bare file" (a bare memory doc already has a derived id/key from deriveFrontmatter, so
352
+ // "add" is just this same call supplying real values). Memory `key` is editable here (update()
353
+ // normalizes it). Skill `name` is not — SkillRepository.update() rejects it outright since
354
+ // renaming requires a real folder move (see the rename route below).
355
+ router.patch('/api/entries/:table/:id', (req, res) => {
356
+ const { table, id } = req.params;
357
+ const { frontmatter } = req.body;
358
+ if (table !== 'skills' && table !== 'memory_docs') {
359
+ res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
360
+ return;
361
+ }
362
+ if (!id) {
363
+ res.status(400).json({ error: 'id is required' });
364
+ return;
365
+ }
366
+ if (!frontmatter || typeof frontmatter !== 'object') {
367
+ res.status(400).json({ error: 'body must be { frontmatter: object }' });
368
+ return;
369
+ }
370
+ if (table === 'skills' && 'name' in frontmatter) {
371
+ res.status(400).json({ error: 'name cannot be changed via this route — use rename' });
372
+ return;
373
+ }
374
+ try {
375
+ const updated = table === 'skills' ? skillRepo.update(id, frontmatter) : memoryRepo.update(id, frontmatter);
376
+ res.json(updated);
377
+ }
378
+ catch (err) {
379
+ res.status(404).json({ error: err.message });
380
+ }
381
+ });
382
+ router.post('/api/entries/skills/:name/rename', (req, res) => {
383
+ const { name } = req.params;
384
+ const { new_name } = req.body;
385
+ if (!name) {
386
+ res.status(400).json({ error: 'name is required' });
387
+ return;
388
+ }
389
+ if (!new_name) {
390
+ res.status(400).json({ error: 'body must be { new_name: string }' });
391
+ return;
392
+ }
393
+ try {
394
+ const renamed = skillRepo.rename(name, new_name);
395
+ res.json(renamed);
396
+ }
397
+ catch (err) {
398
+ res.status(400).json({ error: err.message });
399
+ }
400
+ });
401
+ // Memory docs only — skill frontmatter (name/description) is required by the agentskills.io
402
+ // spec, so stripping it would produce a non-conformant SKILL.md no compliant agent can load.
403
+ router.delete('/api/entries/memory_docs/:id/frontmatter', (req, res) => {
404
+ const { id } = req.params;
405
+ if (!id) {
406
+ res.status(400).json({ error: 'id is required' });
407
+ return;
408
+ }
409
+ try {
410
+ memoryRepo.stripFrontmatter(id);
411
+ res.json({ id, frontmatterRemoved: true });
412
+ }
413
+ catch (err) {
414
+ res.status(404).json({ error: err.message });
415
+ }
416
+ });
316
417
  // `paused` is a local-only cache toggle (see SkillRepository/MemoryRepository#setPaused) — it
317
418
  // never touches the source file, so this goes through setPaused, not update()/bulkUpdate().
318
419
  router.patch('/api/entries/:table/:id/paused', (req, res) => {
@@ -479,5 +580,32 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo) {
479
580
  const parent = path.dirname(dirPath) !== dirPath ? path.dirname(dirPath) : null;
480
581
  res.json({ path: dirPath, parent, entries });
481
582
  });
583
+ router.post('/api/fs/mkdir', (req, res) => {
584
+ const { path: parentPath, name } = req.body;
585
+ if (!parentPath || !path.isAbsolute(parentPath)) {
586
+ res.status(400).json({ error: 'path must be an absolute directory path' });
587
+ return;
588
+ }
589
+ if (!name || name.trim() !== name || name.includes('/') || name === '.' || name === '..') {
590
+ res.status(400).json({ error: 'invalid folder name' });
591
+ return;
592
+ }
593
+ if (!fs.existsSync(parentPath) || !fs.statSync(parentPath).isDirectory()) {
594
+ res.status(400).json({ error: `not a directory: ${parentPath}` });
595
+ return;
596
+ }
597
+ const newDirPath = path.join(parentPath, name);
598
+ if (fs.existsSync(newDirPath)) {
599
+ res.status(409).json({ error: `already exists: ${newDirPath}` });
600
+ return;
601
+ }
602
+ try {
603
+ fs.mkdirSync(newDirPath);
604
+ res.json({ path: newDirPath, name });
605
+ }
606
+ catch (err) {
607
+ res.status(500).json({ error: err.message });
608
+ }
609
+ });
482
610
  return router;
483
611
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.4.2",
3
+ "version": "0.5.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": {