@echomem/echo-memory-cloud-openclaw-plugin 0.2.0 → 0.2.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/README.md +11 -2
- package/clawdbot.plugin.json +2 -2
- package/index.js +44 -12
- package/lib/config.js +35 -8
- package/lib/local-server.js +331 -138
- package/lib/local-ui/dist/assets/index-CKT0_swv.css +1 -0
- package/lib/local-ui/dist/assets/index-D9Bbjf7A.js +54 -0
- package/lib/local-ui/dist/index.html +2 -2
- package/lib/onboarding.js +2 -1
- package/lib/state.js +59 -4
- package/lib/sync.js +42 -15
- package/moltbot.plugin.json +2 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/lib/local-ui/dist/assets/index-BoyzFR9Q.css +0 -1
- package/lib/local-ui/dist/assets/index-CJrdHn7-.js +0 -54
package/lib/local-server.js
CHANGED
|
@@ -3,10 +3,15 @@ import { spawn } from "node:child_process";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import fs from "node:fs/promises";
|
|
5
5
|
import fsSync from "node:fs";
|
|
6
|
-
import { fileURLToPath } from "node:url";
|
|
7
|
-
import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import { getLocalUiSetupState, saveLocalUiSetup } from "./config.js";
|
|
8
8
|
import { scanFullWorkspace, scanWorkspaceMarkdownFile } from "./openclaw-memory-scan.js";
|
|
9
|
-
import {
|
|
9
|
+
import {
|
|
10
|
+
readLastSyncState,
|
|
11
|
+
readLocalUiPresence,
|
|
12
|
+
resolveUiPresencePath,
|
|
13
|
+
writeLocalUiPresence,
|
|
14
|
+
} from "./state.js";
|
|
10
15
|
|
|
11
16
|
const BASE_PORT = 17823;
|
|
12
17
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
@@ -15,20 +20,29 @@ const UI_WORKDIR = path.join(__dirname, "local-ui");
|
|
|
15
20
|
const UI_DIST_DIR = path.join(__dirname, "local-ui", "dist");
|
|
16
21
|
const UI_NODE_MODULES_DIR = path.join(UI_WORKDIR, "node_modules");
|
|
17
22
|
|
|
18
|
-
let _instance = null;
|
|
19
|
-
let _bootstrapPromise = null;
|
|
20
|
-
let _lastOpenedUrl = null;
|
|
23
|
+
let _instance = null;
|
|
24
|
+
let _bootstrapPromise = null;
|
|
25
|
+
let _lastOpenedUrl = null;
|
|
26
|
+
const BACKEND_SOURCE_LOOKUP_TIMEOUT_MS = 4000;
|
|
27
|
+
const LOCAL_UI_PRESENCE_STALE_MS = 75000;
|
|
21
28
|
|
|
22
29
|
/* ── File Watcher + SSE ────────────────────────────────── */
|
|
23
30
|
|
|
24
31
|
const SKIP_DIRS = new Set(["node_modules", ".git", ".next", "dist", "build", "__pycache__", "logs", "completions", "delivery-queue", "browser", "canvas", "cron", "media"]);
|
|
25
32
|
|
|
26
33
|
/** Debounced file-change broadcaster */
|
|
27
|
-
function createFileWatcher(workspaceDir) {
|
|
28
|
-
const sseClients = new Set();
|
|
29
|
-
const
|
|
30
|
-
|
|
31
|
-
|
|
34
|
+
function createFileWatcher(workspaceDir) {
|
|
35
|
+
const sseClients = new Set();
|
|
36
|
+
const clientWaiters = new Set();
|
|
37
|
+
const watchers = [];
|
|
38
|
+
let debounceTimer = null;
|
|
39
|
+
const DEBOUNCE_MS = 500;
|
|
40
|
+
|
|
41
|
+
function settleClientWaiters(didConnect) {
|
|
42
|
+
for (const finish of [...clientWaiters]) {
|
|
43
|
+
finish(didConnect);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
32
46
|
|
|
33
47
|
function broadcast(eventData) {
|
|
34
48
|
const payload = `data: ${JSON.stringify(eventData)}\n\n`;
|
|
@@ -66,18 +80,48 @@ function createFileWatcher(workspaceDir) {
|
|
|
66
80
|
// Start watching
|
|
67
81
|
watchRecursive(workspaceDir);
|
|
68
82
|
|
|
69
|
-
return {
|
|
70
|
-
sseClients,
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
83
|
+
return {
|
|
84
|
+
sseClients,
|
|
85
|
+
addSseClient(res) {
|
|
86
|
+
sseClients.add(res);
|
|
87
|
+
settleClientWaiters(true);
|
|
88
|
+
},
|
|
89
|
+
removeSseClient(res) {
|
|
90
|
+
sseClients.delete(res);
|
|
91
|
+
},
|
|
92
|
+
waitForClient(timeoutMs = 0) {
|
|
93
|
+
if (sseClients.size > 0) {
|
|
94
|
+
return Promise.resolve(true);
|
|
95
|
+
}
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
let timer = null;
|
|
98
|
+
const finish = (didConnect) => {
|
|
99
|
+
if (!clientWaiters.delete(finish)) {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
if (timer) {
|
|
103
|
+
clearTimeout(timer);
|
|
104
|
+
timer = null;
|
|
105
|
+
}
|
|
106
|
+
resolve(didConnect);
|
|
107
|
+
};
|
|
108
|
+
clientWaiters.add(finish);
|
|
109
|
+
if (timeoutMs > 0) {
|
|
110
|
+
timer = setTimeout(() => finish(false), timeoutMs);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
113
|
+
},
|
|
114
|
+
broadcast,
|
|
115
|
+
close() {
|
|
116
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
117
|
+
for (const w of watchers) { try { w.close(); } catch {} }
|
|
118
|
+
watchers.length = 0;
|
|
119
|
+
for (const res of sseClients) { try { res.end(); } catch {} }
|
|
120
|
+
sseClients.clear();
|
|
121
|
+
settleClientWaiters(false);
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
81
125
|
|
|
82
126
|
function tryListen(server, port) {
|
|
83
127
|
return new Promise((resolve) => {
|
|
@@ -224,17 +268,17 @@ function getBrowserOpenCommand(url) {
|
|
|
224
268
|
return null;
|
|
225
269
|
}
|
|
226
270
|
|
|
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
|
-
}
|
|
271
|
+
export async function openUrlInDefaultBrowser(url, opts = {}) {
|
|
272
|
+
const { logger, force = false } = opts;
|
|
273
|
+
if (!force) {
|
|
274
|
+
const skipReason = detectBrowserOpenSkipReason();
|
|
275
|
+
if (skipReason) {
|
|
276
|
+
return { opened: false, reason: skipReason };
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
if (!force && _lastOpenedUrl === url) {
|
|
280
|
+
return { opened: false, reason: "already_opened" };
|
|
281
|
+
}
|
|
238
282
|
const command = getBrowserOpenCommand(url);
|
|
239
283
|
if (!command) {
|
|
240
284
|
return { opened: false, reason: "unsupported_platform" };
|
|
@@ -290,34 +334,143 @@ function readBody(req) {
|
|
|
290
334
|
});
|
|
291
335
|
}
|
|
292
336
|
|
|
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
|
-
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
337
|
+
function resolveStoredFilePath(workspaceDir, entry) {
|
|
338
|
+
const rawPath = entry?.filePath || entry?.file_path || entry?.path || null;
|
|
339
|
+
if (!rawPath) {
|
|
340
|
+
return null;
|
|
341
|
+
}
|
|
342
|
+
return path.isAbsolute(rawPath) ? path.normalize(rawPath) : path.resolve(workspaceDir, rawPath);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
function toPathKey(targetPath) {
|
|
346
|
+
const normalized = path.normalize(String(targetPath || ""));
|
|
347
|
+
return process.platform === "win32" ? normalized.toLowerCase() : normalized;
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
function resolveBackendFilePath(workspaceDir, rawPath) {
|
|
351
|
+
if (!rawPath) {
|
|
352
|
+
return null;
|
|
353
|
+
}
|
|
354
|
+
return path.isAbsolute(rawPath)
|
|
355
|
+
? path.normalize(rawPath)
|
|
356
|
+
: path.resolve(workspaceDir, String(rawPath));
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function readBackendSourceMap(apiClient, workspaceDir) {
|
|
360
|
+
const sources = new Map();
|
|
361
|
+
if (!apiClient) {
|
|
362
|
+
return sources;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
const addSource = (sourcePath, latestAt = null) => {
|
|
366
|
+
const resolvedPath = resolveBackendFilePath(workspaceDir, sourcePath);
|
|
367
|
+
if (!resolvedPath) {
|
|
368
|
+
return;
|
|
369
|
+
}
|
|
370
|
+
const pathKey = toPathKey(resolvedPath);
|
|
371
|
+
const existing = sources.get(pathKey);
|
|
372
|
+
if (!existing || (latestAt && (!existing.latestAt || latestAt > existing.latestAt))) {
|
|
373
|
+
sources.set(pathKey, {
|
|
374
|
+
latestAt: latestAt || existing?.latestAt || null,
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
};
|
|
378
|
+
|
|
379
|
+
try {
|
|
380
|
+
const data = await apiClient.listAllSources();
|
|
381
|
+
for (const source of data?.paths || []) {
|
|
382
|
+
addSource(source?.file_path, source?.latest_at ?? null);
|
|
383
|
+
}
|
|
384
|
+
return sources;
|
|
385
|
+
} catch {
|
|
386
|
+
// Fall through to import-status fallback.
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
try {
|
|
390
|
+
const status = await apiClient.getImportStatus();
|
|
391
|
+
for (const source of status?.recent_sources || []) {
|
|
392
|
+
addSource(source?.file_path, source?.created_at ?? null);
|
|
393
|
+
}
|
|
394
|
+
} catch {
|
|
395
|
+
// Ignore backend status fallback failures in the local UI.
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
return sources;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
function resolveWithin(timeoutMs, value) {
|
|
402
|
+
return new Promise((resolve) => {
|
|
403
|
+
setTimeout(() => resolve(value), timeoutMs);
|
|
404
|
+
});
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function getLocalUiPresencePath(syncRunner) {
|
|
408
|
+
try {
|
|
409
|
+
const statePath = syncRunner?.getStatePath?.();
|
|
410
|
+
return statePath ? resolveUiPresencePath(path.dirname(statePath)) : null;
|
|
411
|
+
} catch {
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
async function updateLocalUiPresence(syncRunner, payload = {}) {
|
|
417
|
+
const presencePath = getLocalUiPresencePath(syncRunner);
|
|
418
|
+
if (!presencePath) {
|
|
419
|
+
return null;
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
const previous = await readLocalUiPresence(presencePath);
|
|
423
|
+
const next = {
|
|
424
|
+
clientId: payload.clientId || previous?.clientId || null,
|
|
425
|
+
serverInstanceId: payload.serverInstanceId || previous?.serverInstanceId || null,
|
|
426
|
+
active: payload.active !== undefined ? Boolean(payload.active) : true,
|
|
427
|
+
lastSeenAt: payload.lastSeenAt || new Date().toISOString(),
|
|
428
|
+
};
|
|
429
|
+
await writeLocalUiPresence(presencePath, next);
|
|
430
|
+
return next;
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
export async function hasRecentLocalUiPresence(syncRunner, { maxAgeMs = LOCAL_UI_PRESENCE_STALE_MS } = {}) {
|
|
434
|
+
const presencePath = getLocalUiPresencePath(syncRunner);
|
|
435
|
+
if (!presencePath) {
|
|
436
|
+
return false;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
const presence = await readLocalUiPresence(presencePath);
|
|
440
|
+
if (!presence?.lastSeenAt || presence.active === false) {
|
|
441
|
+
return false;
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
const ageMs = Date.now() - new Date(presence.lastSeenAt).getTime();
|
|
445
|
+
return Number.isFinite(ageMs) && ageMs >= 0 && ageMs <= maxAgeMs;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath, apiClient }) {
|
|
449
|
+
const backendSourcesPromise = readBackendSourceMap(apiClient, workspaceDir).catch(() => new Map());
|
|
450
|
+
const [lastState, files, backendSources] = await Promise.all([
|
|
451
|
+
statePath ? readLastSyncState(statePath) : Promise.resolve(null),
|
|
452
|
+
scanFullWorkspace(workspaceDir),
|
|
453
|
+
Promise.race([
|
|
454
|
+
backendSourcesPromise,
|
|
455
|
+
resolveWithin(BACKEND_SOURCE_LOOKUP_TIMEOUT_MS, new Map()),
|
|
456
|
+
]),
|
|
457
|
+
]);
|
|
458
|
+
|
|
459
|
+
const storedResults = Array.isArray(lastState?.results) ? lastState.results : [];
|
|
460
|
+
const resultMap = new Map();
|
|
461
|
+
for (const entry of storedResults) {
|
|
462
|
+
const storedPath = resolveStoredFilePath(workspaceDir, entry);
|
|
463
|
+
if (!storedPath) continue;
|
|
464
|
+
resultMap.set(toPathKey(storedPath), entry);
|
|
465
|
+
}
|
|
314
466
|
|
|
315
467
|
const DATE_RE = /^\d{4}-\d{2}-\d{2}/;
|
|
316
468
|
const eligibleAbsolutePaths = new Set();
|
|
317
469
|
const eligibleRelativePaths = [];
|
|
318
470
|
|
|
319
471
|
const fileStatuses = files.map((f) => {
|
|
320
|
-
const absPath = path.resolve(workspaceDir, f.relativePath);
|
|
472
|
+
const absPath = path.resolve(workspaceDir, f.relativePath);
|
|
473
|
+
const pathKey = toPathKey(absPath);
|
|
321
474
|
const isPrivate =
|
|
322
475
|
f.relativePath.startsWith("memory/private/") ||
|
|
323
476
|
f.privacyLevel === "private";
|
|
@@ -325,16 +478,22 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
|
|
|
325
478
|
Boolean(syncMemoryDir) && path.dirname(absPath) === syncMemoryDir;
|
|
326
479
|
const isDaily =
|
|
327
480
|
f.relativePath.startsWith("memory/") && DATE_RE.test(f.fileName);
|
|
328
|
-
const stored = resultMap.get(
|
|
481
|
+
const stored = resultMap.get(pathKey) ?? null;
|
|
329
482
|
const storedStatus = String(stored?.status || "").trim().toLowerCase() || null;
|
|
330
483
|
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
|
|
484
|
+
const successfulHash =
|
|
485
|
+
stored?.lastSuccessfulContentHash
|
|
486
|
+
|| stored?.last_successful_content_hash
|
|
487
|
+
|| (storedStatus && storedStatus !== "failed" ? attemptedHash : null);
|
|
488
|
+
const lastError = stored?.lastError || stored?.last_error || stored?.error || null;
|
|
489
|
+
const lastAttemptAt = stored?.lastAttemptAt || stored?.last_attempt_at || null;
|
|
490
|
+
const backendSource = backendSources.get(pathKey) ?? null;
|
|
491
|
+
const lastSuccessAt =
|
|
492
|
+
stored?.lastSuccessAt
|
|
493
|
+
|| stored?.last_success_at
|
|
494
|
+
|| backendSource?.latestAt
|
|
495
|
+
|| lastState?.finished_at
|
|
496
|
+
|| null;
|
|
338
497
|
|
|
339
498
|
if (isPrivate) {
|
|
340
499
|
return {
|
|
@@ -359,14 +518,16 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
|
|
|
359
518
|
eligibleAbsolutePaths.add(absPath);
|
|
360
519
|
eligibleRelativePaths.push(f.relativePath);
|
|
361
520
|
|
|
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 (
|
|
368
|
-
status = "
|
|
369
|
-
}
|
|
521
|
+
let status = "new";
|
|
522
|
+
if (storedStatus === "failed" && attemptedHash && attemptedHash === f.contentHash) {
|
|
523
|
+
status = "failed";
|
|
524
|
+
} else if (successfulHash) {
|
|
525
|
+
status = isDaily || successfulHash === f.contentHash ? "synced" : "modified";
|
|
526
|
+
} else if (backendSource) {
|
|
527
|
+
status = "synced";
|
|
528
|
+
} else if (storedStatus === "failed") {
|
|
529
|
+
status = "modified";
|
|
530
|
+
}
|
|
370
531
|
|
|
371
532
|
return {
|
|
372
533
|
fileName: f.fileName,
|
|
@@ -388,10 +549,10 @@ async function buildWorkspaceSyncView({ workspaceDir, syncMemoryDir, statePath }
|
|
|
388
549
|
};
|
|
389
550
|
}
|
|
390
551
|
|
|
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;
|
|
552
|
+
function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
553
|
+
const normalizedBase = path.resolve(workspaceDir) + path.sep;
|
|
554
|
+
const { apiClient, syncRunner, cfg, fileWatcher, logger, serverInstanceId } = opts;
|
|
555
|
+
const syncMemoryDir = cfg?.memoryDir ? path.resolve(cfg.memoryDir) : null;
|
|
395
556
|
|
|
396
557
|
return async function handler(req, res) {
|
|
397
558
|
setCorsHeaders(res);
|
|
@@ -414,22 +575,47 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
414
575
|
return;
|
|
415
576
|
}
|
|
416
577
|
|
|
417
|
-
// SSE endpoint — push file-change events to the frontend
|
|
418
|
-
if (url.pathname === "/api/events") {
|
|
419
|
-
res.writeHead(200, {
|
|
420
|
-
"Content-Type": "text/event-stream",
|
|
421
|
-
"Cache-Control": "no-cache",
|
|
422
|
-
"Connection": "keep-alive",
|
|
423
|
-
});
|
|
424
|
-
res.write(":
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
578
|
+
// SSE endpoint — push file-change events to the frontend
|
|
579
|
+
if (url.pathname === "/api/events") {
|
|
580
|
+
res.writeHead(200, {
|
|
581
|
+
"Content-Type": "text/event-stream",
|
|
582
|
+
"Cache-Control": "no-cache",
|
|
583
|
+
"Connection": "keep-alive",
|
|
584
|
+
});
|
|
585
|
+
res.write("retry: 1000\n");
|
|
586
|
+
res.write(`data: ${JSON.stringify({
|
|
587
|
+
type: "server-connected",
|
|
588
|
+
serverInstanceId,
|
|
589
|
+
connectedAt: new Date().toISOString(),
|
|
590
|
+
})}\n\n`);
|
|
591
|
+
if (fileWatcher) {
|
|
592
|
+
fileWatcher.addSseClient(res);
|
|
593
|
+
req.on("close", () => fileWatcher.removeSseClient(res));
|
|
594
|
+
}
|
|
595
|
+
return;
|
|
596
|
+
}
|
|
597
|
+
|
|
598
|
+
if (url.pathname === "/api/ui-presence" && req.method === "POST") {
|
|
599
|
+
try {
|
|
600
|
+
const bodyResult = await readBody(req);
|
|
601
|
+
if (!bodyResult.ok) {
|
|
602
|
+
sendJson(res, { ok: false, error: "invalid_json" });
|
|
603
|
+
return;
|
|
604
|
+
}
|
|
605
|
+
await updateLocalUiPresence(syncRunner, {
|
|
606
|
+
clientId: typeof bodyResult.body?.clientId === "string" ? bodyResult.body.clientId : null,
|
|
607
|
+
serverInstanceId: typeof bodyResult.body?.serverInstanceId === "string" ? bodyResult.body.serverInstanceId : serverInstanceId,
|
|
608
|
+
active: bodyResult.body?.active,
|
|
609
|
+
});
|
|
610
|
+
sendJson(res, { ok: true });
|
|
611
|
+
} catch (error) {
|
|
612
|
+
res.writeHead(500, { "Content-Type": "application/json; charset=utf-8" });
|
|
613
|
+
res.end(JSON.stringify({ ok: false, error: String(error?.message ?? error) }));
|
|
614
|
+
}
|
|
615
|
+
return;
|
|
616
|
+
}
|
|
617
|
+
|
|
618
|
+
if (url.pathname === "/") {
|
|
433
619
|
// Always serve the built React app from dist/
|
|
434
620
|
let content;
|
|
435
621
|
try {
|
|
@@ -506,21 +692,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
506
692
|
return;
|
|
507
693
|
}
|
|
508
694
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
});
|
|
520
|
-
return;
|
|
521
|
-
}
|
|
522
|
-
|
|
523
|
-
sendJson(res, { fileName: fileScan.fileName, content: fileScan.content });
|
|
695
|
+
sendJson(res, {
|
|
696
|
+
fileName: fileScan.fileName,
|
|
697
|
+
content: fileScan.content,
|
|
698
|
+
privacyLevel: fileScan.privacyLevel,
|
|
699
|
+
privacyAutoUpgraded: fileScan.privacyAutoUpgraded,
|
|
700
|
+
hasSensitiveContent: fileScan.hasSensitiveContent,
|
|
701
|
+
hasHighRiskSensitiveContent: fileScan.hasHighRiskSensitiveContent,
|
|
702
|
+
sensitiveSummary: fileScan.sensitiveSummary,
|
|
703
|
+
sensitiveFindings: fileScan.sensitiveFindings,
|
|
704
|
+
});
|
|
524
705
|
return;
|
|
525
706
|
}
|
|
526
707
|
|
|
@@ -576,11 +757,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
576
757
|
typeof body.apiKey === "string" && body.apiKey.trim()
|
|
577
758
|
? "false"
|
|
578
759
|
: "true",
|
|
579
|
-
};
|
|
580
|
-
const saveResult = saveLocalUiSetup(payload);
|
|
581
|
-
if (
|
|
582
|
-
|
|
583
|
-
|
|
760
|
+
};
|
|
761
|
+
const saveResult = saveLocalUiSetup(payload);
|
|
762
|
+
if (saveResult.migratedFrom) {
|
|
763
|
+
logger?.info?.(
|
|
764
|
+
`[echo-memory] Migrated local UI setup from ${saveResult.migratedFrom} to ${saveResult.targetPath}`,
|
|
765
|
+
);
|
|
766
|
+
}
|
|
767
|
+
if (cfg) {
|
|
768
|
+
cfg.apiKey = payload.ECHOMEM_API_KEY.trim();
|
|
769
|
+
cfg.localOnlyMode = payload.ECHOMEM_LOCAL_ONLY_MODE === "true";
|
|
584
770
|
cfg.memoryDir = payload.ECHOMEM_MEMORY_DIR.trim() || cfg.memoryDir;
|
|
585
771
|
}
|
|
586
772
|
sendJson(res, {
|
|
@@ -632,11 +818,12 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
632
818
|
if (url.pathname === "/api/sync-status") {
|
|
633
819
|
try {
|
|
634
820
|
const statePath = syncRunner?.getStatePath() ?? null;
|
|
635
|
-
const syncView = await buildWorkspaceSyncView({
|
|
636
|
-
workspaceDir,
|
|
637
|
-
syncMemoryDir,
|
|
638
|
-
statePath,
|
|
639
|
-
|
|
821
|
+
const syncView = await buildWorkspaceSyncView({
|
|
822
|
+
workspaceDir,
|
|
823
|
+
syncMemoryDir,
|
|
824
|
+
statePath,
|
|
825
|
+
apiClient,
|
|
826
|
+
});
|
|
640
827
|
sendJson(res, {
|
|
641
828
|
lastSyncAt: syncView.lastState?.finished_at ?? null,
|
|
642
829
|
syncedFileCount: syncView.fileStatuses.filter((status) => status.status === 'synced').length,
|
|
@@ -723,11 +910,12 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
723
910
|
}
|
|
724
911
|
|
|
725
912
|
const statePath = syncRunner?.getStatePath() ?? null;
|
|
726
|
-
const syncView = await buildWorkspaceSyncView({
|
|
727
|
-
workspaceDir,
|
|
728
|
-
syncMemoryDir,
|
|
729
|
-
statePath,
|
|
730
|
-
|
|
913
|
+
const syncView = await buildWorkspaceSyncView({
|
|
914
|
+
workspaceDir,
|
|
915
|
+
syncMemoryDir,
|
|
916
|
+
statePath,
|
|
917
|
+
apiClient,
|
|
918
|
+
});
|
|
731
919
|
const statusMap = new Map(syncView.fileStatuses.map((status) => [status.relativePath, status]));
|
|
732
920
|
const requestedFilterPaths = new Set();
|
|
733
921
|
const requestedInvalidPaths = [];
|
|
@@ -794,15 +982,16 @@ function createRequestHandler(workspaceDir, htmlContent, opts = {}) {
|
|
|
794
982
|
};
|
|
795
983
|
}
|
|
796
984
|
|
|
797
|
-
export async function startLocalServer(workspaceDir, opts = {}) {
|
|
798
|
-
if (_instance) {
|
|
799
|
-
return _instance.url;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
await ensureLocalUiReady(opts.cfg, opts.logger);
|
|
803
|
-
const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
|
|
804
|
-
const fileWatcher = createFileWatcher(workspaceDir);
|
|
805
|
-
const
|
|
985
|
+
export async function startLocalServer(workspaceDir, opts = {}) {
|
|
986
|
+
if (_instance) {
|
|
987
|
+
return _instance.url;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
await ensureLocalUiReady(opts.cfg, opts.logger);
|
|
991
|
+
const htmlContent = await fs.readFile(UI_HTML_PATH, "utf8");
|
|
992
|
+
const fileWatcher = createFileWatcher(workspaceDir);
|
|
993
|
+
const serverInstanceId = `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
|
|
994
|
+
const unsubscribeSyncProgress = typeof opts.syncRunner?.onProgress === "function"
|
|
806
995
|
? opts.syncRunner.onProgress((event) => {
|
|
807
996
|
const mapPath = (targetPath) => {
|
|
808
997
|
if (!targetPath) return null;
|
|
@@ -832,8 +1021,8 @@ export async function startLocalServer(workspaceDir, opts = {}) {
|
|
|
832
1021
|
});
|
|
833
1022
|
})
|
|
834
1023
|
: null;
|
|
835
|
-
const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher });
|
|
836
|
-
const server = http.createServer(handler);
|
|
1024
|
+
const handler = createRequestHandler(workspaceDir, htmlContent, { ...opts, fileWatcher, serverInstanceId });
|
|
1025
|
+
const server = http.createServer(handler);
|
|
837
1026
|
|
|
838
1027
|
let port = null;
|
|
839
1028
|
for (let attempt = 0; attempt < 3; attempt++) {
|
|
@@ -849,11 +1038,15 @@ export async function startLocalServer(workspaceDir, opts = {}) {
|
|
|
849
1038
|
fileWatcher.close();
|
|
850
1039
|
throw new Error(`Could not bind to ports ${BASE_PORT}–${BASE_PORT + 2}. All in use.`);
|
|
851
1040
|
}
|
|
852
|
-
|
|
853
|
-
const url = `http://127.0.0.1:${port}`;
|
|
854
|
-
_instance = { server, url, fileWatcher, unsubscribeSyncProgress };
|
|
855
|
-
return url;
|
|
856
|
-
}
|
|
1041
|
+
|
|
1042
|
+
const url = `http://127.0.0.1:${port}`;
|
|
1043
|
+
_instance = { server, url, fileWatcher, unsubscribeSyncProgress, serverInstanceId };
|
|
1044
|
+
return url;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
export async function waitForLocalUiClient({ timeoutMs = 0 } = {}) {
|
|
1048
|
+
return _instance?.fileWatcher?.waitForClient(timeoutMs) ?? false;
|
|
1049
|
+
}
|
|
857
1050
|
|
|
858
1051
|
export function stopLocalServer() {
|
|
859
1052
|
if (_instance) {
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.card{position:absolute;border-radius:4px;overflow:hidden;box-shadow:0 1px 3px #0000002e;cursor:pointer;display:flex;flex-direction:column;padding:8px 10px;contain:layout style paint;animation:cardFadeIn .5s ease-out both}@keyframes cardFadeIn{0%{opacity:0}to{opacity:1}}.card:hover{box-shadow:0 4px 16px #0000004d;z-index:10}.card-selected{transform:scale(1.08);transform-origin:center center;box-shadow:0 8px 32px #a78bfa59,0 0 0 2px #a78bfa99;z-index:50!important;transition:transform .25s cubic-bezier(.34,1.56,.64,1),box-shadow .25s ease}.card-dimmed{opacity:.35;transition:opacity .3s ease}.card-dimmed:hover{opacity:.7}.card-expand-btn{flex-shrink:0;width:20px;height:20px;border:none;border-radius:4px;background:#a78bfa33;color:#a78bfa;font-size:12px;font-weight:700;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,transform .15s;line-height:1}.card-expand-btn:hover{background:#a78bfa66;transform:scale(1.15)}.card-checkbox{flex-shrink:0;font-size:14px;cursor:pointer;color:#999;line-height:1;transition:color .15s}.card-checkbox-on{color:#a78bfa}.card-lod0{position:absolute;border-radius:3px;contain:strict;pointer-events:none}.card-header{display:flex;align-items:center;gap:6px;flex-shrink:0;margin-bottom:4px}.card-name{font:600 11px/1.3 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;flex:1;min-width:0}.card-warning-toggle{flex-shrink:0;max-width:48%;display:inline-flex;align-items:center;gap:4px;border:1px solid rgba(160,112,48,.35);border-radius:999px;background:#a070301a;color:#8b6128;padding:2px 6px;font:600 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;min-width:0}.card-warning-toggle-high{border-color:#b040406b;background:#b040401a;color:#9c3d3d}.card-warning-toggle__icon{flex-shrink:0}.card-warning-toggle__text{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-private-badge{flex-shrink:0;border:1px solid rgba(156,61,61,.35);border-radius:999px;background:#9c3d3d14;color:#9c3d3d;padding:2px 6px;font:700 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif}.card-cluster-badge{flex-shrink:0;border-radius:999px;padding:2px 6px;font:700 8px/1.1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;letter-spacing:.3px;border:1px solid rgba(255,255,255,.18);background:#ffffff1f;color:#d6d0c6;max-width:38%;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.card-cluster-badge-identity{color:#fca5a5;border-color:#fca5a547;background:#fca5a514}.card-cluster-badge-long-term{color:#fdba74;border-color:#fdba7447;background:#fdba7414}.card-cluster-badge-journal{color:#fde68a;border-color:#fde68a47;background:#fde68a14}.card-cluster-badge-goals{color:#d6b388;border-color:#d6b38847;background:#d6b38814}.card-cluster-badge-technical{color:#93c5fd;border-color:#93c5fd47;background:#93c5fd14}.card-cluster-badge-thematic{color:#d8b4fe;border-color:#d8b4fe47;background:#d8b4fe14}.card-cluster-badge-knowledge{color:#86efac;border-color:#86efac47;background:#86efac14}.card-cluster-badge-system{color:#cbd5e1;border-color:#cbd5e147;background:#cbd5e114}.card-warning-panel{margin-bottom:6px;border:1px solid rgba(176,64,64,.2);border-radius:4px;background:#fff8f4eb;padding:6px 8px;font:500 9px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#6b443d;pointer-events:none}.card-warning-panel__header,.card-warning-panel__row{display:flex;align-items:center;justify-content:space-between;gap:8px}.card-warning-panel__header{font-weight:700;margin-bottom:4px}.card-warning-panel__privacy{color:#9c3d3d}.card-warning-panel__row+.card-warning-panel__row{margin-top:2px}.card-content{flex:1;overflow:hidden;font:400 9.5px/1.45 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;letter-spacing:.01em;white-space:pre-wrap;word-break:break-word;-webkit-mask-image:linear-gradient(to bottom,#000 60%,transparent 100%);mask-image:linear-gradient(to bottom,#000 60%,transparent 100%);opacity:.72}.card-session-log{animation:cardFadeInLog .5s ease-out both}.card-session-log:hover{opacity:.75}@keyframes cardFadeInLog{0%{opacity:0}to{opacity:.5}}.session-badge{font-size:9px;flex-shrink:0}.card-content-log{flex:1;overflow:hidden;font:400 8px/1.35 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;color:#aaa;white-space:pre-wrap;word-break:break-word;-webkit-mask-image:linear-gradient(to bottom,#000 40%,transparent 90%);mask-image:linear-gradient(to bottom,#000 40%,transparent 90%);opacity:.5}.stamp{flex-shrink:0;font:700 7px/1 -apple-system,sans-serif;letter-spacing:.5px;padding:2px 5px;border-radius:1px;pointer-events:none;border:1.5px solid;text-transform:uppercase;transform:rotate(-2deg);opacity:.75}.stamp-new,.stamp-mod{color:#a07030;border-color:#a07030;background:#a0703014}.stamp-synced{color:#9090a0;border-color:#9090a0;background:#9090a00f;opacity:.5}.stamp-local{color:#8d7e67;border-color:#8d7e67;background:#8d7e6714}.stamp-transient{opacity:.92}.stamp-queued{color:#7da2d6;border-color:#7da2d6;background:#7da2d61f}.stamp-syncing{color:#67d5c1;border-color:#67d5c1;background:#67d5c124}.stamp-done{color:#74c98a;border-color:#74c98a;background:#74c98a1f}.stamp-failed{color:#e58a8a;border-color:#e58a8a;background:#e58a8a24}.card-unselectable{opacity:.88}.card-checkbox-disabled{opacity:.35}.stamp-overlay{position:absolute;top:50%;left:50%;transform:translate(-50%,-50%) rotate(-8deg);font:800 14px/1 -apple-system,Songti SC,"Noto Serif SC",serif;letter-spacing:2px;color:#b04040;border:2.5px solid #b04040;padding:4px 10px;border-radius:2px;opacity:.55;pointer-events:none;white-space:nowrap;text-transform:uppercase}.stamp-overlay.stamp-sealed{text-shadow:0 0 2px rgba(176,64,64,.3);box-shadow:inset 0 0 4px #b0404026}.card-sealed .card-content{filter:blur(2px);opacity:.4}.minimap{position:absolute;bottom:40px;right:12px;border-radius:6px;overflow:hidden;border:1px solid #2a2a36;box-shadow:0 4px 16px #0006;z-index:80;cursor:pointer;opacity:.85;transition:opacity .15s}.minimap:hover{opacity:1}.minimap-canvas{display:block;width:180px;height:120px}.viewport-root{flex:1;position:relative;overflow:hidden}.viewport{width:100%;height:100%;cursor:grab;-webkit-user-select:none;user-select:none;overflow:hidden}.viewport:active{cursor:grabbing}.canvas{position:absolute;top:0;left:0;transform-origin:0 0;will-change:transform}.section-label{position:absolute;font-size:13px;font-weight:700;letter-spacing:1.2px;text-transform:uppercase;opacity:.3;pointer-events:none;white-space:nowrap;display:flex;align-items:baseline;gap:8px}.section-count{font-size:11px;font-weight:500;opacity:.6}.zoom-indicator{position:absolute;bottom:8px;right:8px;font-size:11px;color:#555;background:#0a0a0fcc;padding:3px 8px;border-radius:4px;pointer-events:none;z-index:50}.viewport-controls{position:absolute;right:12px;top:12px;display:flex;flex-direction:column;gap:8px;z-index:60}.viewport-control-pad{display:flex;flex-direction:column;gap:6px}.viewport-control-pad__row{display:flex;gap:6px}.viewport-control{min-width:64px;height:34px;border:1px solid rgba(90,86,78,.24);border-radius:8px;background:#faf8f3eb;color:#3a342d;font:600 12px/1 -apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;cursor:pointer;box-shadow:0 8px 20px #2d28201f;transition:transform .12s ease,background .12s ease,border-color .12s ease}.viewport-control:hover{background:#fffcf5fa;border-color:#7a623066;transform:translateY(-1px)}.viewport-control:active{transform:translateY(0)}.viewport-control-fit{min-width:70px}.reading-panel-wrapper{flex:1;display:flex;overflow:hidden;background:#faf9f6;animation:rpFadeIn .3s ease-out}@keyframes rpFadeIn{0%{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}.reading-panel{flex:1;display:flex;flex-direction:column;max-width:800px;margin:0 auto;width:100%}.rp-header{flex-shrink:0;padding:24px 32px 16px;border-bottom:1px solid #e0ddd8;display:flex;flex-wrap:wrap;align-items:center;gap:8px 16px}.rp-back{flex-shrink:0;width:32px;height:32px;border:none;border-radius:6px;background:#0000000f;color:#666;font-size:16px;cursor:pointer;display:flex;align-items:center;justify-content:center;transition:background .15s,color .15s;margin-right:8px}.rp-back:hover{background:#0000001f;color:#333}.rp-title{font:700 24px/1.2 Georgia,"Noto Serif",serif;color:#2a2520;flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.rp-path{font:400 11px/1 -apple-system,sans-serif;color:#a09888;word-break:break-all;width:100%;padding-left:40px}.rp-body{flex:1;overflow-y:auto;padding:32px 32px 64px;font:400 16px/1.8 Georgia,"Noto Serif",serif;color:#3a3530;-webkit-font-smoothing:antialiased}.rp-h1{font-size:28px;font-weight:800;margin:36px 0 14px;color:#1a1510;line-height:1.3}.rp-h2{font-size:22px;font-weight:700;margin:32px 0 12px;color:#2a2520;line-height:1.3;border-bottom:1px solid #e8e4df;padding-bottom:8px}.rp-h3{font-size:18px;font-weight:700;margin:24px 0 8px;color:#3a3530;line-height:1.3}.rp-h4{font-size:16px;font-weight:700;margin:20px 0 6px;color:#4a4540;line-height:1.4}.rp-h5{font-size:15px;font-weight:700;margin:16px 0 4px;color:#5a5550}.rp-h6{font-size:13px;font-weight:600;margin:14px 0 4px;color:#6a6560;text-transform:uppercase;letter-spacing:.5px}.rp-p{margin:0 0 4px}.rp-spacer{height:14px}.rp-list{margin:4px 0 8px 24px;padding:0}.rp-list li{margin:4px 0}.rp-quote{margin:14px 0;padding:10px 20px;border-left:3px solid #c8b898;background:#c8b89814;color:#6a6050;font-style:italic}.rp-code-block{margin:14px 0;padding:16px 18px;background:#f0ede8;border-radius:6px;overflow-x:auto;font:400 13.5px/1.55 SF Mono,Fira Code,Consolas,monospace;color:#4a4540}.rp-inline-code{background:#ede9e3;padding:2px 6px;border-radius:3px;font:400 13.5px/1 SF Mono,Fira Code,Consolas,monospace;color:#6a5540}.rp-body a{color:#7c6a4f;text-decoration:underline;text-decoration-color:#7c6a4f4d;transition:text-decoration-color .15s}.rp-body a:hover{text-decoration-color:#7c6a4fcc}.rp-hr{border:none;border-top:1px solid #ddd8d0;margin:28px 0}.rp-body strong{font-weight:700;color:#2a2520}.rp-body em{font-style:italic}.rp-body del{text-decoration:line-through;opacity:.5}.rp-warning{max-width:640px;margin:0 auto 24px;border:1px solid #d7c49a;border-radius:10px;background:#fff9eb;padding:24px 26px;box-shadow:0 10px 30px #82641e14}.rp-warning__title{font:700 22px/1.25 Georgia,"Noto Serif",serif;color:#7a5a1f;margin-bottom:10px}.rp-warning__copy{margin:0 0 14px}.rp-warning__privacy{font:600 13px/1.4 -apple-system,sans-serif;color:#8a6421;margin-bottom:12px}.rp-warning__row{display:flex;align-items:center;justify-content:space-between;gap:16px;padding:8px 0;border-top:1px solid rgba(138,100,33,.14);font:600 13px/1.4 -apple-system,sans-serif}.rp-body::-webkit-scrollbar{width:6px}.rp-body::-webkit-scrollbar-track{background:transparent}.rp-body::-webkit-scrollbar-thumb{background:#d0ccc4;border-radius:3px}.rp-body::-webkit-scrollbar-thumb:hover{background:#b0aaa0}:root{--canvas-bg: #1a1a22;--chrome-bg: rgba(10, 10, 15, .92);--chrome-border: #2a2a36;--accent: #a78bfa;--setup-width: 340px}*,*:before,*:after{box-sizing:border-box;margin:0;padding:0}body{background:var(--canvas-bg);color:#e0e0f0;font-family:-apple-system,BlinkMacSystemFont,Segoe UI,sans-serif;height:100vh;overflow:hidden}#root{display:flex;flex-direction:column;height:100vh}.setup-sidebar{position:fixed;top:44px;left:0;bottom:32px;width:22px;z-index:120;transition:width .22s ease}.setup-sidebar:hover{width:var(--setup-width)}.setup-sidebar__rail{position:absolute;inset:0 auto 0 0;width:22px;display:flex;align-items:center;justify-content:center;writing-mode:vertical-rl;transform:rotate(180deg);background:#16161feb;border-right:1px solid var(--chrome-border);color:#9389c9;font-size:10px;letter-spacing:.18em;text-transform:uppercase}.setup-sidebar__panel{position:absolute;inset:0 auto 0 22px;width:calc(var(--setup-width) - 22px);padding:18px 16px 16px;background:linear-gradient(180deg,#101019fa,#0b0b12fa),radial-gradient(circle at top,rgba(167,139,250,.18),transparent 42%);border-right:1px solid var(--chrome-border);overflow-y:auto;transform:translate(calc(-100% - 22px));transition:transform .22s ease}.setup-sidebar:hover .setup-sidebar__panel{transform:translate(0)}.setup-sidebar__header{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:14px}.setup-sidebar__header h2{font-size:16px;color:#f3efff;margin-bottom:4px}.setup-sidebar__header p{color:#aaa2c8;font-size:12px;line-height:1.45}.setup-pill{border:1px solid #50476a;border-radius:999px;color:#d0c7f4;font-size:10px;padding:5px 9px;white-space:nowrap}.setup-pill--ok{border-color:#2d6f4b;color:#9ff0be}.setup-card{background:#ffffff0a;border:1px solid rgba(255,255,255,.08);border-radius:12px;padding:12px;margin-bottom:12px;box-shadow:0 10px 30px #0000002e}.setup-card__title{color:#f1eaff;font-size:12px;font-weight:700;margin-bottom:8px;text-transform:uppercase;letter-spacing:.08em}.setup-copy{color:#c4bedc;font-size:12px;line-height:1.55}.setup-copy code{color:#fff;font-size:11px;word-break:break-all}.setup-steps{margin-left:16px;margin-bottom:8px;color:#d7d0ee;font-size:12px;line-height:1.6}.setup-field{display:flex;flex-direction:column;gap:5px;margin-bottom:10px}.setup-field span{color:#efe9ff;font-size:11px;font-weight:600}.setup-field input{background:#09090fbf;border:1px solid #3a3450;border-radius:10px;color:#f4f0ff;padding:10px 12px;font-size:12px;outline:none}.setup-field input:focus{border-color:var(--accent)}.setup-field small{color:#9087af;font-size:10px}.setup-save-btn{width:100%;border:1px solid #3d2f69;border-radius:10px;background:linear-gradient(135deg,#34255f,#5b43a0);color:#fff;font-weight:700;font-size:12px;padding:10px 12px;cursor:pointer}.setup-save-btn:disabled{opacity:.65;cursor:default}.setup-msg{margin-top:10px;font-size:11px;line-height:1.4}.setup-msg--ok{color:#8df0b2}.setup-msg--error{color:#ff9898}.hdr{height:44px;background:var(--chrome-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-bottom:1px solid var(--chrome-border);display:flex;align-items:center;padding:0 16px;gap:12px;z-index:100;flex-shrink:0}.hdr-icon{font-size:11px;letter-spacing:.1em;text-transform:uppercase;color:#8d84b8}.hdr-title{font-size:14px;font-weight:600;color:var(--accent);letter-spacing:.3px}.hdr-title-system{color:#9ca3af}.hdr-back{font-size:12px;color:#888;cursor:pointer;padding:3px 8px;border-radius:4px;transition:background .15s,color .15s}.hdr-back:hover{background:#ffffff14;color:var(--accent)}.hdr-search{background:#16161f;border:1px solid var(--chrome-border);border-radius:6px;color:#8f8fb0;font-size:12px;padding:5px 10px;width:180px;outline:none}.hdr-spacer{flex:1}.hdr-meta{font-size:11px;color:#555}.hdr-meta b{color:#777;font-weight:500}.hdr-conn{font-size:11px}.conn-ok{color:#4ade80}.conn-off{color:#f59e0b}.ftr{height:32px;background:var(--chrome-bg);-webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);border-top:1px solid var(--chrome-border);display:flex;align-items:center;padding:0 16px;gap:16px;font-size:11px;color:#555;z-index:100;flex-shrink:0}.ftr b{color:#777;font-weight:500}.ftr-spacer{flex:1}.sync-btn{background:#2d1b69;color:var(--accent);border:1px solid #3b2a7a;border-radius:4px;padding:3px 12px;font-size:11px;font-weight:600;cursor:pointer}.sync-btn:hover{background:#3b2a7a}.sync-btn:disabled{opacity:.4;cursor:default}.ftr-system{color:#666;font-size:10px;cursor:pointer;padding:2px 8px;border-radius:4px;background:#ffffff0a;border:1px solid #2a2a36}.ftr-system:hover{background:#ffffff14;color:#888}.ftr-select-toggle{background:none;border:1px solid #333;border-radius:4px;color:#888;font-size:11px;padding:2px 8px;cursor:pointer}.ftr-select-toggle:hover{background:#ffffff0f;color:#bbb}.explore-btn{background:linear-gradient(135deg,#6d28d9,#a855f7);color:#fff;border:none;border-radius:4px;padding:3px 12px;font-size:11px;font-weight:600;cursor:pointer;text-decoration:none}.explore-btn:hover{opacity:.88;transform:translateY(-1px)}.explore-btn[aria-disabled=true]{opacity:.5;cursor:default;transform:none}.sync-result{color:#4ade80}.sync-error{color:#f87171}.sync-progress-dock{height:56px;background:#0d0d13f2;border-top:1px solid var(--chrome-border);border-bottom:1px solid var(--chrome-border);padding:10px 16px 8px;display:flex;flex-direction:column;gap:6px;z-index:100;flex-shrink:0}.sync-progress-top{display:flex;align-items:center;gap:12px;font-size:11px;color:#b8b8c8;flex-wrap:wrap}.sync-progress-title{color:#f0eff8;font-weight:700}.sync-progress-meta{color:#9a98ad}.sync-progress-track{width:100%;height:8px;border-radius:999px;background:#ffffff14;overflow:hidden}.sync-progress-fill{height:100%;border-radius:999px;background:linear-gradient(90deg,#4d6cff,#68dfc4);transition:width .18s ease}.sync-progress-detail{color:#f3b2b2;font-size:11px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}.empty-state{flex:1;display:flex;align-items:center;justify-content:center;color:#666;font-size:14px}.selection-copy{color:#a78bfa}@media(max-width:900px){.setup-sidebar{width:18px}.setup-sidebar:hover{width:min(92vw,var(--setup-width))}.hdr-search{width:120px}.hdr-meta{display:none}}
|