@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.
- package/clawdbot.plugin.json +1 -1
- package/lib/api-client.js +125 -44
- package/lib/local-server.js +554 -472
- package/lib/local-ui/src/App.jsx +131 -23
- package/lib/local-ui/src/canvas/Viewport.jsx +10 -8
- package/lib/local-ui/src/cards/Card.css +13 -0
- package/lib/local-ui/src/cards/Card.jsx +10 -1
- package/lib/local-ui/src/styles/global.css +8 -0
- package/lib/local-ui/src/sync/api.js +6 -5
- package/lib/sync.js +480 -207
- package/moltbot.plugin.json +1 -1
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
package/lib/local-server.js
CHANGED
|
@@ -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
|
-
/*
|
|
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
|
|
294
|
-
const
|
|
295
|
-
|
|
296
|
-
|
|
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
|
|
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
|
|
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
|
|
524
|
-
|
|
525
|
-
|
|
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:
|
|
605
|
-
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
}
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
)
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
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
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
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}
|
|
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
|
+
}
|