@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.
- package/clawdbot.plugin.json +1 -1
- package/index.js +254 -216
- package/lib/api-client.js +125 -44
- package/lib/local-server.js +537 -381
- package/lib/local-ui/src/App.jsx +133 -25
- 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 +16 -14
- package/lib/sync.js +507 -215
- 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,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
|
-
|
|
264
|
-
|
|
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
|
|
271
|
-
const
|
|
272
|
-
|
|
273
|
-
|
|
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
|
|
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
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
|
488
|
-
|
|
489
|
-
|
|
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:
|
|
569
|
-
|
|
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
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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({
|
|
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
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
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}
|
|
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
|
+
}
|