memory-gateway-sync 0.7.0 → 0.8.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.
Files changed (3) hide show
  1. package/index.js +287 -0
  2. package/package.json +3 -2
  3. package/src/index.ts +96 -33
package/index.js ADDED
@@ -0,0 +1,287 @@
1
+ import { definePluginEntry } from "openclaw/plugin-sdk/plugin-entry";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import crypto from "node:crypto";
5
+ import os from "node:os";
6
+ var index_default = definePluginEntry({
7
+ id: "memory-gateway-sync",
8
+ name: "Memory Gateway Sync",
9
+ 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",
10
+ kind: "generic",
11
+ register(api) {
12
+ const cfg = api.pluginConfig ?? {};
13
+ const gatewayUrl = cfg.gatewayUrl;
14
+ const gatewayToken = cfg.gatewayToken;
15
+ const debounceMs = cfg.debounceMs ?? 1500;
16
+ const defaultAgentId = cfg.agentId ?? "default";
17
+ const ownerClawUserId = (() => {
18
+ const token = process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
19
+ const parts = token.split("-");
20
+ return parts.length >= 3 ? parts[parts.length - 1] : null;
21
+ })();
22
+ const podReleaseName = process.env.botID ?? null;
23
+ if (!gatewayUrl || !gatewayToken) {
24
+ api.logger.warn(
25
+ "memory-gateway-sync: gatewayUrl \u6216 gatewayToken \u672A\u914D\u7F6E\uFF0C\u63D2\u4EF6\u4E0D\u542F\u52A8"
26
+ );
27
+ return;
28
+ }
29
+ api.registerService({
30
+ id: "memory-gateway-sync",
31
+ start: async () => {
32
+ const openclawDir = path.join(os.homedir(), ".openclaw");
33
+ const debounceTimers = /* @__PURE__ */ new Map();
34
+ const lastSyncedHash = /* @__PURE__ */ new Map();
35
+ const parseReleaseName = (content) => {
36
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
37
+ if (!match) return null;
38
+ const nameMatch = match[1].match(/^name:\s*(.+)$/m);
39
+ return nameMatch ? nameMatch[1].trim() : null;
40
+ };
41
+ const registeredWorkspaces = /* @__PURE__ */ new Map();
42
+ const registeredRealPaths = /* @__PURE__ */ new Set();
43
+ const discoveredDataDirs = /* @__PURE__ */ new Set();
44
+ const findWorkspace = (absPath) => {
45
+ for (const [wsDir, ctx] of registeredWorkspaces) {
46
+ if (absPath.startsWith(wsDir + path.sep) || absPath === wsDir) {
47
+ return ctx;
48
+ }
49
+ }
50
+ return null;
51
+ };
52
+ const resolveAgentId = (dirName) => {
53
+ if (dirName === "workspace") {
54
+ return defaultAgentId;
55
+ }
56
+ if (dirName.startsWith("workspace-")) {
57
+ return dirName.substring("workspace-".length);
58
+ }
59
+ return defaultAgentId;
60
+ };
61
+ const syncFile = async (absPath, eventType) => {
62
+ const ws = findWorkspace(absPath);
63
+ if (!ws) {
64
+ api.logger.warn(
65
+ `memory-gateway-sync: \u627E\u4E0D\u5230\u6240\u5C5E workspace: ${absPath}`
66
+ );
67
+ return;
68
+ }
69
+ let content;
70
+ try {
71
+ content = await fs.promises.readFile(absPath, "utf-8");
72
+ } catch (err) {
73
+ api.logger.warn(
74
+ `memory-gateway-sync: \u8BFB\u53D6\u6587\u4EF6\u5931\u8D25 ${absPath}: ${String(err)}`
75
+ );
76
+ return;
77
+ }
78
+ const contentHash = crypto.createHash("md5").update(content).digest("hex");
79
+ if (lastSyncedHash.get(absPath) === contentHash) {
80
+ return;
81
+ }
82
+ const relativePath = path.relative(ws.dir, absPath);
83
+ let userId = null;
84
+ const parts = relativePath.replace(/\\/g, "/").split("/");
85
+ if (relativePath === "MEMORY.md" || parts[0] === "memory" && parts[1] === "owner") {
86
+ userId = ownerClawUserId;
87
+ } else if (parts[0] === "memory" && parts[1] === "peers" && parts.length >= 3) {
88
+ userId = parts[2];
89
+ }
90
+ const payload = {
91
+ path: relativePath,
92
+ content,
93
+ release_name: parseReleaseName(content) ?? podReleaseName,
94
+ agentId: ws.agentId,
95
+ userId,
96
+ workspaceDir: ws.dir,
97
+ eventType,
98
+ syncedAt: (/* @__PURE__ */ new Date()).toISOString()
99
+ };
100
+ try {
101
+ const controller = new AbortController();
102
+ const timeout = setTimeout(() => controller.abort(), 3e4);
103
+ const res = await fetch(gatewayUrl, {
104
+ method: "POST",
105
+ headers: {
106
+ "Content-Type": "application/json",
107
+ Authorization: `Bearer ${gatewayToken}`
108
+ },
109
+ body: JSON.stringify(payload),
110
+ signal: controller.signal
111
+ });
112
+ clearTimeout(timeout);
113
+ if (res.ok) {
114
+ lastSyncedHash.set(absPath, contentHash);
115
+ api.logger.info?.(
116
+ `memory-gateway-sync: [${ws.agentId}] \u540C\u6B65\u6210\u529F ${relativePath} (${content.length} chars)`
117
+ );
118
+ if (relativePath === "MEMORY.md" && content.trim().length > 0) {
119
+ try {
120
+ const backupPath = absPath + ".bak";
121
+ const timestamp = (/* @__PURE__ */ new Date()).toISOString();
122
+ const section = `
123
+ <!-- backup ${timestamp} -->
124
+ ${content}
125
+ `;
126
+ await fs.promises.appendFile(backupPath, section, "utf-8");
127
+ await fs.promises.writeFile(absPath, "", "utf-8");
128
+ const emptyHash = crypto.createHash("md5").update("").digest("hex");
129
+ lastSyncedHash.set(absPath, emptyHash);
130
+ api.logger.info?.(
131
+ `memory-gateway-sync: [${ws.agentId}] MEMORY.md \u5DF2\u5907\u4EFD\u5230 .bak \u5E76\u6E05\u7A7A`
132
+ );
133
+ } catch (err) {
134
+ api.logger.warn(
135
+ `memory-gateway-sync: [${ws.agentId}] \u5907\u4EFD/\u6E05\u7A7A MEMORY.md \u5931\u8D25: ${String(err)}`
136
+ );
137
+ }
138
+ }
139
+ } else {
140
+ const body = await res.text().catch(() => "");
141
+ api.logger.warn(
142
+ `memory-gateway-sync: [${ws.agentId}] Gateway \u8FD4\u56DE ${res.status} for ${relativePath}: ${body}`
143
+ );
144
+ }
145
+ } catch (err) {
146
+ api.logger.warn(
147
+ `memory-gateway-sync: [${ws.agentId}] POST \u5931\u8D25 ${relativePath}: ${String(err)}`
148
+ );
149
+ }
150
+ };
151
+ const scheduleSync = (absPath, eventType) => {
152
+ const existing = debounceTimers.get(absPath);
153
+ if (existing) clearTimeout(existing);
154
+ debounceTimers.set(
155
+ absPath,
156
+ setTimeout(() => {
157
+ debounceTimers.delete(absPath);
158
+ syncFile(absPath, eventType);
159
+ }, debounceMs)
160
+ );
161
+ };
162
+ const registerWorkspace = async (wsDir, wsAgentId) => {
163
+ const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
164
+ if (registeredRealPaths.has(realDir)) return;
165
+ registeredRealPaths.add(realDir);
166
+ registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId });
167
+ const memoryMdPath = path.join(realDir, "MEMORY.md");
168
+ try {
169
+ await fs.promises.writeFile(memoryMdPath, "", { flag: "a" });
170
+ fs.watch(memoryMdPath, (eventType) => {
171
+ scheduleSync(memoryMdPath, eventType ?? "change");
172
+ });
173
+ api.logger.info?.(
174
+ `memory-gateway-sync: [${wsAgentId}] \u5F00\u59CB\u76D1\u542C MEMORY.md`
175
+ );
176
+ } catch (err) {
177
+ api.logger.warn(
178
+ `memory-gateway-sync: [${wsAgentId}] \u65E0\u6CD5\u76D1\u542C MEMORY.md: ${String(err)}`
179
+ );
180
+ }
181
+ api.logger.info(
182
+ `memory-gateway-sync: [${wsAgentId}] workspace \u6CE8\u518C\u5B8C\u6210 \u2192 ${wsDir}`
183
+ );
184
+ };
185
+ const scanWorkspaces = async () => {
186
+ try {
187
+ const entries = await fs.promises.readdir(openclawDir, {
188
+ withFileTypes: true
189
+ });
190
+ for (const entry of entries) {
191
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
192
+ const name = entry.name;
193
+ if (name !== "workspace" && !name.startsWith("workspace-")) continue;
194
+ const wsDir = path.join(openclawDir, name);
195
+ if (entry.isSymbolicLink()) {
196
+ try {
197
+ const stat = await fs.promises.stat(wsDir);
198
+ if (!stat.isDirectory()) continue;
199
+ const realTarget = await fs.promises.realpath(wsDir);
200
+ discoveredDataDirs.add(path.dirname(realTarget));
201
+ } catch {
202
+ continue;
203
+ }
204
+ }
205
+ const wsAgentId = resolveAgentId(name);
206
+ await registerWorkspace(wsDir, wsAgentId);
207
+ }
208
+ } catch (err) {
209
+ api.logger.warn(
210
+ `memory-gateway-sync: \u626B\u63CF workspace \u5931\u8D25: ${String(err)}`
211
+ );
212
+ }
213
+ for (const dataDir of discoveredDataDirs) {
214
+ if (dataDir === openclawDir) continue;
215
+ try {
216
+ const entries = await fs.promises.readdir(dataDir, {
217
+ withFileTypes: true
218
+ });
219
+ for (const entry of entries) {
220
+ if (!entry.isDirectory()) continue;
221
+ const name = entry.name;
222
+ if (name !== "workspace" && !name.startsWith("workspace-")) continue;
223
+ const wsDir = path.join(dataDir, name);
224
+ const wsAgentId = resolveAgentId(name);
225
+ await registerWorkspace(wsDir, wsAgentId);
226
+ }
227
+ } catch (err) {
228
+ api.logger.warn(
229
+ `memory-gateway-sync: \u626B\u63CF\u6570\u636E\u76EE\u5F55 ${dataDir} \u5931\u8D25: ${String(err)}`
230
+ );
231
+ }
232
+ }
233
+ };
234
+ const watchDirForWorkspaces = (dir) => {
235
+ try {
236
+ fs.watch(dir, async (eventType, filename) => {
237
+ if (!filename) return;
238
+ if (filename !== "workspace" && !filename.startsWith("workspace-")) {
239
+ return;
240
+ }
241
+ const wsDir = path.join(dir, filename);
242
+ try {
243
+ const stat = await fs.promises.stat(wsDir);
244
+ if (!stat.isDirectory()) return;
245
+ const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
246
+ if (registeredRealPaths.has(realDir)) return;
247
+ const wsAgentId = resolveAgentId(filename);
248
+ api.logger.info(
249
+ `memory-gateway-sync: \u53D1\u73B0\u65B0 workspace \u2192 ${filename} (agentId=${wsAgentId}) in ${dir}`
250
+ );
251
+ await registerWorkspace(wsDir, wsAgentId);
252
+ const parentDir = path.dirname(realDir);
253
+ if (!discoveredDataDirs.has(parentDir) && parentDir !== openclawDir) {
254
+ discoveredDataDirs.add(parentDir);
255
+ watchDirForWorkspaces(parentDir);
256
+ }
257
+ } catch {
258
+ }
259
+ });
260
+ } catch (err) {
261
+ api.logger.warn(
262
+ `memory-gateway-sync: \u65E0\u6CD5\u76D1\u542C ${dir}: ${String(err)}`
263
+ );
264
+ }
265
+ };
266
+ watchDirForWorkspaces(openclawDir);
267
+ await scanWorkspaces();
268
+ for (const dataDir of discoveredDataDirs) {
269
+ if (dataDir !== openclawDir) {
270
+ watchDirForWorkspaces(dataDir);
271
+ }
272
+ }
273
+ const wsCount = registeredWorkspaces.size;
274
+ const wsIds = Array.from(registeredWorkspaces.values()).map((w) => w.agentId).join(", ");
275
+ api.logger.info(
276
+ `memory-gateway-sync: \u542F\u52A8\u5B8C\u6210 \u2192 ${gatewayUrl} (\u9632\u6296 ${debounceMs}ms, ${wsCount} \u4E2A workspace: ${wsIds})`
277
+ );
278
+ },
279
+ stop: () => {
280
+ api.logger.info("memory-gateway-sync: \u505C\u6B62");
281
+ }
282
+ });
283
+ }
284
+ });
285
+ export {
286
+ index_default as default
287
+ };
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "memory-gateway-sync",
3
- "version": "0.7.0",
3
+ "version": "0.8.0",
4
4
  "description": "Memory 双写插件:fs.watch 感知变化后同步到外部 Gateway",
