mcp-memory-bucket 0.7.4 → 0.8.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.
@@ -3,12 +3,15 @@ import os from 'node:os';
3
3
  import path from 'node:path';
4
4
  import express from 'express';
5
5
  import matter from 'gray-matter';
6
- import { saveFolder, removeFolder as removeFolderFromConfig, sanitizeFolderName } from '../config.js';
6
+ import { saveFolder, saveRemoteFolder, removeFolder as removeFolderFromConfig, sanitizeFolderName, mirrorDirFor } from '../config.js';
7
7
  import { stripKey } from '../memory/repository.js';
8
8
  import { initialScan } from '../store/sync.js';
9
9
  import { sanitizeFtsQuery } from '../store/search.js';
10
10
  import { resolveWithinBase } from '../store/safe-path.js';
11
11
  import { attachmentsDirFor, guessMimeType } from '../attachments/storage.js';
12
+ import { listFolders as listFolderfooFolders } from '../remote/folderfoo-client.js';
13
+ import { setCredential } from '../remote/credentials.js';
14
+ import { pollOne } from '../remote/remote-sync.js';
12
15
  function asArray(v) {
13
16
  if (v === undefined)
14
17
  return [];
@@ -268,10 +271,17 @@ function buildHealth(db) {
268
271
  .map((m) => m.id);
269
272
  return { danglingExtends, danglingRelatedTo, emptyTriggerPhrases, staleActiveMemoryDocs };
270
273
  }
271
- export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec) {
274
+ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, memorySpec, remotePollers) {
272
275
  const router = express.Router();
273
- router.post('/api/rebuild-cache', (_req, res) => {
276
+ // Resyncs every remote source FIRST (force: true - always does real work,
277
+ // including reconcileDeletions, regardless of the watermark check), so a
278
+ // deletion made on folderfoo shows up here even if the poller's next
279
+ // regular tick hasn't fired yet. Without this, the wipe-and-rescan below
280
+ // is LOCAL-ONLY - it would silently resurrect a file that was deleted on
281
+ // folderfoo but whose stale mirror copy hadn't been reconciled away yet.
282
+ router.post('/api/rebuild-cache', async (_req, res) => {
274
283
  try {
284
+ await Promise.all([remotePollers?.skill?.resyncAll(), remotePollers?.memory?.resyncAll()]);
275
285
  db.exec(`DELETE FROM skills; DELETE FROM memory_docs; DELETE FROM search_index; DELETE FROM doc_dates;`);
276
286
  initialScan(db, skillSpec);
277
287
  initialScan(db, memorySpec);
@@ -399,7 +409,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
399
409
  }
400
410
  });
401
411
  });
402
- router.patch('/api/entries/:table/:id/deprecated', (req, res) => {
412
+ router.patch('/api/entries/:table/:id/deprecated', async (req, res) => {
403
413
  const { table, id } = req.params;
404
414
  const { deprecated } = req.body;
405
415
  if (table !== 'skills' && table !== 'memory_docs') {
@@ -416,16 +426,16 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
416
426
  }
417
427
  try {
418
428
  if (table === 'skills')
419
- skillRepo.update(id, { deprecated });
429
+ await skillRepo.update(id, { deprecated });
420
430
  else
421
- memoryRepo.update(id, { deprecated });
431
+ await memoryRepo.update(id, { deprecated });
422
432
  res.json({ id, deprecated });
423
433
  }
424
434
  catch (err) {
425
435
  res.status(404).json({ error: err.message });
426
436
  }
427
437
  });
428
- router.post('/api/entries/:table/bulk/deprecated', (req, res) => {
438
+ router.post('/api/entries/:table/bulk/deprecated', async (req, res) => {
429
439
  const { table } = req.params;
430
440
  const { ids, deprecated } = req.body;
431
441
  if (table !== 'skills' && table !== 'memory_docs') {
@@ -436,7 +446,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
436
446
  res.status(400).json({ error: 'body must be { ids: string[], deprecated: boolean }' });
437
447
  return;
438
448
  }
439
- const results = table === 'skills' ? skillRepo.bulkUpdate(ids, { deprecated }) : memoryRepo.bulkUpdate(ids, { deprecated });
449
+ const results = table === 'skills' ? await skillRepo.bulkUpdate(ids, { deprecated }) : await memoryRepo.bulkUpdate(ids, { deprecated });
440
450
  res.json({ results });
441
451
  });
442
452
  // General frontmatter edit — covers both "edit an existing doc's fields" and "add frontmatter
@@ -444,7 +454,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
444
454
  // "add" is just this same call supplying real values). Memory `key` is editable here (update()
445
455
  // normalizes it). Skill `name` is not — SkillRepository.update() rejects it outright since
446
456
  // renaming requires a real folder move (see the rename route below).
447
- router.patch('/api/entries/:table/:id', (req, res) => {
457
+ router.patch('/api/entries/:table/:id', async (req, res) => {
448
458
  const { table, id } = req.params;
449
459
  const { frontmatter } = req.body;
450
460
  if (table !== 'skills' && table !== 'memory_docs') {
@@ -464,14 +474,14 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
464
474
  return;
465
475
  }
466
476
  try {
467
- const updated = table === 'skills' ? skillRepo.update(id, frontmatter) : memoryRepo.update(id, frontmatter);
477
+ const updated = table === 'skills' ? await skillRepo.update(id, frontmatter) : await memoryRepo.update(id, frontmatter);
468
478
  res.json(updated);
469
479
  }
470
480
  catch (err) {
471
481
  res.status(404).json({ error: err.message });
472
482
  }
473
483
  });
474
- router.post('/api/entries/skills/:name/rename', (req, res) => {
484
+ router.post('/api/entries/skills/:name/rename', async (req, res) => {
475
485
  const { name } = req.params;
476
486
  const { new_name } = req.body;
477
487
  if (!name) {
@@ -483,7 +493,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
483
493
  return;
484
494
  }
485
495
  try {
486
- const renamed = skillRepo.rename(name, new_name);
496
+ const renamed = await skillRepo.rename(name, new_name);
487
497
  res.json(renamed);
488
498
  }
489
499
  catch (err) {
@@ -492,14 +502,14 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
492
502
  });
493
503
  // Memory docs only — skill frontmatter (name/description) is required by the agentskills.io
494
504
  // spec, so stripping it would produce a non-conformant SKILL.md no compliant agent can load.
495
- router.delete('/api/entries/memory_docs/:id/frontmatter', (req, res) => {
505
+ router.delete('/api/entries/memory_docs/:id/frontmatter', async (req, res) => {
496
506
  const { id } = req.params;
497
507
  if (!id) {
498
508
  res.status(400).json({ error: 'id is required' });
499
509
  return;
500
510
  }
501
511
  try {
502
- memoryRepo.stripFrontmatter(id);
512
+ await memoryRepo.stripFrontmatter(id);
503
513
  res.json({ id, frontmatterRemoved: true });
504
514
  }
505
515
  catch (err) {
@@ -508,7 +518,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
508
518
  });
509
519
  // `paused` is a local-only cache toggle (see SkillRepository/MemoryRepository#setPaused) — it
510
520
  // never touches the source file, so this goes through setPaused, not update()/bulkUpdate().
511
- router.patch('/api/entries/:table/:id/paused', (req, res) => {
521
+ router.patch('/api/entries/:table/:id/paused', async (req, res) => {
512
522
  const { table, id } = req.params;
513
523
  const { paused } = req.body;
514
524
  if (table !== 'skills' && table !== 'memory_docs') {
@@ -523,14 +533,14 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
523
533
  res.status(400).json({ error: 'body must be { paused: boolean }' });
524
534
  return;
525
535
  }
526
- const [result] = table === 'skills' ? skillRepo.setPaused([id], paused) : memoryRepo.setPaused([id], paused);
536
+ const [result] = table === 'skills' ? await skillRepo.setPaused([id], paused) : await memoryRepo.setPaused([id], paused);
527
537
  if (!result?.ok) {
528
538
  res.status(404).json({ error: result?.error ?? 'not found' });
529
539
  return;
530
540
  }
531
541
  res.json({ id, paused });
532
542
  });
533
- router.post('/api/entries/:table/bulk/paused', (req, res) => {
543
+ router.post('/api/entries/:table/bulk/paused', async (req, res) => {
534
544
  const { table } = req.params;
535
545
  const { ids, paused } = req.body;
536
546
  if (table !== 'skills' && table !== 'memory_docs') {
@@ -541,10 +551,10 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
541
551
  res.status(400).json({ error: 'body must be { ids: string[], paused: boolean }' });
542
552
  return;
543
553
  }
544
- const results = table === 'skills' ? skillRepo.setPaused(ids, paused) : memoryRepo.setPaused(ids, paused);
554
+ const results = table === 'skills' ? await skillRepo.setPaused(ids, paused) : await memoryRepo.setPaused(ids, paused);
545
555
  res.json({ results });
546
556
  });
547
- router.delete('/api/entries/:table/:id', (req, res) => {
557
+ router.delete('/api/entries/:table/:id', async (req, res) => {
548
558
  const { table, id } = req.params;
549
559
  if (table !== 'skills' && table !== 'memory_docs') {
550
560
  res.status(400).json({ error: 'table must be "skills" or "memory_docs"' });
@@ -556,16 +566,16 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
556
566
  }
557
567
  try {
558
568
  if (table === 'skills')
559
- skillRepo.delete(id);
569
+ await skillRepo.delete(id);
560
570
  else
561
- memoryRepo.delete(id);
571
+ await memoryRepo.delete(id);
562
572
  res.json({ deleted: id });
563
573
  }
564
574
  catch (err) {
565
575
  res.status(404).json({ error: err.message });
566
576
  }
567
577
  });
568
- router.post('/api/entries/:table/bulk/delete', (req, res) => {
578
+ router.post('/api/entries/:table/bulk/delete', async (req, res) => {
569
579
  const { table } = req.params;
570
580
  const { ids } = req.body;
571
581
  if (table !== 'skills' && table !== 'memory_docs') {
@@ -576,7 +586,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
576
586
  res.status(400).json({ error: 'body must be { ids: string[] }' });
577
587
  return;
578
588
  }
579
- const results = table === 'skills' ? skillRepo.bulkDelete(ids) : memoryRepo.bulkDelete(ids);
589
+ const results = table === 'skills' ? await skillRepo.bulkDelete(ids) : await memoryRepo.bulkDelete(ids);
580
590
  res.json({ results });
581
591
  });
582
592
  router.get('/api/facets', (req, res) => {
@@ -594,10 +604,80 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
594
604
  router.get('/api/health', (_req, res) => {
595
605
  res.json(buildHealth(db));
596
606
  });
607
+ // Tells the client which folderfoo deployment (if any) to point the
608
+ // "Connect folderfoo" flow and the page-level login widget at — see
609
+ // config.ts's FolderfooMode doc comment for why this can't be inferred
610
+ // from window.location.hostname the way mindfoo/bulletino/avotuner do.
611
+ router.get('/api/config', (_req, res) => {
612
+ res.json({ folderfooMode: config.folderfooMode, folderfooHost: config.folderfooHost });
613
+ });
597
614
  router.get('/api/folders', (_req, res) => {
598
615
  res.json({
599
- skill: skillRepo.listFolders().map((f) => ({ ...f, kind: 'skill' })),
600
- memory: memoryRepo.listFolders().map((f) => ({ ...f, kind: 'memory' })),
616
+ skill: skillRepo.listFoldersWithRemoteInfo().map((f) => ({ ...f, kind: 'skill' })),
617
+ memory: memoryRepo.listFoldersWithRemoteInfo().map((f) => ({ ...f, kind: 'memory' })),
618
+ });
619
+ });
620
+ // Resolves a file opened via folderfoo's own File Open dialog
621
+ // (<folderfoo-profile-circle>'s "folderfoo-file-open" event) back to a
622
+ // memory-bucket doc, so the web UI can select it in the right panel —
623
+ // and, since that panel's edits already push through to folderfoo for
624
+ // any doc whose folder resolves to a remote source (see PATCH
625
+ // /api/entries/:table/:id), frontmatter changes made there persist to
626
+ // the cloud automatically once the right doc is open, no separate wiring
627
+ // needed for that half.
628
+ //
629
+ // Only works when the opened file's (server, tenantId, folderPath) falls
630
+ // under a folder ALREADY connected as a remote source here — folderfoo's
631
+ // File Open dialog browses the user's whole folderfoo tree, not just
632
+ // connected folders, so a miss (file from an unconnected folder) is a
633
+ // real, expected case the client surfaces as a clear message, not
634
+ // silently swallowed.
635
+ router.post('/api/folderfoo/resolve-open', (req, res) => {
636
+ const { server, tenantId, folderPath, name } = req.body;
637
+ if (!server || !tenantId || !name) {
638
+ res.status(400).json({ error: 'server, tenantId, and name are required' });
639
+ return;
640
+ }
641
+ const openedFolderPath = folderPath || '';
642
+ for (const [table, repo] of [
643
+ ['skills', skillRepo],
644
+ ['memory_docs', memoryRepo],
645
+ ]) {
646
+ for (const remote of repo.listRemoteFolders()) {
647
+ if (remote.server !== server || remote.tenantId !== tenantId)
648
+ continue;
649
+ // The opened file's folder must be remote.folderPath itself, or nested inside it.
650
+ const sourceFolder = remote.folderPath;
651
+ let withinFolder;
652
+ if (openedFolderPath === sourceFolder) {
653
+ withinFolder = '';
654
+ }
655
+ else if (sourceFolder === '') {
656
+ withinFolder = openedFolderPath;
657
+ }
658
+ else if (openedFolderPath.startsWith(`${sourceFolder}/`)) {
659
+ withinFolder = openedFolderPath.slice(sourceFolder.length + 1);
660
+ }
661
+ else {
662
+ continue; // not under this source
663
+ }
664
+ const sourcePath = table === 'skills'
665
+ ? path.join(remote.mirrorDir, withinFolder, name, 'SKILL.md')
666
+ : path.join(remote.mirrorDir, withinFolder, `${name}.md`);
667
+ const row = db.prepare(`SELECT id FROM ${table} WHERE source_path = ?`).get(sourcePath);
668
+ if (row) {
669
+ res.json({ table, id: row.id });
670
+ return;
671
+ }
672
+ // Matched the connected source but no cache row yet (e.g. the
673
+ // poller hasn't picked it up since it was just created elsewhere) —
674
+ // a different, more specific miss than "not connected at all".
675
+ res.status(404).json({ error: `matched remote source "${remote.name}", but no cached doc found yet for this file — try Resync` });
676
+ return;
677
+ }
678
+ }
679
+ res.status(404).json({
680
+ error: `this file's folder isn't connected in memory-bucket yet — add it as a remote source first`,
601
681
  });
602
682
  });
603
683
  router.post('/api/folders', (req, res) => {
@@ -629,6 +709,121 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
629
709
  res.status(409).json({ error: err.message });
630
710
  }
631
711
  });
712
+ // Persists a folderfoo JWT the browser already obtained (see
713
+ // credentials.ts — keyed by server URL, so every remote source pointing
714
+ // at the same folderfoo deployment shares this one login). The actual
715
+ // login UI is folderfoo's own <folderfoo-profile-circle> widget,
716
+ // dynamically imported into the client page exactly like every other
717
+ // folderfoo-consuming app (bulletino, mindfoo) — that widget stores its
718
+ // token in THIS page's localStorage (browser-only), so the client reads
719
+ // it back out and POSTs it here to also persist it server-side, where
720
+ // the Node process (poller, live reads/writes) actually needs it. This
721
+ // avoids a second username/password prompt: one login, reused for both
722
+ // the browser tab and the backend.
723
+ router.post('/api/folderfoo/login', async (req, res) => {
724
+ const { server, token } = req.body;
725
+ if (!server || !token) {
726
+ res.status(400).json({ error: 'server and token are required' });
727
+ return;
728
+ }
729
+ setCredential(config.baseDir, server, token);
730
+ res.json({ connected: true, server });
731
+ });
732
+ // Lists the caller's own folderfoo folders for the "connect a folder"
733
+ // picker, once already logged in to `server` (via the route above).
734
+ router.get('/api/folderfoo/folders', async (req, res) => {
735
+ const server = req.query.server;
736
+ const tenantId = req.query.tenantId;
737
+ if (!server || !tenantId) {
738
+ res.status(400).json({ error: 'server and tenantId query params are required' });
739
+ return;
740
+ }
741
+ try {
742
+ const folders = await listFolderfooFolders(server, config.baseDir, tenantId);
743
+ res.json(folders);
744
+ }
745
+ catch (err) {
746
+ res.status(401).json({ error: err.message });
747
+ }
748
+ });
749
+ // Registers a REMOTE (folderfoo) source — the connect-a-folder counterpart
750
+ // to POST /api/folders above, which only ever handles local filesystem
751
+ // paths. Creates the local mirror directory, registers it with the
752
+ // repository (so it starts watching/scanning like any local folder), then
753
+ // immediately triggers one poll so content shows up without waiting for
754
+ // the first interval tick.
755
+ router.post('/api/remote-folders', async (req, res) => {
756
+ const { kind, name, server, tenantId, folderPath } = req.body;
757
+ if (kind !== 'skill' && kind !== 'memory') {
758
+ res.status(400).json({ error: 'kind must be "skill" or "memory"' });
759
+ return;
760
+ }
761
+ if (!server || !tenantId || folderPath === undefined) {
762
+ res.status(400).json({ error: 'server, tenantId, and folderPath are required' });
763
+ return;
764
+ }
765
+ const folderName = sanitizeFolderName(name || folderPath.split('/').filter(Boolean).pop() || tenantId);
766
+ if (!folderName) {
767
+ res.status(400).json({ error: 'could not derive a valid folder name — provide one explicitly' });
768
+ return;
769
+ }
770
+ const repo = kind === 'skill' ? skillRepo : memoryRepo;
771
+ const spec = kind === 'skill' ? skillSpec : memorySpec;
772
+ const mirrorDir = mirrorDirFor(config.baseDir, folderName);
773
+ const remote = { name: folderName, server, tenantId, folderPath, mirrorDir };
774
+ try {
775
+ repo.registerRemoteFolder(remote);
776
+ saveRemoteFolder(config, kind, { name: folderName, server, tenantId, folderPath });
777
+ await pollOne(db, spec, remote, config.baseDir);
778
+ res.json({ name: folderName, server, tenantId, folderPath, kind });
779
+ }
780
+ catch (err) {
781
+ res.status(409).json({ error: err.message });
782
+ }
783
+ });
784
+ // Triggers one immediate out-of-cycle poll for a single remote source —
785
+ // backs the web UI's per-source "resync now" action, so a user who just
786
+ // saved something remotely doesn't have to wait out the fixed poll
787
+ // interval to see it reflected locally.
788
+ router.post('/api/remote-folders/:kind/:name/resync', async (req, res) => {
789
+ const { kind, name } = req.params;
790
+ if (kind !== 'skill' && kind !== 'memory') {
791
+ res.status(400).json({ error: 'kind must be "skill" or "memory"' });
792
+ return;
793
+ }
794
+ if (!name) {
795
+ res.status(400).json({ error: 'name is required' });
796
+ return;
797
+ }
798
+ const handle = kind === 'skill' ? remotePollers?.skill : remotePollers?.memory;
799
+ if (!handle) {
800
+ res.status(404).json({ error: `no remote ${kind} sources configured` });
801
+ return;
802
+ }
803
+ try {
804
+ await handle.resyncNow(name);
805
+ res.json({ resynced: name, kind });
806
+ }
807
+ catch (err) {
808
+ res.status(404).json({ error: err.message });
809
+ }
810
+ });
811
+ // Lightweight "check every remote source for changes right now" — unlike
812
+ // rebuild-cache (which wipes the WHOLE local cache and re-scans
813
+ // everything, including local-only folders), this only force-resyncs the
814
+ // remote sources' own incremental pull+reconcile, so it's cheap enough to
815
+ // call on every tab focus without the user needing to hit rebuild-cache
816
+ // by hand just to see a remote change (e.g. a deletion) sooner than the
817
+ // fixed poll interval.
818
+ router.post('/api/remote-folders/resync-all', async (_req, res) => {
819
+ try {
820
+ await Promise.all([remotePollers?.skill?.resyncAll(), remotePollers?.memory?.resyncAll()]);
821
+ res.json({ resynced: true });
822
+ }
823
+ catch (err) {
824
+ res.status(500).json({ error: err.message });
825
+ }
826
+ });
632
827
  router.delete('/api/folders/:kind/:name', (req, res) => {
633
828
  const { kind, name } = req.params;
634
829
  if (kind !== 'skill' && kind !== 'memory') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.7.4",
3
+ "version": "0.8.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": {