mcp-memory-bucket 0.10.12 → 0.10.13
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-3eH2lCtQ.js → index-BuS9zM5d.js} +265 -217
- package/dist/client/index.html +1 -1
- package/dist/src/config.js +9 -4
- package/dist/src/memory/repository.js +74 -19
- package/dist/src/remote/folderfoo-client.js +71 -31
- package/dist/src/remote/remote-sync.js +4 -4
- package/dist/src/remote/shared-items.js +51 -12
- package/dist/src/shared/bucket-share-tool.js +1 -1
- package/dist/src/skills/repository.js +48 -15
- package/dist/src/web/routes.js +165 -46
- package/package.json +1 -1
|
@@ -83,9 +83,9 @@ export class SkillRepository {
|
|
|
83
83
|
const remote = this.remoteFor(targetFolderName);
|
|
84
84
|
if (!remote || !this.credentialsBaseDir)
|
|
85
85
|
return;
|
|
86
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolderName);
|
|
86
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolderName, remote.owner);
|
|
87
87
|
const skillDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(filePath)));
|
|
88
|
-
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', fileContents);
|
|
88
|
+
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', fileContents, remote.owner);
|
|
89
89
|
}
|
|
90
90
|
/**
|
|
91
91
|
* Pushes one attachment's raw binary content to folderfoo, if `folderName` resolves to a remote
|
|
@@ -100,9 +100,9 @@ export class SkillRepository {
|
|
|
100
100
|
const remote = this.remoteFor(folderName);
|
|
101
101
|
if (!remote || !this.credentialsBaseDir)
|
|
102
102
|
return;
|
|
103
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
|
|
103
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
|
|
104
104
|
const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
|
|
105
|
-
await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType);
|
|
105
|
+
await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType, remote.owner);
|
|
106
106
|
}
|
|
107
107
|
/**
|
|
108
108
|
* Trashes one attachment's remote copy on folderfoo, if `folderName` resolves to a remote source
|
|
@@ -115,9 +115,9 @@ export class SkillRepository {
|
|
|
115
115
|
const remote = this.remoteFor(folderName);
|
|
116
116
|
if (!remote || !this.credentialsBaseDir)
|
|
117
117
|
return;
|
|
118
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
|
|
118
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
|
|
119
119
|
const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
|
|
120
|
-
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath));
|
|
120
|
+
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), remote.owner);
|
|
121
121
|
}
|
|
122
122
|
/** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
|
|
123
123
|
setWatcher(watcher) {
|
|
@@ -162,6 +162,10 @@ export class SkillRepository {
|
|
|
162
162
|
* perform the initial pull itself — the caller (the web route) does one
|
|
163
163
|
* immediate poll right after this returns, so content shows up without
|
|
164
164
|
* waiting for the first interval tick.
|
|
165
|
+
*
|
|
166
|
+
* `remote.name` (and `remote.mirrorDir`, derived from it) must already be the FINAL, resolved
|
|
167
|
+
* name — see resolveAvailableName, which the web route calls first (before it derives mirrorDir)
|
|
168
|
+
* so a possibly-auto-suffixed name is settled before anything touches disk.
|
|
165
169
|
*/
|
|
166
170
|
registerRemoteFolder(remote) {
|
|
167
171
|
if (this.folders.some((f) => f.name === remote.name)) {
|
|
@@ -169,11 +173,40 @@ export class SkillRepository {
|
|
|
169
173
|
}
|
|
170
174
|
fs.mkdirSync(remote.mirrorDir, { recursive: true });
|
|
171
175
|
this.remoteFolders.push(remote);
|
|
172
|
-
this.addFolder({ name: remote.name, path: remote.mirrorDir });
|
|
176
|
+
this.addFolder({ name: remote.name, path: remote.mirrorDir }, { skipCollisionCheck: true });
|
|
173
177
|
}
|
|
174
|
-
/**
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
/**
|
|
179
|
+
* `name` must stay globally unique across EVERY identity this process has ever seen — see
|
|
180
|
+
* memory/repository.ts's identical method for the full reasoning and the own-identity-vs-
|
|
181
|
+
* different-identity distinction (auto-suffix vs reject). Called by the web route BEFORE deriving
|
|
182
|
+
* mirrorDir/calling registerRemoteFolder, so the name is fully settled before anything touches disk.
|
|
183
|
+
*/
|
|
184
|
+
resolveAvailableName(requestedName, connectingUsername) {
|
|
185
|
+
const takenByOwnIdentity = (name) => this.folders.some((f) => f.name === name) && this.isFolderNameVisible(name);
|
|
186
|
+
const taken = (name) => this.folders.some((f) => f.name === name);
|
|
187
|
+
if (!taken(requestedName))
|
|
188
|
+
return requestedName;
|
|
189
|
+
if (takenByOwnIdentity(requestedName)) {
|
|
190
|
+
throw new Error(`a skill folder named "${requestedName}" already exists`);
|
|
191
|
+
}
|
|
192
|
+
let candidate = `${requestedName} (${connectingUsername})`;
|
|
193
|
+
let suffix = 2;
|
|
194
|
+
while (taken(candidate)) {
|
|
195
|
+
if (takenByOwnIdentity(candidate)) {
|
|
196
|
+
throw new Error(`a skill folder named "${candidate}" already exists`);
|
|
197
|
+
}
|
|
198
|
+
candidate = `${requestedName} (${connectingUsername}) ${suffix}`;
|
|
199
|
+
suffix++;
|
|
200
|
+
}
|
|
201
|
+
return candidate;
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Registers a new folder: appends it, scans it once, and starts watching it live. A purely LOCAL
|
|
205
|
+
* folder has no "different identity" to auto-suffix against — any collision here is
|
|
206
|
+
* unconditionally a plain rejection, unlike registerRemoteFolder's own resolveAvailableName.
|
|
207
|
+
*/
|
|
208
|
+
addFolder(folder, options) {
|
|
209
|
+
if (!options?.skipCollisionCheck && this.folders.some((f) => f.name === folder.name)) {
|
|
177
210
|
throw new Error(`a skill folder named "${folder.name}" already exists`);
|
|
178
211
|
}
|
|
179
212
|
this.folders.push(folder);
|
|
@@ -367,7 +400,7 @@ export class SkillRepository {
|
|
|
367
400
|
// block around this already-frontmattered blob (see memory/
|
|
368
401
|
// repository.ts's get() for the confirmed real-world corruption this
|
|
369
402
|
// caused).
|
|
370
|
-
const liveBody = matter(await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL')).content.trim();
|
|
403
|
+
const liveBody = matter(await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, 'SKILL', remote.owner)).content.trim();
|
|
371
404
|
return { ...doc, body: liveBody };
|
|
372
405
|
}
|
|
373
406
|
/** Fetches many skills by name in one call — e.g. hydrating full bodies for a batch of search() hits. Missing names are simply absent from the result, not errors. */
|
|
@@ -506,10 +539,10 @@ export class SkillRepository {
|
|
|
506
539
|
await writeRemoteThenLocal(async () => {
|
|
507
540
|
if (!remote || !this.credentialsBaseDir)
|
|
508
541
|
return;
|
|
509
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
542
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
510
543
|
const oldDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, oldDir));
|
|
511
544
|
const newDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, newDir));
|
|
512
|
-
await renameRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, oldDirRelPath, newDirRelPath);
|
|
545
|
+
await renameRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, oldDirRelPath, newDirRelPath, remote.owner);
|
|
513
546
|
}, () => {
|
|
514
547
|
fs.renameSync(oldDir, newDir);
|
|
515
548
|
// The rename above already moved SKILL.md verbatim (old name/frontmatter) along with the
|
|
@@ -618,9 +651,9 @@ export class SkillRepository {
|
|
|
618
651
|
const skillDir = path.dirname(existing.source_path);
|
|
619
652
|
const remote = this.remoteFor(existing.folder);
|
|
620
653
|
if (remote && this.credentialsBaseDir) {
|
|
621
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
654
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
622
655
|
const skillDirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, skillDir));
|
|
623
|
-
await trashRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath);
|
|
656
|
+
await trashRemoteFolder(remote.server, this.credentialsBaseDir, remote.tenantId, skillDirRelPath, remote.owner);
|
|
624
657
|
}
|
|
625
658
|
fs.rmSync(skillDir, { recursive: true, force: true });
|
|
626
659
|
removeFile(this.db, 'skills', existing.source_path);
|
package/dist/src/web/routes.js
CHANGED
|
@@ -5,7 +5,7 @@ import express from 'express';
|
|
|
5
5
|
import matter from 'gray-matter';
|
|
6
6
|
import { saveFolder, saveRemoteFolder, removeFolder as removeFolderFromConfig, updateRemoteFolderPath as updateRemoteFolderPathInConfig, sanitizeFolderName, mirrorDirFor, } from '../config.js';
|
|
7
7
|
import { stripKey } from '../memory/repository.js';
|
|
8
|
-
import { initialScan } from '../store/sync.js';
|
|
8
|
+
import { initialScan, walkMarkdownFiles } 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, ATTACHMENT_MAX_BYTES } from '../attachments/storage.js';
|
|
@@ -14,7 +14,7 @@ import { setCredential } from '../remote/credentials.js';
|
|
|
14
14
|
import { pollOne } from '../remote/remote-sync.js';
|
|
15
15
|
import { decodeUsername, isFolderVisible } from '../remote/identity.js';
|
|
16
16
|
import { refreshSharedItems, listSharedItems, dismissRevokedSharedItem, addSharedItem, getSharedItem, resolveShareTarget, } from '../remote/shared-items.js';
|
|
17
|
-
import { shareWithUser, unshareWithUser, createShareLink, createPublicLink, joinRemoteFolderPath } from '../remote/folderfoo-client.js';
|
|
17
|
+
import { shareWithUser, unshareWithUser, createShareLink, createPublicLink, joinRemoteFolderPath, getSharedWithMe, FolderfooRequestError } from '../remote/folderfoo-client.js';
|
|
18
18
|
import { readMarkdownFile } from '../store/markdown-file.js';
|
|
19
19
|
import { getChannel, listChannels } from '../channels/store.js';
|
|
20
20
|
function asArray(v) {
|
|
@@ -952,59 +952,109 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
952
952
|
* opposed to whether its contents changed.
|
|
953
953
|
*
|
|
954
954
|
* Reuses folderfoo's existing GET /folders (via listFolderfooFolders) — one bulk call per
|
|
955
|
-
* distinct folderfoo server returns every currently-existing folder path for
|
|
956
|
-
* than a per-folder existence check would be for an account with many connected
|
|
957
|
-
*
|
|
958
|
-
*
|
|
959
|
-
*
|
|
960
|
-
*
|
|
961
|
-
*
|
|
962
|
-
* folder
|
|
955
|
+
* distinct folderfoo server returns every currently-existing folder path for the caller's OWN
|
|
956
|
+
* tree, cheaper than a per-folder existence check would be for an account with many connected own
|
|
957
|
+
* sources; these dedupe to one call per server (the common case — see credentials.ts, one login
|
|
958
|
+
* per server). A source connected to a folder someone ELSE shared (RemoteFolder.owner set) is a
|
|
959
|
+
* different case entirely — GET /folders without an owner never includes a shared folder at all,
|
|
960
|
+
* so each of those is checked individually via owner+rootFolder instead (see the loop below). A
|
|
961
|
+
* confirmed-gone folder is auto-removed via the same removeFolder()+removeFolderFromConfig() pair
|
|
962
|
+
* the manual "✕ remove folder" route above already uses — this never touches a folder's contents,
|
|
963
|
+
* only mem-bucket's own registration of it (see removeFolder's doc comment: it drops DB rows + the
|
|
964
|
+
* local mirror dir, never anything the folder's write path would consider "the user's own content"
|
|
965
|
+
* beyond that mirror).
|
|
963
966
|
*
|
|
964
967
|
* Deliberately does NOT run at write time: a write against a since-deleted remote folder instead
|
|
965
968
|
* fails loudly with its own specific error (see repository create()/update()'s remote-push
|
|
966
969
|
* handling) rather than silently recreating the folder — auto-recreating on write would silently
|
|
967
970
|
* undo a deletion the user may have made deliberately, with no visibility that it happened.
|
|
971
|
+
*
|
|
972
|
+
* Only ever checks/prunes sources connected under the CURRENTLY LOGGED-IN identity (see the
|
|
973
|
+
* isFolderVisible guard in the loop below) — credentials.ts stores exactly one JWT per server, so
|
|
974
|
+
* a different identity's source checked against the wrong credential would always look
|
|
975
|
+
* "confirmed gone" and get silently deleted the moment ANY other identity's login triggered a
|
|
976
|
+
* /api/folders load. This was a real, confirmed bug: switching from user A to user B made B's own
|
|
977
|
+
* previously-connected folders vanish, because A's login (or nobody's) was the only credential on
|
|
978
|
+
* hand to check them with.
|
|
968
979
|
*/
|
|
969
980
|
async function pruneDeletedRemoteFolders() {
|
|
970
|
-
const
|
|
981
|
+
const ownFolderSources = [];
|
|
982
|
+
const sharedFolderSources = [];
|
|
983
|
+
const currentIdentity = identity.current();
|
|
971
984
|
for (const [kind, repo] of [
|
|
972
985
|
['skill', skillRepo],
|
|
973
986
|
['memory', memoryRepo],
|
|
974
987
|
]) {
|
|
975
988
|
for (const remote of repo.listRemoteFolders()) {
|
|
976
|
-
|
|
977
|
-
|
|
978
|
-
|
|
989
|
+
// Only ever check a source connected under the IDENTITY CURRENTLY LOGGED IN — credentials.ts
|
|
990
|
+
// stores exactly one JWT per server, overwritten on every login, so a source belonging to a
|
|
991
|
+
// different identity (e.g. bbb's own folder, checked right after anatoli logs in) would be
|
|
992
|
+
// checked with the WRONG credential: that credential's owner's tree obviously never contains
|
|
993
|
+
// the other identity's folder, so it would always look "confirmed gone" and get silently
|
|
994
|
+
// deleted the moment ANY other identity's login triggered a /api/folders load — exactly the
|
|
995
|
+
// "my folder disappeared after switching users" bug this skip fixes. A source that doesn't
|
|
996
|
+
// match is left untouched here entirely; it re-enters this check once its own identity logs
|
|
997
|
+
// back in and can be verified with the right credential again.
|
|
998
|
+
if (!isFolderVisible(remote, currentIdentity))
|
|
999
|
+
continue;
|
|
1000
|
+
if (remote.owner) {
|
|
1001
|
+
sharedFolderSources.push({ kind, name: remote.name, folderPath: remote.folderPath, server: remote.server, tenantId: remote.tenantId, owner: remote.owner });
|
|
1002
|
+
}
|
|
1003
|
+
else {
|
|
1004
|
+
ownFolderSources.push({ kind, name: remote.name, folderPath: remote.folderPath, server: remote.server, tenantId: remote.tenantId });
|
|
1005
|
+
}
|
|
979
1006
|
}
|
|
980
1007
|
}
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
for (const [server, sources] of byServer) {
|
|
984
|
-
let livePaths;
|
|
1008
|
+
const doRemove = (source) => {
|
|
1009
|
+
const repo = source.kind === 'skill' ? skillRepo : memoryRepo;
|
|
985
1010
|
try {
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
// call.
|
|
989
|
-
const folders = await listFolderfooFolders(server, config.baseDir, sources[0].tenantId);
|
|
990
|
-
livePaths = new Set(folders.map((f) => f.path));
|
|
1011
|
+
repo.removeFolder(source.name);
|
|
1012
|
+
removeFolderFromConfig(config, source.kind, source.name);
|
|
991
1013
|
}
|
|
992
|
-
catch {
|
|
993
|
-
|
|
994
|
-
// than treating "couldn't check" as "confirmed gone."
|
|
995
|
-
continue;
|
|
1014
|
+
catch (err) {
|
|
1015
|
+
console.error(`[memory-bucket] failed to auto-remove deleted remote folder "${source.name}":`, err);
|
|
996
1016
|
}
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1017
|
+
};
|
|
1018
|
+
// Own-folder sources: unchanged from before — GET /folders without an owner returns the
|
|
1019
|
+
// caller's own flat tree in one call per distinct server (sources sharing one server share one
|
|
1020
|
+
// login/tenant, so any one source's tenantId is representative for the whole group).
|
|
1021
|
+
if (ownFolderSources.length > 0) {
|
|
1022
|
+
const byServer = new Map();
|
|
1023
|
+
for (const source of ownFolderSources) {
|
|
1024
|
+
const list = byServer.get(source.server) ?? [];
|
|
1025
|
+
list.push(source);
|
|
1026
|
+
byServer.set(source.server, list);
|
|
1027
|
+
}
|
|
1028
|
+
for (const [server, sources] of byServer) {
|
|
1029
|
+
let livePaths;
|
|
1001
1030
|
try {
|
|
1002
|
-
|
|
1003
|
-
|
|
1031
|
+
const folders = await listFolderfooFolders(server, config.baseDir, sources[0].tenantId);
|
|
1032
|
+
livePaths = new Set(folders.map((f) => f.path));
|
|
1004
1033
|
}
|
|
1005
|
-
catch
|
|
1006
|
-
|
|
1034
|
+
catch {
|
|
1035
|
+
continue; // not logged in, network blip, etc. — "couldn't check" is not "confirmed gone"
|
|
1007
1036
|
}
|
|
1037
|
+
for (const source of sources) {
|
|
1038
|
+
if (!livePaths.has(source.folderPath))
|
|
1039
|
+
doRemove(source);
|
|
1040
|
+
}
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
1043
|
+
// Shared-folder sources: GET /folders without an owner never includes them at all (a shared
|
|
1044
|
+
// folder isn't part of the caller's own tree — see listFolders' doc comment), so the own-folder
|
|
1045
|
+
// check above would wrongly treat every one of these as "confirmed gone" on every single
|
|
1046
|
+
// /api/folders load if it ran against them. Each is checked individually instead, via
|
|
1047
|
+
// owner+rootFolder=its own folderPath — this is exactly what the share grant is for, so success
|
|
1048
|
+
// confirms both "the folder still exists" AND "the share still stands"; a 403 (share revoked) or
|
|
1049
|
+
// 404 (folder deleted) confirms gone, while any OTHER failure (not logged in, network blip)
|
|
1050
|
+
// again means "couldn't check," not "confirmed gone."
|
|
1051
|
+
for (const source of sharedFolderSources) {
|
|
1052
|
+
try {
|
|
1053
|
+
await listFolderfooFolders(source.server, config.baseDir, source.tenantId, { owner: source.owner, rootFolder: source.folderPath });
|
|
1054
|
+
}
|
|
1055
|
+
catch (err) {
|
|
1056
|
+
if (err instanceof FolderfooRequestError && (err.status === 403 || err.status === 404))
|
|
1057
|
+
doRemove(source);
|
|
1008
1058
|
}
|
|
1009
1059
|
}
|
|
1010
1060
|
}
|
|
@@ -1160,23 +1210,49 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
1160
1210
|
const unsubscribe = identity.onIdentityChange(send);
|
|
1161
1211
|
req.on('close', unsubscribe);
|
|
1162
1212
|
});
|
|
1163
|
-
// Lists
|
|
1164
|
-
//
|
|
1213
|
+
// Lists folderfoo folders for the "connect a folder" picker, once already logged in to `server`
|
|
1214
|
+
// (via the route above). Own folders by default; pass `owner`+`rootFolder` (a folder from
|
|
1215
|
+
// GET /api/folderfoo/shared-folders below) to browse INTO a folder someone else shared instead —
|
|
1216
|
+
// folderfoo's own GET /folders gates that via the same hasAccess check every other shared read
|
|
1217
|
+
// uses, so this route just forwards the params through rather than re-checking anything itself.
|
|
1165
1218
|
router.get('/api/folderfoo/folders', async (req, res) => {
|
|
1166
1219
|
const server = req.query.server;
|
|
1167
1220
|
const tenantId = req.query.tenantId;
|
|
1221
|
+
const owner = req.query.owner;
|
|
1222
|
+
const rootFolder = req.query.rootFolder;
|
|
1168
1223
|
if (!server || !tenantId) {
|
|
1169
1224
|
res.status(400).json({ error: 'server and tenantId query params are required' });
|
|
1170
1225
|
return;
|
|
1171
1226
|
}
|
|
1172
1227
|
try {
|
|
1173
|
-
const folders = await listFolderfooFolders(server, config.baseDir, tenantId);
|
|
1228
|
+
const folders = await listFolderfooFolders(server, config.baseDir, tenantId, { owner, rootFolder });
|
|
1174
1229
|
res.json(folders);
|
|
1175
1230
|
}
|
|
1176
1231
|
catch (err) {
|
|
1177
1232
|
res.status(401).json({ error: err.message });
|
|
1178
1233
|
}
|
|
1179
1234
|
});
|
|
1235
|
+
// Lists whole FOLDERS (not individual memory docs/skills — see /api/shared-items above for that)
|
|
1236
|
+
// someone has shared directly with the caller, across every owner — backs the "connect a folder"
|
|
1237
|
+
// picker's "Shared with me" source, so a folder like bbbmemz shows up as something to connect
|
|
1238
|
+
// instead of only being visible via folderfoo's own File Open UI. Filters getSharedWithMe's
|
|
1239
|
+
// combined file+folder list down to type:'folder' — a shared FILE has no meaning here, that's the
|
|
1240
|
+
// item-level sharing feature instead.
|
|
1241
|
+
router.get('/api/folderfoo/shared-folders', async (req, res) => {
|
|
1242
|
+
const server = req.query.server;
|
|
1243
|
+
const tenantId = req.query.tenantId;
|
|
1244
|
+
if (!server || !tenantId) {
|
|
1245
|
+
res.status(400).json({ error: 'server and tenantId query params are required' });
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
try {
|
|
1249
|
+
const shared = await getSharedWithMe(server, config.baseDir, tenantId);
|
|
1250
|
+
res.json(shared.filter((entry) => entry.type === 'folder').map((entry) => ({ owner: entry.owner, path: entry.name, role: entry.role })));
|
|
1251
|
+
}
|
|
1252
|
+
catch (err) {
|
|
1253
|
+
res.status(401).json({ error: err.message });
|
|
1254
|
+
}
|
|
1255
|
+
});
|
|
1180
1256
|
// Registers a REMOTE (folderfoo) source — the connect-a-folder counterpart
|
|
1181
1257
|
// to POST /api/folders above, which only ever handles local filesystem
|
|
1182
1258
|
// paths. Creates the local mirror directory, registers it with the
|
|
@@ -1184,7 +1260,7 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
1184
1260
|
// immediately triggers one poll so content shows up without waiting for
|
|
1185
1261
|
// the first interval tick.
|
|
1186
1262
|
router.post('/api/remote-folders', async (req, res) => {
|
|
1187
|
-
const { kind, name, server, tenantId, folderPath } = req.body;
|
|
1263
|
+
const { kind, name, server, tenantId, folderPath, owner } = req.body;
|
|
1188
1264
|
if (kind !== 'skill' && kind !== 'memory') {
|
|
1189
1265
|
res.status(400).json({ error: 'kind must be "skill" or "memory"' });
|
|
1190
1266
|
return;
|
|
@@ -1208,18 +1284,53 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
1208
1284
|
}
|
|
1209
1285
|
const repo = kind === 'skill' ? skillRepo : memoryRepo;
|
|
1210
1286
|
const spec = kind === 'skill' ? skillSpec : memorySpec;
|
|
1211
|
-
const mirrorDir = mirrorDirFor(config.baseDir, current.mode, current.username, folderName);
|
|
1212
|
-
const remote = { name: folderName, server, tenantId, folderPath, mirrorDir, mode: current.mode, username: current.username };
|
|
1213
1287
|
try {
|
|
1288
|
+
// Resolved FIRST (before deriving mirrorDir) — auto-suffixes the requested name when it
|
|
1289
|
+
// collides with a folder connected under a DIFFERENT folderfoo login (e.g. two different users
|
|
1290
|
+
// each naturally wanting "bbbmemz"), still rejecting a collision against the CALLER's own
|
|
1291
|
+
// identity (a genuine naming mistake) — see resolveAvailableName's own doc comment. Everything
|
|
1292
|
+
// downstream (mirrorDir, the saved config entry, the response, the kind-mismatch check) uses
|
|
1293
|
+
// this resolved name, never the originally-requested one, so nothing reads/writes to a path
|
|
1294
|
+
// computed from a name that ended up not being what actually got connected.
|
|
1295
|
+
const resolvedName = repo.resolveAvailableName(folderName, current.username);
|
|
1296
|
+
const mirrorDir = mirrorDirFor(config.baseDir, current.mode, current.username, resolvedName, owner);
|
|
1297
|
+
const remote = { name: resolvedName, server, tenantId, folderPath, mirrorDir, mode: current.mode, username: current.username, owner };
|
|
1214
1298
|
repo.registerRemoteFolder(remote);
|
|
1215
|
-
saveRemoteFolder(config, kind, { name:
|
|
1299
|
+
saveRemoteFolder(config, kind, { name: resolvedName, server, tenantId, folderPath, mode: current.mode, username: current.username, owner });
|
|
1216
1300
|
await pollOne(db, spec, remote, config.baseDir);
|
|
1217
|
-
res.json({ name:
|
|
1301
|
+
res.json({ name: resolvedName, server, tenantId, folderPath, kind, owner, kindMismatchWarning: detectKindMismatch(kind, mirrorDir) });
|
|
1218
1302
|
}
|
|
1219
1303
|
catch (err) {
|
|
1220
1304
|
res.status(409).json({ error: err.message });
|
|
1221
1305
|
}
|
|
1222
1306
|
});
|
|
1307
|
+
// Cheap best-effort "did the user pick the wrong kind" heuristic, checked once right after connect
|
|
1308
|
+
// (content is already mirrored locally from the pollOne call above, so this is a local directory
|
|
1309
|
+
// walk, not another network round trip) — surfaced to the client as a dismissible warning, never a
|
|
1310
|
+
// hard block, since a folder can legitimately be empty or genuinely mixed. A skill's remote content
|
|
1311
|
+
// always lives under a fixed literal filename "SKILL.md" (see remoteFilename's own doc comment in
|
|
1312
|
+
// sync.ts — skills push under that name regardless of local filename), so "every .md file mirrored
|
|
1313
|
+
// is literally named SKILL.md" is a strong, cheap skill-shaped signal; a memory folder's files keep
|
|
1314
|
+
// their own varied names and are never named exactly that. Returns undefined (no warning) for an
|
|
1315
|
+
// empty folder — nothing synced yet is not evidence of anything.
|
|
1316
|
+
function detectKindMismatch(kind, mirrorDir) {
|
|
1317
|
+
let total = 0;
|
|
1318
|
+
let skillNamed = 0;
|
|
1319
|
+
for (const file of walkMarkdownFiles(mirrorDir)) {
|
|
1320
|
+
total++;
|
|
1321
|
+
if (path.basename(file) === 'SKILL.md')
|
|
1322
|
+
skillNamed++;
|
|
1323
|
+
}
|
|
1324
|
+
if (total === 0)
|
|
1325
|
+
return undefined;
|
|
1326
|
+
if (kind === 'memory' && skillNamed === total) {
|
|
1327
|
+
return `This folder looks like it contains skills (every file is a SKILL.md), not memory docs — you connected it as a memory folder. Remove and reconnect it as a skill folder instead.`;
|
|
1328
|
+
}
|
|
1329
|
+
if (kind === 'skill' && skillNamed === 0) {
|
|
1330
|
+
return `This folder looks like it contains memory docs, not skills (no SKILL.md files found) — you connected it as a skill folder. Remove and reconnect it as a memory folder instead.`;
|
|
1331
|
+
}
|
|
1332
|
+
return undefined;
|
|
1333
|
+
}
|
|
1223
1334
|
// Triggers one immediate out-of-cycle poll for a single remote source —
|
|
1224
1335
|
// backs the web UI's per-source "resync now" action, so a user who just
|
|
1225
1336
|
// saved something remotely doesn't have to wait out the fixed poll
|
|
@@ -1349,11 +1460,19 @@ export function buildWebRouter(db, config, skillRepo, memoryRepo, skillSpec, mem
|
|
|
1349
1460
|
res.status(500).json({ error: err.message });
|
|
1350
1461
|
}
|
|
1351
1462
|
});
|
|
1352
|
-
// The
|
|
1353
|
-
//
|
|
1463
|
+
// The recycle-icon button in the "Shared with me" panel calls this — also the only way a direct
|
|
1464
|
+
// (non-link) share ever gets discovered and added in the first place, since folderfoo's Share
|
|
1465
|
+
// Manager UI and bucket_share_item grant access with no redeem event for this app to react to
|
|
1466
|
+
// (see refreshSharedItems' own doc comment). Polls every server+tenant the caller is connected to
|
|
1467
|
+
// via a remote folder, not just ones already represented in shared_items, so this works even
|
|
1468
|
+
// before the very first share has been accepted.
|
|
1354
1469
|
router.post('/api/shared-items/refresh', async (_req, res) => {
|
|
1355
1470
|
try {
|
|
1356
|
-
const
|
|
1471
|
+
const remoteFolders = [...skillRepo.listRemoteFolders(), ...memoryRepo.listRemoteFolders()].map((f) => ({
|
|
1472
|
+
server: f.server,
|
|
1473
|
+
tenantId: f.tenantId,
|
|
1474
|
+
}));
|
|
1475
|
+
const summary = await refreshSharedItems(db, config.baseDir, skillSpec, memorySpec, remoteFolders);
|
|
1357
1476
|
res.json(summary);
|
|
1358
1477
|
}
|
|
1359
1478
|
catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "mcp-memory-bucket",
|
|
3
|
-
"version": "0.10.
|
|
3
|
+
"version": "0.10.13",
|
|
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": {
|