@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/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, buildReportText, buildStatsPayload } from "./report.js";
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
- }, timeoutMs);
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
- res.writeHead(200, { "Content-Type": "text/html" }).end(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1"></head><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>EchoMem connected</h2><p>Returning to the local dashboard...</p><p><a href="/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1">Continue</a></p></body></html>`);
274
- onToken.resolve({ token, key });
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
- setAuthUrl: (url) => {
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 msg = await verifyAndStore({
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
- console.log(msg);
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
- const srv = await startCallbackServer({ nonce, getStats: () => stats });
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
- srv.setAuthUrl(connectUrl);
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
- console.log(await verifyAndStore({ token, key }));
551
- const disc = discoverMigratableSessions();
552
- const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
553
- const migratable = {
554
- pending: disc.pending.length,
555
- alreadyMigrated: disc.alreadyMigrated,
556
- skippedActive: disc.skippedActive,
557
- eta,
558
- buckets: eta.buckets,
559
- totalChars: eta.totalChars,
560
- approxInputTokens: eta.approxInputTokens,
561
- estimatedSeconds: eta.estimatedSeconds,
562
- estimatedLabel: eta.estimatedLabel,
563
- };
564
- const sessionSummary = {
565
- total: disc.sessions.length,
566
- codex: disc.codexCount,
567
- claudeCode: disc.claudeCount,
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
- stats = await buildStatsPayload([], {
570
- partial: true,
571
- skipMemoryCount: true,
572
- sessions: sessionSummary,
573
- migratable,
574
- });
575
- srv.setStats(stats);
576
- const fullStatsTimer = setTimeout(() => {
577
- void buildStatsPayload(collect(), {
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
- }).then((payload) => {
581
- stats = payload;
582
- srv.setStats(payload);
583
- }).catch(() => {
584
- /* best effort — the fast local counts are already available */
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
- }, 1500);
587
- fullStatsTimer.unref?.();
588
- srv.setProgress({
589
- status: "idle",
590
- total: disc.pending.length,
591
- completed: 0,
592
- running: 0,
593
- queued: disc.pending.length,
594
- failed: 0,
595
- extracted: 0,
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 = disc.pending.length;
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: progressDone + progressFailed < activeJobCount ? 1 : 0,
613
- queued: Math.max(0, activeJobCount - progressDone - progressFailed - 1),
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 (disc.pending.length === 0) {
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
- respondMigrate(res, { error: "NO_PENDING_SESSIONS" }, 409);
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: disc.pending.length, latest: "Creating import session." });
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: disc.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
- respondMigrate(res, { sessionId: h.sessionId, jobCount: h.jobCount, ...(h.capped ? { capped: h.capped } : {}) });
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
- respondMigrate(res, { error: "NOT_LOGGED_IN" }, 401);
1286
+ sendMigrate({ error: "NOT_LOGGED_IN" }, 401);
696
1287
  else if (e?.code === "FORBIDDEN_SCOPE")
697
- respondMigrate(res, { error: "FORBIDDEN_SCOPE" }, 403);
1288
+ sendMigrate({ error: "FORBIDDEN_SCOPE" }, 403);
698
1289
  else if (e?.code === "VAULT_LOCKED")
699
- respondMigrate(res, { error: "VAULT_LOCKED" }, 409);
1290
+ sendMigrate({ error: "VAULT_LOCKED" }, 409);
700
1291
  else if (e?.code === "NO_PENDING_SESSIONS")
701
- respondMigrate(res, { error: "NO_PENDING_SESSIONS" }, 409);
1292
+ sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
702
1293
  else if (e?.code === "IMPORT_START_TIMEOUT")
703
- respondMigrate(res, { error: "IMPORT_START_TIMEOUT" }, 504);
1294
+ sendMigrate({ error: "IMPORT_START_TIMEOUT" }, 504);
704
1295
  else
705
- respondMigrate(res, { error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
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;