@echomem/echo-memory-cloud-openclaw-plugin 0.2.0 → 0.2.1

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/README.md CHANGED
@@ -59,8 +59,11 @@ Path resolution order for `memoryDir`:
59
59
  Supported runtime `.env` locations:
60
60
 
61
61
  - `~/.openclaw/.env`
62
- - `~/.moltbot/.env`
63
- - `~/.clawdbot/.env`
62
+
63
+ Legacy one-release migration bridge:
64
+
65
+ - if `~/.openclaw/.env` is missing, EchoMemory can still read `~/.moltbot/.env` or `~/.clawdbot/.env`
66
+ - new saves and updated setup should go to `~/.openclaw/.env`
64
67
 
65
68
  Supported environment variables:
66
69
 
@@ -132,6 +135,11 @@ Older configs may still include `baseUrl` or `webBaseUrl`. Those keys are deprec
132
135
 
133
136
  ### Install from a local path
134
137
 
138
+ Version note:
139
+
140
+ - on OpenClaw `2026.3.22+`, avoid bare plugin names during install because plugin source precedence changed
141
+ - use an exact local path, `--link`, or the exact scoped npm package
142
+
135
143
  On Windows, quote the path if your username or folders contain spaces:
136
144
 
137
145
  ```powershell
@@ -229,6 +237,7 @@ The plugin starts a localhost workspace UI during gateway startup and can auto-o
229
237
 
230
238
  - first run can automatically trigger `npm install` and `npm run build` under `lib/local-ui`
231
239
  - browser auto-open is skipped automatically for SSH, CI, and headless Linux sessions
240
+ - when the gateway restarts, an already-open local UI tab reconnects and refreshes itself instead of spawning a redundant new tab
232
241
  - `/echo-memory view` returns the current localhost URL for the local markdown workspace UI and also tries to open the browser
233
242
  - natural-language requests can use the `echo_memory_local_ui` tool to get the exact live URL instead of guessing the port
234
243
  - the local markdown archive stays fully browsable even when no Echo Cloud API key is configured
@@ -2,7 +2,7 @@
2
2
  "id": "echo-memory-cloud-openclaw-plugin",
3
3
  "name": "Echo Memory Cloud OpenClaw Plugin",
4
4
  "description": "Sync OpenClaw local markdown memory files to Echo cloud",
5
- "version": "0.2.0",
5
+ "version": "0.2.1",
6
6
  "kind": "lifecycle",
7
7
  "main": "./index.js",
8
8
  "configSchema": {
@@ -22,7 +22,7 @@
22
22
  },
23
23
  "memoryDir": {
24
24
  "type": "string",
25
- "description": "Absolute path to the markdown memory directory. Falls back to ECHOMEM_MEMORY_DIR, then ~/.clawdbot/workspace/memory."
25
+ "description": "Absolute path to the markdown memory directory. Falls back to ECHOMEM_MEMORY_DIR, then ~/.openclaw/workspace/memory."
26
26
  },
27
27
  "localOnlyMode": {
28
28
  "type": "boolean",
package/index.js CHANGED
@@ -15,7 +15,14 @@ import {
15
15
  import { buildOnboardingText } from "./lib/onboarding.js";
16
16
  import { createSyncRunner, formatStatusText } from "./lib/sync.js";
17
17
  import { readLastSyncState } from "./lib/state.js";
18
- import { openUrlInDefaultBrowser, startLocalServer, stopLocalServer } from "./lib/local-server.js";
18
+ import {
19
+ openUrlInDefaultBrowser,
20
+ startLocalServer,
21
+ stopLocalServer,
22
+ waitForLocalUiClient,
23
+ } from "./lib/local-server.js";
24
+
25
+ const LOCAL_UI_RECONNECT_GRACE_MS = 1500;
19
26
 
20
27
  function resolveCommandLabel(channel) {
21
28
  return channel === "discord" ? "/echomemory" : "/echo-memory";
@@ -75,10 +82,15 @@ export default {
75
82
  let openedInBrowser = false;
76
83
  let openReason = "not_requested";
77
84
  if (openInBrowser) {
78
- const openResult = await openUrlInDefaultBrowser(url, {
79
- logger: api.logger,
80
- force: trigger !== "gateway-start",
81
- });
85
+ const existingClientDetected = trigger === "gateway-start"
86
+ ? await waitForLocalUiClient({ timeoutMs: LOCAL_UI_RECONNECT_GRACE_MS })
87
+ : false;
88
+ const openResult = existingClientDetected
89
+ ? { opened: false, reason: "existing_client_reconnected" }
90
+ : await openUrlInDefaultBrowser(url, {
91
+ logger: api.logger,
92
+ force: trigger !== "gateway-start",
93
+ });
82
94
  openedInBrowser = openResult.opened;
83
95
  openReason = openResult.reason;
84
96
  }
@@ -130,7 +142,18 @@ export default {
130
142
  });
131
143
  }
132
144
 
133
- const envStatus = getEnvFileStatus();
145
+ const envStatus = getEnvFileStatus();
146
+ if (envStatus.usingLegacyBridge) {
147
+ api.logger?.warn?.(
148
+ `[echo-memory] Legacy env file detected (${envStatus.legacyPaths.join(", ")}). ` +
149
+ `EchoMemory is using a one-release migration bridge and will write future changes to ${envStatus.primaryPath}.`,
150
+ );
151
+ } else if (envStatus.legacyPaths.length > 0) {
152
+ api.logger?.info?.(
153
+ `[echo-memory] Legacy env file still present (${envStatus.legacyPaths.join(", ")}). ` +
154
+ `Current settings should be kept in ${envStatus.primaryPath}.`,
155
+ );
156
+ }
134
157
  if (!envStatus.found) {
135
158
  api.logger?.warn?.(
136
159
  `[echo-memory] No .env file found in ${envStatus.searchPaths.join(", ")}. Using plugin config or process env.`,
package/lib/config.js CHANGED
@@ -4,12 +4,25 @@ import { dirname, join } from "node:path";
4
4
 
5
5
  const DEFAULT_BASE_URL = "https://echo-mem-chrome.vercel.app";
6
6
  const DEFAULT_WEB_BASE_URL = "https://www.iditor.com";
7
- const DEFAULT_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || join(homedir(), ".openclaw", "openclaw.json");
8
- const ENV_SOURCES = [
9
- join(homedir(), ".openclaw", ".env"),
7
+ function resolveOpenClawHome() {
8
+ const configPath = process.env.OPENCLAW_CONFIG_PATH?.trim();
9
+ if (configPath) {
10
+ return dirname(configPath);
11
+ }
12
+ return join(homedir(), ".openclaw");
13
+ }
14
+
15
+ const OPENCLAW_HOME = resolveOpenClawHome();
16
+ const PRIMARY_ENV_PATH = join(OPENCLAW_HOME, ".env");
17
+ const LEGACY_ENV_SOURCES = [
10
18
  join(homedir(), ".moltbot", ".env"),
11
19
  join(homedir(), ".clawdbot", ".env"),
12
20
  ];
21
+ const DEFAULT_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || join(OPENCLAW_HOME, "openclaw.json");
22
+ const ENV_SOURCES = [
23
+ PRIMARY_ENV_PATH,
24
+ ...LEGACY_ENV_SOURCES,
25
+ ];
13
26
 
14
27
  let cachedEnv = null;
15
28
 
@@ -97,10 +110,15 @@ function parseInteger(value, fallback, { min = Number.NEGATIVE_INFINITY, max = N
97
110
 
98
111
  export function getEnvFileStatus() {
99
112
  const env = loadEnvFiles();
113
+ const legacyPaths = env.foundPaths.filter((envPath) => LEGACY_ENV_SOURCES.includes(envPath));
100
114
  return {
101
115
  found: env.foundPaths.length > 0,
102
116
  paths: env.foundPaths,
103
117
  searchPaths: env.searchPaths,
118
+ primaryPath: PRIMARY_ENV_PATH,
119
+ foundPrimary: env.foundPaths.includes(PRIMARY_ENV_PATH),
120
+ legacyPaths,
121
+ usingLegacyBridge: !env.foundPaths.includes(PRIMARY_ENV_PATH) && legacyPaths.length > 0,
104
122
  };
105
123
  }
106
124
 
@@ -142,7 +160,6 @@ function maskValue(value, { keepStart = 2, keepEnd = 2 } = {}) {
142
160
  export function getLocalUiSetupState(pluginConfig = {}, cfg = null) {
143
161
  const runtimeCfg = cfg ?? buildConfig(pluginConfig);
144
162
  const envStatus = getEnvFileStatus();
145
- const targetEnvPath = envStatus.paths[0] || envStatus.searchPaths[0];
146
163
  const localOnlyMode = resolveConfigValue(
147
164
  pluginConfig,
148
165
  "localOnlyMode",
@@ -162,9 +179,12 @@ export function getLocalUiSetupState(pluginConfig = {}, cfg = null) {
162
179
  targetPath: DEFAULT_CONFIG_PATH,
163
180
  },
164
181
  envFile: {
165
- targetPath: targetEnvPath,
182
+ targetPath: envStatus.primaryPath,
183
+ activePath: envStatus.paths[0] || null,
166
184
  foundPaths: envStatus.paths,
167
185
  searchPaths: envStatus.searchPaths,
186
+ legacyPaths: envStatus.legacyPaths,
187
+ usingLegacyBridge: envStatus.usingLegacyBridge,
168
188
  },
169
189
  localOnlyMode: {
170
190
  enabled: Boolean(runtimeCfg.localOnlyMode),
@@ -190,12 +210,13 @@ export function getLocalUiSetupState(pluginConfig = {}, cfg = null) {
190
210
 
191
211
  export function saveLocalUiSetup(values = {}) {
192
212
  const envStatus = getEnvFileStatus();
193
- const targetPath = envStatus.paths[0] || envStatus.searchPaths[0];
213
+ const targetPath = envStatus.primaryPath;
214
+ const sourcePath = envStatus.foundPrimary ? envStatus.primaryPath : (envStatus.paths[0] || envStatus.primaryPath);
194
215
  mkdirSync(dirname(targetPath), { recursive: true });
195
216
 
196
217
  let lines = [];
197
218
  try {
198
- lines = readFileSync(targetPath, "utf8").split(/\r?\n/);
219
+ lines = readFileSync(sourcePath, "utf8").split(/\r?\n/);
199
220
  } catch {
200
221
  lines = [];
201
222
  }
@@ -237,6 +258,8 @@ export function saveLocalUiSetup(values = {}) {
237
258
  invalidateEnvCache();
238
259
  return {
239
260
  targetPath,
261
+ sourcePath,
262
+ migratedFrom: sourcePath !== targetPath ? sourcePath : null,
240
263
  savedKeys: [...managedKeys],
241
264
  };
242
265
  }
@@ -275,7 +298,7 @@ export function buildConfig(pluginConfig = {}) {
275
298
  memoryDir: String(
276
299
  cfg.memoryDir
277
300
  || loadEnvVar("ECHOMEM_MEMORY_DIR")
278
- || join(homedir(), ".openclaw", "workspace", "memory"),
301
+ || join(OPENCLAW_HOME, "workspace", "memory"),
279
302
  ).trim(),
280
303
  };
281
304
  }
@@ -24,11 +24,18 @@ let _lastOpenedUrl = null;
24
24
  const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "__pycache__", "logs", "completions", "delivery-queue", "browser", "canvas", "cron", "media"]);
25
25
 
26
26
  /** Debounced file-change broadcaster */
27
- function createFileWatcher(workspaceDir) {
28
- const sseClients = new Set();
29
- const watchers = [];
30
- let debounceTimer = null;
31
- const DEBOUNCE_MS = 500;
27
+ function createFileWatcher(workspaceDir) {
28
+ const sseClients = new Set();
29
+ const clientWaiters = new Set();
30
+ const watchers = [];
31
+ let debounceTimer = null;
32
+ const DEBOUNCE_MS = 500;
33
+
34
+ function settleClientWaiters(didConnect) {
35
+ for (const finish of [...clientWaiters]) {
36
+ finish(didConnect);
37
+ }
38
+ }
32
39
 
33
40
  function broadcast(eventData) {
34
41
  const payload = `data: ${JSON.stringify(eventData)}\n\n`;
@@ -66,18 +73,48 @@ function createFileWatcher(workspaceDir) {
66
73
  // Start watching
67
74
  watchRecursive(workspaceDir);
68
75
 
69
- return {
70
- sseClients,
71
- broadcast,
72
- close() {
73
- if (debounceTimer) clearTimeout(debounceTimer);
74
- for (const w of watchers) { try { w.close(); } catch {} }
75
- watchers.length = 0;
76
- for (const res of sseClients) { try { res.end(); } catch {} }
77
- sseClients.clear();
78
- },
79
- };
80
- }
76
+ return {
77
+ sseClients,
78
+ addSseClient(res) {
79
+ sseClients.add(res);
80
+ settleClientWaiters(true);
81
+ },
82
+ removeSseClient(res) {
83
+ sseClients.delete(res);
84
+ },
85
+ waitForClient(timeoutMs = 0) {
86
+ if (sseClients.size > 0) {
87
+ return Promise.resolve(true);
88
+ }
89
+ return new Promise((resolve) => {
90
+ let timer = null;
91
+ const finish = (didConnect) => {
92
+ if (!clientWaiters.delete(finish)) {
93
+ return;
94
+ }
95
+ if (timer) {
96
+ clearTimeout(timer);
97
+ timer = null;
98
+ }
99
+ resolve(didConnect);
100
+ };
101
+ clientWaiters.add(finish);
102
+ if (timeoutMs > 0) {
103
+ timer = setTimeout(() => finish(false), timeoutMs);
104
+ }
105
+ });
106
+ },
107
+ broadcast,
108
+ close() {
109
+ if (debounceTimer) clearTimeout(debounceTimer);
110
+ for (const w of watchers) { try { w.close(); } catch {} }
111
+ watchers.length = 0;
112
+ for (const res of sseClients) { try { res.end(); } catch {} }
113
+ sseClients.clear();
114
+ settleClientWaiters(false);
115
+ },
116
+ };
117
+ }
81
118
 
82
119
  function tryListen(server, port) {
83
120
  return new Promise((resolve) => {
@@ -388,10 +425,10 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
388
425
  };
389
426
  }
390
427
 
391
- function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
392
- const normalizedBase = path.resolve(workspaceDir) + path.sep;
393
- const { apiClient, syncRunner, cfg, fileWatcher, logger } = opts;
394
- const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
428
+ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
429
+ const normalizedBase = path.resolve(workspaceDir) + path.sep;
430
+ const { apiClient, syncRunner, cfg, fileWatcher, logger, serverInstanceId } = opts;
431
+ const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
395
432
 
396
433
  return async function handler(req, res) {
397
434
  setCorsHeaders(res);
@@ -415,19 +452,24 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
415
452
  }
416
453
 
417
454
  // SSE endpoint — push file-change events to the frontend
418
- if (url.pathname === "/api/events") {
419
- res.writeHead(200, {
420
- "Content-Type": "text/event-stream",
421
- "Cache-Control": "no-cache",
422
- "Connection": "keep-alive",
423
- });
424
- res.write(": connected\n\n");
425
- if (fileWatcher) {
426
- fileWatcher.sseClients.add(res);
427
- req.on("close", () => fileWatcher.sseClients.delete(res));
428
- }
429
- return;
430
- }
455
+ if (url.pathname === "/api/events") {
456
+ res.writeHead(200, {
457
+ "Content-Type": "text/event-stream",
458
+ "Cache-Control": "no-cache",
459
+ "Connection": "keep-alive",
460
+ });
461
+ res.write("retry: 1000\n");
462
+ res.write(`data: ${JSON.stringify({
463
+ type: "server-connected",
464
+ serverInstanceId,
465
+ connectedAt: new Date().toISOString(),
466
+ })}\n\n`);
467
+ if (fileWatcher) {
468
+ fileWatcher.addSseClient(res);
469
+ req.on("close", () => fileWatcher.removeSseClient(res));
470
+ }
471
+ return;
472
+ }
431
473
 
432
474
  if (url.pathname === "/") {
433
475
  // Always serve the built React app from dist/
@@ -576,11 +618,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
576
618
  typeof body.apiKey === "string" && body.apiKey.trim()
577
619
  ? "false"
578
620
  : "true",
579
- };
580
- const saveResult = saveLocalUiSetup(payload);
581
- if (cfg) {
582
- cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
583
- cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
621
+ };
622
+ const saveResult = saveLocalUiSetup(payload);
623
+ if (saveResult.migratedFrom) {
624
+ logger?.info?.(
625
+ `[echo-memory] Migrated local UI setup from ${saveResult.migratedFrom} to ${saveResult.targetPath}`,
626
+ );
627
+ }
628
+ if (cfg) {
629
+ cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
630
+ cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
584
631
  cfg.memoryDir = payload.ECHOMEM_MEMORY_DIR.trim() || cfg.memoryDir;
585
632
  }
586
633
  sendJson(res, {
@@ -794,15 +841,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
794
841
  };
795
842
  }
796
843
 
797
- export async function startLocalServer(workspaceDir, opts = {}) {
798
- if (_instance) {
799
- return _instance.url;
800
- }
801
-
802
- await ensureLocalUiReady(opts.cfg, opts.logger);
803
- const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
804
- const fileWatcher = createFileWatcher(workspaceDir);
805
- const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
844
+ export async function startLocalServer(workspaceDir, opts = {}) {
845
+ if (_instance) {
846
+ return _instance.url;
847
+ }
848
+
849
+ await ensureLocalUiReady(opts.cfg, opts.logger);
850
+ const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
851
+ const fileWatcher = createFileWatcher(workspaceDir);
852
+ const serverInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
853
+ const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
806
854
  ? opts.syncRunner.onProgress((event) => {
807
855
  const mapPath = (targetPath) => {
808
856
  if (!targetPath) return null;
@@ -832,8 +880,8 @@ export async function startLocalServer(workspaceDir, opts = {}) {
832
880
  });
833
881
  })
834
882
  : null;
835
- const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher });
836
- const server = http.createServer(handler);
883
+ const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher, serverInstanceId });
884
+ const server = http.createServer(handler);
837
885
 
838
886
  let port = null;
839
887
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -849,11 +897,15 @@ export async function startLocalServer(workspaceDir, opts = {}) {
849
897
  fileWatcher.close();
850
898
  throw new Error(`Could not bind to ports ${BASE_PORT}–${BASE_PORT + 2}. All in use.`);
851
899
  }
852
-
853
- const url = `http://127.0.0.1:${port}`;
854
- _instance = { server, url, fileWatcher, unsubscribeSyncProgress };
855
- return url;
856
- }
900
+
901
+ const url = `http://127.0.0.1:${port}`;
902
+ _instance = { server, url, fileWatcher, unsubscribeSyncProgress, serverInstanceId };
903
+ return url;
904
+ }
905
+
906
+ export async function waitForLocalUiClient({ timeoutMs = 0 } = {}) {
907
+ return _instance?.fileWatcher?.waitForClient(timeoutMs) ?? false;
908
+ }
857
909
 
858
910
  export function stopLocalServer() {
859
911
  if (_instance) {