@echomem/echo-memory-cloud-openclaw-plugin 0.1.0 → 0.1.2

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.
@@ -1,25 +1,25 @@
1
- import http from "node:http";
2
- import { spawn } from "node:child_process";
3
- import path from "node:path";
4
- import fs from "node:fs/promises";
5
- import fsSync from "node:fs";
6
- import { fileURLToPath } from "node:url";
7
- import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
8
- import { scanFullWorkspace } from "./openclaw-memory-scan.js";
9
- import { readLastSyncState } from "./state.js";
10
-
11
- const BASE_PORT = 17823;
12
- const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
- const UI_HTML_PATH = path.join(__dirname, "local-ui.html");
14
- const UI_WORKDIR = path.join(__dirname, "local-ui");
15
- const UI_DIST_DIR = path.join(__dirname, "local-ui", "dist");
16
- const UI_NODE_MODULES_DIR = path.join(UI_WORKDIR, "node_modules");
17
-
18
- let _instance = null;
19
- let _bootstrapPromise = null;
20
- let _lastOpenedUrl = null;
21
-
22
- /* ── File Watcher + SSE ────────────────────────────────── */
1
+ import http from "node:http";
2
+ import { spawn } from "node:child_process";
3
+ import path from "node:path";
4
+ import fs from "node:fs/promises";
5
+ import fsSync from "node:fs";
6
+ import { fileURLToPath } from "node:url";
7
+ import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
8
+ import { scanFullWorkspace } from "./openclaw-memory-scan.js";
9
+ import { readLastSyncState } from "./state.js";
10
+
11
+ const BASE_PORT = 17823;
12
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
13
+ const UI_HTML_PATH = path.join(__dirname, "local-ui.html");
14
+ const UI_WORKDIR = path.join(__dirname, "local-ui");
15
+ const UI_DIST_DIR = path.join(__dirname, "local-ui", "dist");
16
+ const UI_NODE_MODULES_DIR = path.join(UI_WORKDIR, "node_modules");
17
+
18
+ let _instance = null;
19
+ let _bootstrapPromise = null;
20
+ let _lastOpenedUrl = null;
21
+
22
+ /* ── File Watcher + SSE ────────────────────────────────── */
23
23
 
24
24
  const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "__pycache__", "logs", "completions", "delivery-queue", "browser", "canvas", "cron", "media"]);
25
25
 
