memory-gateway-sync 0.11.0 → 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/index.js CHANGED
@@ -4,21 +4,45 @@ import fs from "node:fs";
4
4
  import path from "node:path";
5
5
  import crypto from "node:crypto";
6
6
  import os from "node:os";
7
+
8
+ function resolveIdentity(params) {
9
+ const testScene = process.env.MEMORY_GATEWAY_TEST_SCENE;
10
+ if (testScene) {
11
+ return {
12
+ scene: testScene,
13
+ owner_id: process.env.MEMORY_GATEWAY_TEST_OWNER_ID || "test_owner",
14
+ group_id: process.env.MEMORY_GATEWAY_TEST_GROUP_ID || null,
15
+ };
16
+ }
17
+ const ownerId = params.owner_id?.trim() || "";
18
+ const groupId = params.group_id?.trim() || null;
19
+ if (!ownerId) {
20
+ console.warn(
21
+ "[identity] owner_id is empty — owner queries will be skipped to prevent cross-user leak."
22
+ );
23
+ }
24
+ if (groupId) {
25
+ return { scene: "group", owner_id: ownerId, group_id: groupId };
26
+ }
27
+ return { scene: "owner", owner_id: ownerId, group_id: null };
28
+ }
29
+
7
30
  var registered = false;
8
31
  var index_default = definePluginEntry({
9
32
  id: "memory-gateway-sync",
10
33
  name: "Memory Gateway Sync",
11
- description: "fs.watch \u76D1\u542C\u591A workspace \u7684 MEMORY \u6587\u4EF6\u53D8\u5316\uFF0C\u53CC\u5199\u5230\u5916\u90E8 Gateway\uFF08\u652F\u6301 peers/groups/knowledge \u5B50\u76EE\u5F55\uFF09",
34
+ description: "fs.watch 监听多 workspace MEMORY 文件变化,双写到外部 Gateway(支持 peers/groups/knowledge 子目录)",
12
35
  kind: "generic",
13
36
  register(api) {
14
37
  if (registered) {
15
- api.logger.info("memory-gateway-sync: \u5DF2\u6CE8\u518C\uFF0C\u8DF3\u8FC7\u91CD\u590D\u52A0\u8F7D");
38
+ api.logger.info("memory-gateway-sync: 已注册,跳过重复加载");
16
39
  return;
17
40
  }
18
41
  registered = true;
19
42
  const cfg = api.pluginConfig ?? {};
20
43
  const gatewayUrl = cfg.gatewayUrl || process.env.MEMORY_SYNC_GATEWAY_URL || "";
21
44
  const gatewayToken = cfg.gatewayToken || process.env.MEMORY_SYNC_GATEWAY_TOKEN || "";
45
+ const tenantsDir = cfg.tenantsDir || process.env.MEMORY_SYNC_TENANTS_DIR || "";
22
46
  const debounceMs = cfg.debounceMs ?? 1500;
23
47
  const defaultAgentId = cfg.agentId ?? "default";
24
48
  const ownerClawUserId = (() => {
@@ -29,7 +53,7 @@ var index_default = definePluginEntry({
29
53
  const podReleaseName = process.env.botID ?? null;
30
54
  if (!gatewayUrl || !gatewayToken) {
31
55
  api.logger.warn(
32
- "memory-gateway-sync: gatewayUrl \u6216 gatewayToken \u672A\u914D\u7F6E\uFF0C\u63D2\u4EF6\u4E0D\u542F\u52A8"
56
+ "memory-gateway-sync: gatewayUrl gatewayToken 未配置,插件不启动"
33
57
  );
34
58
  return;
35
59
  }
@@ -69,7 +93,7 @@ var index_default = definePluginEntry({
69
93
  const ws = findWorkspace(absPath);
70
94
  if (!ws) {
71
95
  api.logger.warn(
72
- `memory-gateway-sync: \u627E\u4E0D\u5230\u6240\u5C5E workspace: ${absPath}`
96
+ `memory-gateway-sync: 找不到所属 workspace: ${absPath}`
73
97
  );
74
98
  return;
75
99
  }
@@ -78,7 +102,7 @@ var index_default = definePluginEntry({
78
102
  content = await fs.promises.readFile(absPath, "utf-8");
79
103
  } catch (err) {
80
104
  api.logger.warn(
81
- `memory-gateway-sync: \u8BFB\u53D6\u6587\u4EF6\u5931\u8D25 ${absPath}: ${String(err)}`
105
+ `memory-gateway-sync: 读取文件失败 ${absPath}: ${String(err)}`
82
106
  );
83
107
  return;
84
108
  }
@@ -98,19 +122,23 @@ var index_default = definePluginEntry({
98
122
  );
99
123
  }
100
124
  }
101
- let userId = null;
125
+ let owner_id = ws.userId ?? ownerClawUserId ?? undefined;
126
+ let group_id;
102
127
  const parts = relativePath.replace(/\\/g, "/").split("/");
103
- if (relativePath === "MEMORY.md" || parts[0] === "memory" && parts[1] === "owner") {
104
- userId = ownerClawUserId;
105
- } else if (parts[0] === "memory" && parts[1] === "peers" && parts.length >= 3) {
106
- userId = parts[2];
128
+ if (parts[0] === "memory" && parts[1] === "peers" && parts.length >= 3) {
129
+ owner_id = parts[2];
130
+ } else if (parts[0] === "memory" && parts[1] === "groups" && parts.length >= 3) {
131
+ group_id = parts[2];
107
132
  }
133
+ const identity = resolveIdentity({ owner_id, group_id });
108
134
  const payload = {
109
135
  path: relativePath,
110
136
  content,
111
137
  release_name: parseReleaseName(content) ?? podReleaseName,
112
138
  agentId: ws.agentId,
113
- userId,
139
+ userId: identity.owner_id,
140
+ scene: identity.scene,
141
+ group_id: identity.group_id,
114
142
  workspaceDir: ws.dir,
115
143
  eventType,
116
144
  syncedAt: (/* @__PURE__ */ new Date()).toISOString()
@@ -133,17 +161,17 @@ var index_default = definePluginEntry({
133
161
  syncOk = true;
134
162
  lastSyncedHash.set(absPath, contentHash);
135
163
  api.logger.info?.(
136
- `memory-gateway-sync: [${ws.agentId}] \u540C\u6B65\u6210\u529F ${relativePath} (${content.length} chars)`
164
+ `memory-gateway-sync: [${ws.agentId}] 同步成功 ${relativePath} (${content.length} chars)`
137
165
  );
138
166
  } else {
139
167
  const body = await res.text().catch(() => "");
140
168
  api.logger.warn(
141
- `memory-gateway-sync: [${ws.agentId}] Gateway \u8FD4\u56DE ${res.status} for ${relativePath}: ${body}`
169
+ `memory-gateway-sync: [${ws.agentId}] Gateway 返回 ${res.status} for ${relativePath}: ${body}`
142
170
  );
143
171
  }
144
172
  } catch (err) {
145
173
  api.logger.warn(
146
- `memory-gateway-sync: [${ws.agentId}] POST \u5931\u8D25 ${relativePath}: ${String(err)}`
174
+ `memory-gateway-sync: [${ws.agentId}] POST 失败 ${relativePath}: ${String(err)}`
147
175
  );
148
176
  }
149
177
  if (relativePath === "MEMORY.md" && content.trim().length > 0) {
@@ -153,11 +181,11 @@ var index_default = definePluginEntry({
153
181
  const backupPath = syncOk ? absPath + ".bak" : absPath + ".failed";
154
182
  await fs.promises.appendFile(backupPath, section, "utf-8");
155
183
  api.logger.info?.(
156
- `memory-gateway-sync: [${ws.agentId}] MEMORY.md \u5DF2\u5907\u4EFD\u5230 ${syncOk ? ".bak" : ".failed"}`
184
+ `memory-gateway-sync: [${ws.agentId}] MEMORY.md 已备份到 ${syncOk ? ".bak" : ".failed"}`
157
185
  );
158
186
  } catch (err) {
159
187
  api.logger.warn(
160
- `memory-gateway-sync: [${ws.agentId}] \u5907\u4EFD/\u6E05\u7A7A MEMORY.md \u5931\u8D25: ${String(err)}`
188
+ `memory-gateway-sync: [${ws.agentId}] 备份 MEMORY.md 失败: ${String(err)}`
161
189
  );
162
190
  }
163
191
  }
@@ -173,11 +201,11 @@ var index_default = definePluginEntry({
173
201
  }, debounceMs)
174
202
  );
175
203
  };
176
- const registerWorkspace = async (wsDir, wsAgentId) => {
204
+ const registerWorkspace = async (wsDir, wsAgentId, wsUserId) => {
177
205
  const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
178
206
  if (registeredRealPaths.has(realDir)) return;
179
207
  registeredRealPaths.add(realDir);
180
- registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId });
208
+ registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId, userId: wsUserId });
181
209
  const memoryMdPath = path.join(realDir, "MEMORY.md");
182
210
  try {
183
211
  await fs.promises.writeFile(memoryMdPath, "", { flag: "a" });
@@ -185,16 +213,16 @@ var index_default = definePluginEntry({
185
213
  scheduleSync(memoryMdPath, eventType ?? "change");
186
214
  });
187
215
  api.logger.info?.(
188
- `memory-gateway-sync: [${wsAgentId}] \u5F00\u59CB\u76D1\u542C MEMORY.md`
216
+ `memory-gateway-sync: [${wsAgentId}] 开始监听 MEMORY.md`
189
217
  );
190
218
  scheduleSync(memoryMdPath, "startup");
191
219
  } catch (err) {
192
220
  api.logger.warn(
193
- `memory-gateway-sync: [${wsAgentId}] \u65E0\u6CD5\u76D1\u542C MEMORY.md: ${String(err)}`
221
+ `memory-gateway-sync: [${wsAgentId}] 无法监听 MEMORY.md: ${String(err)}`
194
222
  );
195
223
  }
196
224
  api.logger.info(
197
- `memory-gateway-sync: [${wsAgentId}] workspace \u6CE8\u518C\u5B8C\u6210 \u2192 ${wsDir}`
225
+ `memory-gateway-sync: [${wsAgentId}] workspace 注册完成 ${wsDir}`
198
226
  );
199
227
  };
200
228
  const scanWorkspaces = async () => {
@@ -218,11 +246,11 @@ var index_default = definePluginEntry({
218
246
  }
219
247
  }
220
248
  const wsAgentId = resolveAgentId(name);
221
- await registerWorkspace(wsDir, wsAgentId);
249
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
222
250
  }
223
251
  } catch (err) {
224
252
  api.logger.warn(
225
- `memory-gateway-sync: \u626B\u63CF workspace \u5931\u8D25: ${String(err)}`
253
+ `memory-gateway-sync: 扫描 workspace 失败: ${String(err)}`
226
254
  );
227
255
  }
228
256
  for (const dataDir of discoveredDataDirs) {
@@ -237,11 +265,11 @@ var index_default = definePluginEntry({
237
265
  if (name !== "workspace" && !name.startsWith("workspace-")) continue;
238
266
  const wsDir = path.join(dataDir, name);
239
267
  const wsAgentId = resolveAgentId(name);
240
- await registerWorkspace(wsDir, wsAgentId);
268
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
241
269
  }
242
270
  } catch (err) {
243
271
  api.logger.warn(
244
- `memory-gateway-sync: \u626B\u63CF\u6570\u636E\u76EE\u5F55 ${dataDir} \u5931\u8D25: ${String(err)}`
272
+ `memory-gateway-sync: 扫描数据目录 ${dataDir} 失败: ${String(err)}`
245
273
  );
246
274
  }
247
275
  }
@@ -261,9 +289,9 @@ var index_default = definePluginEntry({
261
289
  if (registeredRealPaths.has(realDir)) return;
262
290
  const wsAgentId = resolveAgentId(filename);
263
291
  api.logger.info(
264
- `memory-gateway-sync: \u53D1\u73B0\u65B0 workspace \u2192 ${filename} (agentId=${wsAgentId}) in ${dir}`
292
+ `memory-gateway-sync: 发现新 workspace ${filename} (agentId=${wsAgentId}) in ${dir}`
265
293
  );
266
- await registerWorkspace(wsDir, wsAgentId);
294
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
267
295
  const parentDir = path.dirname(realDir);
268
296
  if (!discoveredDataDirs.has(parentDir) && parentDir !== openclawDir) {
269
297
  discoveredDataDirs.add(parentDir);
@@ -274,10 +302,90 @@ var index_default = definePluginEntry({
274
302
  });
275
303
  } catch (err) {
276
304
  api.logger.warn(
277
- `memory-gateway-sync: \u65E0\u6CD5\u76D1\u542C ${dir}: ${String(err)}`
305
+ `memory-gateway-sync: 无法监听 ${dir}: ${String(err)}`
278
306
  );
279
307
  }
280
308
  };
309
+
310
+ // ── Tenants 模式 ──
311
+
312
+ const scanTenantWorkspaces = async (tenantDir, userId) => {
313
+ const workspacesDir = path.join(tenantDir, "workspaces");
314
+ try {
315
+ const entries = await fs.promises.readdir(workspacesDir, { withFileTypes: true });
316
+ for (const entry of entries) {
317
+ if (!entry.isDirectory()) continue;
318
+ const agentId = entry.name;
319
+ const wsDir = path.join(workspacesDir, agentId);
320
+ await registerWorkspace(wsDir, agentId, userId);
321
+ }
322
+ } catch {
323
+ }
324
+ };
325
+ const watchTenantWorkspacesDir = (tenantDir, userId) => {
326
+ const workspacesDir = path.join(tenantDir, "workspaces");
327
+ try {
328
+ fs.watch(workspacesDir, async (_eventType, filename) => {
329
+ if (!filename) return;
330
+ const wsDir = path.join(workspacesDir, filename);
331
+ try {
332
+ const stat = await fs.promises.stat(wsDir);
333
+ if (!stat.isDirectory()) return;
334
+ const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
335
+ if (registeredRealPaths.has(realDir)) return;
336
+ api.logger.info(
337
+ `memory-gateway-sync: [tenants] 发现新 workspace → ${userId}/${filename}`
338
+ );
339
+ await registerWorkspace(wsDir, filename, userId);
340
+ } catch {
341
+ }
342
+ });
343
+ } catch {
344
+ }
345
+ };
346
+ const scanTenants = async () => {
347
+ try {
348
+ const entries = await fs.promises.readdir(tenantsDir, { withFileTypes: true });
349
+ for (const entry of entries) {
350
+ if (!entry.isDirectory()) continue;
351
+ const userId = entry.name;
352
+ const tenantDir = path.join(tenantsDir, userId);
353
+ await scanTenantWorkspaces(tenantDir, userId);
354
+ watchTenantWorkspacesDir(tenantDir, userId);
355
+ }
356
+ } catch (err) {
357
+ api.logger.warn(
358
+ `memory-gateway-sync: [tenants] 扫描 ${tenantsDir} 失败: ${String(err)}`
359
+ );
360
+ }
361
+ };
362
+ const watchTenantsDir = () => {
363
+ try {
364
+ fs.watch(tenantsDir, async (_eventType, filename) => {
365
+ if (!filename) return;
366
+ const tenantDir = path.join(tenantsDir, filename);
367
+ try {
368
+ const stat = await fs.promises.stat(tenantDir);
369
+ if (!stat.isDirectory()) return;
370
+ const userId = filename;
371
+ api.logger.info(
372
+ `memory-gateway-sync: [tenants] 发现新 tenant → ${userId}`
373
+ );
374
+ await scanTenantWorkspaces(tenantDir, userId);
375
+ watchTenantWorkspacesDir(tenantDir, userId);
376
+ } catch {
377
+ }
378
+ });
379
+ } catch (err) {
380
+ api.logger.warn(
381
+ `memory-gateway-sync: [tenants] 无法监听 ${tenantsDir}: ${String(err)}`
382
+ );
383
+ }
384
+ };
385
+
386
+ // ── 启动 ──
387
+
388
+ // 旧模式
281
389
  watchDirForWorkspaces(openclawDir);
282
390
  await scanWorkspaces();
283
391
  for (const dataDir of discoveredDataDirs) {
@@ -285,14 +393,24 @@ var index_default = definePluginEntry({
285
393
  watchDirForWorkspaces(dataDir);
286
394
  }
287
395
  }
396
+
397
+ // Tenants 模式
398
+ if (tenantsDir) {
399
+ watchTenantsDir();
400
+ await scanTenants();
401
+ api.logger.info(
402
+ `memory-gateway-sync: [tenants] 扫描完成 → ${tenantsDir}`
403
+ );
404
+ }
405
+
288
406
  const wsCount = registeredWorkspaces.size;
289
- const wsIds = Array.from(registeredWorkspaces.values()).map((w) => w.agentId).join(", ");
407
+ const wsIds = Array.from(registeredWorkspaces.values()).map((w) => `${w.agentId}(${w.userId ?? "?"})`).join(", ");
290
408
  api.logger.info(
291
- `memory-gateway-sync: \u542F\u52A8\u5B8C\u6210 \u2192 ${gatewayUrl} (\u9632\u6296 ${debounceMs}ms, ${wsCount} \u4E2A workspace: ${wsIds})`
409
+ `memory-gateway-sync: 启动完成 ${gatewayUrl} (防抖 ${debounceMs}ms, ${wsCount} workspace: ${wsIds})`
292
410
  );
293
411
  },
294
412
  stop: () => {
295
- api.logger.info("memory-gateway-sync: \u505C\u6B62");
413
+ api.logger.info("memory-gateway-sync: 停止");
296
414
  }
297
415
  });
298
416
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-gateway-sync",
3
- "version": "0.11.0",
3
+ "version": "0.13.0",
4
4
  "description": "Memory 双写插件:fs.watch 感知变化后同步到外部 Gateway",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -0,0 +1,48 @@
1
+ /**
2
+ * 身份解析模块
3
+ *
4
+ * group_id 有值 → scene = "group"
5
+ * group_id 无值 → scene = "owner"
6
+ *
7
+ * owner_id 用于 SQL 数据隔离。
8
+ */
9
+
10
+ export type Scene = "owner" | "group" | "knowledge";
11
+
12
+ export interface ResolvedIdentity {
13
+ scene: Scene;
14
+ owner_id: string;
15
+ group_id: string | null;
16
+ }
17
+
18
+ export interface IdentityParams {
19
+ owner_id?: string;
20
+ group_id?: string;
21
+ }
22
+
23
+ export function resolveIdentity(params: IdentityParams): ResolvedIdentity {
24
+ const testScene = process.env.MEMORY_GATEWAY_TEST_SCENE as Scene | undefined;
25
+ if (testScene) {
26
+ return {
27
+ scene: testScene,
28
+ owner_id: process.env.MEMORY_GATEWAY_TEST_OWNER_ID || "test_owner",
29
+ group_id: process.env.MEMORY_GATEWAY_TEST_GROUP_ID || null,
30
+ };
31
+ }
32
+
33
+ const ownerId = params.owner_id?.trim() || "";
34
+ const groupId = params.group_id?.trim() || null;
35
+
36
+ if (!ownerId) {
37
+ console.warn(
38
+ "[identity] owner_id is empty — " +
39
+ "owner queries will be skipped to prevent cross-user leak."
40
+ );
41
+ }
42
+
43
+ if (groupId) {
44
+ return { scene: "group", owner_id: ownerId, group_id: groupId };
45
+ }
46
+
47
+ return { scene: "owner", owner_id: ownerId, group_id: null };
48
+ }
package/src/index.ts CHANGED
@@ -3,12 +3,14 @@ import fs from "node:fs";
3
3
  import path from "node:path";
4
4
  import crypto from "node:crypto";
5
5
  import os from "node:os";
6
+ import { resolveIdentity, type ResolvedIdentity } from "./identity";
6
7
 
7
8
  // ── 类型 ──────────────────────────────────��───────────────────────────────────
8
9
 
9
10
  interface PluginConfig {
10
11
  gatewayUrl: string;
11
12
  gatewayToken: string;
13
+ tenantsDir?: string;
12
14
  debounceMs?: number;
13
15
  agentId?: string;
14
16
  }
@@ -18,7 +20,9 @@ interface IngestPayload {
18
20
  content: string;
19
21
  release_name: string | null;
20
22
  agentId: string;
21
- userId: string | null;
23
+ userId: string;
24
+ scene: string;
25
+ group_id: string | null;
22
26
  workspaceDir: string;
23
27
  eventType: string;
24
28
  syncedAt: string;
@@ -27,6 +31,7 @@ interface IngestPayload {
27
31
  interface WorkspaceContext {
28
32
  dir: string;
29
33
  agentId: string;
34
+ userId: string | null;
30
35
  }
31
36
 
32
37
  // ── 防重复加载 ───────────────────────────────────────────────────────────────
@@ -50,6 +55,7 @@ export default definePluginEntry({
50
55
  const cfg = (api.pluginConfig ?? {}) as Partial<PluginConfig>;
51
56
  const gatewayUrl = cfg.gatewayUrl || process.env.MEMORY_SYNC_GATEWAY_URL || "";
52
57
  const gatewayToken = cfg.gatewayToken || process.env.MEMORY_SYNC_GATEWAY_TOKEN || "";
58
+ const tenantsDir = cfg.tenantsDir || process.env.MEMORY_SYNC_TENANTS_DIR || "";
53
59
  const debounceMs = cfg.debounceMs ?? 1500;
54
60
  const defaultAgentId = cfg.agentId ?? "default";
55
61
  const ownerClawUserId = (() => {
@@ -163,24 +169,28 @@ export default definePluginEntry({
163
169
  }
164
170
  }
165
171
 
166
- // 根据路径解析 userId
167
- // MEMORY.md / memory/owner/* 环境变量 owner_claw_user_id
168
- // memory/peers/{peerId}/* 路径中的 peerId
169
- // 其他 → null
170
- let userId: string | null = null;
172
+ // 身份解析:优先使用 workspace 注册时的 userId(tenants 模式从路径获取,旧模式为 ownerClawUserId)
173
+ // memory/peers/{peerId}/* 覆盖 owner_id
174
+ // memory/groups/{groupId}/* 设置 group_id
175
+ let owner_id: string | undefined = ws.userId ?? ownerClawUserId ?? undefined;
176
+ let group_id: string | undefined;
171
177
  const parts = relativePath.replace(/\\/g, "/").split("/");
172
- if (relativePath === "MEMORY.md" || parts[0] === "memory" && parts[1] === "owner") {
173
- userId = ownerClawUserId;
174
- } else if (parts[0] === "memory" && parts[1] === "peers" && parts.length >= 3) {
175
- userId = parts[2];
178
+ if (parts[0] === "memory" && parts[1] === "peers" && parts.length >= 3) {
179
+ owner_id = parts[2];
180
+ } else if (parts[0] === "memory" && parts[1] === "groups" && parts.length >= 3) {
181
+ group_id = parts[2];
176
182
  }
177
183
 
184
+ const identity = resolveIdentity({ owner_id, group_id });
185
+
178
186
  const payload: IngestPayload = {
179
187
  path: relativePath,
180
188
  content,
181
189
  release_name: parseReleaseName(content) ?? podReleaseName,
182
190
  agentId: ws.agentId,
183
- userId,
191
+ userId: identity.owner_id,
192
+ scene: identity.scene,
193
+ group_id: identity.group_id,
184
194
  workspaceDir: ws.dir,
185
195
  eventType,
186
196
  syncedAt: new Date().toISOString(),
@@ -255,12 +265,12 @@ export default definePluginEntry({
255
265
 
256
266
  // ── 注册单个 workspace ───────────────────────────────────────────
257
267
 
258
- const registerWorkspace = async (wsDir: string, wsAgentId: string): Promise<void> => {
268
+ const registerWorkspace = async (wsDir: string, wsAgentId: string, wsUserId: string | null): Promise<void> => {
259
269
  const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
260
270
  if (registeredRealPaths.has(realDir)) return;
261
271
  registeredRealPaths.add(realDir);
262
272
 
263
- registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId });
273
+ registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId, userId: wsUserId });
264
274
 
265
275
  // 监听 MEMORY.md(使用真实路径,确保 symlink 场景下 fs.watch 正常工作)
266
276
  const memoryMdPath = path.join(realDir, "MEMORY.md");
@@ -312,7 +322,7 @@ export default definePluginEntry({
312
322
  }
313
323
 
314
324
  const wsAgentId = resolveAgentId(name);
315
- await registerWorkspace(wsDir, wsAgentId);
325
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
316
326
  }
317
327
  } catch (err) {
318
328
  api.logger.warn(
@@ -334,7 +344,7 @@ export default definePluginEntry({
334
344
 
335
345
  const wsDir = path.join(dataDir, name);
336
346
  const wsAgentId = resolveAgentId(name);
337
- await registerWorkspace(wsDir, wsAgentId);
347
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
338
348
  }
339
349
  } catch (err) {
340
350
  api.logger.warn(
@@ -370,7 +380,7 @@ export default definePluginEntry({
370
380
  api.logger.info(
371
381
  `memory-gateway-sync: 发现新 workspace → ${filename} (agentId=${wsAgentId}) in ${dir}`
372
382
  );
373
- await registerWorkspace(wsDir, wsAgentId);
383
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
374
384
 
375
385
  // 如果是 symlink,也发现其数据目录并监听
376
386
  const parentDir = path.dirname(realDir);
@@ -389,23 +399,112 @@ export default definePluginEntry({
389
399
  }
390
400
  };
391
401
 
392
- // 监听 openclawDir(在 scan 之前注册,不会遗漏 scan 期间创建的目录)
393
- watchDirForWorkspaces(openclawDir);
402
+ // ── Tenants 模式:扫描 {tenantsDir}/{userId}/workspaces/{agentId}/ ──
394
403
 
395
- // ── 启动:扫描所有已有 workspace ─────────────────────────────────
404
+ const scanTenantWorkspaces = async (tenantDir: string, userId: string): Promise<void> => {
405
+ const workspacesDir = path.join(tenantDir, "workspaces");
406
+ try {
407
+ const entries = await fs.promises.readdir(workspacesDir, { withFileTypes: true });
408
+ for (const entry of entries) {
409
+ if (!entry.isDirectory()) continue;
410
+ const agentId = entry.name;
411
+ const wsDir = path.join(workspacesDir, agentId);
412
+ await registerWorkspace(wsDir, agentId, userId);
413
+ }
414
+ } catch {
415
+ // workspaces 目录不存在,跳过
416
+ }
417
+ };
396
418
 
397
- await scanWorkspaces();
419
+ const watchTenantWorkspacesDir = (tenantDir: string, userId: string): void => {
420
+ const workspacesDir = path.join(tenantDir, "workspaces");
421
+ try {
422
+ fs.watch(workspacesDir, async (_eventType, filename) => {
423
+ if (!filename) return;
424
+ const wsDir = path.join(workspacesDir, filename);
425
+ try {
426
+ const stat = await fs.promises.stat(wsDir);
427
+ if (!stat.isDirectory()) return;
428
+ const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
429
+ if (registeredRealPaths.has(realDir)) return;
430
+ api.logger.info(
431
+ `memory-gateway-sync: [tenants] 发现新 workspace → ${userId}/${filename}`
432
+ );
433
+ await registerWorkspace(wsDir, filename, userId);
434
+ } catch {
435
+ // 目录不存在或已删除
436
+ }
437
+ });
438
+ } catch {
439
+ // workspaces 目录不存在,无法监听
440
+ }
441
+ };
398
442
 
399
- // 监听从 symlink 发现的数据目录(scan 之后才有 discoveredDataDirs)
443
+ const scanTenants = async (): Promise<void> => {
444
+ try {
445
+ const entries = await fs.promises.readdir(tenantsDir, { withFileTypes: true });
446
+ for (const entry of entries) {
447
+ if (!entry.isDirectory()) continue;
448
+ const userId = entry.name;
449
+ const tenantDir = path.join(tenantsDir, userId);
450
+ await scanTenantWorkspaces(tenantDir, userId);
451
+ watchTenantWorkspacesDir(tenantDir, userId);
452
+ }
453
+ } catch (err) {
454
+ api.logger.warn(
455
+ `memory-gateway-sync: [tenants] 扫描 ${tenantsDir} 失败: ${String(err)}`
456
+ );
457
+ }
458
+ };
459
+
460
+ const watchTenantsDir = (): void => {
461
+ try {
462
+ fs.watch(tenantsDir, async (_eventType, filename) => {
463
+ if (!filename) return;
464
+ const tenantDir = path.join(tenantsDir, filename);
465
+ try {
466
+ const stat = await fs.promises.stat(tenantDir);
467
+ if (!stat.isDirectory()) return;
468
+ const userId = filename;
469
+ api.logger.info(
470
+ `memory-gateway-sync: [tenants] 发现新 tenant → ${userId}`
471
+ );
472
+ await scanTenantWorkspaces(tenantDir, userId);
473
+ watchTenantWorkspacesDir(tenantDir, userId);
474
+ } catch {
475
+ // 目录不存在或已删除
476
+ }
477
+ });
478
+ } catch (err) {
479
+ api.logger.warn(
480
+ `memory-gateway-sync: [tenants] 无法监听 ${tenantsDir}: ${String(err)}`
481
+ );
482
+ }
483
+ };
484
+
485
+ // ── 启动 ─────────────────────────────────────────────────────────
486
+
487
+ // 旧模式:~/.openclaw/workspace*
488
+ watchDirForWorkspaces(openclawDir);
489
+ await scanWorkspaces();
400
490
  for (const dataDir of discoveredDataDirs) {
401
491
  if (dataDir !== openclawDir) {
402
492
  watchDirForWorkspaces(dataDir);
403
493
  }
404
494
  }
405
495
 
496
+ // Tenants 模式
497
+ if (tenantsDir) {
498
+ watchTenantsDir();
499
+ await scanTenants();
500
+ api.logger.info(
501
+ `memory-gateway-sync: [tenants] 扫描完成 → ${tenantsDir}`
502
+ );
503
+ }
504
+
406
505
  const wsCount = registeredWorkspaces.size;
407
506
  const wsIds = Array.from(registeredWorkspaces.values())
408
- .map((w) => w.agentId)
507
+ .map((w) => `${w.agentId}(${w.userId ?? "?"})`)
409
508
  .join(", ");
410
509
  api.logger.info(
411
510
  `memory-gateway-sync: 启动完成 → ${gatewayUrl} (防抖 ${debounceMs}ms, ${wsCount} 个 workspace: ${wsIds})`