@echomem/echo-memory-cloud-openclaw-plugin 0.1.1 → 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,209 +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
- }
257
-
258
- function readBody(req) {
259
- return new Promise((resolve, reject) => {
260
- const chunks = [];
261
- req.on("data", (c) => chunks.push(c));
262
- req.on("end", () => {
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
- }
288
- });
289
- req.on("error", reject);
290
- });
291
- }
292
-
293
- function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
294
- const normalizedBase = path.resolve(workspaceDir) + path.sep;
295
- const { apiClient, syncRunner, cfg, fileWatcher, logger } = opts;
296
- const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
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
+
258
+ function readBody(req) {
259
+ return new Promise((resolve, reject) => {
260
+ const chunks = [];
261
+ req.on("data", (c) => chunks.push(c));
262
+ req.on("end", () => {
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
+ }
288
+ });
289
+ req.on("error", reject);
290
+ });
291
+ }
292
+
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;
297
395
 
298
396
  return async function handler(req, res) {
299
397
  setCorsHeaders(res);
@@ -316,7 +414,7 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
316
414
  return;
317
415
  }
318
416
 
319
- // SSE endpoint push file-change events to the frontend
417
+ // SSE endpoint — push file-change events to the frontend
320
418
  if (url.pathname === "/api/events") {
321
419
  res.writeHead(200, {
322
420
  "Content-Type": "text/event-stream",
@@ -412,7 +510,7 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
412
510
  return;
413
511
  }
414
512
 
415
- if (url.pathname === "/api/auth-status") {
513
+ if (url.pathname === "/api/auth-status") {
416
514
  if (!apiClient) {
417
515
  sendJson(res, { connected: false, reason: "no_client" });
418
516
  return;
@@ -433,57 +531,57 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
433
531
  } catch (e) {
434
532
  sendJson(res, { connected: false, reason: "auth_failed", error: String(e?.message ?? e) });
435
533
  }
436
- return;
437
- }
438
-
439
- if (url.pathname === "/api/setup-status") {
440
- sendJson(res, getLocalUiSetupState(opts.pluginConfig ?? {}, cfg));
441
- return;
442
- }
443
-
444
- if (url.pathname === "/api/setup-config" && req.method === "POST") {
445
- try {
446
- const bodyResult = await readBody(req);
447
- if (!bodyResult.ok) {
448
- logger?.warn?.(
449
- `[echo-memory] invalid JSON for ${url.pathname}: ${bodyResult.parseError}; body=${JSON.stringify(bodyResult.rawText.slice(0, 400))}`,
450
- );
451
- res.writeHead(400, { "Content-Type": "application/json; charset=utf-8" });
452
- res.end(JSON.stringify({
453
- ok: false,
454
- error: "Invalid JSON body",
455
- details: bodyResult.parseError,
456
- }));
457
- return;
458
- }
459
- const body = bodyResult.body;
460
- const payload = {
461
- ECHOMEM_API_KEY: typeof body.apiKey === "string" ? body.apiKey : "",
462
- ECHOMEM_MEMORY_DIR: typeof body.memoryDir === "string" ? body.memoryDir : "",
463
- ECHOMEM_LOCAL_ONLY_MODE:
464
- typeof body.apiKey === "string" && body.apiKey.trim()
465
- ? "false"
466
- : "true",
467
- };
468
- const saveResult = saveLocalUiSetup(payload);
469
- if (cfg) {
470
- cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
471
- cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
472
- cfg.memoryDir = payload.ECHOMEM_MEMORY_DIR.trim() || cfg.memoryDir;
473
- }
474
- sendJson(res, {
475
- ok: true,
476
- ...saveResult,
477
- setup: getLocalUiSetupState(opts.pluginConfig ?? {}, cfg),
478
- });
479
- } catch (error) {
480
- res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
481
- res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
482
- }
483
- return;
484
- }
485
-
486
- // 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
487
585
  if (url.pathname === "/api/backend-sources") {
488
586
  if (!apiClient) {
489
587
  sendJson(res, { ok: false, sources: [], error: "no_client" });
@@ -520,90 +618,21 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
520
618
  if (url.pathname === "/api/sync-status") {
521
619
  try {
522
620
  const statePath = syncRunner?.getStatePath() ?? null;
523
- const [lastState, files] = await Promise.all([
524
- statePath ? readLastSyncState(statePath) : Promise.resolve(null),
525
- scanFullWorkspace(workspaceDir),
526
- ]);
527
- // Pure local comparison — no backend calls.
528
- // Build map: absFilePath -> contentHash from last sync results
529
- const lastSyncedMap = new Map();
530
- if (Array.isArray(lastState?.results)) {
531
- for (const r of lastState.results) {
532
- // Support both camelCase (local dry-run) and snake_case (API response)
533
- const fp = r.filePath || r.file_path;
534
- if (fp && (r.status === "imported" || !r.status)) {
535
- // Store contentHash if available; otherwise mark as synced without hash
536
- // Note: older sync states may not have status field — treat as imported
537
- lastSyncedMap.set(fp, r.contentHash || "__synced__");
538
- }
539
- }
540
- }
541
-
542
- // Daily files (YYYY-MM-DD*.md in memory/) are agent-generated and
543
- // effectively immutable once written. We only check whether they've
544
- // been synced before. Mutable files (root-level MEMORY.md, SOUL.md,
545
- // etc.) need a content-hash comparison to detect edits.
546
- const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
547
-
548
- const fileStatuses = files.map((f) => {
549
- const absPath = path.resolve(workspaceDir, f.relativePath);
550
- const isPrivate =
551
- f.relativePath.startsWith("memory/private/") ||
552
- f.privacyLevel === "private";
553
- const isSyncTarget =
554
- Boolean(syncMemoryDir) && path.dirname(absPath) === syncMemoryDir;
555
-
556
- // Private files are never syncable
557
- if (isPrivate) {
558
- return { fileName: f.fileName, relativePath: f.relativePath, status: null };
559
- }
560
-
561
- // Files outside the configured sync target stay local-only in the UI.
562
- if (!isSyncTarget) {
563
- return { fileName: f.fileName, relativePath: f.relativePath, status: "local" };
564
- }
565
-
566
- const isDaily =
567
- f.relativePath.startsWith("memory/") && DATE_RE.test(f.fileName);
568
-
569
- if (!lastState) {
570
- // Never synced at all — everything is new (except private)
571
- return { fileName: f.fileName, relativePath: f.relativePath, status: "new" };
572
- }
573
-
574
- if (lastSyncedMap.has(absPath)) {
575
- // Daily files don't change, skip expensive hash compare
576
- if (isDaily) {
577
- return {
578
- fileName: f.fileName,
579
- relativePath: f.relativePath,
580
- status: "synced",
581
- lastSynced: lastState.finished_at,
582
- };
583
- }
584
- // Mutable file — compare hash if available
585
- const savedHash = lastSyncedMap.get(absPath);
586
- const status =
587
- savedHash === "__synced__" || savedHash === f.contentHash
588
- ? "synced"
589
- : "modified";
590
- return {
591
- fileName: f.fileName,
592
- relativePath: f.relativePath,
593
- status,
594
- lastSynced: lastState.finished_at,
595
- };
596
- }
597
-
598
- // Not in sync state → new
599
- return { fileName: f.fileName, relativePath: f.relativePath, status: "new" };
621
+ const syncView = await buildWorkspaceSyncView({
622
+ workspaceDir,
623
+ syncMemoryDir,
624
+ statePath,
600
625
  });
601
-
602
626
  sendJson(res, {
603
- lastSyncAt: lastState?.finished_at ?? null,
604
- syncedFileCount: lastSyncedMap.size,
605
- 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,
606
633
  });
634
+ return;
635
+
607
636
  } catch (err) {
608
637
  res.writeHead(500, { "Content-Type": "application/json" });
609
638
  res.end(JSON.stringify({ error: String(err?.message ?? err) }));
@@ -617,6 +646,14 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
617
646
  res.end(JSON.stringify({ error: "Sync not available" }));
618
647
  return;
619
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
+ }
620
657
  try {
621
658
  const result = await syncRunner.runSync("local-ui");
622
659
  sendJson(res, result);
@@ -627,73 +664,111 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
627
664
  return;
628
665
  }
629
666
 
630
- if (url.pathname === "/api/sync-selected" && req.method === "POST") {
631
- if (!syncRunner) {
632
- res.writeHead(400, { "Content-Type": "application/json" });
633
- res.end(JSON.stringify({ error: "Sync not available" }));
634
- return;
635
- }
636
- try {
637
- const bodyResult = await readBody(req);
638
- if (!bodyResult.ok) {
639
- logger?.warn?.(
640
- `[echo-memory] invalid JSON for ${url.pathname}: ${bodyResult.parseError}; body=${JSON.stringify(bodyResult.rawText.slice(0, 400))}`,
641
- );
642
- res.writeHead(400, { "Content-Type": "application/json" });
643
- res.end(JSON.stringify({
644
- error: "Invalid JSON body for sync-selected",
645
- details: bodyResult.parseError,
646
- receivedBodyPreview: bodyResult.rawText.slice(0, 400),
647
- }));
648
- return;
649
- }
650
-
651
- const body = bodyResult.body;
652
- const relativePaths = body.paths;
653
- if (!Array.isArray(relativePaths) || relativePaths.length === 0) {
654
- logger?.warn?.(
655
- `[echo-memory] invalid sync-selected payload: expected non-empty paths array; body=${JSON.stringify(body).slice(0, 400)}`,
656
- );
657
- res.writeHead(400, { "Content-Type": "application/json" });
658
- res.end(JSON.stringify({
659
- error: "paths array required",
660
- details: "Expected request body like {\"paths\":[\"memory/2026-03-17.md\"]}",
661
- receivedBody: body,
662
- }));
663
- return;
664
- }
665
-
666
- const filterPaths = new Set();
667
- const invalidPaths = [];
668
- for (const rp of relativePaths) {
669
- if (typeof rp !== "string" || !rp.trim()) {
670
- invalidPaths.push(rp);
671
- continue;
672
- }
673
- const absPath = path.resolve(workspaceDir, rp);
674
- if (!absPath.startsWith(path.resolve(workspaceDir) + path.sep) || !absPath.endsWith(".md")) {
675
- invalidPaths.push(rp);
676
- continue;
677
- }
678
- filterPaths.add(absPath);
679
- }
680
-
681
- if (filterPaths.size === 0) {
682
- logger?.warn?.(
683
- `[echo-memory] sync-selected contained no valid markdown paths; requested=${JSON.stringify(relativePaths).slice(0, 400)}`,
684
- );
685
- res.writeHead(400, { "Content-Type": "application/json" });
686
- res.end(JSON.stringify({
687
- error: "No valid markdown paths to sync",
688
- details: "All provided paths were outside the workspace or did not end with .md",
689
- invalidPaths,
690
- requestedPaths: relativePaths,
691
- }));
692
- return;
693
- }
694
- const result = await syncRunner.runSync("local-ui-selected", filterPaths);
695
- sendJson(res, result);
696
- } catch (e) {
667
+ if (url.pathname === "/api/sync-selected" && req.method === "POST") {
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;
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
+ );
759
+ res.writeHead(400, { "Content-Type": "application/json" });
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
+ }));
765
+ return;
766
+ }
767
+
768
+ const result = await syncRunner.runSync("local-ui-selected", requestedFilterPaths);
769
+ sendJson(res, result);
770
+ return;
771
+ } catch (e) {
697
772
  res.writeHead(500, { "Content-Type": "application/json" });
698
773
  res.end(JSON.stringify({ error: String(e?.message ?? e) }));
699
774
  }
@@ -705,39 +780,46 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
705
780
  };
706
781
  }
707
782
 
708
- export async function startLocalServer(workspaceDir, opts = {}) {
709
- if (_instance) {
710
- return _instance.url;
711
- }
712
-
713
- await ensureLocalUiReady(opts.cfg, opts.logger);
714
- const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
715
- const fileWatcher = createFileWatcher(workspaceDir);
716
- const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
717
- ? opts.syncRunner.onProgress((event) => {
718
- const mapPath = (targetPath) => {
719
- if (!targetPath) return null;
720
- const relativePath = path.relative(workspaceDir, targetPath);
721
- if (!relativePath || relativePath.startsWith("..")) return null;
722
- return relativePath.replace(/\\/g, "/");
723
- };
724
-
725
- fileWatcher.broadcast({
726
- type: "sync-progress",
727
- progress: {
728
- ...event,
729
- queuedRelativePaths: event.phase === "started"
730
- ? (event.currentFilePaths || []).map(mapPath).filter(Boolean)
731
- : [],
732
- currentRelativePaths: (event.currentFilePaths || []).map(mapPath).filter(Boolean),
733
- completedRelativePaths: (event.completedFilePaths || []).map(mapPath).filter(Boolean),
734
- failedRelativePaths: (event.failedFilePaths || []).map(mapPath).filter(Boolean),
735
- },
736
- });
737
- })
738
- : null;
739
- const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher });
740
- 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);
741
823
 
742
824
  let port = null;
743
825
  for (let attempt = 0; attempt < 3; attempt++) {
@@ -751,19 +833,19 @@ export async function startLocalServer(workspaceDir, opts = {}) {
751
833
 
752
834
  if (port === null) {
753
835
  fileWatcher.close();
754
- 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.`);
755
837
  }
756
838
 
757
839
  const url = `http://127.0.0.1:${port}`;
758
- _instance = { server, url, fileWatcher, unsubscribeSyncProgress };
840
+ _instance = { server, url, fileWatcher, unsubscribeSyncProgress };
759
841
  return url;
760
842
  }
761
843
 
762
844
  export function stopLocalServer() {
763
- if (_instance) {
764
- _instance.server.close();
765
- if (_instance.fileWatcher) _instance.fileWatcher.close();
766
- if (_instance.unsubscribeSyncProgress) _instance.unsubscribeSyncProgress();
767
- _instance = null;
768
- }
769
- }
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
+ }