5
5
  "type": "module",
6
+ "main": "index.js",
6
7
  "openclaw": {
7
8
  "extensions": [
8
- "./src/index.ts"
9
+ "./index.js"
9
10
  ]
10
11
  },
11
12
  "devDependencies": {
package/src/index.ts CHANGED
@@ -83,6 +83,10 @@ export default definePluginEntry({
83
83
 
84
84
  // 已注册的 workspace 集合(避免重复注册)
85
85
  const registeredWorkspaces = new Map<string, WorkspaceContext>();
86
+ // 用 realpath 去重,防止同一目录通过 symlink 和真实路径重复注册
87
+ const registeredRealPaths = new Set<string>();
88
+ // 从 symlink 目标发现的数据目录(如 /data/),用于扫描未建立 symlink 的 workspace
89
+ const discoveredDataDirs = new Set<string>();
86
90
 
87
91
  // ── 根据文件路径找到所属 workspace ────────────────────────────────
88
92
 
@@ -232,12 +236,14 @@ export default definePluginEntry({
232
236
  // ── 注册单个 workspace ───────────────────────────────────────────
233
237
 
234
238
  const registerWorkspace = async (wsDir: string, wsAgentId: string): Promise<void> => {
235
- if (registeredWorkspaces.has(wsDir)) return;
239
+ const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
240
+ if (registeredRealPaths.has(realDir)) return;
241
+ registeredRealPaths.add(realDir);
236
242
 
237
- registeredWorkspaces.set(wsDir, { dir: wsDir, agentId: wsAgentId });
243
+ registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId });
238
244
 
239
- // 监听 MEMORY.md
240
- const memoryMdPath = path.join(wsDir, "MEMORY.md");
245
+ // 监听 MEMORY.md(使用真实路径,确保 symlink 场景下 fs.watch 正常工作)
246
+ const memoryMdPath = path.join(realDir, "MEMORY.md");
241
247
  try {
242
248
  await fs.promises.writeFile(memoryMdPath, "", { flag: "a" });
243
249
  fs.watch(memoryMdPath, (eventType) => {
@@ -260,64 +266,121 @@ export default definePluginEntry({
260
266
  // ── 扫描所有 workspace 目录 ──────────────────────────────────────
261
267
 
262
268
  const scanWorkspaces = async (): Promise<void> => {
269
+ // 1. 扫描 openclawDir(处理 symlink 和物理目录)
263
270
  try {
264
271
  const entries = await fs.promises.readdir(openclawDir, {
265
272
  withFileTypes: true,
266
273
  });
267
274
  for (const entry of entries) {
268
- if (!entry.isDirectory()) continue;
275
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
269
276
  const name = entry.name;
277
+ if (name !== "workspace" && !name.startsWith("workspace-")) continue;
270
278
 
271
- if (name === "workspace" || name.startsWith("workspace-")) {
272
- const wsDir = path.join(openclawDir, name);
273
- const wsAgentId = resolveAgentId(name);
274
- await registerWorkspace(wsDir, wsAgentId);
279
+ const wsDir = path.join(openclawDir, name);
280
+
281
+ if (entry.isSymbolicLink()) {
282
+ try {
283
+ const stat = await fs.promises.stat(wsDir);
284
+ if (!stat.isDirectory()) continue;
285
+ const realTarget = await fs.promises.realpath(wsDir);
286
+ discoveredDataDirs.add(path.dirname(realTarget));
287
+ } catch {
288
+ continue;
289
+ }
275
290
  }
291
+
292
+ const wsAgentId = resolveAgentId(name);
293
+ await registerWorkspace(wsDir, wsAgentId);
276
294
  }
277
295
  } catch (err) {
278
296
  api.logger.warn(
279
297
  `memory-gateway-sync: 扫描 workspace 失败: ${String(err)}`
280
298
  );
281
299
  }
300
+
301
+ // 2. 扫描从 symlink 目标发现的数据目录,找到未建立 symlink 的 workspace
302
+ for (const dataDir of discoveredDataDirs) {
303
+ if (dataDir === openclawDir) continue;
304
+ try {
305
+ const entries = await fs.promises.readdir(dataDir, {
306
+ withFileTypes: true,
307
+ });
308
+ for (const entry of entries) {
309
+ if (!entry.isDirectory()) continue;
310
+ const name = entry.name;
311
+ if (name !== "workspace" && !name.startsWith("workspace-")) continue;
312
+
313
+ const wsDir = path.join(dataDir, name);
314
+ const wsAgentId = resolveAgentId(name);
315
+ await registerWorkspace(wsDir, wsAgentId);
316
+ }
317
+ } catch (err) {
318
+ api.logger.warn(
319
+ `memory-gateway-sync: 扫描数据目录 ${dataDir} 失败: ${String(err)}`
320
+ );
321
+ }
322
+ }
282
323
  };
283
324
 
284
- // ── 监听 .openclaw/ 目录,动态感知新 workspace ──────────────────
325
+ // ── 监听目录变化,动态感知新 workspace ─────────────────────────
285
326
 
286
- try {
287
- fs.watch(openclawDir, async (eventType, filename) => {
288
- if (!filename) return;
289
- if (
290
- filename !== "workspace" &&
291
- !filename.startsWith("workspace-")
292
- ) {
293
- return;
294
- }
327
+ const watchDirForWorkspaces = (dir: string): void => {
328
+ try {
329
+ fs.watch(dir, async (eventType, filename) => {
330
+ if (!filename) return;
331
+ if (
332
+ filename !== "workspace" &&
333
+ !filename.startsWith("workspace-")
334
+ ) {
335
+ return;
336
+ }
295
337
 
296
- const wsDir = path.join(openclawDir, filename);
338
+ const wsDir = path.join(dir, filename);
339
+
340
+ try {
341
+ const stat = await fs.promises.stat(wsDir);
342
+ if (!stat.isDirectory()) return;
343
+
344
+ const realDir = await fs.promises.realpath(wsDir).catch(() => wsDir);
345
+ if (registeredRealPaths.has(realDir)) return;
297
346
 
298
- try {
299
- const stat = await fs.promises.stat(wsDir);
300
- if (stat.isDirectory() && !registeredWorkspaces.has(wsDir)) {
301
347
  const wsAgentId = resolveAgentId(filename);
302
348
  api.logger.info(
303
- `memory-gateway-sync: 发现新 workspace → ${filename} (agentId=${wsAgentId})`
349
+ `memory-gateway-sync: 发现新 workspace → ${filename} (agentId=${wsAgentId}) in ${dir}`
304
350
  );
305
351
  await registerWorkspace(wsDir, wsAgentId);
352
+
353
+ // 如果是 symlink,也发现其数据目录并监听
354
+ const parentDir = path.dirname(realDir);
355
+ if (!discoveredDataDirs.has(parentDir) && parentDir !== openclawDir) {
356
+ discoveredDataDirs.add(parentDir);
357
+ watchDirForWorkspaces(parentDir);
358
+ }
359
+ } catch {
360
+ // 目录不存在或已删除,忽略
306
361
  }
307
- } catch {
308
- // 目录不存在或已删除,忽略
309
- }
310
- });
311
- } catch (err) {
312
- api.logger.warn(
313
- `memory-gateway-sync: 无法监听 ${openclawDir}: ${String(err)}`
314
- );
315
- }
362
+ });
363
+ } catch (err) {
364
+ api.logger.warn(
365
+ `memory-gateway-sync: 无法监听 ${dir}: ${String(err)}`
366
+ );
367
+ }
368
+ };
369
+
370
+ // 监听 openclawDir(在 scan 之前注册,不会遗漏 scan 期间创建的目录)
371
+ watchDirForWorkspaces(openclawDir);
316
372
 
317
373
  // ── 启动:扫描所有已有 workspace ─────────────────────────────────
318
374
 
319
375
  await scanWorkspaces();
320
376
 
377
+ // 监听从 symlink 发现的数据目录(scan 之后才有 discoveredDataDirs)
378
+ for (const dataDir of discoveredDataDirs) {
379
+ if (dataDir !== openclawDir) {
380
+ watchDirForWorkspaces(dataDir);
381
+ }
382
+ }
383
+
321
384
  const wsCount = registeredWorkspaces.size;
322
385
  const wsIds = Array.from(registeredWorkspaces.values())
323
386
  .map((w) => w.agentId)