@echomem/mcp 1.3.1 → 1.4.0
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/dist/city/_live.html +37 -0
- package/dist/city/_serve.mjs +45 -0
- package/dist/city/card-data.json +15 -0
- package/dist/city/city-data.json +248 -0
- package/dist/city/echo-ai-city-only.html +1254 -0
- package/dist/city/echo-ai-city-only.template.html +1254 -0
- package/dist/city/echo-extraction-plate.html +330 -0
- package/dist/city/generate-echo-city-only.mjs +112 -0
- package/dist/city/vendor/OrbitControls.js +1417 -0
- package/dist/city/vendor/RoundedBoxGeometry.js +155 -0
- package/dist/city/vendor/echo_general-file-21.riv +0 -0
- package/dist/city/vendor/rive.js +8139 -0
- package/dist/city/vendor/rive.wasm +0 -0
- package/dist/city/vendor/three.module.min.js +6 -0
- package/dist/forensics.js +753 -0
- package/dist/index.js +61 -4
- package/dist/migrate.js +459 -99
- package/dist/report.js +1 -0
- package/dist/setup-page.js +1356 -308
- package/dist/setup.js +666 -75
- package/package.json +2 -2
package/dist/setup.js
CHANGED
|
@@ -15,17 +15,20 @@
|
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
|
+
import { Worker } from "node:worker_threads";
|
|
18
19
|
import fs from "node:fs";
|
|
19
20
|
import os from "node:os";
|
|
20
21
|
import path from "node:path";
|
|
21
22
|
import readline from "node:readline";
|
|
23
|
+
import { fileURLToPath } from "node:url";
|
|
22
24
|
import axios from "axios";
|
|
23
25
|
import { KeyStore } from "./keystore.js";
|
|
24
26
|
import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
|
|
25
|
-
import { collect, runReport,
|
|
26
|
-
import { cmdMigrate, discoverMigratableSessions, estimateMigrationEta, startMigration } from "./migrate.js";
|
|
27
|
+
import { collect, runReport, buildStatsPayload } from "./report.js";
|
|
28
|
+
import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, discoverPendingSessionsTargeted, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
|
|
27
29
|
import { syncCodexUsage } from "./codex-sync.js";
|
|
28
30
|
import { renderSetupPage } from "./setup-page.js";
|
|
31
|
+
import { repoLabel } from "./forensics.js";
|
|
29
32
|
// The hosted connect-device page is now only the account-auth/token courier. The dashboard itself is
|
|
30
33
|
// served by this localhost bridge, where local logs and processed/unprocessed counts never leave the
|
|
31
34
|
// device unless the user explicitly starts migration. Override the hosted auth origin with ECHO_WEB_URL.
|
|
@@ -149,6 +152,50 @@ function openBrowser(url) {
|
|
|
149
152
|
/* headless — caller prints the URL */
|
|
150
153
|
}
|
|
151
154
|
}
|
|
155
|
+
function migratableFromDiscovery(disc) {
|
|
156
|
+
const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
|
|
157
|
+
return {
|
|
158
|
+
pending: disc.pending.length,
|
|
159
|
+
pendingTotal: disc.pendingTotal,
|
|
160
|
+
alreadyMigrated: disc.alreadyMigrated,
|
|
161
|
+
skippedActive: disc.skippedActive,
|
|
162
|
+
limited: disc.limited,
|
|
163
|
+
eta,
|
|
164
|
+
buckets: eta.buckets,
|
|
165
|
+
totalChars: eta.totalChars,
|
|
166
|
+
approxInputTokens: eta.approxInputTokens,
|
|
167
|
+
estimatedSeconds: eta.estimatedSeconds,
|
|
168
|
+
estimatedLabel: eta.estimatedLabel,
|
|
169
|
+
accountChecked: disc.accountChecked === true,
|
|
170
|
+
accountCheckFailed: disc.accountCheckFailed === true,
|
|
171
|
+
accountCheckUnavailable: disc.accountCheckUnavailable === true,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function sessionsFromDiscovery(disc) {
|
|
175
|
+
return {
|
|
176
|
+
total: disc.sessions.length,
|
|
177
|
+
codex: disc.codexCount,
|
|
178
|
+
claudeCode: disc.claudeCount,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
function migratableFromFastSummary(summary) {
|
|
182
|
+
return {
|
|
183
|
+
pending: summary.pending,
|
|
184
|
+
pendingTotal: summary.pendingTotal,
|
|
185
|
+
alreadyMigrated: summary.alreadyMigrated,
|
|
186
|
+
skippedActive: summary.skippedActive,
|
|
187
|
+
eta: summary.eta,
|
|
188
|
+
buckets: summary.eta.buckets,
|
|
189
|
+
totalChars: summary.eta.totalChars,
|
|
190
|
+
approxInputTokens: summary.eta.approxInputTokens,
|
|
191
|
+
estimatedSeconds: summary.eta.estimatedSeconds,
|
|
192
|
+
estimatedLabel: summary.eta.estimatedLabel,
|
|
193
|
+
quickEstimate: true,
|
|
194
|
+
accountChecked: summary.accountChecked === true,
|
|
195
|
+
accountCheckFailed: summary.accountCheckFailed === true,
|
|
196
|
+
accountCheckUnavailable: summary.accountCheckUnavailable === true,
|
|
197
|
+
};
|
|
198
|
+
}
|
|
152
199
|
function deferred() {
|
|
153
200
|
let done = false;
|
|
154
201
|
let resolveInner;
|
|
@@ -214,6 +261,137 @@ function withTimeout(promise, ms, code, onTimeout) {
|
|
|
214
261
|
clearTimeout(timer);
|
|
215
262
|
});
|
|
216
263
|
}
|
|
264
|
+
function delay(ms) {
|
|
265
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
266
|
+
}
|
|
267
|
+
const CITY_ASSET_TYPES = {
|
|
268
|
+
".html": "text/html; charset=utf-8",
|
|
269
|
+
".js": "text/javascript; charset=utf-8",
|
|
270
|
+
".json": "application/json; charset=utf-8",
|
|
271
|
+
".wasm": "application/wasm",
|
|
272
|
+
".riv": "application/octet-stream",
|
|
273
|
+
".png": "image/png",
|
|
274
|
+
".svg": "image/svg+xml",
|
|
275
|
+
};
|
|
276
|
+
function repoCityArtifactsRoot() {
|
|
277
|
+
// Monorepo/dev: read the city assets live from repo-root /artifacts.
|
|
278
|
+
const repo = fileURLToPath(new URL("../../../artifacts/", import.meta.url));
|
|
279
|
+
if (fs.existsSync(repo))
|
|
280
|
+
return repo;
|
|
281
|
+
// Published install: fall back to the copy bundled into dist/city by prepack (bundle-city.mjs).
|
|
282
|
+
return fileURLToPath(new URL("./city/", import.meta.url));
|
|
283
|
+
}
|
|
284
|
+
function serveRepoCityAsset(reqPath, res) {
|
|
285
|
+
const root = repoCityArtifactsRoot();
|
|
286
|
+
const rel = reqPath === "/city" || reqPath === "/city/" ? "echo-ai-city-only.html" : decodeURIComponent(reqPath.slice("/city/".length));
|
|
287
|
+
const filePath = path.resolve(root, rel);
|
|
288
|
+
const rootWithSep = root.endsWith(path.sep) ? root : root + path.sep;
|
|
289
|
+
if (!filePath.startsWith(rootWithSep)) {
|
|
290
|
+
res.writeHead(403).end("forbidden");
|
|
291
|
+
return true;
|
|
292
|
+
}
|
|
293
|
+
if (!fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
|
|
294
|
+
res.writeHead(404).end("not found");
|
|
295
|
+
return true;
|
|
296
|
+
}
|
|
297
|
+
res.writeHead(200, {
|
|
298
|
+
"Content-Type": CITY_ASSET_TYPES[path.extname(filePath)] || "application/octet-stream",
|
|
299
|
+
"Cache-Control": "no-store",
|
|
300
|
+
});
|
|
301
|
+
fs.createReadStream(filePath).pipe(res);
|
|
302
|
+
return true;
|
|
303
|
+
}
|
|
304
|
+
function discoverMigratableSessionsOffThread() {
|
|
305
|
+
const migrateUrl = new URL("./migrate.js", import.meta.url).href;
|
|
306
|
+
const code = `
|
|
307
|
+
import { parentPort } from "node:worker_threads";
|
|
308
|
+
import { discoverMigratableSessions } from ${JSON.stringify(migrateUrl)};
|
|
309
|
+
|
|
310
|
+
try {
|
|
311
|
+
parentPort?.postMessage({ ok: true, discovery: discoverMigratableSessions() });
|
|
312
|
+
} catch (error) {
|
|
313
|
+
parentPort?.postMessage({
|
|
314
|
+
ok: false,
|
|
315
|
+
message: error instanceof Error ? error.message : String(error),
|
|
316
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
317
|
+
});
|
|
318
|
+
}
|
|
319
|
+
`;
|
|
320
|
+
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
|
|
321
|
+
return new Promise((resolve, reject) => {
|
|
322
|
+
let settled = false;
|
|
323
|
+
worker.once("message", (message) => {
|
|
324
|
+
settled = true;
|
|
325
|
+
const msg = message;
|
|
326
|
+
if (msg.ok === true && msg.discovery && typeof msg.discovery === "object") {
|
|
327
|
+
resolve(msg.discovery);
|
|
328
|
+
return;
|
|
329
|
+
}
|
|
330
|
+
const err = new Error(typeof msg.message === "string" ? msg.message : "Exact local discovery failed");
|
|
331
|
+
if (typeof msg.stack === "string")
|
|
332
|
+
err.stack = msg.stack;
|
|
333
|
+
reject(err);
|
|
334
|
+
});
|
|
335
|
+
worker.once("error", (error) => {
|
|
336
|
+
if (settled)
|
|
337
|
+
return;
|
|
338
|
+
settled = true;
|
|
339
|
+
reject(error);
|
|
340
|
+
});
|
|
341
|
+
worker.once("exit", (code) => {
|
|
342
|
+
// Reject on ANY unsettled exit (incl. code 0): a worker that exits without posting a result must
|
|
343
|
+
// not leave this promise pending forever (that was the "stuck at finishing local job sizing" hang).
|
|
344
|
+
if (settled)
|
|
345
|
+
return;
|
|
346
|
+
settled = true;
|
|
347
|
+
reject(new Error(`Exact local discovery worker exited (code ${code}) without a result`));
|
|
348
|
+
});
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
/** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
|
|
352
|
+
* blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
|
|
353
|
+
function buildForensicReportOffThread(onProgress) {
|
|
354
|
+
const forensicsUrl = new URL("./forensics.js", import.meta.url).href;
|
|
355
|
+
const code = `
|
|
356
|
+
import { parentPort } from "node:worker_threads";
|
|
357
|
+
import { buildForensicReport } from ${JSON.stringify(forensicsUrl)};
|
|
358
|
+
try {
|
|
359
|
+
const report = buildForensicReport({ onProgress: (done, total) => parentPort?.postMessage({ progress: { done, total } }) });
|
|
360
|
+
parentPort?.postMessage({ ok: true, report });
|
|
361
|
+
} catch (error) {
|
|
362
|
+
parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
|
|
363
|
+
}
|
|
364
|
+
`;
|
|
365
|
+
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
|
|
366
|
+
return new Promise((resolve, reject) => {
|
|
367
|
+
let settled = false;
|
|
368
|
+
worker.on("message", (message) => {
|
|
369
|
+
const msg = message;
|
|
370
|
+
if (msg.progress) {
|
|
371
|
+
onProgress?.(msg.progress.done, msg.progress.total);
|
|
372
|
+
return;
|
|
373
|
+
}
|
|
374
|
+
settled = true;
|
|
375
|
+
if (msg.ok === true && msg.report && typeof msg.report === "object")
|
|
376
|
+
resolve(msg.report);
|
|
377
|
+
else
|
|
378
|
+
reject(new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed"));
|
|
379
|
+
void worker.terminate();
|
|
380
|
+
});
|
|
381
|
+
worker.once("error", (error) => {
|
|
382
|
+
if (settled)
|
|
383
|
+
return;
|
|
384
|
+
settled = true;
|
|
385
|
+
reject(error);
|
|
386
|
+
});
|
|
387
|
+
worker.once("exit", (code) => {
|
|
388
|
+
if (settled)
|
|
389
|
+
return;
|
|
390
|
+
settled = true;
|
|
391
|
+
reject(new Error(`Forensic report worker exited (code ${code}) without a result`));
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
}
|
|
217
395
|
export function respondMigrate(res, body, status = 200) {
|
|
218
396
|
if (res.writableEnded)
|
|
219
397
|
return;
|
|
@@ -226,6 +404,7 @@ export function respondMigrate(res, body, status = 200) {
|
|
|
226
404
|
*/
|
|
227
405
|
export function startCallbackServer(opts = {}) {
|
|
228
406
|
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
407
|
+
const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
|
|
229
408
|
const expectedNonce = opts.nonce;
|
|
230
409
|
return new Promise((resolveOuter, rejectOuter) => {
|
|
231
410
|
const onToken = deferred();
|
|
@@ -233,9 +412,12 @@ export function startCallbackServer(opts = {}) {
|
|
|
233
412
|
const migrateRequest = deferred();
|
|
234
413
|
let stats = null;
|
|
235
414
|
let authUrl = "";
|
|
415
|
+
let switchAccountUrl = "";
|
|
236
416
|
let connected = false;
|
|
237
417
|
let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
238
418
|
let migrateStarted = false;
|
|
419
|
+
let tokenRefreshHandler = null;
|
|
420
|
+
let logoutHandler = null;
|
|
239
421
|
let timer;
|
|
240
422
|
let closed = false;
|
|
241
423
|
let server;
|
|
@@ -254,6 +436,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
254
436
|
const armTimeout = () => {
|
|
255
437
|
if (timer)
|
|
256
438
|
clearTimeout(timer);
|
|
439
|
+
const waitMs = onToken.settled() ? dashboardTimeoutMs : timeoutMs;
|
|
257
440
|
timer = setTimeout(() => {
|
|
258
441
|
if (!onToken.settled()) {
|
|
259
442
|
onToken.reject(new Error("timed out waiting for browser approval"));
|
|
@@ -262,16 +445,27 @@ export function startCallbackServer(opts = {}) {
|
|
|
262
445
|
decision.resolve("timeout");
|
|
263
446
|
}
|
|
264
447
|
close();
|
|
265
|
-
},
|
|
448
|
+
}, waitMs);
|
|
449
|
+
timer.unref?.();
|
|
266
450
|
};
|
|
267
451
|
const handleCallback = (res, token, key, nonce) => {
|
|
268
452
|
if (!checkNonce(nonce))
|
|
269
453
|
return void text(res, 403, "bad nonce");
|
|
270
454
|
if (!token)
|
|
271
455
|
return void text(res, 400, "missing token");
|
|
456
|
+
const firstToken = !onToken.settled();
|
|
272
457
|
connected = true;
|
|
273
|
-
|
|
274
|
-
|
|
458
|
+
const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
|
|
459
|
+
res.writeHead(200, { "Content-Type": "text/html" }).end(`<!doctype html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>EchoMem connected</h2><p>Returning to the local setup page...</p><p><a href="${setupPath}">Continue</a></p><script>(function(){var target=${JSON.stringify(setupPath)};try{if(window.opener&&!window.opener.closed){window.opener.postMessage({type:"echomem:connected",nonce:${JSON.stringify(nonce || "")}},window.location.origin);window.close();setTimeout(function(){window.location.href=target;},500);return;}}catch(_){}window.location.href=target;})();</script></body></html>`);
|
|
460
|
+
const callbackToken = { token, key };
|
|
461
|
+
if (firstToken) {
|
|
462
|
+
onToken.resolve(callbackToken);
|
|
463
|
+
}
|
|
464
|
+
else if (tokenRefreshHandler) {
|
|
465
|
+
Promise.resolve(tokenRefreshHandler(callbackToken)).catch((e) => {
|
|
466
|
+
console.error(`Could not refresh local login: ${e instanceof Error ? e.message : String(e)}`);
|
|
467
|
+
});
|
|
468
|
+
}
|
|
275
469
|
armTimeout();
|
|
276
470
|
};
|
|
277
471
|
server = http.createServer((req, res) => {
|
|
@@ -285,6 +479,10 @@ export function startCallbackServer(opts = {}) {
|
|
|
285
479
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
286
480
|
const route = url.pathname;
|
|
287
481
|
const run = async () => {
|
|
482
|
+
if ((route === "/city" || route.startsWith("/city/")) && req.method === "GET") {
|
|
483
|
+
serveRepoCityAsset(route, res);
|
|
484
|
+
return;
|
|
485
|
+
}
|
|
288
486
|
if (route === "/setup" && req.method === "GET") {
|
|
289
487
|
res.writeHead(200, {
|
|
290
488
|
"Content-Type": "text/html; charset=utf-8",
|
|
@@ -295,7 +493,7 @@ export function startCallbackServer(opts = {}) {
|
|
|
295
493
|
if (route === "/config" && req.method === "GET") {
|
|
296
494
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
297
495
|
return void text(res, 403, "bad nonce");
|
|
298
|
-
json(res, 200, { connected, authUrl, localOnly: true });
|
|
496
|
+
json(res, 200, { connected, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
|
|
299
497
|
return;
|
|
300
498
|
}
|
|
301
499
|
if (route === "/callback" && req.method === "GET") {
|
|
@@ -323,12 +521,58 @@ export function startCallbackServer(opts = {}) {
|
|
|
323
521
|
json(res, 200, payload);
|
|
324
522
|
return;
|
|
325
523
|
}
|
|
524
|
+
if (route === "/report" && req.method === "GET") {
|
|
525
|
+
// Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
|
|
526
|
+
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
527
|
+
return void text(res, 403, "bad nonce");
|
|
528
|
+
const payload = opts.getReport ? opts.getReport() : null;
|
|
529
|
+
if (payload == null) {
|
|
530
|
+
// 202 carries scan progress so the page can show a live "scanned N/total" indicator.
|
|
531
|
+
const prog = opts.getReportProgress ? opts.getReportProgress() : { scanned: 0, total: 0 };
|
|
532
|
+
return void res.writeHead(202, { "Content-Type": "application/json" }).end(JSON.stringify(prog));
|
|
533
|
+
}
|
|
534
|
+
json(res, 200, payload);
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
326
537
|
if (route === "/progress" && req.method === "GET") {
|
|
327
538
|
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
328
539
|
return void text(res, 403, "bad nonce");
|
|
329
540
|
json(res, 200, progress);
|
|
330
541
|
return;
|
|
331
542
|
}
|
|
543
|
+
if (route === "/logout" && req.method === "POST") {
|
|
544
|
+
let body;
|
|
545
|
+
try {
|
|
546
|
+
body = await readJsonBody(req);
|
|
547
|
+
}
|
|
548
|
+
catch {
|
|
549
|
+
text(res, 400, "bad json");
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
if (!checkNonce(asString(body.nonce)))
|
|
553
|
+
return void text(res, 403, "bad nonce");
|
|
554
|
+
if (migrateStarted && (progress.status === "starting" || progress.status === "running")) {
|
|
555
|
+
json(res, 409, { error: "MIGRATION_RUNNING", message: "Wait for extraction to finish before signing out locally." });
|
|
556
|
+
return;
|
|
557
|
+
}
|
|
558
|
+
try {
|
|
559
|
+
fs.rmSync(new KeyStore().path(), { force: true });
|
|
560
|
+
}
|
|
561
|
+
catch {
|
|
562
|
+
/* already logged out locally */
|
|
563
|
+
}
|
|
564
|
+
connected = false;
|
|
565
|
+
stats = null;
|
|
566
|
+
migrateStarted = false;
|
|
567
|
+
progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
568
|
+
json(res, 200, { ok: true, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
|
|
569
|
+
Promise.resolve()
|
|
570
|
+
.then(() => logoutHandler?.())
|
|
571
|
+
.catch((e) => {
|
|
572
|
+
console.error(`Could not reset local login state: ${e instanceof Error ? e.message : String(e)}`);
|
|
573
|
+
});
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
332
576
|
if (route === "/migrate" && req.method === "POST") {
|
|
333
577
|
let body;
|
|
334
578
|
try {
|
|
@@ -385,8 +629,15 @@ export function startCallbackServer(opts = {}) {
|
|
|
385
629
|
wait: onToken.promise,
|
|
386
630
|
decision: decision.promise,
|
|
387
631
|
migrateRequest: migrateRequest.promise,
|
|
388
|
-
|
|
632
|
+
setTokenRefreshHandler: (handler) => {
|
|
633
|
+
tokenRefreshHandler = handler;
|
|
634
|
+
},
|
|
635
|
+
setLogoutHandler: (handler) => {
|
|
636
|
+
logoutHandler = handler;
|
|
637
|
+
},
|
|
638
|
+
setAuthUrl: (url, nextSwitchAccountUrl) => {
|
|
389
639
|
authUrl = url;
|
|
640
|
+
switchAccountUrl = nextSwitchAccountUrl || url;
|
|
390
641
|
},
|
|
391
642
|
setStats: (s) => {
|
|
392
643
|
stats = s;
|
|
@@ -408,6 +659,42 @@ function authedAxios(token) {
|
|
|
408
659
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
409
660
|
});
|
|
410
661
|
}
|
|
662
|
+
function formatVerificationError(error) {
|
|
663
|
+
if (axios.isAxiosError(error)) {
|
|
664
|
+
const status = typeof error.response?.status === "number" ? error.response.status : null;
|
|
665
|
+
const statusText = error.response?.statusText ? ` ${error.response.statusText}` : "";
|
|
666
|
+
const requestPath = typeof error.config?.url === "string" ? error.config.url : "";
|
|
667
|
+
const endpoint = requestPath
|
|
668
|
+
? requestPath.startsWith("http")
|
|
669
|
+
? requestPath
|
|
670
|
+
: `${API_BASE_URL}${requestPath}`
|
|
671
|
+
: API_BASE_URL;
|
|
672
|
+
const statusLabel = status ? `HTTP ${status}${statusText}` : error.code || error.message;
|
|
673
|
+
const localApi = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?/.test(API_BASE_URL);
|
|
674
|
+
const routeHint = status === 404 && requestPath.includes("/api/extension/account/encryption")
|
|
675
|
+
? localApi
|
|
676
|
+
? "That usually means ECHO_API_BASE_URL is pointing at a different local app or an older EchoMem API server."
|
|
677
|
+
: "That usually means the EchoMem API route is missing on the configured server."
|
|
678
|
+
: null;
|
|
679
|
+
const localHint = localApi ? "Start the EchoMem-Chrome Next API on that port, or unset ECHO_API_BASE_URL to use production." : null;
|
|
680
|
+
return [
|
|
681
|
+
`Could not verify this device token against ${endpoint}: ${statusLabel}.`,
|
|
682
|
+
routeHint,
|
|
683
|
+
localHint,
|
|
684
|
+
].filter(Boolean).join(" ");
|
|
685
|
+
}
|
|
686
|
+
return `Could not verify this device token: ${error instanceof Error ? error.message : String(error)}`;
|
|
687
|
+
}
|
|
688
|
+
async function verifyAndPrint(input) {
|
|
689
|
+
try {
|
|
690
|
+
console.log(await verifyAndStore(input));
|
|
691
|
+
return true;
|
|
692
|
+
}
|
|
693
|
+
catch (error) {
|
|
694
|
+
console.error(`❌ ${formatVerificationError(error)}`);
|
|
695
|
+
return false;
|
|
696
|
+
}
|
|
697
|
+
}
|
|
411
698
|
/**
|
|
412
699
|
* Verify supplied secrets and persist them. Given a token (required), and EITHER a base64 key or a
|
|
413
700
|
* passphrase (optional — only for encrypted accounts), this verifies the key against the server's
|
|
@@ -415,8 +702,8 @@ function authedAxios(token) {
|
|
|
415
702
|
*/
|
|
416
703
|
export async function verifyAndStore(input) {
|
|
417
704
|
const store = new KeyStore();
|
|
418
|
-
store.saveToken(input.token);
|
|
419
705
|
const config = await fetchEncryptionConfig(authedAxios(input.token));
|
|
706
|
+
store.saveToken(input.token);
|
|
420
707
|
if (!config.enabled) {
|
|
421
708
|
return input.key || input.passphrase
|
|
422
709
|
? "Token saved. (Account is not encrypted — the supplied key was ignored.)"
|
|
@@ -505,23 +792,17 @@ async function cmdSetup(flags) {
|
|
|
505
792
|
}
|
|
506
793
|
console.log("");
|
|
507
794
|
await cmdLogin(flags);
|
|
508
|
-
// Onboarding reveal: show the local usage audit right after connecting (proactive trigger).
|
|
509
|
-
try {
|
|
510
|
-
console.log("\n" + (await buildReportText(true)));
|
|
511
|
-
}
|
|
512
|
-
catch {
|
|
513
|
-
/* report is best-effort — never block setup */
|
|
514
|
-
}
|
|
515
795
|
}
|
|
516
796
|
async function cmdLogin(flags) {
|
|
517
797
|
// Manual path (also the headless path): secrets supplied as flags.
|
|
518
798
|
if (typeof flags.token === "string") {
|
|
519
|
-
const
|
|
799
|
+
const ok = await verifyAndPrint({
|
|
520
800
|
token: flags.token,
|
|
521
801
|
key: typeof flags.key === "string" ? flags.key : undefined,
|
|
522
802
|
passphrase: typeof flags.passphrase === "string" ? flags.passphrase : undefined,
|
|
523
803
|
});
|
|
524
|
-
|
|
804
|
+
if (!ok)
|
|
805
|
+
process.exitCode = 1;
|
|
525
806
|
return;
|
|
526
807
|
}
|
|
527
808
|
// Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
|
|
@@ -529,13 +810,36 @@ async function cmdLogin(flags) {
|
|
|
529
810
|
console.log("Opening your browser to approve this device…");
|
|
530
811
|
const nonce = randomUUID();
|
|
531
812
|
let stats = null;
|
|
532
|
-
|
|
813
|
+
let forensicReport = null;
|
|
814
|
+
let forensicProgress = { scanned: 0, total: 0 };
|
|
815
|
+
const srv = await startCallbackServer({
|
|
816
|
+
nonce,
|
|
817
|
+
getStats: () => stats,
|
|
818
|
+
getReport: () => forensicReport,
|
|
819
|
+
getReportProgress: () => forensicProgress,
|
|
820
|
+
});
|
|
533
821
|
const callbackUrl = `http://127.0.0.1:${srv.port}/callback`;
|
|
534
822
|
const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
|
|
535
823
|
const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}&return_to=${encodeURIComponent(localSetupUrl)}`;
|
|
536
|
-
|
|
824
|
+
const switchAccountUrl = new URL(connectUrl);
|
|
825
|
+
// The hosted connect-device page should clear its own Supabase/browser session before minting
|
|
826
|
+
// the localhost token when this hint is present. Localhost cannot safely clear yeahecho.com auth.
|
|
827
|
+
switchAccountUrl.searchParams.set("force_signout", "1");
|
|
828
|
+
switchAccountUrl.searchParams.set("prompt", "login");
|
|
829
|
+
srv.setAuthUrl(connectUrl, switchAccountUrl.toString());
|
|
537
830
|
openBrowser(localSetupUrl);
|
|
538
831
|
console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
|
|
832
|
+
// Scan-first: build the local forensic "Context Doctor" report off-thread so the page shows it
|
|
833
|
+
// BEFORE the user connects an account (the scan is local-only; nothing leaves the machine).
|
|
834
|
+
buildForensicReportOffThread((done, total) => {
|
|
835
|
+
forensicProgress = { scanned: done, total };
|
|
836
|
+
})
|
|
837
|
+
.then((r) => {
|
|
838
|
+
forensicReport = r;
|
|
839
|
+
})
|
|
840
|
+
.catch((e) => {
|
|
841
|
+
console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
|
|
842
|
+
});
|
|
539
843
|
let token;
|
|
540
844
|
let key;
|
|
541
845
|
try {
|
|
@@ -547,61 +851,345 @@ async function cmdLogin(flags) {
|
|
|
547
851
|
process.exitCode = 1;
|
|
548
852
|
return;
|
|
549
853
|
}
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
854
|
+
if (!await verifyAndPrint({ token, key })) {
|
|
855
|
+
srv.close();
|
|
856
|
+
process.exitCode = 1;
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
let disc = null;
|
|
860
|
+
let exactDiscovery = Promise.resolve(null);
|
|
861
|
+
let refreshGeneration = 0;
|
|
862
|
+
let latestPendingEstimate = 0;
|
|
863
|
+
// The account's already-imported keys, shared so the /migrate sizing can assemble ONLY pending sessions.
|
|
864
|
+
let lastProcessedImportKeys = null;
|
|
865
|
+
const resetLocalLoginState = () => {
|
|
866
|
+
refreshGeneration++;
|
|
867
|
+
stats = null;
|
|
868
|
+
disc = null;
|
|
869
|
+
exactDiscovery = Promise.resolve(null);
|
|
870
|
+
latestPendingEstimate = 0;
|
|
871
|
+
lastProcessedImportKeys = null;
|
|
872
|
+
srv.setStats(null);
|
|
873
|
+
srv.setProgress({
|
|
874
|
+
status: "idle",
|
|
875
|
+
total: 0,
|
|
876
|
+
completed: 0,
|
|
877
|
+
running: 0,
|
|
878
|
+
queued: 0,
|
|
879
|
+
failed: 0,
|
|
880
|
+
extracted: 0,
|
|
881
|
+
});
|
|
568
882
|
};
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
883
|
+
const refreshLocalStatsForToken = async (activeToken) => {
|
|
884
|
+
const generation = ++refreshGeneration;
|
|
885
|
+
lastProcessedImportKeys = null; // shared with /migrate so it can assemble only the pending sessions
|
|
886
|
+
let importStatusUnavailable = false;
|
|
887
|
+
const quickDiscovery = discoverMigratableFastDiscovery();
|
|
888
|
+
const quick = summarizeFastMigratableDiscovery(quickDiscovery);
|
|
889
|
+
let migratable = migratableFromFastSummary(quick);
|
|
890
|
+
latestPendingEstimate = migratable.pending;
|
|
891
|
+
let sessionSummary = {
|
|
892
|
+
total: quick.sessions,
|
|
893
|
+
codex: quick.codexCount,
|
|
894
|
+
claudeCode: quick.claudeCount,
|
|
895
|
+
};
|
|
896
|
+
stats = await buildStatsPayload([], {
|
|
897
|
+
partial: true,
|
|
898
|
+
skipMemoryCount: true,
|
|
578
899
|
sessions: sessionSummary,
|
|
579
900
|
migratable,
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
901
|
+
discovery: { phase: "quick", exact: false },
|
|
902
|
+
});
|
|
903
|
+
if (generation !== refreshGeneration)
|
|
904
|
+
return;
|
|
905
|
+
srv.setStats(stats);
|
|
906
|
+
srv.setProgress({
|
|
907
|
+
status: "idle",
|
|
908
|
+
total: quick.pending,
|
|
909
|
+
completed: 0,
|
|
910
|
+
running: 0,
|
|
911
|
+
queued: quick.pending,
|
|
912
|
+
failed: 0,
|
|
913
|
+
extracted: 0,
|
|
585
914
|
});
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
915
|
+
const fastAccountController = new AbortController();
|
|
916
|
+
const fastAccountCheck = withTimeout(fetchProcessedImportKeys(activeToken, quickDiscovery.sessions, fastAccountController.signal), 8_000, "ACCOUNT_STATUS_TIMEOUT", () => fastAccountController.abort()).then(async (processedKeys) => {
|
|
917
|
+
if (generation !== refreshGeneration)
|
|
918
|
+
return;
|
|
919
|
+
lastProcessedImportKeys = processedKeys;
|
|
920
|
+
const cloudQuick = applyFastAccountImportStatus(quickDiscovery, processedKeys);
|
|
921
|
+
const currentPhase = stats?.discovery?.phase;
|
|
922
|
+
if (currentPhase !== "quick")
|
|
923
|
+
return;
|
|
924
|
+
const cloudSummary = summarizeFastMigratableDiscovery(cloudQuick);
|
|
925
|
+
migratable = migratableFromFastSummary(cloudSummary);
|
|
926
|
+
latestPendingEstimate = migratable.pending;
|
|
927
|
+
sessionSummary = {
|
|
928
|
+
total: cloudSummary.sessions,
|
|
929
|
+
codex: cloudSummary.codexCount,
|
|
930
|
+
claudeCode: cloudSummary.claudeCount,
|
|
931
|
+
};
|
|
932
|
+
const cloudPayload = await buildStatsPayload([], {
|
|
933
|
+
partial: true,
|
|
934
|
+
skipMemoryCount: true,
|
|
935
|
+
sessions: sessionSummary,
|
|
936
|
+
migratable,
|
|
937
|
+
discovery: { phase: "account", exact: false },
|
|
938
|
+
});
|
|
939
|
+
if (generation !== refreshGeneration)
|
|
940
|
+
return;
|
|
941
|
+
stats = cloudPayload;
|
|
942
|
+
srv.setStats(cloudPayload);
|
|
943
|
+
srv.setProgress({
|
|
944
|
+
status: "idle",
|
|
945
|
+
total: cloudSummary.pending,
|
|
946
|
+
completed: 0,
|
|
947
|
+
running: 0,
|
|
948
|
+
queued: cloudSummary.pending,
|
|
949
|
+
failed: 0,
|
|
950
|
+
extracted: 0,
|
|
951
|
+
});
|
|
952
|
+
}).catch(async (e) => {
|
|
953
|
+
if (generation !== refreshGeneration)
|
|
954
|
+
return;
|
|
955
|
+
if (isImportStatusUnsupported(e)) {
|
|
956
|
+
importStatusUnavailable = true;
|
|
957
|
+
const unavailableQuick = markFastAccountImportStatusUnavailable(quickDiscovery);
|
|
958
|
+
const currentPhase = stats?.discovery?.phase;
|
|
959
|
+
if (currentPhase !== "quick")
|
|
960
|
+
return;
|
|
961
|
+
const unavailableSummary = summarizeFastMigratableDiscovery(unavailableQuick);
|
|
962
|
+
migratable = migratableFromFastSummary(unavailableSummary);
|
|
963
|
+
latestPendingEstimate = migratable.pending;
|
|
964
|
+
sessionSummary = {
|
|
965
|
+
total: unavailableSummary.sessions,
|
|
966
|
+
codex: unavailableSummary.codexCount,
|
|
967
|
+
claudeCode: unavailableSummary.claudeCount,
|
|
968
|
+
};
|
|
969
|
+
const unavailablePayload = await buildStatsPayload([], {
|
|
970
|
+
partial: true,
|
|
971
|
+
skipMemoryCount: true,
|
|
972
|
+
sessions: sessionSummary,
|
|
973
|
+
migratable,
|
|
974
|
+
discovery: { phase: "account", exact: false },
|
|
975
|
+
});
|
|
976
|
+
if (generation !== refreshGeneration)
|
|
977
|
+
return;
|
|
978
|
+
stats = unavailablePayload;
|
|
979
|
+
srv.setStats(unavailablePayload);
|
|
980
|
+
srv.setProgress({
|
|
981
|
+
status: "idle",
|
|
982
|
+
total: unavailableSummary.pending,
|
|
983
|
+
completed: 0,
|
|
984
|
+
running: 0,
|
|
985
|
+
queued: unavailableSummary.pending,
|
|
986
|
+
failed: 0,
|
|
987
|
+
extracted: 0,
|
|
988
|
+
});
|
|
989
|
+
return;
|
|
990
|
+
}
|
|
991
|
+
if (generation === refreshGeneration) {
|
|
992
|
+
console.error(`Could not check this EchoMem account's import status quickly: ${e instanceof Error ? e.message : String(e)}`);
|
|
993
|
+
}
|
|
994
|
+
});
|
|
995
|
+
exactDiscovery = new Promise((resolve, reject) => {
|
|
996
|
+
const timer = setTimeout(() => {
|
|
997
|
+
void (async () => {
|
|
998
|
+
try {
|
|
999
|
+
// Start the local sizing pass without waiting on cloud/account status. The
|
|
1000
|
+
// account check is useful for tighter counts, but extraction can safely start
|
|
1001
|
+
// from local candidates because the import path skips true duplicates.
|
|
1002
|
+
await delay(250);
|
|
1003
|
+
resolve(await discoverMigratableSessionsOffThread());
|
|
1004
|
+
}
|
|
1005
|
+
catch (e) {
|
|
1006
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
1007
|
+
}
|
|
1008
|
+
})();
|
|
1009
|
+
}, 250);
|
|
1010
|
+
timer.unref?.();
|
|
1011
|
+
}).then(async (exact) => {
|
|
1012
|
+
if (generation !== refreshGeneration)
|
|
1013
|
+
return disc;
|
|
1014
|
+
const initialExact = lastProcessedImportKeys
|
|
1015
|
+
? applyAccountImportStatus(exact, lastProcessedImportKeys)
|
|
1016
|
+
: importStatusUnavailable
|
|
1017
|
+
? markAccountImportStatusUnavailable(exact)
|
|
1018
|
+
: exact;
|
|
1019
|
+
disc = initialExact;
|
|
1020
|
+
migratable = migratableFromDiscovery(initialExact);
|
|
1021
|
+
latestPendingEstimate = migratable.pending;
|
|
1022
|
+
sessionSummary = sessionsFromDiscovery(initialExact);
|
|
1023
|
+
const partialPayload = await buildStatsPayload([], {
|
|
1024
|
+
partial: true,
|
|
1025
|
+
skipMemoryCount: true,
|
|
1026
|
+
sessions: sessionSummary,
|
|
1027
|
+
migratable,
|
|
1028
|
+
discovery: { phase: "exact", exact: true },
|
|
1029
|
+
});
|
|
1030
|
+
if (generation !== refreshGeneration)
|
|
1031
|
+
return disc;
|
|
1032
|
+
stats = partialPayload;
|
|
1033
|
+
srv.setStats(partialPayload);
|
|
1034
|
+
srv.setProgress({
|
|
1035
|
+
status: "idle",
|
|
1036
|
+
total: initialExact.pending.length,
|
|
1037
|
+
completed: 0,
|
|
1038
|
+
running: 0,
|
|
1039
|
+
queued: initialExact.pending.length,
|
|
1040
|
+
failed: 0,
|
|
1041
|
+
extracted: 0,
|
|
1042
|
+
});
|
|
1043
|
+
void (async () => {
|
|
1044
|
+
let reconciled = initialExact;
|
|
1045
|
+
try {
|
|
1046
|
+
await fastAccountCheck;
|
|
1047
|
+
const controller = new AbortController();
|
|
1048
|
+
const processedKeys = await withTimeout(fetchProcessedImportKeys(activeToken, exact.sessions, controller.signal), 15_000, "ACCOUNT_STATUS_TIMEOUT", () => controller.abort());
|
|
1049
|
+
lastProcessedImportKeys = processedKeys;
|
|
1050
|
+
reconciled = applyAccountImportStatus(exact, processedKeys);
|
|
1051
|
+
}
|
|
1052
|
+
catch (e) {
|
|
1053
|
+
if (lastProcessedImportKeys) {
|
|
1054
|
+
reconciled = applyAccountImportStatus(exact, lastProcessedImportKeys);
|
|
1055
|
+
if (!isImportStatusUnsupported(e)) {
|
|
1056
|
+
console.error(`Could not refresh this EchoMem account's exact import status; keeping the last successful account check. ${e instanceof Error ? e.message : String(e)}`);
|
|
1057
|
+
}
|
|
1058
|
+
}
|
|
1059
|
+
else if (isImportStatusUnsupported(e)) {
|
|
1060
|
+
reconciled = markAccountImportStatusUnavailable(exact);
|
|
1061
|
+
}
|
|
1062
|
+
else {
|
|
1063
|
+
reconciled = markAccountImportStatusFailed(exact);
|
|
1064
|
+
console.error(`Could not check this EchoMem account's import status: ${e instanceof Error ? e.message : String(e)}`);
|
|
1065
|
+
}
|
|
1066
|
+
}
|
|
1067
|
+
if (generation !== refreshGeneration)
|
|
1068
|
+
return;
|
|
1069
|
+
disc = reconciled;
|
|
1070
|
+
migratable = migratableFromDiscovery(reconciled);
|
|
1071
|
+
latestPendingEstimate = migratable.pending;
|
|
1072
|
+
sessionSummary = sessionsFromDiscovery(reconciled);
|
|
1073
|
+
const reconciledPayload = await buildStatsPayload([], {
|
|
1074
|
+
partial: true,
|
|
1075
|
+
skipMemoryCount: true,
|
|
1076
|
+
sessions: sessionSummary,
|
|
1077
|
+
migratable,
|
|
1078
|
+
discovery: { phase: "exact", exact: true },
|
|
1079
|
+
});
|
|
1080
|
+
if (generation !== refreshGeneration)
|
|
1081
|
+
return;
|
|
1082
|
+
stats = reconciledPayload;
|
|
1083
|
+
srv.setStats(reconciledPayload);
|
|
1084
|
+
srv.setProgress({
|
|
1085
|
+
status: "idle",
|
|
1086
|
+
total: reconciled.pending.length,
|
|
1087
|
+
completed: 0,
|
|
1088
|
+
running: 0,
|
|
1089
|
+
queued: reconciled.pending.length,
|
|
1090
|
+
failed: 0,
|
|
1091
|
+
extracted: 0,
|
|
1092
|
+
});
|
|
1093
|
+
const fullPayload = await buildStatsPayload(collect(), {
|
|
1094
|
+
sessions: sessionSummary,
|
|
1095
|
+
migratable,
|
|
1096
|
+
discovery: { phase: "full", exact: true },
|
|
1097
|
+
});
|
|
1098
|
+
if (generation !== refreshGeneration)
|
|
1099
|
+
return;
|
|
1100
|
+
stats = fullPayload;
|
|
1101
|
+
srv.setStats(fullPayload);
|
|
1102
|
+
})();
|
|
1103
|
+
return initialExact;
|
|
1104
|
+
}).catch((e) => {
|
|
1105
|
+
if (generation === refreshGeneration) {
|
|
1106
|
+
console.error(`Could not finish exact local extraction estimate: ${e instanceof Error ? e.message : String(e)}`);
|
|
1107
|
+
}
|
|
1108
|
+
return disc;
|
|
1109
|
+
});
|
|
1110
|
+
};
|
|
1111
|
+
srv.setLogoutHandler(resetLocalLoginState);
|
|
1112
|
+
srv.setTokenRefreshHandler(async ({ token: nextToken, key: nextKey }) => {
|
|
1113
|
+
if (!await verifyAndPrint({ token: nextToken, key: nextKey }))
|
|
1114
|
+
return;
|
|
1115
|
+
await refreshLocalStatsForToken(nextToken);
|
|
596
1116
|
});
|
|
1117
|
+
await refreshLocalStatsForToken(token);
|
|
597
1118
|
const choice = await srv.decision;
|
|
598
1119
|
if (choice === "migrate") {
|
|
599
1120
|
const { res } = await srv.migrateRequest;
|
|
1121
|
+
let migrateResponded = false;
|
|
1122
|
+
const sendMigrate = (body, status = 200) => {
|
|
1123
|
+
if (migrateResponded)
|
|
1124
|
+
return;
|
|
1125
|
+
migrateResponded = true;
|
|
1126
|
+
respondMigrate(res, body, status);
|
|
1127
|
+
};
|
|
600
1128
|
let activeSessionId = "";
|
|
601
|
-
let activeJobCount =
|
|
1129
|
+
let activeJobCount = latestPendingEstimate;
|
|
602
1130
|
let progressDone = 0;
|
|
603
1131
|
let progressFailed = 0;
|
|
604
1132
|
let progressExtracted = 0;
|
|
1133
|
+
if (!disc) {
|
|
1134
|
+
srv.setProgress({
|
|
1135
|
+
status: "starting",
|
|
1136
|
+
total: activeJobCount,
|
|
1137
|
+
completed: 0,
|
|
1138
|
+
running: 0,
|
|
1139
|
+
queued: activeJobCount,
|
|
1140
|
+
failed: 0,
|
|
1141
|
+
extracted: 0,
|
|
1142
|
+
latest: "Finishing local job sizing before import starts.",
|
|
1143
|
+
});
|
|
1144
|
+
sendMigrate({
|
|
1145
|
+
status: "preparing",
|
|
1146
|
+
jobCount: activeJobCount,
|
|
1147
|
+
message: "Finishing local job sizing before import starts.",
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
let exact = disc;
|
|
1151
|
+
if (!exact) {
|
|
1152
|
+
// The full account-reconciled scan (disc) is not ready yet. Do NOT read the whole local history to
|
|
1153
|
+
// size a few new jobs — assemble ONLY the sessions the fast scan already flagged as pending (using
|
|
1154
|
+
// the account's processed keys when we have them). This is the fast path for "start extraction".
|
|
1155
|
+
try {
|
|
1156
|
+
exact = discoverPendingSessionsTargeted(lastProcessedImportKeys);
|
|
1157
|
+
}
|
|
1158
|
+
catch (e) {
|
|
1159
|
+
console.error(`Targeted local sizing failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
if (!exact) {
|
|
1163
|
+
// Last resort only: the off-thread full scan, then an in-process full scan. Capped so a stalled
|
|
1164
|
+
// worker can never leave extraction stuck at "finishing local job sizing".
|
|
1165
|
+
exact = await withTimeout(exactDiscovery, 40_000, "SIZING_TIMEOUT").catch(() => null);
|
|
1166
|
+
if (!exact) {
|
|
1167
|
+
srv.setProgress({ status: "starting", total: activeJobCount, completed: 0, running: 0, queued: activeJobCount, failed: 0, extracted: 0, latest: "Sizing your sessions…" });
|
|
1168
|
+
try {
|
|
1169
|
+
exact = discoverMigratableSessions();
|
|
1170
|
+
}
|
|
1171
|
+
catch (e) {
|
|
1172
|
+
console.error(`Direct local sizing failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
if (!exact) {
|
|
1177
|
+
srv.setProgress({
|
|
1178
|
+
status: "failed",
|
|
1179
|
+
total: activeJobCount,
|
|
1180
|
+
completed: progressDone,
|
|
1181
|
+
running: 0,
|
|
1182
|
+
queued: Math.max(0, activeJobCount - progressDone - progressFailed),
|
|
1183
|
+
failed: progressFailed || 1,
|
|
1184
|
+
extracted: progressExtracted,
|
|
1185
|
+
error: "Local session discovery did not finish.",
|
|
1186
|
+
});
|
|
1187
|
+
sendMigrate({ error: "IMPORT_START_FAILED", message: "Local session discovery did not finish." }, 500);
|
|
1188
|
+
srv.close();
|
|
1189
|
+
process.exitCode = 1;
|
|
1190
|
+
return;
|
|
1191
|
+
}
|
|
1192
|
+
activeJobCount = exact.pending.length;
|
|
605
1193
|
const updateProgress = (patch) => {
|
|
606
1194
|
srv.setProgress({
|
|
607
1195
|
status: "running",
|
|
@@ -609,15 +1197,15 @@ async function cmdLogin(flags) {
|
|
|
609
1197
|
jobCount: activeJobCount,
|
|
610
1198
|
total: activeJobCount,
|
|
611
1199
|
completed: progressDone,
|
|
612
|
-
running:
|
|
613
|
-
queued: Math.max(0, activeJobCount - progressDone - progressFailed -
|
|
1200
|
+
running: Math.min(MIGRATE_CONCURRENCY, Math.max(0, activeJobCount - progressDone - progressFailed)),
|
|
1201
|
+
queued: Math.max(0, activeJobCount - progressDone - progressFailed - MIGRATE_CONCURRENCY),
|
|
614
1202
|
failed: progressFailed,
|
|
615
1203
|
extracted: progressExtracted,
|
|
616
1204
|
...patch,
|
|
617
1205
|
});
|
|
618
1206
|
};
|
|
619
1207
|
try {
|
|
620
|
-
if (
|
|
1208
|
+
if (exact.pending.length === 0) {
|
|
621
1209
|
srv.setProgress({
|
|
622
1210
|
status: "completed",
|
|
623
1211
|
total: 0,
|
|
@@ -628,33 +1216,36 @@ async function cmdLogin(flags) {
|
|
|
628
1216
|
extracted: 0,
|
|
629
1217
|
latest: "No unprocessed local conversations found.",
|
|
630
1218
|
});
|
|
631
|
-
|
|
1219
|
+
sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
|
|
632
1220
|
srv.close();
|
|
633
1221
|
console.log("Setup complete — no unprocessed local conversations to extract.");
|
|
634
1222
|
return;
|
|
635
1223
|
}
|
|
636
|
-
updateProgress({ status: "starting", running: 0, queued:
|
|
1224
|
+
updateProgress({ status: "starting", running: 0, queued: exact.pending.length, latest: "Creating import session." });
|
|
637
1225
|
const controller = new AbortController();
|
|
638
1226
|
const h = await withTimeout(startMigration({
|
|
639
|
-
pending:
|
|
1227
|
+
pending: exact.pending,
|
|
640
1228
|
signal: controller.signal,
|
|
641
1229
|
onProgress: (ev) => {
|
|
1230
|
+
let latestRepo;
|
|
642
1231
|
if (ev.error) {
|
|
643
1232
|
progressFailed += 1;
|
|
644
1233
|
}
|
|
645
1234
|
else {
|
|
646
1235
|
progressDone += 1;
|
|
647
1236
|
progressExtracted += ev.memories ?? 0;
|
|
1237
|
+
latestRepo = repoLabel(ev.session.cwd); // building this completed conversation belongs to
|
|
648
1238
|
}
|
|
649
1239
|
updateProgress({
|
|
650
1240
|
latest: `${ev.session.source} ${(ev.session.firstTs || "").slice(0, 10)}${ev.error ? ` failed: ${ev.error}` : ""}`,
|
|
1241
|
+
...(latestRepo ? { latestRepo } : {}),
|
|
651
1242
|
});
|
|
652
1243
|
},
|
|
653
1244
|
}), 30_000, "IMPORT_START_TIMEOUT", () => controller.abort());
|
|
654
1245
|
activeSessionId = h.sessionId;
|
|
655
1246
|
activeJobCount = h.jobCount;
|
|
656
1247
|
updateProgress({ status: "running", sessionId: h.sessionId, jobCount: h.jobCount, total: h.jobCount, capped: h.capped, latest: "Import session created." });
|
|
657
|
-
|
|
1248
|
+
sendMigrate({ sessionId: h.sessionId, jobCount: h.jobCount, ...(h.capped ? { capped: h.capped } : {}) });
|
|
658
1249
|
console.log("Migrating your history… keep this terminal open until it completes.");
|
|
659
1250
|
console.log(`Migration metrics: ${h.metricsFile}`);
|
|
660
1251
|
const r = await h.done;
|
|
@@ -692,17 +1283,17 @@ async function cmdLogin(flags) {
|
|
|
692
1283
|
error: String(e?.message || e),
|
|
693
1284
|
});
|
|
694
1285
|
if (e?.code === "NOT_LOGGED_IN")
|
|
695
|
-
|
|
1286
|
+
sendMigrate({ error: "NOT_LOGGED_IN" }, 401);
|
|
696
1287
|
else if (e?.code === "FORBIDDEN_SCOPE")
|
|
697
|
-
|
|
1288
|
+
sendMigrate({ error: "FORBIDDEN_SCOPE" }, 403);
|
|
698
1289
|
else if (e?.code === "VAULT_LOCKED")
|
|
699
|
-
|
|
1290
|
+
sendMigrate({ error: "VAULT_LOCKED" }, 409);
|
|
700
1291
|
else if (e?.code === "NO_PENDING_SESSIONS")
|
|
701
|
-
|
|
1292
|
+
sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
|
|
702
1293
|
else if (e?.code === "IMPORT_START_TIMEOUT")
|
|
703
|
-
|
|
1294
|
+
sendMigrate({ error: "IMPORT_START_TIMEOUT" }, 504);
|
|
704
1295
|
else
|
|
705
|
-
|
|
1296
|
+
sendMigrate({ error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
|
|
706
1297
|
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
707
1298
|
srv.close();
|
|
708
1299
|
process.exitCode = 1;
|