@@ -66,10 +66,10 @@ function createFileWatcher(workspaceDir) {
66
66
  // Start watching
67
67
  watchRecursive(workspaceDir);
68
68
 
69
- return {
70
- sseClients,
71
- broadcast,
72
- close() {
69
+ return {
70
+ sseClients,
71
+ broadcast,
72
+ close() {
73
73
  if (debounceTimer) clearTimeout(debounceTimer);
74
74
  for (const w of watchers) { try { w.close(); } catch {} }
75
75
  watchers.length = 0;
@@ -91,186 +91,307 @@ function setCorsHeaders(res) {
91
91
  res.setHeader("Cache-Control", "no-store");
92
92
  }
93
93
 
94
- function sendJson(res, data) {
95
- const body = JSON.stringify(data);
96
- res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
97
- res.end(body);
98
- }
99
-
100
- async function pathExists(targetPath) {
101
- try {
102
- await fs.access(targetPath);
103
- return true;
104
- } catch {
105
- return false;
106
- }
107
- }
108
-
109
- async function isLocalUiBuildReady() {
110
- const indexPath = path.join(UI_DIST_DIR, "index.html");
111
- if (!(await pathExists(indexPath))) {
112
- return false;
113
- }
114
- const assetDir = path.join(UI_DIST_DIR, "assets");
115
- if (!(await pathExists(assetDir))) {
116
- return false;
117
- }
118
- try {
119
- const assetNames = await fs.readdir(assetDir);
120
- return assetNames.some((name) => name.endsWith(".js"));
121
- } catch {
122
- return false;
123
- }
124
- }
125
-
126
- function getNpmCommand() {
127
- return process.platform === "win32" ? "npm.cmd" : "npm";
128
- }
129
-
130
- function runNpmCommand(args, logger) {
131
- return new Promise((resolve, reject) => {
132
- const child = spawn(getNpmCommand(), args, {
133
- cwd: UI_WORKDIR,
134
- env: process.env,
135
- stdio: "pipe",
136
- windowsHide: true,
137
- });
138
- const stdout = [];
139
- const stderr = [];
140
- child.stdout?.on("data", (chunk) => stdout.push(String(chunk)));
141
- child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
142
- child.on("error", (error) => reject(error));
143
- child.on("close", (code) => {
144
- if (code === 0) {
145
- resolve();
146
- return;
147
- }
148
- const output = [...stderr, ...stdout].join("").trim();
149
- reject(new Error(output || `npm ${args.join(" ")} exited with code ${code ?? "unknown"}`));
150
- });
151
- }).catch((error) => {
152
- logger?.warn?.(`[echo-memory] local-ui npm ${args.join(" ")} failed: ${String(error?.message ?? error)}`);
153
- throw error;
154
- });
155
- }
156
-
157
- export async function ensureLocalUiReady(cfg = {}, logger) {
158
- if (await isLocalUiBuildReady()) {
159
- return;
160
- }
161
- if (_bootstrapPromise) {
162
- return _bootstrapPromise;
163
- }
164
-
165
- _bootstrapPromise = (async () => {
166
- const hasNodeModules = await pathExists(UI_NODE_MODULES_DIR);
167
- if (!hasNodeModules) {
168
- if (!cfg?.localUiAutoInstall) {
169
- throw new Error("local-ui dependencies are missing and auto-install is disabled");
170
- }
171
- logger?.info?.("[echo-memory] Installing local-ui dependencies...");
172
- await runNpmCommand(["install"], logger);
173
- }
174
-
175
- if (!(await isLocalUiBuildReady())) {
176
- logger?.info?.("[echo-memory] Building local-ui frontend...");
177
- await runNpmCommand(["run", "build"], logger);
178
- }
179
-
180
- if (!(await isLocalUiBuildReady())) {
181
- throw new Error("local-ui build did not produce expected dist assets");
182
- }
183
- })();
184
-
185
- try {
186
- await _bootstrapPromise;
187
- } finally {
188
- _bootstrapPromise = null;
189
- }
190
- }
191
-
192
- function detectBrowserOpenSkipReason() {
193
- if (process.env.CI) {
194
- return "ci_environment";
195
- }
196
- if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
197
- return "ssh_session";
198
- }
199
- if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
200
- return "missing_display";
201
- }
202
- return null;
203
- }
204
-
205
- function getBrowserOpenCommand(url) {
206
- if (process.platform === "win32") {
207
- return {
208
- command: "cmd",
209
- args: ["/c", "start", "\"\"", url],
210
- };
211
- }
212
- if (process.platform === "darwin") {
213
- return {
214
- command: "open",
215
- args: [url],
216
- };
217
- }
218
- if (process.platform === "linux") {
219
- return {
220
- command: "xdg-open",
221
- args: [url],
222
- };
223
- }
224
- return null;
225
- }
226
-
227
- export async function openUrlInDefaultBrowser(url, opts = {}) {
228
- const { logger, force = false } = opts;
229
- if (!force) {
230
- const skipReason = detectBrowserOpenSkipReason();
231
- if (skipReason) {
232
- return { opened: false, reason: skipReason };
233
- }
234
- }
235
- if (_lastOpenedUrl === url) {
236
- return { opened: false, reason: "already_opened" };
237
- }
238
- const command = getBrowserOpenCommand(url);
239
- if (!command) {
240
- return { opened: false, reason: "unsupported_platform" };
241
- }
242
-
243
- try {
244
- const child = spawn(command.command, command.args, {
245
- detached: true,
246
- stdio: "ignore",
247
- windowsHide: true,
248
- });
249
- child.unref();
250
- _lastOpenedUrl = url;
251
- return { opened: true, reason: "opened" };
252
- } catch (error) {
253
- logger?.warn?.(`[echo-memory] browser open failed: ${String(error?.message ?? error)}`);
254
- return { opened: false, reason: "spawn_failed" };
255
- }
256
- }
94
+ function sendJson(res, data) {
95
+ const body = JSON.stringify(data);
96
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
97
+ res.end(body);
98
+ }
99
+
100
+ async function pathExists(targetPath) {
101
+ try {
102
+ await fs.access(targetPath);
103
+ return true;
104
+ } catch {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ async function isLocalUiBuildReady() {
110
+ const indexPath = path.join(UI_DIST_DIR, "index.html");
111
+ if (!(await pathExists(indexPath))) {
112
+ return false;
113
+ }
114
+ const assetDir = path.join(UI_DIST_DIR, "assets");
115
+ if (!(await pathExists(assetDir))) {
116
+ return false;
117
+ }
118
+ try {
119
+ const assetNames = await fs.readdir(assetDir);
120
+ return assetNames.some((name) => name.endsWith(".js"));
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function getNpmCommand() {
127
+ return process.platform === "win32" ? "npm.cmd" : "npm";
128
+ }
129
+
130
+ function runNpmCommand(args, logger) {
131
+ return new Promise((resolve, reject) => {
132
+ const child = spawn(getNpmCommand(), args, {
133
+ cwd: UI_WORKDIR,
134
+ env: process.env,
135
+ stdio: "pipe",
136
+ windowsHide: true,
137
+ });
138
+ const stdout = [];
139
+ const stderr = [];
140
+ child.stdout?.on("data", (chunk) => stdout.push(String(chunk)));
141
+ child.stderr?.on("data", (chunk) => stderr.push(String(chunk)));
142
+ child.on("error", (error) => reject(error));
143
+ child.on("close", (code) => {
144
+ if (code === 0) {
145
+ resolve();
146
+ return;
147
+ }
148
+ const output = [...stderr, ...stdout].join("").trim();
149
+ reject(new Error(output || `npm ${args.join(" ")} exited with code ${code ?? "unknown"}`));
150
+ });
151
+ }).catch((error) => {
152
+ logger?.warn?.(`[echo-memory] local-ui npm ${args.join(" ")} failed: ${String(error?.message ?? error)}`);
153
+ throw error;
154
+ });
155
+ }
156
+
157
+ export async function ensureLocalUiReady(cfg = {}, logger) {
158
+ if (await isLocalUiBuildReady()) {
159
+ return;
160
+ }
161
+ if (_bootstrapPromise) {
162
+ return _bootstrapPromise;
163
+ }
164
+
165
+ _bootstrapPromise = (async () => {
166
+ const hasNodeModules = await pathExists(UI_NODE_MODULES_DIR);
167
+ if (!hasNodeModules) {
168
+ if (!cfg?.localUiAutoInstall) {
169
+ throw new Error("local-ui dependencies are missing and auto-install is disabled");
170
+ }
171
+ logger?.info?.("[echo-memory] Installing local-ui dependencies...");
172
+ await runNpmCommand(["install"], logger);
173
+ }
174
+
175
+ if (!(await isLocalUiBuildReady())) {
176
+ logger?.info?.("[echo-memory] Building local-ui frontend...");
177
+ await runNpmCommand(["run", "build"], logger);
178
+ }
179
+
180
+ if (!(await isLocalUiBuildReady())) {
181
+ throw new Error("local-ui build did not produce expected dist assets");
182
+ }
183
+ })();
184
+
185
+ try {
186
+ await _bootstrapPromise;
187
+ } finally {
188
+ _bootstrapPromise = null;
189
+ }
190
+ }
191
+
192
+ function detectBrowserOpenSkipReason() {
193
+ if (process.env.CI) {
194
+ return "ci_environment";
195
+ }
196
+ if (process.env.SSH_CONNECTION || process.env.SSH_TTY) {
197
+ return "ssh_session";
198
+ }
199
+ if (process.platform === "linux" && !process.env.DISPLAY && !process.env.WAYLAND_DISPLAY) {
200
+ return "missing_display";
201
+ }
202
+ return null;
203
+ }
204
+
205
+ function getBrowserOpenCommand(url) {
206
+ if (process.platform === "win32") {
207
+ return {
208
+ command: "cmd",
209
+ args: ["/c", "start", "\"\"", url],
210
+ };
211
+ }
212
+ if (process.platform === "darwin") {
213
+ return {
214
+ command: "open",
215
+ args: [url],
216
+ };
217
+ }
218
+ if (process.platform === "linux") {
219
+ return {
220
+ command: "xdg-open",
221
+ args: [url],
222
+ };
223
+ }
224
+ return null;
225
+ }
226
+
227
+ export async function openUrlInDefaultBrowser(url, opts = {}) {
228
+ const { logger, force = false } = opts;
229
+ if (!force) {
230
+ const skipReason = detectBrowserOpenSkipReason();
231
+ if (skipReason) {
232
+ return { opened: false, reason: skipReason };
233
+ }
234
+ }
235
+ if (_lastOpenedUrl === url) {
236
+ return { opened: false, reason: "already_opened" };
237
+ }
238
+ const command = getBrowserOpenCommand(url);
239
+ if (!command) {
240
+ return { opened: false, reason: "unsupported_platform" };
241
+ }
242
+
243
+ try {
244
+ const child = spawn(command.command, command.args, {
245
+ detached: true,
246
+ stdio: "ignore",
247
+ windowsHide: true,
248
+ });
249
+ child.unref();
250
+ _lastOpenedUrl = url;
251
+ return { opened: true, reason: "opened" };
252
+ } catch (error) {
253
+ logger?.warn?.(`[echo-memory] browser open failed: ${String(error?.message ?? error)}`);
254
+ return { opened: false, reason: "spawn_failed" };
255
+ }
256
+ }
257
257
 
258
258
  function readBody(req) {
259
259
  return new Promise((resolve, reject) => {
260
260
  const chunks = [];
261
261
  req.on("data", (c) => chunks.push(c));
262
262
  req.on("end", () => {
263
- try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); }
264
- catch { resolve({}); }
263
+ const rawText = Buffer.concat(chunks).toString("utf8");
264
+ if (!rawText.trim()) {
265
+ resolve({
266
+ ok: true,
267
+ body: {},
268
+ rawText,
269
+ parseError: null,
270
+ });
271
+ return;
272
+ }
273
+ try {
274
+ resolve({
275
+ ok: true,
276
+ body: JSON.parse(rawText),
277
+ rawText,
278
+ parseError: null,
279
+ });
280
+ } catch (error) {
281
+ resolve({
282
+ ok: false,
283
+ body: null,
284
+ rawText,
285
+ parseError: String(error?.message ?? error),
286
+ });
287
+ }
265
288
  });
266
289
  req.on("error", reject);
267
290
  });
268
291
  }
269
292
 
270
- function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
271
- const normalizedBase = path.resolve(workspaceDir) + path.sep;
272
- const { apiClient, syncRunner, cfg, fileWatcher } = opts;
273
- const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
293
+ function resolveStoredFilePath(workspaceDir, entry) {
294
+ const rawPath = entry?.filePath || entry?.file_path || entry?.path || null;
295
+ if (!rawPath) {
296
+ return null;
297
+ }
298
+ return path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(workspaceDir, rawPath);
299
+ }
300
+
301
+ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }) {
302
+ const [lastState, files] = await Promise.all([
303
+ statePath ? readLastSyncState(statePath) : Promise.resolve(null),
304
+ scanFullWorkspace(workspaceDir),
305
+ ]);
306
+
307
+ const storedResults = Array.isArray(lastState?.results) ? lastState.results : [];
308
+ const resultMap = new Map();
309
+ for (const entry of storedResults) {
310
+ const storedPath = resolveStoredFilePath(workspaceDir, entry);
311
+ if (!storedPath) continue;
312
+ resultMap.set(storedPath, entry);
313
+ }
314
+
315
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
316
+ const eligibleAbsolutePaths = new Set();
317
+ const eligibleRelativePaths = [];
318
+
319
+ const fileStatuses = files.map((f) => {
320
+ const absPath = path.resolve(workspaceDir, f.relativePath);
321
+ const isPrivate =
322
+ f.relativePath.startsWith("memory/private/") ||
323
+ f.privacyLevel === "private";
324
+ const isSyncTarget =
325
+ Boolean(syncMemoryDir) && path.dirname(absPath) === syncMemoryDir;
326
+ const isDaily =
327
+ f.relativePath.startsWith("memory/") && DATE_RE.test(f.fileName);
328
+ const stored = resultMap.get(absPath) ?? null;
329
+ const storedStatus = String(stored?.status || "").trim().toLowerCase() || null;
330
+ const attemptedHash = stored?.contentHash || stored?.content_hash || null;
331
+ const successfulHash =
332
+ stored?.lastSuccessfulContentHash
333
+ || stored?.last_successful_content_hash
334
+ || (storedStatus && storedStatus !== "failed" ? attemptedHash : null);
335
+ const lastError = stored?.lastError || stored?.last_error || stored?.error || null;
336
+ const lastAttemptAt = stored?.lastAttemptAt || stored?.last_attempt_at || null;
337
+ const lastSuccessAt = stored?.lastSuccessAt || stored?.last_success_at || lastState?.finished_at || null;
338
+
339
+ if (isPrivate) {
340
+ return {
341
+ fileName: f.fileName,
342
+ relativePath: f.relativePath,
343
+ status: null,
344
+ syncEligible: false,
345
+ syncReason: "private",
346
+ };
347
+ }
348
+
349
+ if (!isSyncTarget) {
350
+ return {
351
+ fileName: f.fileName,
352
+ relativePath: f.relativePath,
353
+ status: "local",
354
+ syncEligible: false,
355
+ syncReason: "outside_memory_dir",
356
+ };
357
+ }
358
+
359
+ eligibleAbsolutePaths.add(absPath);
360
+ eligibleRelativePaths.push(f.relativePath);
361
+
362
+ let status = "new";
363
+ if (storedStatus === "failed" && attemptedHash && attemptedHash === f.contentHash) {
364
+ status = "failed";
365
+ } else if (successfulHash) {
366
+ status = isDaily || successfulHash === f.contentHash ? "synced" : "modified";
367
+ } else if (storedStatus === "failed") {
368
+ status = "modified";
369
+ }
370
+
371
+ return {
372
+ fileName: f.fileName,
373
+ relativePath: f.relativePath,
374
+ status,
375
+ syncEligible: true,
376
+ syncReason: "eligible",
377
+ lastError,
378
+ lastAttemptAt,
379
+ lastSuccessAt,
380
+ };
381
+ });
382
+
383
+ return {
384
+ lastState,
385
+ fileStatuses,
386
+ eligibleAbsolutePaths,
387
+ eligibleRelativePaths,
388
+ };
389
+ }
390
+
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;
274
395
 
275
396
  return async function handler(req, res) {
276
397
  setCorsHeaders(res);
@@ -293,7 +414,7 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
293
414
  return;
294
415
  }
295
416
 
296
- // SSE endpoint push file-change events to the frontend
417
+ // SSE endpoint — push file-change events to the frontend
297
418
  if (url.pathname === "/api/events") {
298
419
  res.writeHead(200, {
299
420
  "Content-Type": "text/event-stream",
@@ -389,7 +510,7 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
389
510
  return;
390
511
  }
391
512
 
392
- if (url.pathname === "/api/auth-status") {
513
+ if (url.pathname === "/api/auth-status") {
393
514
  if (!apiClient) {
394
515
  sendJson(res, { connected: false, reason: "no_client" });
395
516
  return;
@@ -410,44 +531,57 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
410
531
  } catch (e) {
411
532
  sendJson(res, { connected: false, reason: "auth_failed", error: String(e?.message ?? e) });
412
533
  }
413
- return;
414
- }
415
-
416
- if (url.pathname === "/api/setup-status") {
417
- sendJson(res, getLocalUiSetupState(opts.pluginConfig ?? {}, cfg));
418
- return;
419
- }
420
-
421
- if (url.pathname === "/api/setup-config" && req.method === "POST") {
422
- try {
423
- const body = await readBody(req);
424
- const payload = {
425
- ECHOMEM_API_KEY: typeof body.apiKey === "string" ? body.apiKey : "",
426
- ECHOMEM_MEMORY_DIR: typeof body.memoryDir === "string" ? body.memoryDir : "",
427
- ECHOMEM_LOCAL_ONLY_MODE:
428
- typeof body.apiKey === "string" && body.apiKey.trim()
429
- ? "false"
430
- : "true",
431
- };
432
- const saveResult = saveLocalUiSetup(payload);
433
- if (cfg) {
434
- cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
435
- cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
436
- cfg.memoryDir = payload.ECHOMEM_MEMORY_DIR.trim() || cfg.memoryDir;
437
- }
438
- sendJson(res, {
439
- ok: true,
440
- ...saveResult,
441
- setup: getLocalUiSetupState(opts.pluginConfig ?? {}, cfg),
442
- });
443
- } catch (error) {
444
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
445
- res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
446
- }
447
- return;
448
- }
449
-
450
- // Backend sources — authoritative COMPLETE list of files already synced to Echo cloud
534
+ return;
535
+ }
536
+
537
+ if (url.pathname === "/api/setup-status") {
538
+ sendJson(res, getLocalUiSetupState(opts.pluginConfig ?? {}, cfg));
539
+ return;
540
+ }
541
+
542
+ if (url.pathname === "/api/setup-config" && req.method === "POST") {
543
+ try {
544
+ const bodyResult = await readBody(req);
545
+ if (!bodyResult.ok) {
546
+ logger?.warn?.(
547
+ `[echo-memory] invalid JSON for ${url.pathname}: ${bodyResult.parseError}; body=${JSON.stringify(bodyResult.rawText.slice(0, 400))}`,
548
+ );
549
+ res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
550
+ res.end(JSON.stringify({
551
+ ok: false,
552
+ error: "Invalid JSON body",
553
+ details: bodyResult.parseError,
554
+ }));
555
+ return;
556
+ }
557
+ const body = bodyResult.body;
558
+ const payload = {
559
+ ECHOMEM_API_KEY: typeof body.apiKey === "string" ? body.apiKey : "",
560
+ ECHOMEM_MEMORY_DIR: typeof body.memoryDir === "string" ? body.memoryDir : "",
561
+ ECHOMEM_LOCAL_ONLY_MODE:
562
+ typeof body.apiKey === "string" && body.apiKey.trim()
563
+ ? "false"
564
+ : "true",
565
+ };
566
+ const saveResult = saveLocalUiSetup(payload);
567
+ if (cfg) {
568
+ cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
569
+ cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
570
+ cfg.memoryDir = payload.ECHOMEM_MEMORY_DIR.trim() || cfg.memoryDir;
571
+ }
572
+ sendJson(res, {
573
+ ok: true,
574
+ ...saveResult,
575
+ setup: getLocalUiSetupState(opts.pluginConfig ?? {}, cfg),
576
+ });
577
+ } catch (error) {
578
+ res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
579
+ res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
580
+ }
581
+ return;
582
+ }
583
+
584
+ // Backend sources — authoritative COMPLETE list of files already synced to Echo cloud
451
585
  if (url.pathname === "/api/backend-sources") {
452
586
  if (!apiClient) {
453
587
  sendJson(res, { ok: false, sources: [], error: "no_client" });
@@ -484,90 +618,21 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
484
618
  if (url.pathname === "/api/sync-status") {
485
619
  try {
486
620
  const statePath = syncRunner?.getStatePath() ?? null;
487
- const [lastState, files] = await Promise.all([
488
- statePath ? readLastSyncState(statePath) : Promise.resolve(null),
489
- scanFullWorkspace(workspaceDir),
490
- ]);
491
- // Pure local comparison — no backend calls.
492
- // Build map: absFilePath -> contentHash from last sync results
493
- const lastSyncedMap = new Map();
494
- if (Array.isArray(lastState?.results)) {
495
- for (const r of lastState.results) {
496
- // Support both camelCase (local dry-run) and snake_case (API response)
497
- const fp = r.filePath || r.file_path;
498
- if (fp && (r.status === "imported" || !r.status)) {
499
- // Store contentHash if available; otherwise mark as synced without hash
500
- // Note: older sync states may not have status field — treat as imported
501
- lastSyncedMap.set(fp, r.contentHash || "__synced__");
502
- }
503
- }
504
- }
505
-
506
- // Daily files (YYYY-MM-DD*.md in memory/) are agent-generated and
507
- // effectively immutable once written. We only check whether they've
508
- // been synced before. Mutable files (root-level MEMORY.md, SOUL.md,
509
- // etc.) need a content-hash comparison to detect edits.
510
- const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
511
-
512
- const fileStatuses = files.map((f) => {
513
- const absPath = path.resolve(workspaceDir, f.relativePath);
514
- const isPrivate =
515
- f.relativePath.startsWith("memory/private/") ||
516
- f.privacyLevel === "private";
517
- const isSyncTarget =
518
- Boolean(syncMemoryDir) && path.dirname(absPath) === syncMemoryDir;
519
-
520
- // Private files are never syncable
521
- if (isPrivate) {
522
- return { fileName: f.fileName, relativePath: f.relativePath, status: null };
523
- }
524
-
525
- // Files outside the configured sync target stay local-only in the UI.
526
- if (!isSyncTarget) {
527
- return { fileName: f.fileName, relativePath: f.relativePath, status: "local" };
528
- }
529
-
530
- const isDaily =
531
- f.relativePath.startsWith("memory/") && DATE_RE.test(f.fileName);
532
-
533
- if (!lastState) {
534
- // Never synced at all — everything is new (except private)
535
- return { fileName: f.fileName, relativePath: f.relativePath, status: "new" };
536
- }
537
-
538
- if (lastSyncedMap.has(absPath)) {
539
- // Daily files don't change, skip expensive hash compare
540
- if (isDaily) {
541
- return {
542
- fileName: f.fileName,
543
- relativePath: f.relativePath,
544
- status: "synced",
545
- lastSynced: lastState.finished_at,
546
- };
547
- }
548
- // Mutable file — compare hash if available
549
- const savedHash = lastSyncedMap.get(absPath);
550
- const status =
551
- savedHash === "__synced__" || savedHash === f.contentHash
552
- ? "synced"
553
- : "modified";
554
- return {
555
- fileName: f.fileName,
556
- relativePath: f.relativePath,
557
- status,
558
- lastSynced: lastState.finished_at,
559
- };
560
- }
561
-
562
- // Not in sync state → new
563
- return { fileName: f.fileName, relativePath: f.relativePath, status: "new" };
621
+ const syncView = await buildWorkspaceSyncView({
622
+ workspaceDir,
623
+ syncMemoryDir,
624
+ statePath,
564
625
  });
565
-
566
626
  sendJson(res, {
567
- lastSyncAt: lastState?.finished_at ?? null,
568
- syncedFileCount: lastSyncedMap.size,
569
- fileStatuses,
627
+ lastSyncAt: syncView.lastState?.finished_at ?? null,
628
+ syncedFileCount: syncView.fileStatuses.filter((status) => status.status === 'synced').length,
629
+ syncTargetRoot: syncMemoryDir,
630
+ runInProgress: syncRunner?.isRunning?.() ?? false,
631
+ activeRun: syncRunner?.getActiveRunInfo?.() ?? null,
632
+ fileStatuses: syncView.fileStatuses,
570
633
  });
634
+ return;
635
+
571
636
  } catch (err) {
572
637
  res.writeHead(500, { "Content-Type": "application/json" });
573
638
  res.end(JSON.stringify({ error: String(err?.message ?? err) }));
@@ -581,6 +646,14 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
581
646
  res.end(JSON.stringify({ error: "Sync not available" }));
582
647
  return;
583
648
  }
649
+ if (syncRunner.isRunning?.()) {
650
+ res.writeHead(409, { "Content-Type": "application/json" });
651
+ res.end(JSON.stringify({
652
+ error: "A sync run is already in progress",
653
+ activeRun: syncRunner.getActiveRunInfo?.() ?? null,
654
+ }));
655
+ return;
656
+ }
584
657
  try {
585
658
  const result = await syncRunner.runSync("local-ui");
586
659
  sendJson(res, result);
@@ -592,34 +665,110 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
592
665
  }
593
666
 
594
667
  if (url.pathname === "/api/sync-selected" && req.method === "POST") {
595
- if (!syncRunner) {
596
- res.writeHead(400, { "Content-Type": "application/json" });
597
- res.end(JSON.stringify({ error: "Sync not available" }));
598
- return;
599
- }
600
- try {
601
- const body = await readBody(req);
602
- const relativePaths = body.paths;
668
+ if (!syncRunner) {
669
+ res.writeHead(400, { "Content-Type": "application/json" });
670
+ res.end(JSON.stringify({ error: "Sync not available" }));
671
+ return;
672
+ }
673
+ if (syncRunner.isRunning?.()) {
674
+ res.writeHead(409, { "Content-Type": "application/json" });
675
+ res.end(JSON.stringify({
676
+ error: "A sync run is already in progress",
677
+ activeRun: syncRunner.getActiveRunInfo?.() ?? null,
678
+ }));
679
+ return;
680
+ }
681
+ try {
682
+ const bodyResult = await readBody(req);
683
+ if (!bodyResult.ok) {
684
+ logger?.warn?.(
685
+ `[echo-memory] invalid JSON for ${url.pathname}: ${bodyResult.parseError}; body=${JSON.stringify(bodyResult.rawText.slice(0, 400))}`,
686
+ );
687
+ res.writeHead(400, { "Content-Type": "application/json" });
688
+ res.end(JSON.stringify({
689
+ error: "Invalid JSON body for sync-selected",
690
+ details: bodyResult.parseError,
691
+ receivedBodyPreview: bodyResult.rawText.slice(0, 400),
692
+ }));
693
+ return;
694
+ }
695
+
696
+ const body = bodyResult.body;
697
+ const relativePaths = body.paths;
603
698
  if (!Array.isArray(relativePaths) || relativePaths.length === 0) {
699
+ logger?.warn?.(
700
+ `[echo-memory] invalid sync-selected payload: expected non-empty paths array; body=${JSON.stringify(body).slice(0, 400)}`,
701
+ );
702
+ res.writeHead(400, { "Content-Type": "application/json" });
703
+ res.end(JSON.stringify({
704
+ error: "paths array required",
705
+ details: "Expected request body like {\"paths\":[\"memory/2026-03-17.md\"]}",
706
+ receivedBody: body,
707
+ }));
708
+ return;
709
+ }
710
+
711
+ const statePath = syncRunner?.getStatePath() ?? null;
712
+ const syncView = await buildWorkspaceSyncView({
713
+ workspaceDir,
714
+ syncMemoryDir,
715
+ statePath,
716
+ });
717
+ const statusMap = new Map(syncView.fileStatuses.map((status) => [status.relativePath, status]));
718
+ const requestedFilterPaths = new Set();
719
+ const requestedInvalidPaths = [];
720
+ for (const rp of relativePaths) {
721
+ if (typeof rp !== "string" || !rp.trim()) {
722
+ requestedInvalidPaths.push({ path: rp, reason: "invalid_path" });
723
+ continue;
724
+ }
725
+ const absPath = path.resolve(workspaceDir, rp);
726
+ if (!absPath.startsWith(path.resolve(workspaceDir) + path.sep) || !absPath.endsWith(".md")) {
727
+ requestedInvalidPaths.push({ path: rp, reason: "invalid_path" });
728
+ continue;
729
+ }
730
+ const status = statusMap.get(rp);
731
+ if (!status?.syncEligible || !syncView.eligibleAbsolutePaths.has(absPath)) {
732
+ requestedInvalidPaths.push({
733
+ path: rp,
734
+ reason: status?.syncReason || "not_sync_eligible",
735
+ });
736
+ continue;
737
+ }
738
+ requestedFilterPaths.add(absPath);
739
+ }
740
+
741
+ if (requestedInvalidPaths.length > 0) {
742
+ logger?.warn?.(
743
+ `[echo-memory] sync-selected rejected invalid or ineligible paths; requested=${JSON.stringify(relativePaths).slice(0, 400)}`,
744
+ );
745
+ res.writeHead(400, { "Content-Type": "application/json" });
746
+ res.end(JSON.stringify({
747
+ error: "One or more selected files cannot be synced",
748
+ details: "Selected files must be markdown files directly inside the configured memory directory.",
749
+ invalidPaths: requestedInvalidPaths,
750
+ requestedPaths: relativePaths,
751
+ }));
752
+ return;
753
+ }
754
+
755
+ if (requestedFilterPaths.size === 0) {
756
+ logger?.warn?.(
757
+ `[echo-memory] sync-selected contained no valid markdown paths; requested=${JSON.stringify(relativePaths).slice(0, 400)}`,
758
+ );
604
759
  res.writeHead(400, { "Content-Type": "application/json" });
605
- res.end(JSON.stringify({ error: "paths array required" }));
760
+ res.end(JSON.stringify({
761
+ error: "No valid markdown paths to sync",
762
+ details: "All provided paths were outside the configured memory directory or were not sync-eligible.",
763
+ requestedPaths: relativePaths,
764
+ }));
606
765
  return;
607
766
  }
608
767
 
609
- const filterPaths = new Set();
610
- for (const rp of relativePaths) {
611
- const absPath = path.resolve(workspaceDir, rp);
612
- if (!absPath.startsWith(path.resolve(workspaceDir) + path.sep) || !absPath.endsWith(".md")) continue;
613
- filterPaths.add(absPath);
614
- }
615
-
616
- if (filterPaths.size === 0) {
617
- sendJson(res, { trigger: "local-ui-selected", summary: { file_count: 0 }, results: [] });
618
- return;
619
- }
620
- const result = await syncRunner.runSync("local-ui-selected", filterPaths);
621
- sendJson(res, result);
622
- } catch (e) {
768
+ const result = await syncRunner.runSync("local-ui-selected", requestedFilterPaths);
769
+ sendJson(res, result);
770
+ return;
771
+ } catch (e) {
623
772
  res.writeHead(500, { "Content-Type": "application/json" });
624
773
  res.end(JSON.stringify({ error: String(e?.message ?? e) }));
625
774
  }
@@ -631,39 +780,46 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
631
780
  };
632
781
  }
633
782
 
634
- export async function startLocalServer(workspaceDir, opts = {}) {
635
- if (_instance) {
636
- return _instance.url;
637
- }
638
-
639
- await ensureLocalUiReady(opts.cfg, opts.logger);
640
- const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
641
- const fileWatcher = createFileWatcher(workspaceDir);
642
- const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
643
- ? opts.syncRunner.onProgress((event) => {
644
- const mapPath = (targetPath) => {
645
- if (!targetPath) return null;
646
- const relativePath = path.relative(workspaceDir, targetPath);
647
- if (!relativePath || relativePath.startsWith("..")) return null;
648
- return relativePath.replace(/\\/g, "/");
649
- };
650
-
651
- fileWatcher.broadcast({
652
- type: "sync-progress",
653
- progress: {
654
- ...event,
655
- queuedRelativePaths: event.phase === "started"
656
- ? (event.currentFilePaths || []).map(mapPath).filter(Boolean)
657
- : [],
658
- currentRelativePaths: (event.currentFilePaths || []).map(mapPath).filter(Boolean),
659
- completedRelativePaths: (event.completedFilePaths || []).map(mapPath).filter(Boolean),
660
- failedRelativePaths: (event.failedFilePaths || []).map(mapPath).filter(Boolean),
661
- },
662
- });
663
- })
664
- : null;
665
- const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher });
666
- const server = http.createServer(handler);
783
+ export async function startLocalServer(workspaceDir, opts = {}) {
784
+ if (_instance) {
785
+ return _instance.url;
786
+ }
787
+
788
+ await ensureLocalUiReady(opts.cfg, opts.logger);
789
+ const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
790
+ const fileWatcher = createFileWatcher(workspaceDir);
791
+ const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
792
+ ? opts.syncRunner.onProgress((event) => {
793
+ const mapPath = (targetPath) => {
794
+ if (!targetPath) return null;
795
+ const relativePath = path.relative(workspaceDir, targetPath);
796
+ if (!relativePath || relativePath.startsWith("..")) return null;
797
+ return relativePath.replace(/\\/g, "/");
798
+ };
799
+
800
+ fileWatcher.broadcast({
801
+ type: "sync-progress",
802
+ progress: {
803
+ ...event,
804
+ queuedRelativePaths: event.phase === "started"
805
+ ? (event.currentFilePaths || []).map(mapPath).filter(Boolean)
806
+ : [],
807
+ currentRelativePath: mapPath(event.currentFilePath),
808
+ currentRelativePaths: (event.currentFilePaths || []).map(mapPath).filter(Boolean),
809
+ completedRelativePaths: (event.completedFilePaths || []).map(mapPath).filter(Boolean),
810
+ failedRelativePaths: (event.failedFilePaths || []).map(mapPath).filter(Boolean),
811
+ recentFileResult: event.recentFileResult
812
+ ? {
813
+ ...event.recentFileResult,
814
+ relativePath: mapPath(event.recentFileResult.filePath),
815
+ }
816
+ : null,
817
+ },
818
+ });
819
+ })
820
+ : null;
821
+ const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher });
822
+ const server = http.createServer(handler);
667
823
 
668
824
  let port = null;
669
825
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -677,19 +833,19 @@ export async function startLocalServer(workspaceDir, opts = {}) {
677
833
 
678
834
  if (port === null) {
679
835
  fileWatcher.close();
680
- throw new Error(`Could not bind to ports ${BASE_PORT}–${BASE_PORT + 2}. All in use.`);
836
+ throw new Error(`Could not bind to ports ${BASE_PORT}–${BASE_PORT + 2}. All in use.`);
681
837
  }
682
838
 
683
839
  const url = `http://127.0.0.1:${port}`;
684
- _instance = { server, url, fileWatcher, unsubscribeSyncProgress };
840
+ _instance = { server, url, fileWatcher, unsubscribeSyncProgress };
685
841
  return url;
686
842
  }
687
843
 
688
844
  export function stopLocalServer() {
689
- if (_instance) {
690
- _instance.server.close();
691
- if (_instance.fileWatcher) _instance.fileWatcher.close();
692
- if (_instance.unsubscribeSyncProgress) _instance.unsubscribeSyncProgress();
693
- _instance = null;
694
- }
695
- }
845
+ if (_instance) {
846
+ _instance.server.close();
847
+ if (_instance.fileWatcher) _instance.fileWatcher.close();
848
+ if (_instance.unsubscribeSyncProgress) _instance.unsubscribeSyncProgress();
849
+ _instance = null;
850
+ }
851
+ }