memory-gateway-sync 0.6.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 +100 -36
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.6.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
@@ -46,11 +46,12 @@ export default definePluginEntry({
46
46
  const defaultAgentId = cfg.agentId ?? "default";
47
47
  const ownerClawUserId = (() => {
48
48
  const token = process.env.OPENCLAW_GATEWAY_TOKEN ?? "";
49
- // 格式: oc-user-{userId},用 - 切开取最后一段
50
49
  const parts = token.split("-");
51
50
  return parts.length >= 3 ? parts[parts.length - 1] : null;
52
51
  })();
53
52
 
53
+ const podReleaseName = process.env.botID ?? null;
54
+
54
55
  if (!gatewayUrl || !gatewayToken) {
55
56
  api.logger.warn(
56
57
  "memory-gateway-sync: gatewayUrl 或 gatewayToken 未配置,插件不启动"
@@ -82,6 +83,10 @@ export default definePluginEntry({
82
83
 
83
84
  // 已注册的 workspace 集合(避免重复注册)
84
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>();
85
90
 
86
91
  // ── 根据文件路径找到所属 workspace ────────────────────────────────
87
92
 
@@ -152,7 +157,7 @@ export default definePluginEntry({
152
157
  const payload: IngestPayload = {
153
158
  path: relativePath,
154
159
  content,
155
- release_name: parseReleaseName(content),
160
+ release_name: parseReleaseName(content) ?? podReleaseName,
156
161
  agentId: ws.agentId,
157
162
  userId,
158
163
  workspaceDir: ws.dir,
@@ -162,7 +167,7 @@ export default definePluginEntry({
162
167
 
163
168
  try {
164
169
  const controller = new AbortController();
165
- const timeout = setTimeout(() => controller.abort(), 5000);
170
+ const timeout = setTimeout(() => controller.abort(), 30000);
166
171
 
167
172
  const res = await fetch(gatewayUrl, {
168
173
  method: "POST",
@@ -231,12 +236,14 @@ export default definePluginEntry({
231
236
  // ── 注册单个 workspace ───────────────────────────────────────────
232
237
 
233
238
  const registerWorkspace = async (wsDir: string, wsAgentId: string): Promise<void> => {
234
- 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);
235
242
 
236
- registeredWorkspaces.set(wsDir, { dir: wsDir, agentId: wsAgentId });
243
+ registeredWorkspaces.set(realDir, { dir: realDir, agentId: wsAgentId });
237
244
 
238
- // 监听 MEMORY.md
239
- const memoryMdPath = path.join(wsDir, "MEMORY.md");
245
+ // 监听 MEMORY.md(使用真实路径,确保 symlink 场景下 fs.watch 正常工作)
246
+ const memoryMdPath = path.join(realDir, "MEMORY.md");
240
247
  try {
241
248
  await fs.promises.writeFile(memoryMdPath, "", { flag: "a" });
242
249
  fs.watch(memoryMdPath, (eventType) => {
@@ -259,64 +266,121 @@ export default definePluginEntry({
259
266
  // ── 扫描所有 workspace 目录 ──────────────────────────────────────
260
267
 
261
268
  const scanWorkspaces = async (): Promise<void> => {
269
+ // 1. 扫描 openclawDir(处理 symlink 和物理目录)
262
270
  try {
263
271
  const entries = await fs.promises.readdir(openclawDir, {
264
272
  withFileTypes: true,
265
273
  });
266
274
  for (const entry of entries) {
267
- if (!entry.isDirectory()) continue;
275
+ if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
268
276
  const name = entry.name;
277
+ if (name !== "workspace" && !name.startsWith("workspace-")) continue;
269
278
 
270
- if (name === "workspace" || name.startsWith("workspace-")) {
271
- const wsDir = path.join(openclawDir, name);
272
- const wsAgentId = resolveAgentId(name);
273
- 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
+ }
274
290
  }
291
+
292
+ const wsAgentId = resolveAgentId(name);
293
+ await registerWorkspace(wsDir, wsAgentId);
275
294
  }
276
295
  } catch (err) {
277
296
  api.logger.warn(
278
297
  `memory-gateway-sync: 扫描 workspace 失败: ${String(err)}`
279
298
  );
280
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
+ }
281
323
  };
282
324
 
283
- // ── 监听 .openclaw/ 目录,动态感知新 workspace ──────────────────
325
+ // ── 监听目录变化,动态感知新 workspace ─────────────────────────
284
326
 
285
- try {
286
- fs.watch(openclawDir, async (eventType, filename) => {
287
- if (!filename) return;
288
- if (
289
- filename !== "workspace" &&
290
- !filename.startsWith("workspace-")
291
- ) {
292
- return;
293
- }
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
+ }
294
337
 
295
- 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;
296
346
 
297
- try {
298
- const stat = await fs.promises.stat(wsDir);
299
- if (stat.isDirectory() && !registeredWorkspaces.has(wsDir)) {
300
347
  const wsAgentId = resolveAgentId(filename);
301
348
  api.logger.info(
302
- `memory-gateway-sync: 发现新 workspace → ${filename} (agentId=${wsAgentId})`
349
+ `memory-gateway-sync: 发现新 workspace → ${filename} (agentId=${wsAgentId}) in ${dir}`
303
350
  );
304
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
+ // 目录不存在或已删除,忽略
305
361
  }
306
- } catch {
307
- // 目录不存在或已删除,忽略
308
- }
309
- });
310
- } catch (err) {
311
- api.logger.warn(
312
- `memory-gateway-sync: 无法监听 ${openclawDir}: ${String(err)}`
313
- );
314
- }
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);
315
372
 
316
373
  // ── 启动:扫描所有已有 workspace ─────────────────────────────────
317
374
 
318
375
  await scanWorkspaces();
319
376
 
377
+ // 监听从 symlink 发现的数据目录(scan 之后才有 discoveredDataDirs)
378
+ for (const dataDir of discoveredDataDirs) {
379
+ if (dataDir !== openclawDir) {
380
+ watchDirForWorkspaces(dataDir);
381
+ }
382
+ }
383
+
320
384
  const wsCount = registeredWorkspaces.size;
321
385
  const wsIds = Array.from(registeredWorkspaces.values())
322
386
  .map((w) => w.agentId)