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.
@@ -33,7 +33,7 @@
33
33
  z-index: 2147483000;
34
34
  }
35
35
  </style>
36
- <script type="module" crossorigin src="/assets/index-3eH2lCtQ.js"></script>
36
+ <script type="module" crossorigin src="/assets/index-BuS9zM5d.js"></script>
37
37
  </head>
38
38
  <body>
39
39
  <mem-bucket-app></mem-bucket-app>
@@ -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
- /** Registers a new folder: appends it, scans it once, and starts watching it live. */
203
- addFolder(folder) {
204
- if (this.folders.some((f) => f.name === folder.name)) {
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
- export async function getLastChanged(server, baseDir, tenantId, folderPath) {
100
- return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/last-changed?${folderQuery(tenantId, folderPath)}`, {
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 the caller's own folderfoo folders (flat list of full paths, each
106
- * with a createdAt timestamp - folderfoo's own GET /folders response
107
- * shape) via folderfoo's existing GET /folders — used by the "connect a
108
- * folderfoo folder" UI to offer a picker instead of requiring the user to
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
- return withAuth(server, baseDir, (jwt) => fetch(`${server}/folders`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.json());
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
- function filenameParam(folderPath, name) {
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
- /** Reads one file's raw content via GET /data/:filename. */
222
- export async function readFile(server, baseDir, tenantId, folderPath, name) {
223
- return withAuth(server, baseDir, (jwt) => fetch(`${server}/data/${filenameParam(folderPath, name)}`, { headers: { authorization: `Bearer ${jwt}`, 'x-tenant-id': tenantId } }), (res) => res.text());
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
- /** Writes one file's raw content via POST /save/:filename. */
226
- export async function writeFile(server, baseDir, tenantId, folderPath, name, content) {
227
- await withAuth(server, baseDir, (jwt) => fetch(`${server}/save/${filenameParam(folderPath, name)}`, {
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
- await withAuth(server, baseDir, (jwt) => fetch(`${server}/folders/${folderPath.split('/').map(encodeURIComponent).join('/')}`, {
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 exclusively from the web UI's refresh-button handler (see
75
- * web/routes.ts's POST /api/shared-items/refresh). No poller, no timer, no
76
- * auto-refresh on focus/load/tool-call: per the settled design, staleness is
77
- * fully manual, same as a GitHub list you refresh by hand. Diffs
78
- * GET /shared-with-me (one call per distinct server+tenant, covering every
79
- * owner at once) against locally-tracked origin_ids:
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 was last fetched via the web UI\'s explicit refresh button — refreshing is a UI-only action this tool does not trigger, so this may be stale if nobody has refreshed recently.', {}, async () => {
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
  }