mcp-memory-bucket 0.12.6 → 0.13.0

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.
@@ -44,7 +44,7 @@
44
44
  z-index: 2147483000;
45
45
  }
46
46
  </style>
47
- <script type="module" crossorigin src="/assets/index-B4HSkaso.js"></script>
47
+ <script type="module" crossorigin src="/assets/index-CGe-JXLz.js"></script>
48
48
  </head>
49
49
  <body>
50
50
  <mem-bucket-app></mem-bucket-app>
@@ -13,6 +13,7 @@ import { normalizeKey } from '../types.js';
13
13
  import { readFile as readRemoteFile, writeFile as writeRemoteFile, writeBinaryFile as writeRemoteBinaryFile, renameFile as renameRemoteFile, trashFile as trashRemoteFile, joinRemoteFolderPath, assertRemoteFolderExists, FolderfooRequestError } from '../remote/folderfoo-client.js';
14
14
  import { isFolderVisible } from '../remote/identity.js';
15
15
  import { writeRemoteThenLocal } from '../remote/write-order.js';
16
+ import { isSharedFolderName, sharedItemReadOnlyError } from '../remote/shared-items.js';
16
17
  /** Uppercases and strips everything but letters/digits — used to compare keys that differ only in
17
18
  * punctuation/whitespace formatting (e.g. `RMXS-15` and `RMXS15` strip to the same `RMXS15`). */
18
19
  export function stripKey(s) {
@@ -136,8 +137,12 @@ export class MemoryRepository {
136
137
  best = folder;
137
138
  }
138
139
  }
139
- if (!best)
140
- return undefined;
140
+ if (!best) {
141
+ // An item-level share's mirror lives outside every configured folder — it is addressed by
142
+ // the folder its cache row is filed under instead (see get()).
143
+ const shared = this.db.prepare(`SELECT folder FROM memory_docs WHERE source_path = ?`).get(sourcePath);
144
+ return shared && isSharedFolderName(shared.folder) ? { folder: shared.folder, filename: path.basename(sourcePath) } : undefined;
145
+ }
141
146
  return { folder: best.name, filename: path.relative(path.resolve(best.path), resolved) };
142
147
  }
143
148
  /** Normalizes an agent-supplied filename to always end in `.md`, matching how docs are stored on disk. */
