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.
- package/dist/client/assets/{index-_w_b_MQl.js → index-y6XKwUMi.js} +70 -70
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +45 -0
- package/dist/src/memory/repository.js +41 -6
- package/dist/src/remote/folderfoo-client.js +25 -0
- package/dist/src/server.js +8 -0
- package/dist/src/shared/bucket-root-tool.js +66 -0
- package/dist/src/shared/relocate.js +17 -1
- package/dist/src/skills/repository.js +84 -21
- package/dist/src/skills/tools.js +29 -15
- package/dist/src/store/db.js +51 -13
- package/dist/src/store/search.js +9 -6
- package/dist/src/store/sync.js +43 -26
- package/dist/src/web/routes.js +138 -7
- package/package.json +1 -1
package/dist/src/store/db.js
CHANGED
|
@@ -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
|
|
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`
|
|
84
|
-
* existed need it added.
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
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();
|
package/dist/src/store/search.js
CHANGED
|
@@ -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
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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)
|
package/dist/src/store/sync.js
CHANGED
|
@@ -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
|
-
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
//
|
|
112
|
-
//
|
|
113
|
-
//
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
//
|
|
117
|
-
//
|
|
118
|
-
|
|
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
|
-
|
|
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(
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
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
|
-
|
|
155
|
-
|
|
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. */
|
package/dist/src/web/routes.js
CHANGED
|
@@ -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
|
-
|
|
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'
|
|
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'
|
|
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.
|
|
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": {
|