memory-gateway-sync 0.12.0 → 0.14.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
  }
@@ -1,7 +1,19 @@
1
1
  {
2
2
  "id": "memory-gateway-sync",
3
+ "version": "0.12.0",
3
4
  "name": "Memory Gateway Sync",
4
5
  "description": "监听 MEMORY.md / memory/*.md 文件变化,双写到外部 Gateway",
6
+ "kind": "generic",
7
+ "permissions": {
8
+ "filesystem": {
9
+ "read": ["~/.openclaw/workspace*/**/*.md"],
10
+ "write": ["~/.openclaw/workspace*/**/MEMORY.md", "~/.openclaw/workspace*/**/MEMORY.md.bak", "~/.openclaw/workspace*/**/MEMORY.md.failed"]
11
+ },
12
+ "network": {
13
+ "outbound": ["${config.gatewayUrl}"]
14
+ }
15
+ },
16
+ "services": ["memory-gateway-sync"],
5
17
  "configSchema": {
6
18
  "type": "object",
7
19
  "additionalProperties": false,
@@ -22,7 +34,11 @@
22
34
  "agentId": {
23
35
  "type": "string",
24
36
  "description": "当前 agent 标识,写入 Gateway 时携带,用于后续多 agent 隔离"
37
+ },
38
+ "tenantsDir": {
39
+ "type": "string",
40
+ "description": "多租户数据根目录,启用后按 tenantsDir/{userId}/workspaces/{agentId}/ 扫描"
25
41
  }
26
- },
42
+ }
27
43
  }
28
44
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "memory-gateway-sync",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Memory 双写插件:fs.watch 感知变化后同步到外部 Gateway",
5
5
  "type": "module",
6
6
  "main": "index.js",
@@ -9,6 +9,12 @@
9
9
  "./index.js"
10
10
  ]
11
11
  },
12
+ "engines": {
13
+ "node": ">=22.19.0"
14
+ },
15
+ "peerDependencies": {
16
+ "openclaw": ">=2026.6.1"
17
+ },
12
18
  "devDependencies": {
13
19
  "typescript": "^5.4.0"
14
20
  }
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { resolveIdentity, type ResolvedIdentity } from "./identity";
10
10
  interface PluginConfig {
11
11
  gatewayUrl: string;
12
12
  gatewayToken: string;
13
+ tenantsDir?: string;
13
14
  debounceMs?: number;
14
15
  agentId?: string;
15
16
  }
