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
package/dist/client/index.html
CHANGED
package/dist/src/config.js
CHANGED
|
@@ -13,10 +13,14 @@ function isRemoteEntry(entry) {
|
|
|
13
13
|
* get mistaken for one another (see identity.ts) - a mirror connected while
|
|
14
14
|
* logged into "dev" as "anatoli" lands at
|
|
15
15
|
* .memory-bucket-remote-cache/dev_anatoli/<name>/, distinct from the same
|
|
16
|
-
* folder name connected under "cloud" or a different username.
|
|
16
|
+
* folder name connected under "cloud" or a different username. `owner`, when
|
|
17
|
+
* this source is a folder someone ELSE shared (see RemoteFolder.owner),
|
|
18
|
+
* folds into the identity segment too - otherwise a shared folder connected
|
|
19
|
+
* under the same `name` as one of the caller's own folders would collide on
|
|
20
|
+
* the same mirror directory.
|
|
17
21
|
*/
|
|
18
|
-
export function mirrorDirFor(baseDir, mode, username, name) {
|
|
19
|
-
const identitySegment = sanitizeFolderName(`${mode}_${username}`);
|
|
22
|
+
export function mirrorDirFor(baseDir, mode, username, name, owner) {
|
|
23
|
+
const identitySegment = sanitizeFolderName(owner ? `${mode}_${username}_shared-by-${owner}` : `${mode}_${username}`);
|
|
20
24
|
return path.join(baseDir, '.memory-bucket-remote-cache', identitySegment, sanitizeFolderName(name));
|
|
21
25
|
}
|
|
22
26
|
function memoryDirFlag(argv) {
|
|
@@ -65,7 +69,7 @@ function resolveFolders(entries, baseDir) {
|
|
|
65
69
|
return { name: nameFromPath(entry), path: path.resolve(baseDir, entry) };
|
|
66
70
|
}
|
|
67
71
|
if (isRemoteEntry(entry)) {
|
|
68
|
-
const mirrorDir = mirrorDirFor(baseDir, entry.remote.mode, entry.remote.username, entry.name);
|
|
72
|
+
const mirrorDir = mirrorDirFor(baseDir, entry.remote.mode, entry.remote.username, entry.name, entry.remote.owner);
|
|
69
73
|
remoteFolders.push({ name: entry.name, ...entry.remote, mirrorDir });
|
|
70
74
|
return { name: entry.name, path: mirrorDir };
|
|
71
75
|
}
|
|
@@ -159,6 +163,7 @@ export function saveRemoteFolder(config, kind, entry) {
|
|
|
159
163
|
folderPath: entry.folderPath,
|
|
160
164
|
mode: entry.mode,
|
|
161
165
|
username: entry.username,
|
|
166
|
+
...(entry.owner ? { owner: entry.owner } : {}),
|
|
162
167
|
},
|
|
163
168
|
},
|
|
164
169
|
],
|
|
@@ -84,9 +84,9 @@ export class MemoryRepository {
|
|
|
84
84
|
const remote = this.remoteFor(folderName);
|
|
85
85
|
if (!remote || !this.credentialsBaseDir)
|
|
86
86
|
return;
|
|
87
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
|
|
87
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
|
|
88
88
|
const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
|
|
89
|
-
await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType);
|
|
89
|
+
await writeRemoteBinaryFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), data, mimeType, remote.owner);
|
|
90
90
|
}
|
|
91
91
|
/**
|
|
92
92
|
* Trashes one attachment's remote copy on folderfoo, if `folderName` resolves to a remote source
|
|
@@ -102,9 +102,9 @@ export class MemoryRepository {
|
|
|
102
102
|
const remote = this.remoteFor(folderName);
|
|
103
103
|
if (!remote || !this.credentialsBaseDir)
|
|
104
104
|
return;
|
|
105
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName);
|
|
105
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, folderName, remote.owner);
|
|
106
106
|
const dirRelPath = joinRemoteFolderPath(remote.folderPath, path.relative(remote.mirrorDir, path.dirname(attachmentFilePath)));
|
|
107
|
-
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath));
|
|
107
|
+
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dirRelPath, path.basename(attachmentFilePath), remote.owner);
|
|
108
108
|
}
|
|
109
109
|
/** Attaches the live chokidar watcher so addFolder/removeFolder can mutate it without a restart. */
|
|
110
110
|
setWatcher(watcher) {
|
|
@@ -190,6 +190,11 @@ export class MemoryRepository {
|
|
|
190
190
|
* perform the initial pull itself — the caller (the web route) does one
|
|
191
191
|
* immediate poll right after this returns, so content shows up without
|
|
192
192
|
* waiting for the first interval tick.
|
|
193
|
+
*
|
|
194
|
+
* `remote.name` (and `remote.mirrorDir`, derived from it) must already be the FINAL, resolved
|
|
195
|
+
* name — see resolveAvailableName, which the web route calls first (before it derives mirrorDir)
|
|
196
|
+
* so a possibly-auto-suffixed name is settled before anything touches disk, rather than this
|
|
197
|
+
* method renaming out from under an already-computed mirrorDir.
|
|
193
198
|
*/
|
|
194
199
|
registerRemoteFolder(remote) {
|
|
195
200
|
if (this.folders.some((f) => f.name === remote.name)) {
|
|
@@ -197,11 +202,61 @@ export class MemoryRepository {
|
|
|
197
202
|
}
|
|
198
203
|
fs.mkdirSync(remote.mirrorDir, { recursive: true });
|
|
199
204
|
this.remoteFolders.push(remote);
|
|
200
|
-
this.addFolder({ name: remote.name, path: remote.mirrorDir });
|
|
205
|
+
this.addFolder({ name: remote.name, path: remote.mirrorDir }, { skipCollisionCheck: true });
|
|
201
206
|
}
|
|
202
|
-
/**
|
|
203
|
-
|
|
204
|
-
|
|
207
|
+
/**
|
|
208
|
+
* `name` must stay globally unique across EVERY identity this process has ever seen, not just
|
|
209
|
+
* currently-visible folders — `folder` is the sole key used everywhere a doc/skill addresses which
|
|
210
|
+
* folder it lives in (the memory_docs.folder/skills.folder DB columns, every `folder = ?` SQL
|
|
211
|
+
* filter, MCP tool `folder` params, the web UI's folder-chip clicks). Two folders sharing a name
|
|
212
|
+
* would make all of those ambiguous the instant both became visible in the same request. Called by
|
|
213
|
+
* the web route BEFORE deriving mirrorDir/calling registerRemoteFolder, so the name is fully
|
|
214
|
+
* settled before anything touches disk or the config file.
|
|
215
|
+
*
|
|
216
|
+
* Two different cases, handled differently:
|
|
217
|
+
* - Collides with a folder the SAME identity already has (own or shared) → a genuine naming
|
|
218
|
+
* mistake (e.g. reusing a name they already picked themselves) → rejected.
|
|
219
|
+
* - Collides with a folder a DIFFERENT identity owns (e.g. two different folderfoo logins each
|
|
220
|
+
* naturally wanting "bbbmemz") → auto-suffixed with " (<connecting username>)" instead of
|
|
221
|
+
* rejected, since forcing every user to invent an arbitrary alternate name for their OWN
|
|
222
|
+
* folder just because someone else's login already used the obvious one is exactly the friction
|
|
223
|
+
* this exists to avoid. The suffixed form is still checked for its own (now vanishingly
|
|
224
|
+
* unlikely, but not impossible — e.g. two different usernames on two different modes) collision
|
|
225
|
+
* and suffixed further with an incrementing counter if needed, so this always terminates with a
|
|
226
|
+
* genuinely available name rather than looping forever or erroring on a contrived edge case.
|
|
227
|
+
*/
|
|
228
|
+
resolveAvailableName(requestedName, connectingUsername) {
|
|
229
|
+
const takenByOwnIdentity = (name) => this.folders.some((f) => f.name === name) && this.isFolderNameVisible(name);
|
|
230
|
+
const taken = (name) => this.folders.some((f) => f.name === name);
|
|
231
|
+
if (!taken(requestedName))
|
|
232
|
+
return requestedName;
|
|
233
|
+
if (takenByOwnIdentity(requestedName)) {
|
|
234
|
+
throw new Error(`a memory folder named "${requestedName}" already exists`);
|
|
235
|
+
}
|
|
236
|
+
// Collides with a DIFFERENT identity's folder — auto-suffix rather than reject (see this
|
|
237
|
+
// method's own doc comment). Re-checks the suffixed candidate too: astronomically unlikely to
|
|
238
|
+
// still collide, but not impossible (e.g. someone literally named a folder "bbbmemz (bbb)"), so
|
|
239
|
+
// this keeps incrementing until it lands on a name nobody's using yet, rather than assuming the
|
|
240
|
+
// first suffix attempt always works.
|
|
241
|
+
let candidate = `${requestedName} (${connectingUsername})`;
|
|
242
|
+
let suffix = 2;
|
|
243
|
+
while (taken(candidate)) {
|
|
244
|
+
if (takenByOwnIdentity(candidate)) {
|
|
245
|
+
throw new Error(`a memory folder named "${candidate}" already exists`);
|
|
246
|
+
}
|
|
247
|
+
candidate = `${requestedName} (${connectingUsername}) ${suffix}`;
|
|
248
|
+
suffix++;
|
|
249
|
+
}
|
|
250
|
+
return candidate;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Registers a new folder: appends it, scans it once, and starts watching it live. A purely LOCAL
|
|
254
|
+
* folder (no folderfoo login involved) has no "different identity" to auto-suffix against — any
|
|
255
|
+
* collision here is unconditionally a plain rejection, unlike registerRemoteFolder's own
|
|
256
|
+
* resolveAvailableName.
|
|
257
|
+
*/
|
|
258
|
+
addFolder(folder, options) {
|
|
259
|
+
if (!options?.skipCollisionCheck && this.folders.some((f) => f.name === folder.name)) {
|
|
205
260
|
throw new Error(`a memory folder named "${folder.name}" already exists`);
|
|
206
261
|
}
|
|
207
262
|
this.folders.push(folder);
|
|
@@ -412,7 +467,7 @@ export class MemoryRepository {
|
|
|
412
467
|
// edits). Must parse it exactly like a local file read would.
|
|
413
468
|
let raw;
|
|
414
469
|
try {
|
|
415
|
-
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name);
|
|
470
|
+
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, remote.owner);
|
|
416
471
|
}
|
|
417
472
|
catch (err) {
|
|
418
473
|
// Also try the LEGACY extensionless remote name — a doc pushed before the
|
|
@@ -422,7 +477,7 @@ export class MemoryRepository {
|
|
|
422
477
|
// FolderfooAuthError or any other failure, which must surface as-is.
|
|
423
478
|
if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !name.endsWith('.md'))
|
|
424
479
|
throw err;
|
|
425
|
-
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name.slice(0, -3));
|
|
480
|
+
raw = await readRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name.slice(0, -3), remote.owner);
|
|
426
481
|
}
|
|
427
482
|
const liveBody = matter(raw).content.trim();
|
|
428
483
|
return { ...doc, body: liveBody };
|
|
@@ -511,7 +566,7 @@ export class MemoryRepository {
|
|
|
511
566
|
// Confirms the remote folder still exists before writing — folderfoo's own save endpoint
|
|
512
567
|
// would otherwise silently recreate a deleted folder rather than failing (see
|
|
513
568
|
// assertRemoteFolderExists's doc comment).
|
|
514
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolder.name);
|
|
569
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, targetFolder.name, remote.owner);
|
|
515
570
|
// The remote filename keeps the SAME name (including .md) as the local mirror file — no
|
|
516
571
|
// extension stripping. Historically this stripped ".md" to match the old opaque id (which
|
|
517
572
|
// never carried an extension), which meant a memory doc's OWN remote file was named
|
|
@@ -524,7 +579,7 @@ export class MemoryRepository {
|
|
|
524
579
|
const relPath = path.relative(remote.mirrorDir, filePath);
|
|
525
580
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
|
|
526
581
|
const name = path.basename(relPath);
|
|
527
|
-
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
|
|
582
|
+
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents, remote.owner);
|
|
528
583
|
}, () => {
|
|
529
584
|
fs.mkdirSync(targetDir, { recursive: true });
|
|
530
585
|
fs.writeFileSync(filePath, fileContents, 'utf-8');
|
|
@@ -569,12 +624,12 @@ export class MemoryRepository {
|
|
|
569
624
|
await writeRemoteThenLocal(async () => {
|
|
570
625
|
if (!remote || !this.credentialsBaseDir)
|
|
571
626
|
return;
|
|
572
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
627
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
573
628
|
// No .md stripping — see create()'s comment.
|
|
574
629
|
const relPath = path.relative(remote.mirrorDir, existing.source_path);
|
|
575
630
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
|
|
576
631
|
const name = path.basename(relPath);
|
|
577
|
-
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents);
|
|
632
|
+
await writeRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, fileContents, remote.owner);
|
|
578
633
|
}, () => fs.writeFileSync(existing.source_path, fileContents, 'utf-8'));
|
|
579
634
|
upsertFile(this.db, this.syncSpec, existing.source_path);
|
|
580
635
|
return { ...merged, body: newBody, paused: existingPaused };
|
|
@@ -649,11 +704,11 @@ export class MemoryRepository {
|
|
|
649
704
|
// recoverable if the delete was a mistake.
|
|
650
705
|
const remote = this.remoteFor(existing.folder);
|
|
651
706
|
if (remote && this.credentialsBaseDir) {
|
|
652
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
707
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
653
708
|
const relPath = path.relative(remote.mirrorDir, existing.source_path);
|
|
654
709
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(relPath));
|
|
655
710
|
const name = path.basename(relPath);
|
|
656
|
-
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name);
|
|
711
|
+
await trashRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, name, remote.owner);
|
|
657
712
|
}
|
|
658
713
|
// The sibling wrapper directory exists solely to hold attachments/ for this doc (memory docs
|
|
659
714
|
// are otherwise flat files), so remove the whole wrapper — not just attachments/ — to avoid
|
|
@@ -691,13 +746,13 @@ export class MemoryRepository {
|
|
|
691
746
|
await writeRemoteThenLocal(async () => {
|
|
692
747
|
if (!remote || !this.credentialsBaseDir)
|
|
693
748
|
return;
|
|
694
|
-
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder);
|
|
749
|
+
await assertRemoteFolderExists(remote.server, this.credentialsBaseDir, remote.tenantId, remote.folderPath, existing.folder, remote.owner);
|
|
695
750
|
const oldRelPath = path.relative(remote.mirrorDir, existing.source_path);
|
|
696
751
|
const dir = joinRemoteFolderPath(remote.folderPath, path.dirname(oldRelPath));
|
|
697
752
|
const oldName = path.basename(oldRelPath);
|
|
698
753
|
const newName = path.basename(newPath);
|
|
699
754
|
try {
|
|
700
|
-
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName);
|
|
755
|
+
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName, newName, remote.owner);
|
|
701
756
|
}
|
|
702
757
|
catch (err) {
|
|
703
758
|
// Same legacy-extensionless fallback as get() above: a doc pushed before the
|
|
@@ -707,7 +762,7 @@ export class MemoryRepository {
|
|
|
707
762
|
// failure, which must surface as-is.
|
|
708
763
|
if (!(err instanceof FolderfooRequestError) || err.status !== 404 || !oldName.endsWith('.md'))
|
|
709
764
|
throw err;
|
|
710
|
-
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName.slice(0, -3), newName);
|
|
765
|
+
await renameRemoteFile(remote.server, this.credentialsBaseDir, remote.tenantId, dir, oldName.slice(0, -3), newName, remote.owner);
|
|
711
766
|
}
|
|
712
767
|
}, () => {
|
|
713
768
|
fs.renameSync(existing.source_path, newPath);
|
|
@@ -96,22 +96,32 @@ function folderQuery(tenantId, folderPath, extra) {
|
|
|
96
96
|
const params = new URLSearchParams({ folderPath, ...extra });
|
|
97
97
|
return params.toString();
|
|
98
98
|
}
|
|
99
|
-
|
|
100
|
-
|
|
99
|
+
/**
|
|
100
|
+
* `owner`, when passed, addresses a folder in someone ELSE's tree via a direct-username share —
|
|
101
|
+
* every folderfoo route below that takes `owner` gates it through the same resolveUserDir/
|
|
102
|
+
* hasAccess check the file-level `owner` param in filenameParam does (see that function's own
|
|
103
|
+
* comment). Omitted entirely (not just falsy) for an own-folder call, matching folderfoo's own
|
|
104
|
+
* `owner ? ... : caller's own dir` branching server-side.
|
|
105
|
+
*/
|
|
106
|
+
export async function getLastChanged(server, baseDir, tenantId, folderPath, owner) {
|
|
107
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/last-changed?${folderQuery(tenantId, folderPath, owner ? { owner } : undefined)}`, {
|
|
101
108
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
102
109
|
}), async (res) => (await res.json()).lastChanged);
|
|
103
110
|
}
|
|
104
111
|
/**
|
|
105
|
-
* Lists
|
|
106
|
-
*
|
|
107
|
-
*
|
|
108
|
-
*
|
|
109
|
-
* type a raw folder path. Own folders only (no owner param) - browsing
|
|
110
|
-
* INTO a shared folder someone else owns is a separate, not-yet-exposed
|
|
111
|
-
* flow.
|
|
112
|
+
* Lists folderfoo folders via GET /folders — the caller's own when `owner`/`rootFolder` are
|
|
113
|
+
* omitted, or a subtree someone else shared with the caller when both are passed (folderfoo gates
|
|
114
|
+
* this via the same hasAccess check every other shared read uses, requiring `rootFolder` to be a
|
|
115
|
+
* folder actually shared with the caller). Used by the "connect a folderfoo folder" UI's picker.
|
|
112
116
|
*/
|
|
113
|
-
export async function listFolders(server, baseDir, tenantId) {
|
|
114
|
-
|
|
117
|
+
export async function listFolders(server, baseDir, tenantId, options) {
|
|
118
|
+
const params = new URLSearchParams();
|
|
119
|
+
if (options?.owner)
|
|
120
|
+
params.set('owner', options.owner);
|
|
121
|
+
if (options?.rootFolder)
|
|
122
|
+
params.set('rootFolder', options.rootFolder);
|
|
123
|
+
const qs = params.toString();
|
|
124
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders${qs ? `?${qs}` : ''}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
|
|
115
125
|
}
|
|
116
126
|
/**
|
|
117
127
|
* Lists everything (files and folders, from every owner) shared with the
|
|
@@ -179,16 +189,26 @@ export class RemoteFolderGoneError extends Error {
|
|
|
179
189
|
* folder — the user may have deleted it deliberately. `folderPath === ''` (a source's own root) is
|
|
180
190
|
* never gone (the user's account root always exists), so this only ever checks a non-root path.
|
|
181
191
|
*/
|
|
182
|
-
export async function assertRemoteFolderExists(server, baseDir, tenantId, folderPath, folderName) {
|
|
192
|
+
export async function assertRemoteFolderExists(server, baseDir, tenantId, folderPath, folderName, owner) {
|
|
183
193
|
if (!folderPath)
|
|
184
194
|
return;
|
|
195
|
+
// A shared folder's OWN path never appears in listFolders' own-tree result (it isn't a
|
|
196
|
+
// subdirectory of the caller's root) - list the owner's tree rooted at folderPath instead and
|
|
197
|
+
// check for a non-empty (i.e. accessible) result, same "does this still resolve" intent as the
|
|
198
|
+
// own-folder branch below, just via the owner-aware GET /folders shape (see listFolders' comment).
|
|
199
|
+
if (owner) {
|
|
200
|
+
await listFolders(server, baseDir, tenantId, { owner, rootFolder: folderPath }).catch(() => {
|
|
201
|
+
throw new RemoteFolderGoneError(folderName);
|
|
202
|
+
});
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
185
205
|
const folders = await listFolders(server, baseDir, tenantId);
|
|
186
206
|
if (!folders.some((f) => f.path === folderPath)) {
|
|
187
207
|
throw new RemoteFolderGoneError(folderName);
|
|
188
208
|
}
|
|
189
209
|
}
|
|
190
|
-
export async function getChangedSince(server, baseDir, tenantId, folderPath, since) {
|
|
191
|
-
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since) })}`, {
|
|
210
|
+
export async function getChangedSince(server, baseDir, tenantId, folderPath, since, owner) {
|
|
211
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/changed-since?${folderQuery(tenantId, folderPath, { since: String(since), ...(owner ? { owner } : {}) })}`, {
|
|
192
212
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
193
213
|
}), async (res) => (await res.json()).files);
|
|
194
214
|
}
|
|
@@ -200,7 +220,15 @@ export async function getChangedSince(server, baseDir, tenantId, folderPath, sin
|
|
|
200
220
|
// itself uses this same form in its own sharing tests for exactly this
|
|
201
221
|
// reason. Always use it here rather than only for the single-segment case,
|
|
202
222
|
// so this client doesn't need to special-case folder depth.
|
|
203
|
-
|
|
223
|
+
//
|
|
224
|
+
// `owner`, when passed, addresses a path in someone ELSE's directory (a
|
|
225
|
+
// direct-username share, resolved via folderfoo's shares table rather than
|
|
226
|
+
// the caller's own files) - see resolveUserDir on the server side, which
|
|
227
|
+
// treats a bare (owner-less) filename as always the CALLER's own directory.
|
|
228
|
+
// Every other caller of this function addresses its own files and omits it.
|
|
229
|
+
function filenameParam(folderPath, name, owner) {
|
|
230
|
+
if (owner)
|
|
231
|
+
return `${encodeURIComponent(owner)}:${folderPath ? encodeURIComponent(folderPath) : ''}:${name}`;
|
|
204
232
|
return folderPath ? `:${encodeURIComponent(folderPath)}:${name}` : name;
|
|
205
233
|
}
|
|
206
234
|
/**
|
|
@@ -218,13 +246,24 @@ export function joinRemoteFolderPath(remoteFolderPath, mirrorRelativeDir) {
|
|
|
218
246
|
return remoteFolderPath;
|
|
219
247
|
return remoteFolderPath ? `${remoteFolderPath}/${mirrorRelativeDir}` : mirrorRelativeDir;
|
|
220
248
|
}
|
|
221
|
-
/**
|
|
222
|
-
|
|
223
|
-
|
|
249
|
+
/**
|
|
250
|
+
* Reads one file's raw content via GET /data/:filename. `owner`, when passed, reads a file from
|
|
251
|
+
* someone ELSE's directory via a direct-username share (see filenameParam's own comment) - used by
|
|
252
|
+
* shared-items.ts to pull a shared item's content, since the caller here is the share's RECIPIENT,
|
|
253
|
+
* not the file's owner.
|
|
254
|
+
*/
|
|
255
|
+
export async function readFile(server, baseDir, tenantId, folderPath, name, owner) {
|
|
256
|
+
return withAuth(server, baseDir, (jwt) => fetch(`${server}/data/${filenameParam(folderPath, name, owner)}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.text());
|
|
224
257
|
}
|
|
225
|
-
/**
|
|
226
|
-
|
|
227
|
-
|
|
258
|
+
/**
|
|
259
|
+
* Writes one file's raw content via POST /save/:filename. `owner`, when passed, writes into
|
|
260
|
+
* someone ELSE's directory via a direct-username share (see filenameParam's own comment) — this is
|
|
261
|
+
* gated 'editor'-role-only server-side (see folderfoo's resolveUserDir(..., 'editor') on this
|
|
262
|
+
* route), so a 'member' (read-only) shared folder correctly 403s here rather than silently
|
|
263
|
+
* succeeding against the caller's own directory instead.
|
|
264
|
+
*/
|
|
265
|
+
export async function writeFile(server, baseDir, tenantId, folderPath, name, content, owner) {
|
|
266
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name, owner)}`, {
|
|
228
267
|
method: 'POST',
|
|
229
268
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'text/markdown' },
|
|
230
269
|
body: content,
|
|
@@ -239,8 +278,8 @@ export async function writeFile(server, baseDir, tenantId, folderPath, name, con
|
|
|
239
278
|
* write those callers already do locally, now also propagated as a real rename remotely, instead
|
|
240
279
|
* of write-new (create) + never delete-old.
|
|
241
280
|
*/
|
|
242
|
-
export async function renameFile(server, baseDir, tenantId, folderPath, name, newName) {
|
|
243
|
-
await withAuth(server, baseDir, (jwt) => fetch(`${server}/rename/${filenameParam(folderPath, name)}`, {
|
|
281
|
+
export async function renameFile(server, baseDir, tenantId, folderPath, name, newName, owner) {
|
|
282
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/rename/${filenameParam(folderPath, name, owner)}`, {
|
|
244
283
|
method: 'POST',
|
|
245
284
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
|
|
246
285
|
body: JSON.stringify({ newName }),
|
|
@@ -255,8 +294,8 @@ export async function renameFile(server, baseDir, tenantId, folderPath, name, ne
|
|
|
255
294
|
* remote file was never touched at all, so the NEXT poll would pull it right back in, making a
|
|
256
295
|
* "deleted" doc silently reappear.
|
|
257
296
|
*/
|
|
258
|
-
export async function trashFile(server, baseDir, tenantId, folderPath, name) {
|
|
259
|
-
await withAuth(server, baseDir, (jwt) => fetch(`${server}/trash/${filenameParam(folderPath, name)}`, {
|
|
297
|
+
export async function trashFile(server, baseDir, tenantId, folderPath, name, owner) {
|
|
298
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/trash/${filenameParam(folderPath, name, owner)}`, {
|
|
260
299
|
method: 'POST',
|
|
261
300
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
262
301
|
}), async () => undefined);
|
|
@@ -270,8 +309,9 @@ export async function trashFile(server, baseDir, tenantId, folderPath, name) {
|
|
|
270
309
|
* it as a wildcard path segment, unlike every other folderPath-bearing endpoint in this client
|
|
271
310
|
* (which use a `?folderPath=` query param), because folderfoo's own route is DELETE /folders/*.
|
|
272
311
|
*/
|
|
273
|
-
export async function trashFolder(server, baseDir, tenantId, folderPath) {
|
|
274
|
-
|
|
312
|
+
export async function trashFolder(server, baseDir, tenantId, folderPath, owner) {
|
|
313
|
+
const qs = owner ? `?owner=${encodeURIComponent(owner)}` : '';
|
|
314
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/${folderPath.split('/').map(encodeURIComponent).join('/')}${qs}`, {
|
|
275
315
|
method: 'DELETE',
|
|
276
316
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId },
|
|
277
317
|
}), async () => undefined);
|
|
@@ -287,11 +327,11 @@ export async function trashFolder(server, baseDir, tenantId, folderPath) {
|
|
|
287
327
|
* "memz/old-skill-name" / "memz/new-skill-name"), sent as JSON body fields per folderfoo's own
|
|
288
328
|
* route (unlike the wildcard-path DELETE /folders/* trashFolder uses).
|
|
289
329
|
*/
|
|
290
|
-
export async function renameFolder(server, baseDir, tenantId, folderPath, newFolderPath) {
|
|
330
|
+
export async function renameFolder(server, baseDir, tenantId, folderPath, newFolderPath, owner) {
|
|
291
331
|
await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/rename`, {
|
|
292
332
|
method: 'POST',
|
|
293
333
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': 'application/json' },
|
|
294
|
-
body: JSON.stringify({ folderPath, newFolderPath }),
|
|
334
|
+
body: JSON.stringify({ folderPath, newFolderPath, owner }),
|
|
295
335
|
}), async () => undefined);
|
|
296
336
|
}
|
|
297
337
|
/**
|
|
@@ -309,8 +349,8 @@ export async function renameFolder(server, baseDir, tenantId, folderPath, newFol
|
|
|
309
349
|
* (attach a dotted/hyphenated filename to a doc in a remote folder, then check the folderfoo UI
|
|
310
350
|
* shows it unmangled) before relying on this for anything beyond best-effort.
|
|
311
351
|
*/
|
|
312
|
-
export async function writeBinaryFile(server, baseDir, tenantId, folderPath, name, data, mimeType) {
|
|
313
|
-
await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name)}`, {
|
|
352
|
+
export async function writeBinaryFile(server, baseDir, tenantId, folderPath, name, data, mimeType, owner) {
|
|
353
|
+
await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name, owner)}`, {
|
|
314
354
|
method: 'POST',
|
|
315
355
|
headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId, 'content-type': mimeType },
|
|
316
356
|
body: data,
|
|
@@ -71,7 +71,7 @@ function toMirrorRelativeDir(remoteFolderPath, absoluteFolderPath) {
|
|
|
71
71
|
* missing deletions.
|
|
72
72
|
*/
|
|
73
73
|
async function reconcileDeletions(db, spec, folder, credentialsBaseDir) {
|
|
74
|
-
const remoteFiles = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, 0);
|
|
74
|
+
const remoteFiles = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, 0, folder.owner);
|
|
75
75
|
const remoteRelPaths = new Set(remoteFiles.map((f) => {
|
|
76
76
|
const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, f.folderPath);
|
|
77
77
|
return mirrorRelativeDir ? path.join(mirrorRelativeDir, f.name) : f.name;
|
|
@@ -169,7 +169,7 @@ async function pullFile(db, spec, folder, credentialsBaseDir, changedFile) {
|
|
|
169
169
|
// correct value to pass straight through to readFile (folderfoo expects
|
|
170
170
|
// that same absolute form for GET /data/:filename), but it must be
|
|
171
171
|
// converted to mirror-relative before joining onto folder.mirrorDir.
|
|
172
|
-
const content = await readFile(folder.server, credentialsBaseDir, folder.tenantId, changedFile.folderPath, changedFile.name);
|
|
172
|
+
const content = await readFile(folder.server, credentialsBaseDir, folder.tenantId, changedFile.folderPath, changedFile.name, folder.owner);
|
|
173
173
|
const mirrorRelativeDir = toMirrorRelativeDir(folder.folderPath, changedFile.folderPath);
|
|
174
174
|
const localFilename = spec.remoteFilename.toLocal(changedFile.name);
|
|
175
175
|
const relPath = mirrorRelativeDir ? path.join(mirrorRelativeDir, localFilename) : localFilename;
|
|
@@ -206,7 +206,7 @@ export async function pollOne(db, spec, folder, credentialsBaseDir, options = {}
|
|
|
206
206
|
// swallow-and-log rather than propagate.
|
|
207
207
|
onAuthExpired) {
|
|
208
208
|
try {
|
|
209
|
-
const lastChanged = await getLastChanged(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath);
|
|
209
|
+
const lastChanged = await getLastChanged(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, folder.owner);
|
|
210
210
|
const localWatermark = readLocalWatermark(folder.mirrorDir);
|
|
211
211
|
// force (manual resync, or rebuild-cache) always does real work,
|
|
212
212
|
// including reconcileDeletions - a user explicitly asking to resync
|
|
@@ -222,7 +222,7 @@ onAuthExpired) {
|
|
|
222
222
|
// be silently invisible to EVERY future poll, forced or not, forever. A real "force resync"
|
|
223
223
|
// has to mean "re-verify everything against the live listing," matching what
|
|
224
224
|
// reconcileDeletions already does unconditionally below.
|
|
225
|
-
const changed = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, options.force ? 0 : localWatermark);
|
|
225
|
+
const changed = await getChangedSince(folder.server, credentialsBaseDir, folder.tenantId, folder.folderPath, options.force ? 0 : localWatermark, folder.owner);
|
|
226
226
|
for (const file of changed) {
|
|
227
227
|
await pullFile(db, spec, folder, credentialsBaseDir, file);
|
|
228
228
|
}
|
|
@@ -36,7 +36,7 @@ export async function addSharedItem(db, baseDir, skillSpec, memorySpec, entry) {
|
|
|
36
36
|
// same shape keeps this code path uniform instead of branching on kind.
|
|
37
37
|
const mirrorPath = path.join(mirrorDir, entry.originId, localFilename);
|
|
38
38
|
fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
|
|
39
|
-
const content = await readFile(entry.server, baseDir, entry.tenantId, dirname(entry.path), path.basename(entry.path));
|
|
39
|
+
const content = await readFile(entry.server, baseDir, entry.tenantId, dirname(entry.path), path.basename(entry.path), entry.owner);
|
|
40
40
|
fs.writeFileSync(mirrorPath, content);
|
|
41
41
|
const now = new Date().toISOString();
|
|
42
42
|
db.prepare(`INSERT INTO shared_items (origin_id, owner, server, tenant_id, kind, role, remote_path, mirror_path, last_seen_modified_at, status, added_at)
|
|
@@ -71,12 +71,21 @@ function dirname(remotePath) {
|
|
|
71
71
|
}
|
|
72
72
|
/**
|
|
73
73
|
* The ONLY place shared_items ever changes after the initial addSharedItem —
|
|
74
|
-
* called
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
74
|
+
* called from the web UI's recycle-icon button AND automatically once when
|
|
75
|
+
* the "Shared with me" panel is opened (see mem-bucket-app.ts's #setView),
|
|
76
|
+
* so a direct (non-link) share is discoverable without the user knowing to
|
|
77
|
+
* click refresh first. Still no poller/timer while the panel stays closed —
|
|
78
|
+
* this is "refresh on demand," not a background sync. Diffs
|
|
79
|
+
* GET /shared-with-me (one call per distinct server+tenant the caller is
|
|
80
|
+
* connected to via a remote folder — see `remoteFolders` below, NOT just the
|
|
81
|
+
* servers/tenants already present in shared_items, so a share that never
|
|
82
|
+
* went through the link-redeem flow at all — e.g. folderfoo's Share Manager
|
|
83
|
+
* UI adding a username directly, or bucket_share_item — is still discovered
|
|
84
|
+
* here, not only shares that started life as a link) against locally-tracked
|
|
85
|
+
* origin_ids:
|
|
86
|
+
* - origin_id present live but NOT locally tracked → a new direct share
|
|
87
|
+
* (never accepted via a link, so addSharedItem never ran for it) — added
|
|
88
|
+
* via addSharedItem exactly as the accept route would.
|
|
80
89
|
* - origin_id missing from the response → the share was revoked (or the
|
|
81
90
|
* item deleted) — evict from the cache/search-index via removeFile and
|
|
82
91
|
* mark the row 'revoked' (kept for the UI's dismissible "no longer
|
|
@@ -91,11 +100,9 @@ function dirname(remotePath) {
|
|
|
91
100
|
* originId, filename)) so a rename never needs a file move, only the
|
|
92
101
|
* `remote_path` bookkeeping column changes.
|
|
93
102
|
*/
|
|
94
|
-
export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec) {
|
|
95
|
-
const summary = { updated: 0, revoked: 0, unchanged: 0 };
|
|
103
|
+
export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec, remoteFolders) {
|
|
104
|
+
const summary = { added: 0, updated: 0, revoked: 0, unchanged: 0 };
|
|
96
105
|
const tracked = db.prepare(`SELECT * FROM shared_items WHERE status = 'active'`).all();
|
|
97
|
-
if (tracked.length === 0)
|
|
98
|
-
return summary;
|
|
99
106
|
const byServerTenant = new Map();
|
|
100
107
|
for (const row of tracked) {
|
|
101
108
|
const key = `${row.server}::${row.tenant_id}`;
|
|
@@ -103,6 +110,14 @@ export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec) {
|
|
|
103
110
|
list.push(row);
|
|
104
111
|
byServerTenant.set(key, list);
|
|
105
112
|
}
|
|
113
|
+
// Union in every connected remote folder's server+tenant too, even ones with zero tracked shared
|
|
114
|
+
// items yet — otherwise a user who's never accepted a share link has no known server to poll at
|
|
115
|
+
// all, and a fresh direct share would never surface no matter how many times they hit refresh.
|
|
116
|
+
for (const { server, tenantId } of remoteFolders) {
|
|
117
|
+
const key = `${server}::${tenantId}`;
|
|
118
|
+
if (!byServerTenant.has(key))
|
|
119
|
+
byServerTenant.set(key, []);
|
|
120
|
+
}
|
|
106
121
|
for (const [key, rows] of byServerTenant) {
|
|
107
122
|
const sepIdx = key.indexOf('::');
|
|
108
123
|
const server = key.slice(0, sepIdx);
|
|
@@ -116,6 +131,7 @@ export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec) {
|
|
|
116
131
|
continue;
|
|
117
132
|
}
|
|
118
133
|
const liveByOrigin = new Map(live.filter((e) => e.originId).map((e) => [e.originId, e]));
|
|
134
|
+
const trackedOrigins = new Set(rows.map((row) => row.origin_id));
|
|
119
135
|
for (const row of rows) {
|
|
120
136
|
const liveEntry = liveByOrigin.get(row.origin_id);
|
|
121
137
|
if (!liveEntry) {
|
|
@@ -130,7 +146,7 @@ export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec) {
|
|
|
130
146
|
continue;
|
|
131
147
|
}
|
|
132
148
|
try {
|
|
133
|
-
const content = await readFile(server, baseDir, tenantId, dirname(liveEntry.name), path.basename(liveEntry.name));
|
|
149
|
+
const content = await readFile(server, baseDir, tenantId, dirname(liveEntry.name), path.basename(liveEntry.name), liveEntry.owner);
|
|
134
150
|
fs.writeFileSync(row.mirror_path, content);
|
|
135
151
|
const mtimeDate = new Date(liveEntry.modifiedAt);
|
|
136
152
|
fs.utimesSync(row.mirror_path, mtimeDate, mtimeDate);
|
|
@@ -145,6 +161,29 @@ export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec) {
|
|
|
145
161
|
console.error(`[memory-bucket] failed to refresh shared item ${row.origin_id}:`, err);
|
|
146
162
|
}
|
|
147
163
|
}
|
|
164
|
+
// Anything live for this server/tenant that isn't already tracked is a share this app never
|
|
165
|
+
// saw an accept event for (a direct-username share, or one made before this feature existed) —
|
|
166
|
+
// file it exactly as the accept route would, kind-filtered since a plain (non-mcp-memory-
|
|
167
|
+
// bucket) file/folder share has kind:null and nothing here knows how to mirror it.
|
|
168
|
+
for (const entry of live) {
|
|
169
|
+
if (!entry.originId || !entry.kind || trackedOrigins.has(entry.originId))
|
|
170
|
+
continue;
|
|
171
|
+
try {
|
|
172
|
+
await addSharedItem(db, baseDir, skillSpec, memorySpec, {
|
|
173
|
+
owner: entry.owner,
|
|
174
|
+
server,
|
|
175
|
+
tenantId,
|
|
176
|
+
path: entry.name,
|
|
177
|
+
originId: entry.originId,
|
|
178
|
+
kind: entry.kind,
|
|
179
|
+
role: entry.role ?? 'member',
|
|
180
|
+
});
|
|
181
|
+
summary.added++;
|
|
182
|
+
}
|
|
183
|
+
catch (err) {
|
|
184
|
+
console.error(`[memory-bucket] failed to add newly-discovered shared item ${entry.originId}:`, err);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
148
187
|
}
|
|
149
188
|
return summary;
|
|
150
189
|
}
|
|
@@ -113,7 +113,7 @@ export function registerBucketShareTools(mcp, config, skillRepo, memoryRepo, db)
|
|
|
113
113
|
return { content: [{ type: 'text', text: err.message }], isError: true };
|
|
114
114
|
}
|
|
115
115
|
});
|
|
116
|
-
mcp.tool('bucket_list_shared_items', 'Lists memory docs/skills someone has shared directly with you, item by item — not a whole connected folder (see bucket_list_remote_folders for that). Each entry\'s `status` is \'active\' or \'revoked\' (no longer shared — see the web UI\'s dismiss action). Reflects whatever
|
|
116
|
+
mcp.tool('bucket_list_shared_items', 'Lists memory docs/skills someone has shared directly with you, item by item — not a whole connected folder (see bucket_list_remote_folders for that). Each entry\'s `status` is \'active\' or \'revoked\' (no longer shared — see the web UI\'s dismiss action). Reflects whatever the web UI last synced (on opening its "Shared with me" panel, or its refresh button) — this tool does not itself trigger a sync, so it may be stale if the panel hasn\'t been opened recently.', {}, async () => {
|
|
117
117
|
return { content: [{ type: 'text', text: JSON.stringify(listSharedItems(db), null, 2) }] };
|
|
118
118
|
});
|
|
119
119
|
}
|