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.
- package/dist/client/assets/{index-B4HSkaso.js → index-CGe-JXLz.js} +161 -157
- package/dist/client/index.html +1 -1
- package/dist/src/memory/repository.js +24 -6
- package/dist/src/remote/folderfoo-client.js +14 -0
- package/dist/src/remote/shared-items.js +258 -121
- package/dist/src/server.js +6 -1
- package/dist/src/shared/bucket-folder-tool.js +2 -0
- package/dist/src/shared/bucket-share-tool.js +30 -40
- package/dist/src/skills/repository.js +8 -3
- package/dist/src/store/db.js +7 -1
- package/dist/src/store/sync.js +5 -2
- package/dist/src/web/routes.js +47 -25
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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,
|
|
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
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
* the item
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
42
|
-
|
|
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'
|
|
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:
|
|
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
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
*
|
|
74
|
-
*
|
|
75
|
-
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
*
|
|
86
|
-
* -
|
|
87
|
-
*
|
|
88
|
-
*
|
|
89
|
-
*
|
|
90
|
-
*
|
|
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,
|
|
104
|
-
const summary = { added: 0, updated: 0, revoked: 0, unchanged: 0 };
|
|
105
|
-
const
|
|
106
|
-
const
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
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
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
190
|
+
if (username === null)
|
|
191
|
+
return summary;
|
|
192
|
+
const byServerTenant = new Map();
|
|
193
|
+
const groupFor = (server, tenantId) => {
|
|
117
194
|
const key = `${server}::${tenantId}`;
|
|
118
|
-
|
|
119
|
-
|
|
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
|
|
122
|
-
|
|
123
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
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
|
-
|
|
240
|
+
noteError(`failed to refresh shared item ${row.origin_id}`, err);
|
|
162
241
|
}
|
|
163
242
|
}
|
|
164
|
-
//
|
|
165
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
203
|
-
* bucket_unshare_item
|
|
204
|
-
*
|
|
205
|
-
*
|
|
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
|
|
210
|
-
|
|
211
|
-
|
|
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
|
|
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
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
}
|
|
234
|
-
|
|
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
|
}
|