@@ -30,6 +31,7 @@ interface IngestPayload {
30
31
  interface WorkspaceContext {
31
32
  dir: string;
32
33
  agentId: string;
34
+ userId: string | null;
33
35
  }
34
36
 
35
37
  // ── 防重复加载 ───────────────────────────────────────────────────────────────
@@ -53,6 +55,7 @@ export default definePluginEntry({
53
55
  const cfg = (api.pluginConfig ?? {}) as Partial<PluginConfig>;
54
56
  const gatewayUrl = cfg.gatewayUrl || process.env.MEMORY_SYNC_GATEWAY_URL || "";
55
57
  const gatewayToken = cfg.gatewayToken || process.env.MEMORY_SYNC_GATEWAY_TOKEN || "";
58
+ const tenantsDir = cfg.tenantsDir || process.env.MEMORY_SYNC_TENANTS_DIR || "";
56
59
  const debounceMs = cfg.debounceMs ?? 1500;
57
60
  const defaultAgentId = cfg.agentId ?? "default";
58
61
  const ownerClawUserId = (() => {
@@ -166,12 +169,10 @@ export default definePluginEntry({
166
169
  }
167
170
  }
168
171
 
169
- // 根据路径解析身份
170
- // MEMORY.md / memory/owner/* → owner_id = ownerClawUserId
171
- // memory/peers/{peerId}/* owner_id = 路径中的 peerId
172
- // memory/groups/{groupId}/* → owner_id = ownerClawUserId, group_id = groupId
173
- // 其他 → owner_id = ownerClawUserId
174
- let owner_id: string | undefined = ownerClawUserId ?? undefined;
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;
175
176
  let group_id: string | undefined;
176
177
  const parts = relativePath.replace(/\\/g, "/").split("/");
177
178
  if (parts[0] === "memory" && parts[1] === "peers" && parts.length >= 3) {
@@ -264,12 +265,12 @@ export default definePluginEntry({
264
265
 
265
266
  // ── 注册单个 workspace ───────────────────────────────────────────
266
267
 
267
- const registerWorkspace = async (wsDir: string, wsAgentId: string): Promise<void> => {
268
+ const registerWorkspace = async (wsDir: string, wsAgentId: string, wsUserId: string | null): Promise<void> => {
268
269
  const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
269
270
  if (registeredRealPaths.has(realDir)) return;
270
271
  registeredRealPaths.add(realDir);
271
272
 
272
- registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId });
273
+ registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId, userId: wsUserId });
273
274
 
274
275
  // 监听 MEMORY.md(使用真实路径,确保 symlink 场景下 fs.watch 正常工作)
275
276
  const memoryMdPath = path.join(realDir, "MEMORY.md");
@@ -321,7 +322,7 @@ export default definePluginEntry({
321
322
  }
322
323
 
323
324
  const wsAgentId = resolveAgentId(name);
324
- await registerWorkspace(wsDir, wsAgentId);
325
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
325
326
  }
326
327
  } catch (err) {
327
328
  api.logger.warn(
@@ -343,7 +344,7 @@ export default definePluginEntry({
343
344
 
344
345
  const wsDir = path.join(dataDir, name);
345
346
  const wsAgentId = resolveAgentId(name);
346
- await registerWorkspace(wsDir, wsAgentId);
347
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
347
348
  }
348
349
  } catch (err) {
349
350
  api.logger.warn(
@@ -379,7 +380,7 @@ export default definePluginEntry({
379
380
  api.logger.info(
380
381
  `memory-gateway-sync: 发现新 workspace → ${filename} (agentId=${wsAgentId}) in ${dir}`
381
382
  );
382
- await registerWorkspace(wsDir, wsAgentId);
383
+ await registerWorkspace(wsDir, wsAgentId, ownerClawUserId);
383
384
 
384
385
  // 如果是 symlink,也发现其数据目录并监听
385
386
  const parentDir = path.dirname(realDir);
@@ -398,23 +399,112 @@ export default definePluginEntry({
398
399
  }
399
400
  };
400
401
 
401
- // 监听 openclawDir(在 scan 之前注册,不会遗漏 scan 期间创建的目录)
402
- watchDirForWorkspaces(openclawDir);
402
+ // ── Tenants 模式:扫描 {tenantsDir}/{userId}/workspaces/{agentId}/ ──
403
403
 
404
- // ── 启动:扫描所有已有 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
+ };
405
418
 
406
- 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
+ };
442
+
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
+ // ── 启动 ─────────────────────────────────────────────────────────
407
486
 
408
- // 监听从 symlink 发现的数据目录(scan 之后才有 discoveredDataDirs)
487
+ // 旧模式:~/.openclaw/workspace*
488
+ watchDirForWorkspaces(openclawDir);
489
+ await scanWorkspaces();
409
490
  for (const dataDir of discoveredDataDirs) {
410
491
  if (dataDir !== openclawDir) {
411
492
  watchDirForWorkspaces(dataDir);
412
493
  }
413
494
  }
414
495
 
496
+ // Tenants 模式
497
+ if (tenantsDir) {
498
+ watchTenantsDir();
499
+ await scanTenants();
500
+ api.logger.info(
501
+ `memory-gateway-sync: [tenants] 扫描完成 → ${tenantsDir}`
502
+ );
503
+ }
504
+
415
505
  const wsCount = registeredWorkspaces.size;
416
506
  const wsIds = Array.from(registeredWorkspaces.values())
417
- .map((w) => w.agentId)
507
+ .map((w) => `${w.agentId}(${w.userId ?? "?"})`)
418
508
  .join(", ");
419
509
  api.logger.info(
420
510
  `memory-gateway-sync: 启动完成 → ${gatewayUrl} (防抖 ${debounceMs}ms, ${wsCount} 个 workspace: ${wsIds})`