@@ -425,10 +430,7 @@ export class MemoryRepository {
425
430
  params.push(limit, offset);
426
431
  try {
427
432
  // shared_items is LEFT JOINed on m.source_path (== mirror_path for an item-level share —
428
- // see remote/shared-items.ts) to surface owner/role on any hit that came from a share, since
429
- // a shared item's own `folder` column is always '' (its mirror path is outside every
430
- // configured source directory, so folderForFile can't attribute it to one) and would
431
- // otherwise look identical to any other unfoldered local doc.
433
+ // see remote/shared-items.ts) to surface owner/role on any hit that came from a share.
432
434
  const rows = this.db
433
435
  .prepare(`SELECT m.source_path, m.key, m.description, m.doc_type, m.folder,
434
436
  snippet(search_index, 3, '<<', '>>', '…', 20) AS snippet,
@@ -461,6 +463,14 @@ export class MemoryRepository {
461
463
  * because the last poll tick hasn't run yet.
462
464
  */
463
465
  async get(folder, filename) {
466
+ // A shared item has no configured folder to resolve a path against — match it by filename
467
+ // among the rows filed under its shared folder. Its mirror is the only copy this side has.
468
+ if (isSharedFolderName(folder)) {
469
+ const wanted = MemoryRepository.normalizeFilename(filename);
470
+ const rows = this.db.prepare(`SELECT * FROM memory_docs WHERE folder = ?`).all(folder);
471
+ const shared = rows.find((r) => path.basename(r.source_path) === wanted);
472
+ return shared ? rowToDoc(shared) : null;
473
+ }
464
474
  const sourcePath = this.sourcePathFor(folder, filename);
465
475
  const row = this.db.prepare(`SELECT * FROM memory_docs WHERE source_path = ?`).get(sourcePath);
466
476
  if (!row)
@@ -629,6 +639,8 @@ export class MemoryRepository {
629
639
  const existing = await this.get(folder, filename);
630
640
  if (!existing)
631
641
  throw new Error(`memory doc "${filename}" not found`);
642
+ if (isSharedFolderName(existing.folder))
643
+ throw sharedItemReadOnlyError(filename);
632
644
  // `paused` is local-cache-only and `body` is written separately as the markdown body, not
633
645
  // frontmatter — both must be split off of `existing` before spreading the rest into
634
646
  // `merged`, or they'd ride along into the MemoryFrontmatter written to disk (duplicating the
@@ -718,6 +730,8 @@ export class MemoryRepository {
718
730
  const existing = await this.get(folder, filename);
719
731
  if (!existing)
720
732
  throw new Error(`memory doc "${filename}" not found`);
733
+ if (isSharedFolderName(existing.folder))
734
+ throw sharedItemReadOnlyError(filename);
721
735
  // Archive the remote copy FIRST, before touching anything local — otherwise a deleted-locally-
722
736
  // but-still-present-remotely doc gets silently pulled right back in on the next poll (exactly
723
737
  // the "deleted doc reappears" bug this fixes). Soft-delete (trash), not a hard remote delete —
@@ -754,6 +768,8 @@ export class MemoryRepository {
754
768
  const existing = await this.get(folder, filename);
755
769
  if (!existing)
756
770
  throw new Error(`memory doc "${filename}" not found`);
771
+ if (isSharedFolderName(existing.folder))
772
+ throw sharedItemReadOnlyError(filename);
757
773
  const normalizedNew = MemoryRepository.normalizeFilename(newFilename);
758
774
  const newPath = path.join(path.dirname(existing.source_path), normalizedNew);
759
775
  if (newPath === existing.source_path)
@@ -807,6 +823,8 @@ export class MemoryRepository {
807
823
  const existing = await this.get(folder, filename);
808
824
  if (!existing)
809
825
  throw new Error(`memory doc "${filename}" not found`);
826
+ if (isSharedFolderName(existing.folder))
827
+ throw sharedItemReadOnlyError(filename);
810
828
  writeMarkdownFile(existing.source_path, {}, existing.body);
811
829
  upsertFile(this.db, this.syncSpec, existing.source_path);
812
830
  }
@@ -172,6 +172,20 @@ export async function createPublicLink(server, baseDir, tenantId, folderPath, na
172
172
  body: JSON.stringify({ path: joinRemoteFolderPath(folderPath, name), kind }),
173
173
  }), (res) => res.json());
174
174
  }
175
+ /**
176
+ * Reads a public link's target via folderfoo's GET /public/:token — no login, the token is the
177
+ * only credential. `content` is the raw file text for a file stored as raw bytes (every memory
178
+ * doc/skill), or parsed JSON for folderfoo's legacy .json form. A revoked, expired, or deleted
179
+ * link is a 404 (FolderfooRequestError).
180
+ */
181
+ export async function fetchPublicLink(server, token) {
182
+ const res = await fetch(`${server}/public/${encodeURIComponent(token)}`);
183
+ if (!res.ok) {
184
+ const body = await res.json().catch(() => ({}));
185
+ throw new FolderfooRequestError(res.status, body.error ?? res.statusText);
186
+ }
187
+ return (await res.json());
188
+ }
175
189
  /** Thrown when a write targets a remote folder that no longer exists on folderfoo (deleted server-side since the last connect/UI-open sync). */
176
190
  export class RemoteFolderGoneError extends Error {
177
191
  constructor(folderName) {
@@ -1,6 +1,6 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
- import { getSharedWithMe, readFile, joinRemoteFolderPath } from './folderfoo-client.js';
3
+ import { getSharedWithMe, readFile, fetchPublicLink, joinRemoteFolderPath, FolderfooAuthError, FolderfooRequestError, } from './folderfoo-client.js';
4
4
  import { upsertFile, removeFile } from '../store/sync.js';
5
5
  import { sanitizeFolderName } from '../config.js';
6
6
  /**
@@ -12,38 +12,101 @@ import { sanitizeFolderName } from '../config.js';
12
12
  * mirrorDirFor nests remote folders under {mode}_{username}, so items from
13
13
  * two different folderfoo deployments/tenants never collide on disk.
14
14
  */
15
+ export function sharedItemsMirrorRoot(baseDir) {
16
+ return path.join(baseDir, '.memory-bucket-shared-items');
17
+ }
15
18
  export function sharedItemsMirrorDir(baseDir, server, tenantId) {
16
19
  const serverSegment = sanitizeFolderName(server.replace(/^https?:\/\//, ''));
17
- return path.join(baseDir, '.memory-bucket-shared-items', serverSegment, sanitizeFolderName(tenantId));
20
+ return path.join(sharedItemsMirrorRoot(baseDir), serverSegment, sanitizeFolderName(tenantId));
21
+ }
22
+ /**
23
+ * The `folder` a shared item's cache row is filed under. A shared item lives outside every
24
+ * configured source directory, so without this every one of them landed in folder '' — where two
25
+ * shared skills with the same name collide on the (folder, id) key and the second is never indexed,
26
+ * and where nothing could tell a shared row from an ordinary one. The prefix is also what the
27
+ * repositories key their read-only guard on (see isSharedFolderName).
28
+ */
29
+ const SHARED_FOLDER_PREFIX = 'shared-by-';
30
+ export function sharedFolderName(owner) {
31
+ return `${SHARED_FOLDER_PREFIX}${sanitizeFolderName(owner) || 'unknown'}`;
32
+ }
33
+ export function isSharedFolderName(folderName) {
34
+ return !!folderName && folderName.startsWith(SHARED_FOLDER_PREFIX);
35
+ }
36
+ /** Thrown by a repository write against a doc that is someone else's shared item. */
37
+ export function sharedItemReadOnlyError(name) {
38
+ return new Error(`"${name}" is shared with you and read-only here — copy it into your own folder with bucket_fork_shared_item (or "Fork to mine" in the web UI) to edit it`);
39
+ }
40
+ // origin_id becomes a directory name under the mirror root (and that directory is removed
41
+ // recursively on revoke), so it must be exactly the uuid folderfoo mints — never a path fragment.
42
+ const ORIGIN_ID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
43
+ /** A row belongs to `username` when it was granted to that login, or isn't login-scoped at all (public link / legacy row). */
44
+ function isVisibleTo(row, username) {
45
+ return row.username === '' || row.username === username;
46
+ }
47
+ function indexMirror(db, specs, row) {
48
+ const folder = sharedFolderName(row.owner);
49
+ const table = row.kind === 'skill' ? 'skills' : 'memory_docs';
50
+ // upsertFile skips a file whose mtime it has already seen, so a row filed under a different
51
+ // folder (e.g. '' from before shared items had their own) would otherwise keep it forever.
52
+ const existing = db.prepare(`SELECT folder FROM ${table} WHERE source_path = ?`).get(row.mirror_path);
53
+ if (existing && existing.folder !== folder)
54
+ removeFile(db, table, row.mirror_path);
55
+ // Branched (not a single `const spec = kind === 'skill' ? ... : ...`) because upsertFile<T> is
56
+ // generic over TFrontmatter and the ternary's union type satisfies neither call signature.
57
+ if (row.kind === 'skill')
58
+ upsertFile(db, specs.skill, row.mirror_path, folder);
59
+ else
60
+ upsertFile(db, specs.memory, row.mirror_path, folder);
61
+ }
62
+ function removeMirror(db, baseDir, row) {
63
+ removeFile(db, row.kind === 'skill' ? 'skills' : 'memory_docs', row.mirror_path);
64
+ const dir = path.resolve(path.dirname(row.mirror_path));
65
+ const root = path.resolve(sharedItemsMirrorRoot(baseDir));
66
+ if (dir.startsWith(root + path.sep))
67
+ fs.rmSync(dir, { recursive: true, force: true });
68
+ }
69
+ function publicContentToMarkdown(content) {
70
+ return typeof content === 'string' ? content : JSON.stringify(content, null, 2);
18
71
  }
19
72
  /**
20
- * Registers a newly-accepted share (from the share-link accept flow, or a
21
- * direct username share the user just picked up) into shared_items and pulls
22
- * its content immediately — this one fetch is the explicit act of accepting
23
- * a share, not a background resync, so it doesn't violate "refresh is a
24
- * UI-only action" (see refreshSharedItems below, which IS that action).
25
- * Idempotent on origin_id: re-adding an already-tracked share just refreshes
26
- * its content once, e.g. if `addSharedItem` is called again by mistake, or
27
- * the item was previously revoked and got re-shared with the same origin.
73
+ * Registers a share into shared_items and pulls its content immediately. Called for an accepted
74
+ * share/public link (the explicit act of accepting — see routes.ts) and by refreshSharedItems for
75
+ * a direct share it just discovered. Idempotent on origin_id: re-adding an already-tracked share
76
+ * (or one previously revoked and re-shared) just refreshes it.
77
+ *
78
+ * `publicToken` marks a public-link item: it has no `shares` row on folderfoo, so its content can
79
+ * only be read back through the link itself (no login), never the owner-addressed read.
80
+ * `paused` files the item hidden from agent-facing list/search until the user unpauses it — used
81
+ * for a share nobody on this side explicitly accepted.
28
82
  */
29
83
  export async function addSharedItem(db, baseDir, skillSpec, memorySpec, entry) {
30
- const role = entry.role ?? 'member';
84
+ if (!ORIGIN_ID_PATTERN.test(entry.originId))
85
+ throw new Error('invalid originId');
86
+ const role = entry.publicToken ? 'member' : (entry.role ?? 'member');
31
87
  const mirrorDir = sharedItemsMirrorDir(baseDir, entry.server, entry.tenantId);
32
- const localFilename = entry.kind === 'skill' ? 'SKILL.md' : path.basename(entry.path);
88
+ const remoteName = path.basename(entry.path);
89
+ if (!remoteName || remoteName === '.' || remoteName === '..')
90
+ throw new Error('invalid shared item path');
91
+ // A memory doc pushed before remote names kept their extension has none (see memorySyncSpec's
92
+ // remoteFilename.toLocal) — its mirror still needs one to be addressable by filename.
93
+ const localFilename = entry.kind === 'skill' ? 'SKILL.md' : remoteName.endsWith('.md') ? remoteName : `${remoteName}.md`;
33
94
  // One subdirectory per origin_id — a skill's SKILL.md needs its own parent
34
95
  // directory anyway (skill identity is (folder, id), i.e. the containing
35
96
  // directory's name), and giving every shared item (memory or skill) the
36
97
  // same shape keeps this code path uniform instead of branching on kind.
37
98
  const mirrorPath = path.join(mirrorDir, entry.originId, localFilename);
99
+ const content = entry.publicToken
100
+ ? publicContentToMarkdown((await fetchPublicLink(entry.server, entry.publicToken)).content)
101
+ : await readFile(entry.server, baseDir, entry.tenantId, dirname(entry.path), remoteName, entry.owner);
38
102
  fs.mkdirSync(path.dirname(mirrorPath), { recursive: true });
39
- const content = await readFile(entry.server, baseDir, entry.tenantId, dirname(entry.path), path.basename(entry.path), entry.owner);
40
103
  fs.writeFileSync(mirrorPath, content);
41
- const now = new Date().toISOString();
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)
43
- VALUES (@origin_id, @owner, @server, @tenant_id, @kind, @role, @remote_path, @mirror_path, @last_seen_modified_at, 'active', @added_at)
104
+ 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, username, public_token)
105
+ VALUES (@origin_id, @owner, @server, @tenant_id, @kind, @role, @remote_path, @mirror_path, @last_seen_modified_at, 'active', @added_at, @username, @public_token)
44
106
  ON CONFLICT(origin_id) DO UPDATE SET
45
107
  owner = excluded.owner, kind = excluded.kind, role = excluded.role, remote_path = excluded.remote_path,
46
- mirror_path = excluded.mirror_path, last_seen_modified_at = excluded.last_seen_modified_at, status = 'active'`).run({
108
+ mirror_path = excluded.mirror_path, last_seen_modified_at = excluded.last_seen_modified_at, status = 'active',
109
+ username = excluded.username, public_token = excluded.public_token`).run({
47
110
  origin_id: entry.originId,
48
111
  owner: entry.owner,
49
112
  server: entry.server,
@@ -52,17 +115,15 @@ export async function addSharedItem(db, baseDir, skillSpec, memorySpec, entry) {
52
115
  role,
53
116
  remote_path: entry.path,
54
117
  mirror_path: mirrorPath,
55
- last_seen_modified_at: null,
56
- added_at: now,
118
+ last_seen_modified_at: entry.modifiedAt ?? null,
119
+ added_at: new Date().toISOString(),
120
+ username: entry.publicToken ? '' : (entry.username ?? ''),
121
+ public_token: entry.publicToken ?? null,
57
122
  });
58
- // Branched (not a single `const spec = kind === 'skill' ? skillSpec : memorySpec`) because
59
- // upsertFile<T> is generic over TFrontmatter — a ternary's inferred union type
60
- // TableSyncSpec<SkillFrontmatter> | TableSyncSpec<MemoryFrontmatter> doesn't satisfy either
61
- // single-generic call signature, even though each branch alone is perfectly type-safe.
62
- if (entry.kind === 'skill')
63
- upsertFile(db, skillSpec, mirrorPath);
64
- else
65
- upsertFile(db, memorySpec, mirrorPath);
123
+ indexMirror(db, { skill: skillSpec, memory: memorySpec }, { kind: entry.kind, owner: entry.owner, mirror_path: mirrorPath });
124
+ if (entry.paused) {
125
+ db.prepare(`UPDATE ${entry.kind === 'skill' ? 'skills' : 'memory_docs'} SET paused = 1 WHERE source_path = ?`).run(mirrorPath);
126
+ }
66
127
  }
67
128
  /** folderfoo's `path` field for a file is relative to the owner's root — everything but the last segment is its containing folder path. */
68
129
  function dirname(remotePath) {
@@ -70,64 +131,85 @@ function dirname(remotePath) {
70
131
  return idx === -1 ? '' : remotePath.slice(0, idx);
71
132
  }
72
133
  /**
73
- * The ONLY place shared_items ever changes after the initial addSharedItem —
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.
89
- * - origin_id missing from the response → the share was revoked (or the
90
- * item deleted) — evict from the cache/search-index via removeFile and
91
- * mark the row 'revoked' (kept for the UI's dismissible "no longer
92
- * shared with you" row, not silently dropped — see routes.ts).
93
- * - origin_id present with a newer modifiedAt → refetch content, rewrite
94
- * the mirror file, re-run upsertFile (which auto-invalidates search_index
95
- * for the new content).
96
- * - origin_id present with an unchanged modifiedAt → left alone.
97
- * A rename on the owner's side is NOT distinguished from a content edit
98
- * here — both just mean "modifiedAt moved and remote_path may differ" — the
99
- * mirror_path is deliberately kept path-independent (path.join(mirrorDir,
100
- * originId, filename)) so a rename never needs a file move, only the
101
- * `remote_path` bookkeeping column changes.
134
+ * Brings shared_items in line with folderfoo for the CURRENT login — called from the web UI's
135
+ * recycle-icon button and once when the "Shared with me" panel opens. Still no poller/timer:
136
+ * this is "refresh on demand," not a background sync.
137
+ *
138
+ * Login-granted items: one GET /shared-with-me per server+tenant in `servers` (plus any already
139
+ * tracked), diffed by origin_id against the rows granted to `username`. Rows granted to a
140
+ * different login are never touched — the other login's shares are simply absent from this
141
+ * response, which is not a revocation.
142
+ * - live but not tracked → a direct share nobody here accepted; added PAUSED, so content someone
143
+ * else chose to push is not agent-visible until the user unpauses it.
144
+ * - tracked but not live → revoked (or deleted); evicted from the cache and mirror, row kept as
145
+ * 'revoked' for the UI's dismissible "no longer shared with you" entry.
146
+ * - newer modifiedAt, or the mirror file is gone → refetched and reindexed.
147
+ * - otherwise only the bookkeeping columns (role, remote_path) are brought up to date — an
148
+ * owner-side rename or role change does not move modifiedAt.
149
+ * The mirror path is origin_id-based, so a rename never needs a file move.
150
+ *
151
+ * Public-link items have no /shared-with-me entry; each is re-read through its own link.
102
152
  */
103
- export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec, remoteFolders) {
104
- const summary = { added: 0, updated: 0, revoked: 0, unchanged: 0 };
105
- const tracked = db.prepare(`SELECT * FROM shared_items WHERE status = 'active'`).all();
106
- const byServerTenant = new Map();
107
- for (const row of tracked) {
108
- const key = `${row.server}::${row.tenant_id}`;
109
- const list = byServerTenant.get(key) ?? [];
110
- list.push(row);
111
- byServerTenant.set(key, list);
153
+ export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec, servers, username) {
154
+ const summary = { added: 0, updated: 0, revoked: 0, unchanged: 0, errors: [], authExpired: false };
155
+ const specs = { skill: skillSpec, memory: memorySpec };
156
+ const noteError = (what, err) => {
157
+ if (err instanceof FolderfooAuthError)
158
+ summary.authExpired = true;
159
+ summary.errors.push(`${what}: ${err.message}`);
160
+ console.error(`[memory-bucket] ${what}:`, err);
161
+ };
162
+ const revoke = (row) => {
163
+ removeMirror(db, baseDir, row);
164
+ db.prepare(`UPDATE shared_items SET status = 'revoked' WHERE origin_id = ?`).run(row.origin_id);
165
+ summary.revoked++;
166
+ };
167
+ const active = db.prepare(`SELECT * FROM shared_items WHERE status = 'active'`).all();
168
+ for (const row of active.filter((r) => r.public_token)) {
169
+ try {
170
+ const content = publicContentToMarkdown((await fetchPublicLink(row.server, row.public_token)).content);
171
+ const current = fs.existsSync(row.mirror_path) ? fs.readFileSync(row.mirror_path, 'utf-8') : null;
172
+ if (current === content) {
173
+ summary.unchanged++;
174
+ continue;
175
+ }
176
+ fs.mkdirSync(path.dirname(row.mirror_path), { recursive: true });
177
+ fs.writeFileSync(row.mirror_path, content);
178
+ indexMirror(db, specs, row);
179
+ summary.updated++;
180
+ }
181
+ catch (err) {
182
+ // 404 is folderfoo's answer for a revoked, expired, or deleted link — anything else is a
183
+ // transient failure that must not be mistaken for a revocation.
184
+ if (err instanceof FolderfooRequestError && err.status === 404)
185
+ revoke(row);
186
+ else
187
+ noteError(`failed to refresh public item ${row.origin_id}`, err);
188
+ }
112
189
  }
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) {
190
+ if (username === null)
191
+ return summary;
192
+ const byServerTenant = new Map();
193
+ const groupFor = (server, tenantId) => {
117
194
  const key = `${server}::${tenantId}`;
118
- if (!byServerTenant.has(key))
119
- byServerTenant.set(key, []);
195
+ let group = byServerTenant.get(key);
196
+ if (!group)
197
+ byServerTenant.set(key, (group = { server, tenantId, rows: [] }));
198
+ return group;
199
+ };
200
+ for (const row of active) {
201
+ if (!row.public_token && isVisibleTo(row, username))
202
+ groupFor(row.server, row.tenant_id).rows.push(row);
120
203
  }
121
- for (const [key, rows] of byServerTenant) {
122
- const sepIdx = key.indexOf('::');
123
- const server = key.slice(0, sepIdx);
124
- const tenantId = key.slice(sepIdx + 2);
204
+ for (const { server, tenantId } of servers)
205
+ groupFor(server, tenantId);
206
+ for (const { server, tenantId, rows } of byServerTenant.values()) {
125
207
  let live;
126
208
  try {
127
209
  live = await getSharedWithMe(server, baseDir, tenantId);
128
210
  }
129
211
  catch (err) {
130
- console.error(`[memory-bucket] failed to refresh shared items for ${server}:`, err);
212
+ noteError(`failed to refresh shared items from ${server}`, err);
131
213
  continue;
132
214
  }
133
215
  const liveByOrigin = new Map(live.filter((e) => e.originId).map((e) => [e.originId, e]));
@@ -135,38 +217,33 @@ export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec, rem
135
217
  for (const row of rows) {
136
218
  const liveEntry = liveByOrigin.get(row.origin_id);
137
219
  if (!liveEntry) {
138
- removeFile(db, row.kind === 'skill' ? 'skills' : 'memory_docs', row.mirror_path);
139
- db.prepare(`UPDATE shared_items SET status = 'revoked' WHERE origin_id = ?`).run(row.origin_id);
140
- fs.rmSync(path.dirname(row.mirror_path), { recursive: true, force: true });
141
- summary.revoked++;
142
- continue;
143
- }
144
- if (liveEntry.modifiedAt === row.last_seen_modified_at) {
145
- summary.unchanged++;
220
+ revoke(row);
146
221
  continue;
147
222
  }
223
+ const role = liveEntry.role ?? row.role;
148
224
  try {
149
- const content = await readFile(server, baseDir, tenantId, dirname(liveEntry.name), path.basename(liveEntry.name), liveEntry.owner);
150
- fs.writeFileSync(row.mirror_path, content);
151
- const mtimeDate = new Date(liveEntry.modifiedAt);
152
- fs.utimesSync(row.mirror_path, mtimeDate, mtimeDate);
153
- if (row.kind === 'skill')
154
- upsertFile(db, skillSpec, row.mirror_path);
155
- else
156
- upsertFile(db, memorySpec, row.mirror_path);
157
- db.prepare(`UPDATE shared_items SET remote_path = ?, last_seen_modified_at = ?, role = ? WHERE origin_id = ?`).run(liveEntry.name, liveEntry.modifiedAt, liveEntry.role ?? row.role, row.origin_id);
158
- summary.updated++;
225
+ if (liveEntry.modifiedAt !== row.last_seen_modified_at || !fs.existsSync(row.mirror_path)) {
226
+ const content = await readFile(server, baseDir, tenantId, dirname(liveEntry.name), path.basename(liveEntry.name), liveEntry.owner);
227
+ fs.mkdirSync(path.dirname(row.mirror_path), { recursive: true });
228
+ fs.writeFileSync(row.mirror_path, content);
229
+ const mtimeDate = new Date(liveEntry.modifiedAt);
230
+ fs.utimesSync(row.mirror_path, mtimeDate, mtimeDate);
231
+ indexMirror(db, specs, row);
232
+ summary.updated++;
233
+ }
234
+ else {
235
+ summary.unchanged++;
236
+ }
237
+ db.prepare(`UPDATE shared_items SET remote_path = ?, last_seen_modified_at = ?, role = ?, username = ? WHERE origin_id = ?`).run(liveEntry.name, liveEntry.modifiedAt, role, username, row.origin_id);
159
238
  }
160
239
  catch (err) {
161
- console.error(`[memory-bucket] failed to refresh shared item ${row.origin_id}:`, err);
240
+ noteError(`failed to refresh shared item ${row.origin_id}`, err);
162
241
  }
163
242
  }
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.
243
+ // kind-filtered: a plain (non-mcp-memory-bucket) file/folder share has kind:null and nothing
244
+ // here knows how to mirror it.
168
245
  for (const entry of live) {
169
- if (!entry.originId || !entry.kind || trackedOrigins.has(entry.originId))
246
+ if (entry.type !== 'file' || !entry.originId || !entry.kind || trackedOrigins.has(entry.originId))
170
247
  continue;
171
248
  try {
172
249
  await addSharedItem(db, baseDir, skillSpec, memorySpec, {
@@ -177,42 +254,103 @@ export async function refreshSharedItems(db, baseDir, skillSpec, memorySpec, rem
177
254
  originId: entry.originId,
178
255
  kind: entry.kind,
179
256
  role: entry.role ?? 'member',
257
+ username,
258
+ modifiedAt: entry.modifiedAt,
259
+ paused: true,
180
260
  });
181
261
  summary.added++;
182
262
  }
183
263
  catch (err) {
184
- console.error(`[memory-bucket] failed to add newly-discovered shared item ${entry.originId}:`, err);
264
+ noteError(`failed to add newly-discovered shared item ${entry.originId}`, err);
185
265
  }
186
266
  }
187
267
  }
188
268
  return summary;
189
269
  }
270
+ /**
271
+ * Makes the skills/memory_docs cache agree with shared_items for the current login: an item the
272
+ * login can see is indexed from its mirror file, anything else (another login's item, a revoked
273
+ * one) is evicted. Run at startup, on every login/logout, and after a cache rebuild — the rebuild
274
+ * wipes both tables and rescans only the configured source directories, which never include the
275
+ * shared mirror. A visible item whose mirror file is gone is marked for refetch on the next refresh.
276
+ */
277
+ export function syncSharedItemsIndex(db, skillSpec, memorySpec, username) {
278
+ const rows = db.prepare(`SELECT * FROM shared_items`).all();
279
+ for (const row of rows) {
280
+ try {
281
+ if (row.status !== 'active' || !isVisibleTo(row, username)) {
282
+ removeFile(db, row.kind === 'skill' ? 'skills' : 'memory_docs', row.mirror_path);
283
+ }
284
+ else if (fs.existsSync(row.mirror_path)) {
285
+ indexMirror(db, { skill: skillSpec, memory: memorySpec }, row);
286
+ }
287
+ else {
288
+ db.prepare(`UPDATE shared_items SET last_seen_modified_at = NULL WHERE origin_id = ?`).run(row.origin_id);
289
+ }
290
+ }
291
+ catch (err) {
292
+ console.error(`[memory-bucket] failed to index shared item ${row.origin_id}:`, err);
293
+ }
294
+ }
295
+ }
190
296
  /** Dismisses a revoked row from the "Shared with me" panel — no-ops (does not un-revoke) for a still-active row; use refreshSharedItems to detect revocation in the first place. */
191
297
  export function dismissRevokedSharedItem(db, originId) {
192
298
  db.prepare(`DELETE FROM shared_items WHERE origin_id = ? AND status = 'revoked'`).run(originId);
193
299
  }
194
- /** Single-row lookup by origin_id — used by the "fork to mine" route (routes.ts) to read the
195
- * mirror_path/kind it needs before handing off to MemoryRepository.create/SkillRepository.create. */
300
+ /** Single-row lookup by origin_id. */
196
301
  export function getSharedItem(db, originId) {
197
302
  return db.prepare(`SELECT * FROM shared_items WHERE origin_id = ?`).get(originId);
198
303
  }
304
+ /**
305
+ * The shared item a "fork to mine" may copy, or the reason it can't — shared by the web route and
306
+ * bucket_fork_shared_item so a revoked item (mirror already evicted) or another login's item fails
307
+ * with a clear message instead of a raw ENOENT from reading the missing mirror file.
308
+ */
309
+ export function getForkableSharedItem(db, originId, username) {
310
+ const item = getSharedItem(db, originId);
311
+ if (!item || !isVisibleTo(item, username))
312
+ return { error: `no shared item found with origin_id "${originId}"` };
313
+ if (item.status !== 'active')
314
+ return { error: 'this item is no longer shared with you' };
315
+ if (!fs.existsSync(item.mirror_path))
316
+ return { error: 'this shared item has no local copy yet — refresh "Shared with me" in the web UI first' };
317
+ return item;
318
+ }
199
319
  /**
200
320
  * Resolves a table/id doc into the folderfoo coordinates (folderPath, name — the name folderfoo
201
321
  * actually stores it under) needed to call shareWithUser/unshareWithUser/createShareLink/
202
- * createPublicLink. Shared by the web UI's share routes (routes.ts) AND the bucket_share_item/
203
- * bucket_unshare_item/bucket_fork_shared_item MCP tools (shared/bucket-share-tool.ts) so the "must
204
- * be remote, must exist under a connected folder" resolution logic lives in exactly one place.
205
- * Returns null if the doc isn't remote (a purely local doc has nothing on folderfoo to address).
322
+ * createPublicLink, or the reason it can't be shared. Shared by the web UI's share routes
323
+ * (routes.ts) AND the bucket_share_item/bucket_unshare_item MCP tools so this resolution lives in
324
+ * exactly one place. `folder` disambiguates a skill name that exists in more than one folder
325
+ * (skill ids are unique per folder only); a memory doc's id is its source_path, already unique.
206
326
  */
207
- export function resolveShareTarget(db, repos, table, id) {
327
+ export function resolveShareTarget(db, repos, table, id, folder) {
208
328
  const idCol = table === 'skills' ? 'id' : 'source_path';
209
- const row = db.prepare(`SELECT source_path, folder FROM ${table} WHERE ${idCol} = ?`).get(id);
210
- if (!row)
211
- return null;
329
+ const rows = (folder !== undefined && table === 'skills'
330
+ ? db.prepare(`SELECT source_path, folder FROM skills WHERE id = ? AND folder = ?`).all(id, folder)
331
+ : db.prepare(`SELECT source_path, folder FROM ${table} WHERE ${idCol} = ?`).all(id));
332
+ // A same-named item someone shared with the caller is never the one meant here — it only
333
+ // counts when it is the sole match, so the error below can say why it can't be shared.
334
+ const own = rows.filter((r) => !isSharedFolderName(r.folder));
335
+ const candidates = own.length > 0 ? own : rows;
336
+ if (candidates.length === 0)
337
+ return { error: 'not found' };
338
+ if (candidates.length > 1) {
339
+ return { error: `exists in more than one folder (${candidates.map((r) => r.folder).join(', ')}) — specify folder` };
340
+ }
341
+ const row = candidates[0];
342
+ if (isSharedFolderName(row.folder)) {
343
+ return { error: 'this item is shared with you, not yours to share — fork it into your own folder first' };
344
+ }
212
345
  const repo = table === 'skills' ? repos.skill : repos.memory;
213
346
  const remote = repo.listRemoteFolders().find((f) => f.name === row.folder);
214
347
  if (!remote)
215
- return null;
348
+ return { error: 'not connected to a folderfoo remote folder — sharing requires a remote-backed doc' };
349
+ // folderfoo only lets a file's owner share it (POST /share rejects any other caller), and
350
+ // addressing remote.folderPath without the owner would resolve inside the CALLER's own tree.
351
+ if (remote.owner) {
352
+ return { error: `lives in a folder ${remote.owner} shared with you — only ${remote.owner} can share it; fork it into your own folder first` };
353
+ }
216
354
  // Skills push to folderfoo under the fixed remote name "SKILL" regardless of local filename
217
355
  // (see sync.ts's remoteFilename.toRemote) — the folderfoo-relative NAME is therefore the
218
356
  // skill's containing directory, with "SKILL" as the addressed file, while memory docs keep
@@ -224,14 +362,13 @@ export function resolveShareTarget(db, repos, table, id) {
224
362
  const folderPath = relDir && relDir !== '.' ? joinRemoteFolderPath(remote.folderPath, relDir) : remote.folderPath;
225
363
  return { server: remote.server, tenantId: remote.tenantId, folderPath, name, kind };
226
364
  }
227
- export function listSharedItems(db) {
228
- const rows = db.prepare(`SELECT * FROM shared_items ORDER BY added_at DESC`).all();
229
- return rows.map((row) => {
230
- if (row.kind === 'memory') {
231
- const found = db.prepare(`SELECT source_path FROM memory_docs WHERE source_path = ?`).get(row.mirror_path);
232
- return { ...row, entryId: found?.source_path ?? null };
233
- }
234
- const found = db.prepare(`SELECT id FROM skills WHERE source_path = ?`).get(row.mirror_path);
235
- return { ...row, entryId: found?.id ?? null };
365
+ /** Shared items the given login can see (its own, plus public-link items), newest first — never another login's. */
366
+ export function listSharedItems(db, username) {
367
+ const rows = db.prepare(`SELECT * FROM shared_items ORDER BY added_at DESC`).all().filter((row) => isVisibleTo(row, username));
368
+ return rows.map(({ public_token, ...row }) => {
369
+ const table = row.kind === 'memory' ? 'memory_docs' : 'skills';
370
+ const idCol = row.kind === 'memory' ? 'source_path' : 'id';
371
+ const found = db.prepare(`SELECT ${idCol} AS id, folder, paused FROM ${table} WHERE source_path = ?`).get(row.mirror_path);
372
+ return { ...row, public: public_token !== null, entryId: found?.id ?? null, folder: found?.folder ?? null, paused: found?.paused === 1 };
236
373
  });
237
374
  }