mcp-memory-bucket 0.8.4 → 0.8.7

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.
@@ -2,9 +2,10 @@ import Database from 'better-sqlite3';
2
2
  export function openCache(dbPath) {
3
3
  const db = new Database(dbPath);
4
4
  db.pragma('journal_mode = WAL');
5
+ ensureSkillsCompoundKey(db);
5
6
  db.exec(`
6
7
  CREATE TABLE IF NOT EXISTS skills (
7
- id TEXT PRIMARY KEY, -- == SKILL.md "name" field == parent folder name
8
+ id TEXT NOT NULL, -- == SKILL.md "name" field == parent folder name
8
9
  description TEXT NOT NULL, -- required by the agentskills.io spec
9
10
  owner TEXT,
10
11
  status TEXT NOT NULL,
@@ -18,7 +19,8 @@ export function openCache(dbPath) {
18
19
  created_at TEXT,
19
20
  attachments TEXT, -- JSON array of AttachmentEntry, mirrors frontmatter
20
21
  body TEXT NOT NULL,
21
- mtime_ms INTEGER NOT NULL
22
+ mtime_ms INTEGER NOT NULL,
23
+ PRIMARY KEY (folder, id) -- name is unique PER FOLDER, not globally — see skill_get's folder param
22
24
  );
23
25
 
24
26
  CREATE TABLE IF NOT EXISTS memory_docs (
@@ -45,6 +47,7 @@ export function openCache(dbPath) {
45
47
  CREATE VIRTUAL TABLE IF NOT EXISTS search_index USING fts5(
46
48
  ref_table UNINDEXED,
47
49
  ref_id UNINDEXED,
50
+ ref_folder UNINDEXED,
48
51
  description,
49
52
  body,
50
53
  tags,
@@ -55,12 +58,14 @@ export function openCache(dbPath) {
55
58
  CREATE TABLE IF NOT EXISTS doc_dates (
56
59
  ref_table TEXT NOT NULL,
57
60
  ref_id TEXT NOT NULL,
61
+ ref_folder TEXT NOT NULL DEFAULT '',
58
62
  date TEXT NOT NULL
59
63
  );
60
64
 
61
65
  CREATE INDEX IF NOT EXISTS idx_doc_dates_date ON doc_dates(date);
62
- CREATE INDEX IF NOT EXISTS idx_doc_dates_ref ON doc_dates(ref_table, ref_id);
66
+ CREATE INDEX IF NOT EXISTS idx_doc_dates_ref ON doc_dates(ref_table, ref_id, ref_folder);
63
67
  `);
68
+ ensureDocDatesHasRefFolderColumn(db);
64
69
  ensureColumns(db, 'skills', [
65
70
  ['folder', "TEXT NOT NULL DEFAULT ''"],
66
71
  ['deprecated', 'INTEGER NOT NULL DEFAULT 0'],
@@ -80,21 +85,26 @@ export function openCache(dbPath) {
80
85
  return db;
81
86
  }
82
87
  /**
83
- * search_index is an FTS5 virtual table — existing cache files created before the `key` column
84
- * existed need it added. FTS5 doesn't support ALTER TABLE ADD COLUMN reliably across versions, and
85
- * search_index is a disposable cache (rebuilt from skills/memory_docs, not the source of truth), so
86
- * the simplest safe migration is: drop and recreate with the new schema, then let backfillSearchIndex
87
- * repopulate everything.
88
+ * search_index is an FTS5 virtual table — existing cache files created before the `key` or
89
+ * `ref_folder` column existed need it added. `ref_folder` was added alongside skills' compound
90
+ * (folder, id) key (see ensureSkillsCompoundKey's doc comment): once two different folders can
91
+ * legitimately have a skill sharing the same `id`, ref_id alone no longer uniquely identifies a
92
+ * search_index row, so ref_folder joins ref_table/ref_id as part of every lookup/delete on this
93
+ * table. FTS5 doesn't support ALTER TABLE ADD COLUMN reliably across versions, and search_index is
94
+ * a disposable cache (rebuilt from skills/memory_docs, not the source of truth), so the simplest
95
+ * safe migration is: drop and recreate with the new schema, then let backfillSearchIndex repopulate
96
+ * everything.
88
97
  */
89
98
  function ensureSearchIndexHasKeyColumn(db) {
90
99
  const cols = db.prepare(`PRAGMA table_info(search_index)`).all();
91
- if (cols.length === 0 || cols.some((c) => c.name === 'key'))
100
+ if (cols.length === 0 || (cols.some((c) => c.name === 'key') && cols.some((c) => c.name === 'ref_folder')))
92
101
  return; // fresh table, or already migrated
93
102
  db.exec(`DROP TABLE search_index`);
94
103
  db.exec(`
95
104
  CREATE VIRTUAL TABLE search_index USING fts5(
96
105
  ref_table UNINDEXED,
97
106
  ref_id UNINDEXED,
107
+ ref_folder UNINDEXED,
98
108
  description,
99
109
  body,
100
110
  tags,
@@ -103,6 +113,34 @@ function ensureSearchIndexHasKeyColumn(db) {
103
113
  );
104
114
  `);
105
115
  }
116
+ /**
117
+ * Migration for cache files created before doc_dates had a ref_folder column — same reason as
118
+ * search_index's ref_folder addition above (ensureSearchIndexHasKeyColumn's doc comment). Unlike
119
+ * search_index, doc_dates is a plain table, so a simple ALTER TABLE ADD COLUMN suffices; no drop
120
+ * needed. Existing rows backfill to '' (unscoped), which is safe: the only NEW ambiguity ref_folder
121
+ * resolves is a skill-name collision across folders, and any pre-existing doc_dates row predates
122
+ * that possibility (skills only just gained a compound key), so nothing has been misattributed yet.
123
+ */
124
+ function ensureDocDatesHasRefFolderColumn(db) {
125
+ const cols = db.prepare(`PRAGMA table_info(doc_dates)`).all();
126
+ if (cols.length === 0 || cols.some((c) => c.name === 'ref_folder'))
127
+ return; // fresh table, or already migrated
128
+ db.exec(`ALTER TABLE doc_dates ADD COLUMN ref_folder TEXT NOT NULL DEFAULT ''`);
129
+ }
130
+ /**
131
+ * Migration for cache files created before `skills` had a compound (folder, id) PRIMARY KEY — name
132
+ * uniqueness moved from global to per-folder (see relocate's folder-scoping fix), so the old bare-`id`
133
+ * key must become (folder, id). `skills` is a disposable cache fully rebuilt from SKILL.md files on
134
+ * disk (initialScan/the file watcher repopulate it), so — same precedent as
135
+ * ensureSearchIndexHasKeyColumn's search_index migration — the simplest safe migration is to drop and
136
+ * let the normal scan repopulate, rather than hand-writing a data-preserving ALTER TABLE.
137
+ */
138
+ function ensureSkillsCompoundKey(db) {
139
+ const pk = db.prepare(`PRAGMA table_info(skills)`).all().filter((c) => c.pk > 0);
140
+ if (pk.length === 0 || pk.some((c) => c.name === 'folder'))
141
+ return; // fresh table, or already migrated to the compound key
142
+ db.exec(`DROP TABLE skills`);
143
+ }
106
144
  /** Migration for cache files created before a given column existed. Safe to call every startup. */
107
145
  function ensureColumns(db, table, columns) {
108
146
  const existing = new Set(db.prepare(`PRAGMA table_info(${table})`).all().map((c) => c.name));
@@ -116,17 +154,17 @@ function backfillSearchIndex(db) {
116
154
  const { count: indexed } = db.prepare(`SELECT COUNT(*) as count FROM search_index`).get();
117
155
  if (indexed > 0)
118
156
  return;
119
- const skillRows = db.prepare(`SELECT id, description, body, tags FROM skills`).all();
157
+ const skillRows = db.prepare(`SELECT id, folder, description, body, tags FROM skills`).all();
120
158
  const memoryRows = db.prepare(`SELECT id, key, description, body, tags FROM memory_docs`).all();
121
159
  if (skillRows.length === 0 && memoryRows.length === 0)
122
160
  return;
123
- const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags, key) VALUES (?, ?, ?, ?, ?, ?)`);
161
+ const insert = db.prepare(`INSERT INTO search_index (ref_table, ref_id, ref_folder, description, body, tags, key) VALUES (?, ?, ?, ?, ?, ?, ?)`);
124
162
  const insertAll = db.transaction(() => {
125
163
  for (const row of skillRows) {
126
- insert.run('skills', row.id, row.description, row.body, flattenTags(row.tags), '');
164
+ insert.run('skills', row.id, row.folder, row.description, row.body, flattenTags(row.tags), '');
127
165
  }
128
166
  for (const row of memoryRows) {
129
- insert.run('memory_docs', row.id, row.description, row.body, flattenTags(row.tags), row.key);
167
+ insert.run('memory_docs', row.id, '', row.description, row.body, flattenTags(row.tags), row.key);
130
168
  }
131
169
  });
132
170
  insertAll();
@@ -93,17 +93,20 @@ export function searchByDate(db, start, end, opts = {}) {
93
93
  params.push(table);
94
94
  params.push(limit, offset);
95
95
  const rows = db
96
- .prepare(`SELECT ref_table, ref_id, MIN(date) AS matched_date
96
+ .prepare(`SELECT ref_table, ref_id, ref_folder, MIN(date) AS matched_date
97
97
  FROM doc_dates
98
98
  WHERE date BETWEEN ? AND ? ${table ? 'AND ref_table = ?' : ''}
99
- GROUP BY ref_table, ref_id
99
+ GROUP BY ref_table, ref_id, ref_folder
100
100
  ORDER BY matched_date
101
101
  LIMIT ? OFFSET ?`)
102
102
  .all(...params);
103
103
  return rows.map((row) => {
104
- const bodyRow = db
105
- .prepare(`SELECT body FROM ${row.ref_table} WHERE id = ?`)
106
- .get(row.ref_id);
104
+ // Scoped by ref_folder for skills (compound key: a bare `id` can match two different
105
+ // folders' same-named skills — see doc_dates'/search_index's ref_folder column). memory_docs
106
+ // ids never collide, so ref_folder is always '' there and the extra condition is a no-op.
107
+ const bodyRow = (row.ref_table === 'skills'
108
+ ? db.prepare(`SELECT body FROM skills WHERE id = ? AND folder = ?`).get(row.ref_id, row.ref_folder)
109
+ : db.prepare(`SELECT body FROM memory_docs WHERE id = ?`).get(row.ref_id));
107
110
  return {
108
111
  ref_table: row.ref_table,
109
112
  ref_id: row.ref_id,
@@ -128,7 +131,7 @@ export function searchCombined(db, query, limit = 20, offset = 0) {
128
131
  snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
129
132
  -bm25(search_index) AS score
130
133
  FROM search_index
131
- LEFT JOIN skills s ON search_index.ref_table = 'skills' AND s.id = search_index.ref_id
134
+ LEFT JOIN skills s ON search_index.ref_table = 'skills' AND s.id = search_index.ref_id AND s.folder = search_index.ref_folder
132
135
  LEFT JOIN memory_docs m ON search_index.ref_table = 'memory_docs' AND m.id = search_index.ref_id
133
136
  WHERE search_index MATCH ?
134
137
  ORDER BY bm25(search_index)
@@ -104,37 +104,50 @@ export function upsertFile(db, spec, filePath) {
104
104
  console.error(`[memory-bucket] skipping ${filePath}: missing required id field in frontmatter`);
105
105
  return;
106
106
  }
107
- // id/name is the table's real PRIMARY KEY (used as the sole addressing
108
- // handle across the whole public API - skill_get(name), memory_get(id),
109
- // etc.), so a DIFFERENT file claiming the same id is a genuine name
110
- // collision across configured folders/sources, not a re-scan of the same
111
- // file. The ON CONFLICT(id) below would otherwise silently overwrite
112
- // whichever row synced first - the first-synced item's row (and its
113
- // full content) simply vanishes from the cache with no error and no way
114
- // to address it, since id is the only lookup key this schema has.
115
- // Refuse the overwrite and warn loudly instead: the first-synced item
116
- // keeps working, the colliding one is visibly excluded rather than
117
- // invisibly clobbering it.
118
- const existingById = db.prepare(`SELECT source_path FROM ${spec.table} WHERE id = ?`).get(id);
107
+ const row = spec.toRow(frontmatter, filePath, parsed.mtimeMs);
108
+ const folder = folderForFile(spec.sources, filePath);
109
+ // `skills` keys on (folder, id): name is unique PER FOLDER, not globally (see skill_get's
110
+ // folder param) — two different folders each having a same-named skill is legitimate, so the
111
+ // collision guard and upsert conflict target both scope by folder too. `memory_docs` keeps a
112
+ // bare `id` PRIMARY KEY: its id already bakes in a random UUID suffix (memory/repository.ts's
113
+ // create()), so a genuine id collision there is a practically-impossible coincidence, not the
114
+ // everyday case skills hits from two folders sharing a human-chosen name.
115
+ const scopedById = spec.table === 'skills';
116
+ // id/name is (part of) the table's real PRIMARY KEY (the sole addressing handle across the
117
+ // whole public API - skill_get(name, folder?), memory_get(id), etc.), so a DIFFERENT file
118
+ // claiming the same id (within the same folder, for skills) is a genuine collision, not a
119
+ // re-scan of the same file. The ON CONFLICT below would otherwise silently overwrite whichever
120
+ // row synced first - the first-synced item's row (and its full content) simply vanishes from
121
+ // the cache with no error and no way to address it. Refuse the overwrite and warn loudly
122
+ // instead: the first-synced item keeps working, the colliding one is visibly excluded rather
123
+ // than invisibly clobbering it.
124
+ const existingById = (scopedById
125
+ ? db.prepare(`SELECT source_path FROM ${spec.table} WHERE folder = ? AND id = ?`).get(folder, id)
126
+ : db.prepare(`SELECT source_path FROM ${spec.table} WHERE id = ?`).get(id));
119
127
  if (existingById && existingById.source_path !== filePath) {
120
- console.error(`[memory-bucket] SKIPPED indexing ${filePath}: id "${id}" already used by ${existingById.source_path} — ` +
121
- `names must be unique across every configured folder. Rename one of these two files (or its frontmatter id/name) to resolve the collision.`);
128
+ console.error(`[memory-bucket] SKIPPED indexing ${filePath}: id "${id}"${scopedById ? ` in folder "${folder}"` : ''} already used by ${existingById.source_path} — ` +
129
+ `${scopedById ? 'names must be unique within a folder' : 'ids must be unique across every configured folder'}. Rename one of these two files (or its frontmatter id/name) to resolve the collision.`);
122
130
  return;
123
131
  }
124
- const row = spec.toRow(frontmatter, filePath, parsed.mtimeMs);
125
- const folder = folderForFile(spec.sources, filePath);
126
132
  const cols = [...spec.columns, 'source_path', 'folder', 'body', 'mtime_ms'];
127
133
  const values = [...spec.columns.map((c) => row[c]), filePath, folder, parsed.body, parsed.mtimeMs];
128
134
  const placeholders = cols.map(() => '?').join(', ');
129
135
  const updateClause = cols
130
- .filter((c) => c !== 'id')
136
+ .filter((c) => c !== 'id' && c !== 'folder')
131
137
  .map((c) => `${c} = excluded.${c}`)
132
138
  .join(', ');
139
+ const conflictTarget = scopedById ? 'folder, id' : 'id';
133
140
  db.prepare(`INSERT INTO ${spec.table} (${cols.join(', ')}) VALUES (${placeholders})
134
- ON CONFLICT(id) DO UPDATE SET ${updateClause}`).run(...values);
135
- db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(spec.table, id);
136
- db.prepare(`INSERT INTO search_index (ref_table, ref_id, description, body, tags, key) VALUES (?, ?, ?, ?, ?, ?)`).run(spec.table, id, String(row.description ?? ''), parsed.body, flattenTags(String(row.tags ?? '[]')), String(row.key ?? ''));
137
- db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ?`).run(spec.table, id);
141
+ ON CONFLICT(${conflictTarget}) DO UPDATE SET ${updateClause}`).run(...values);
142
+ // ref_folder scopes these two tables' delete-then-reinsert the same way the skills upsert above
143
+ // does: two skills in different folders can now legitimately share an `id`, so ref_id alone is
144
+ // no longer enough to identify "this file's" search_index/doc_dates rows without also clobbering
145
+ // the other folder's same-named skill's rows. memory_docs ids never collide (see scopedById's
146
+ // comment above), so '' (unscoped) is safe there.
147
+ const refFolder = scopedById ? folder : '';
148
+ db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(spec.table, id, refFolder);
149
+ db.prepare(`INSERT INTO search_index (ref_table, ref_id, ref_folder, description, body, tags, key) VALUES (?, ?, ?, ?, ?, ?, ?)`).run(spec.table, id, refFolder, String(row.description ?? ''), parsed.body, flattenTags(String(row.tags ?? '[]')), String(row.key ?? ''));
150
+ db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(spec.table, id, refFolder);
138
151
  const dates = new Set(extractDates(parsed.body));
139
152
  // created_at is a UTC instant; convert to the OS-local calendar date so it
140
153
  // lines up with what a user in this timezone would call "today", matching
@@ -142,17 +155,21 @@ export function upsertFile(db, spec, filePath) {
142
155
  if (row.created_at)
143
156
  dates.add(toLocalDate(String(row.created_at)));
144
157
  if (dates.size > 0) {
145
- const insertDate = db.prepare(`INSERT INTO doc_dates (ref_table, ref_id, date) VALUES (?, ?, ?)`);
158
+ const insertDate = db.prepare(`INSERT INTO doc_dates (ref_table, ref_id, ref_folder, date) VALUES (?, ?, ?, ?)`);
146
159
  for (const date of dates)
147
- insertDate.run(spec.table, id, date);
160
+ insertDate.run(spec.table, id, refFolder, date);
148
161
  }
149
162
  }
150
163
  export function removeFile(db, table, filePath) {
151
- const existing = db.prepare(`SELECT id FROM ${table} WHERE source_path = ?`).get(filePath);
164
+ const existing = db.prepare(`SELECT id, folder FROM ${table} WHERE source_path = ?`).get(filePath);
152
165
  db.prepare(`DELETE FROM ${table} WHERE source_path = ?`).run(filePath);
153
166
  if (existing) {
154
- db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ?`).run(table, existing.id);
155
- db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ?`).run(table, existing.id);
167
+ // Scoped by folder for the same reason upsertFile's ref_folder is (skills' compound key means
168
+ // ref_id alone can match another folder's same-named skill) - memory_docs ids never collide so
169
+ // its rows are always stored/looked-up with ref_folder='', matching backfillSearchIndex.
170
+ const refFolder = table === 'skills' ? existing.folder : '';
171
+ db.prepare(`DELETE FROM search_index WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(table, existing.id, refFolder);
172
+ db.prepare(`DELETE FROM doc_dates WHERE ref_table = ? AND ref_id = ? AND ref_folder = ?`).run(table, existing.id, refFolder);
156
173
  }
157
174
  }
158
175
  /** Full scan of all configured source dirs — used once at startup before the watcher takes over. */
@@ -3,7 +3,7 @@ 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, saveRemoteFolder, removeFolder as removeFolderFromConfig, sanitizeFolderName, mirrorDirFor } from '../config.js';
6
+ import { saveFolder, saveRemoteFolder, removeFolder as removeFolderFromConfig, updateRemoteFolderPath as updateRemoteFolderPathInConfig, 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';
@@ -122,7 +122,15 @@ function queryTable(db, table, filters, restrictToIds) {
122
122
  params.push(filters.paused === '1' ? 1 : 0);
123
123
  }
124
124
  if (restrictToIds) {
125
- where += ` AND id IN (${[...restrictToIds].map(() => '?').join(', ')})`;
125
+ // Skills restrict on the composite (folder, id) pair — see skillKey's comment — since a bare
126
+ // `id IN (...)` could match the wrong folder's same-named skill once names are folder-scoped.
127
+ // memory_docs restricts on plain id, unchanged (its ids never collide across folders).
128
+ if (table === 'skills') {
129
+ where += ` AND (folder || '${SKILL_KEY_SEP}' || id) IN (${[...restrictToIds].map(() => '?').join(', ')})`;
130
+ }
131
+ else {
132
+ where += ` AND id IN (${[...restrictToIds].map(() => '?').join(', ')})`;
133
+ }
126
134
  params.push(...restrictToIds);
127
135
  }
128
136
  if (table === 'skills') {
@@ -166,6 +174,15 @@ function queryTable(db, table, filters, restrictToIds) {
166
174
  created_at: r.created_at,
167
175
  }));
168
176
  }
177
+ // Skills key on (folder, id) — two different folders can legitimately share a skill name — so an
178
+ // id-only restriction set can no longer safely identify "this one skill" for filtering (it would
179
+ // either miss a real match or wrongly include the other folder's same-named skill). Skill entries in
180
+ // these restriction sets are therefore composite `folder\0id` strings; memory_docs entries stay
181
+ // plain ids, since memory_docs.id already bakes in a random UUID suffix and never collides.
182
+ const SKILL_KEY_SEP = '';
183
+ function skillKey(folder, id) {
184
+ return `${folder}${SKILL_KEY_SEP}${id}`;
185
+ }
169
186
  /** Combines two optional id-restriction sets (e.g. from `q` and a date range) into one, when both are present. */
170
187
  function intersectIds(a, b) {
171
188
  if (!a)
@@ -189,10 +206,13 @@ function matchDateRange(db, from, to) {
189
206
  params.push(to);
190
207
  }
191
208
  const rows = db
192
- .prepare(`SELECT DISTINCT ref_table, ref_id FROM doc_dates WHERE ${where}`)
209
+ .prepare(`SELECT DISTINCT ref_table, ref_id, ref_folder FROM doc_dates WHERE ${where}`)
193
210
  .all(...params);
194
211
  for (const row of rows) {
195
- (row.ref_table === 'skills' ? skills : memory_docs).add(row.ref_id);
212
+ if (row.ref_table === 'skills')
213
+ skills.add(skillKey(row.ref_folder, row.ref_id));
214
+ else
215
+ memory_docs.add(row.ref_id);
196
216
  }
197
217
  return { skills, memory_docs };
198
218
  }
@@ -203,7 +223,7 @@ function matchSearch(db, q) {
203
223
  let rows;
204
224
  try {
205
225
  rows = db
206
- .prepare(`SELECT ref_table, ref_id FROM search_index WHERE search_index MATCH ? ORDER BY rank`)
226
+ .prepare(`SELECT ref_table, ref_id, ref_folder FROM search_index WHERE search_index MATCH ? ORDER BY rank`)
207
227
  .all(sanitizeFtsQuery(q));
208
228
  }
209
229
  catch {
@@ -211,7 +231,10 @@ function matchSearch(db, q) {
211
231
  return { skills, memory_docs };
212
232
  }
213
233
  for (const row of rows) {
214
- (row.ref_table === 'skills' ? skills : memory_docs).add(row.ref_id);
234
+ if (row.ref_table === 'skills')
235
+ skills.add(skillKey(row.ref_folder, row.ref_id));
236
+ else
237
+ memory_docs.add(row.ref_id);
215
238
  }
216
239
  return { skills, memory_docs };
217
240
  }
@@ -627,12 +650,78 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
627
650
  router.get('/api/config', (_req, res) => {
628
651
  res.json({ folderfooMode: config.folderfooMode, folderfooHost: config.folderfooHost });
629
652
  });
630
- router.get('/api/folders', (_req, res) => {
653
+ router.get('/api/folders', async (_req, res) => {
654
+ await pruneDeletedRemoteFolders();
631
655
  res.json({
632
656
  skill: skillRepo.listFoldersWithRemoteInfo().map((f) => ({ ...f, kind: 'skill' })),
633
657
  memory: memoryRepo.listFoldersWithRemoteInfo().map((f) => ({ ...f, kind: 'memory' })),
634
658
  });
635
659
  });
660
+ /**
661
+ * Checked on every web UI open/focus (this route is what the client's mount/load fetches) rather
662
+ * than on every poll tick — a folder deleted server-side on folderfoo otherwise lingers forever
663
+ * in mem-bucket's config/UI as a permanently-empty source: the poller's own reconcileDeletions
664
+ * (remote-sync.ts) already correctly empties out a deleted remote folder's mirrored FILES on its
665
+ * normal cycle, but nothing previously checked whether the folder itself still exists at all, as
666
+ * opposed to whether its contents changed.
667
+ *
668
+ * Reuses folderfoo's existing GET /folders (via listFolderfooFolders) — one bulk call per
669
+ * distinct folderfoo server returns every currently-existing folder path for that login, cheaper
670
+ * than a per-folder existence check would be for an account with many connected sources. Sources
671
+ * sharing one server (the common case — see credentials.ts, one login per server) dedupe to a
672
+ * single call. A confirmed-gone folder is auto-removed via the same removeFolder()+
673
+ * removeFolderFromConfig() pair the manual "✕ remove folder" route above already uses — this
674
+ * never touches a folder's contents, only mem-bucket's own registration of it (see
675
+ * removeFolder's doc comment: it drops DB rows + the local mirror dir, never anything the
676
+ * folder's write path would consider "the user's own content" beyond that mirror).
677
+ *
678
+ * Deliberately does NOT run at write time: a write against a since-deleted remote folder instead
679
+ * fails loudly with its own specific error (see repository create()/update()'s remote-push
680
+ * handling) rather than silently recreating the folder — auto-recreating on write would silently
681
+ * undo a deletion the user may have made deliberately, with no visibility that it happened.
682
+ */
683
+ async function pruneDeletedRemoteFolders() {
684
+ const byServer = new Map();
685
+ for (const [kind, repo] of [
686
+ ['skill', skillRepo],
687
+ ['memory', memoryRepo],
688
+ ]) {
689
+ for (const remote of repo.listRemoteFolders()) {
690
+ const list = byServer.get(remote.server) ?? [];
691
+ list.push({ kind, name: remote.name, folderPath: remote.folderPath, tenantId: remote.tenantId });
692
+ byServer.set(remote.server, list);
693
+ }
694
+ }
695
+ if (byServer.size === 0)
696
+ return;
697
+ for (const [server, sources] of byServer) {
698
+ let livePaths;
699
+ try {
700
+ // Every source on one server shares one login/tenant (credentials.ts is keyed by server
701
+ // alone), so any one source's tenantId is representative for the whole group's GET /folders
702
+ // call.
703
+ const folders = await listFolderfooFolders(server, config.baseDir, sources[0].tenantId);
704
+ livePaths = new Set(folders.map((f) => f.path));
705
+ }
706
+ catch {
707
+ // Not logged in, network blip, etc. — leave every source on this server untouched rather
708
+ // than treating "couldn't check" as "confirmed gone."
709
+ continue;
710
+ }
711
+ for (const source of sources) {
712
+ if (livePaths.has(source.folderPath))
713
+ continue;
714
+ const repo = source.kind === 'skill' ? skillRepo : memoryRepo;
715
+ try {
716
+ repo.removeFolder(source.name);
717
+ removeFolderFromConfig(config, source.kind, source.name);
718
+ }
719
+ catch (err) {
720
+ console.error(`[memory-bucket] failed to auto-remove deleted remote folder "${source.name}":`, err);
721
+ }
722
+ }
723
+ }
724
+ }
636
725
  // Resolves a file opened via folderfoo's own File Open dialog
637
726
  // (<folderfoo-profile-circle>'s "folderfoo-file-open" event) back to a
638
727
  // memory-bucket doc, so the web UI can select it in the right panel —
@@ -751,6 +840,12 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
751
840
  // already-stamped entries become visible.
752
841
  identity.setUsername(decodeUsername(token));
753
842
  res.json({ connected: true, server });
843
+ // Force-resyncs every remote source now that this identity's folders are
844
+ // visible again, rather than leaving them showing whatever was last
845
+ // synced until the next fixed poll tick or tab-focus resync. Fired after
846
+ // responding (best-effort, same posture as the rest of this route) so
847
+ // login doesn't wait on a round trip to every remote source.
848
+ Promise.all([remotePollers?.skill?.resyncAll(), remotePollers?.memory?.resyncAll()]).catch(() => { });
754
849
  });
755
850
  // Signals that the browser's folderfoo login session just ended (the page
756
851
  // detects folderfoo's own auth-change event going to logged-out) - clears
@@ -900,6 +995,42 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
900
995
  res.status(404).json({ error: err.message });
901
996
  }
902
997
  });
998
+ /**
999
+ * Reacts to the embedded File Open dialog's `folderfoo-folder-changed` event (rename/move, see
1000
+ * the client's mem-bucket-app.ts listener) — repoints any registered remote source whose
1001
+ * folderPath is the renamed folder itself or nested under it, in BOTH repos (a folderPath rename
1002
+ * has no kind of its own; the same folderfoo path could in principle be connected as a skill
1003
+ * source, a memory source, or both, independently). Unlike DELETE /api/folders above, this never
1004
+ * removes anything — same content, same local mirror, just a different remote path going
1005
+ * forward — matching folderfoo's own rename semantics.
1006
+ *
1007
+ * `server` scopes the match: a folderPath string is only meaningful within one folderfoo
1008
+ * deployment, and a source connected to a different server sharing the same path string by
1009
+ * coincidence must not be touched.
1010
+ */
1011
+ router.post('/api/folders/renamed', (req, res) => {
1012
+ const { server, oldPath, newPath } = req.body;
1013
+ if (!server || oldPath === undefined || !newPath) {
1014
+ res.status(400).json({ error: 'server, oldPath, and newPath are required' });
1015
+ return;
1016
+ }
1017
+ const updated = [];
1018
+ for (const [kind, repo] of [
1019
+ ['skill', skillRepo],
1020
+ ['memory', memoryRepo],
1021
+ ]) {
1022
+ for (const remote of repo.listRemoteFolders()) {
1023
+ if (remote.server !== server)
1024
+ continue;
1025
+ if (remote.folderPath !== oldPath && !(oldPath && remote.folderPath.startsWith(`${oldPath}/`)))
1026
+ continue;
1027
+ repo.updateRemoteFolderPath(remote.name, oldPath, newPath);
1028
+ updateRemoteFolderPathInConfig(config, kind, remote.name, oldPath, newPath);
1029
+ updated.push({ kind, name: remote.name, folderPath: repo.listRemoteFolders().find((f) => f.name === remote.name).folderPath });
1030
+ }
1031
+ }
1032
+ res.json({ updated });
1033
+ });
903
1034
  router.get('/api/fs/list', (req, res) => {
904
1035
  const requested = req.query.path?.trim();
905
1036
  const dirPath = requested ? path.resolve(requested) : os.homedir();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mcp-memory-bucket",
3
- "version": "0.8.4",
3
+ "version": "0.8.7",
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": {