@echomem/mcp 1.3.1 → 1.3.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/dist/setup.js CHANGED
@@ -15,6 +15,7 @@
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";
@@ -22,8 +23,8 @@ import readline from "node:readline";
22
23
  import axios from "axios";
23
24
  import { KeyStore } from "./keystore.js";
24
25
  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";
26
+ import { collect, runReport, buildStatsPayload } from "./report.js";
27
+ import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
27
28
  import { syncCodexUsage } from "./codex-sync.js";
28
29
  import { renderSetupPage } from "./setup-page.js";
29
30
  // The hosted connect-device page is now only the account-auth/token courier. The dashboard itself is
@@ -149,6 +150,50 @@ function openBrowser(url) {
149
150
  /* headless — caller prints the URL */
150
151
  }
151
152
  }
153
+ function migratableFromDiscovery(disc) {
154
+ const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
155
+ return {
156
+ pending: disc.pending.length,
157
+ pendingTotal: disc.pendingTotal,
158
+ alreadyMigrated: disc.alreadyMigrated,
159
+ skippedActive: disc.skippedActive,
160
+ limited: disc.limited,
161
+ eta,
162
+ buckets: eta.buckets,
163
+ totalChars: eta.totalChars,
164
+ approxInputTokens: eta.approxInputTokens,
165
+ estimatedSeconds: eta.estimatedSeconds,
166
+ estimatedLabel: eta.estimatedLabel,
167
+ accountChecked: disc.accountChecked === true,
168
+ accountCheckFailed: disc.accountCheckFailed === true,
169
+ accountCheckUnavailable: disc.accountCheckUnavailable === true,
170
+ };
171
+ }
172
+ function sessionsFromDiscovery(disc) {
173
+ return {
174
+ total: disc.sessions.length,
175
+ codex: disc.codexCount,
176
+ claudeCode: disc.claudeCount,
177
+ };
178
+ }
179
+ function migratableFromFastSummary(summary) {
180
+ return {
181
+ pending: summary.pending,
182
+ pendingTotal: summary.pendingTotal,
183
+ alreadyMigrated: summary.alreadyMigrated,
184
+ skippedActive: summary.skippedActive,
185
+ eta: summary.eta,
186
+ buckets: summary.eta.buckets,
187
+ totalChars: summary.eta.totalChars,
188
+ approxInputTokens: summary.eta.approxInputTokens,
189
+ estimatedSeconds: summary.eta.estimatedSeconds,
190
+ estimatedLabel: summary.eta.estimatedLabel,
191
+ quickEstimate: true,
192
+ accountChecked: summary.accountChecked === true,
193
+ accountCheckFailed: summary.accountCheckFailed === true,
194
+ accountCheckUnavailable: summary.accountCheckUnavailable === true,
195
+ };
196
+ }
152
197
  function deferred() {
153
198
  let done = false;
154
199
  let resolveInner;
@@ -214,6 +259,100 @@ function withTimeout(promise, ms, code, onTimeout) {
214
259
  clearTimeout(timer);
215
260
  });
216
261
  }
262
+ function delay(ms) {
263
+ return new Promise((resolve) => setTimeout(resolve, ms));
264
+ }
265
+ function discoverMigratableSessionsOffThread() {
266
+ const migrateUrl = new URL("./migrate.js", import.meta.url).href;
267
+ const code = `
268
+ import { parentPort } from "node:worker_threads";
269
+ import { discoverMigratableSessions } from ${JSON.stringify(migrateUrl)};
270
+
271
+ try {
272
+ parentPort?.postMessage({ ok: true, discovery: discoverMigratableSessions() });
273
+ } catch (error) {
274
+ parentPort?.postMessage({
275
+ ok: false,
276
+ message: error instanceof Error ? error.message : String(error),
277
+ stack: error instanceof Error ? error.stack : undefined,
278
+ });
279
+ }
280
+ `;
281
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
282
+ return new Promise((resolve, reject) => {
283
+ let settled = false;
284
+ worker.once("message", (message) => {
285
+ settled = true;
286
+ const msg = message;
287
+ if (msg.ok === true && msg.discovery && typeof msg.discovery === "object") {
288
+ resolve(msg.discovery);
289
+ return;
290
+ }
291
+ const err = new Error(typeof msg.message === "string" ? msg.message : "Exact local discovery failed");
292
+ if (typeof msg.stack === "string")
293
+ err.stack = msg.stack;
294
+ reject(err);
295
+ });
296
+ worker.once("error", (error) => {
297
+ if (settled)
298
+ return;
299
+ settled = true;
300
+ reject(error);
301
+ });
302
+ worker.once("exit", (code) => {
303
+ // Reject on ANY unsettled exit (incl. code 0): a worker that exits without posting a result must
304
+ // not leave this promise pending forever (that was the "stuck at finishing local job sizing" hang).
305
+ if (settled)
306
+ return;
307
+ settled = true;
308
+ reject(new Error(`Exact local discovery worker exited (code ${code}) without a result`));
309
+ });
310
+ });
311
+ }
312
+ /** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
313
+ * blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
314
+ function buildForensicReportOffThread(onProgress) {
315
+ const forensicsUrl = new URL("./forensics.js", import.meta.url).href;
316
+ const code = `
317
+ import { parentPort } from "node:worker_threads";
318
+ import { buildForensicReport } from ${JSON.stringify(forensicsUrl)};
319
+ try {
320
+ const report = buildForensicReport({ onProgress: (done, total) => parentPort?.postMessage({ progress: { done, total } }) });
321
+ parentPort?.postMessage({ ok: true, report });
322
+ } catch (error) {
323
+ parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
324
+ }
325
+ `;
326
+ const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
327
+ return new Promise((resolve, reject) => {
328
+ let settled = false;
329
+ worker.on("message", (message) => {
330
+ const msg = message;
331
+ if (msg.progress) {
332
+ onProgress?.(msg.progress.done, msg.progress.total);
333
+ return;
334
+ }
335
+ settled = true;
336
+ if (msg.ok === true && msg.report && typeof msg.report === "object")
337
+ resolve(msg.report);
338
+ else
339
+ reject(new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed"));
340
+ void worker.terminate();
341
+ });
342
+ worker.once("error", (error) => {
343
+ if (settled)
344
+ return;
345
+ settled = true;
346
+ reject(error);
347
+ });
348
+ worker.once("exit", (code) => {
349
+ if (settled)
350
+ return;
351
+ settled = true;
352
+ reject(new Error(`Forensic report worker exited (code ${code}) without a result`));
353
+ });
354
+ });
355
+ }
217
356
  export function respondMigrate(res, body, status = 200) {
218
357
  if (res.writableEnded)
219
358
  return;
@@ -226,6 +365,7 @@ export function respondMigrate(res, body, status = 200) {
226
365
  */
227
366
  export function startCallbackServer(opts = {}) {
228
367
  const timeoutMs = opts.timeoutMs ?? 300_000;
368
+ const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
229
369
  const expectedNonce = opts.nonce;
230
370
  return new Promise((resolveOuter, rejectOuter) => {
231
371
  const onToken = deferred();
@@ -233,9 +373,12 @@ export function startCallbackServer(opts = {}) {
233
373
  const migrateRequest = deferred();
234
374
  let stats = null;
235
375
  let authUrl = "";
376
+ let switchAccountUrl = "";
236
377
  let connected = false;
237
378
  let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
238
379
  let migrateStarted = false;
380
+ let tokenRefreshHandler = null;
381
+ let logoutHandler = null;
239
382
  let timer;
240
383
  let closed = false;
241
384
  let server;
@@ -254,6 +397,7 @@ export function startCallbackServer(opts = {}) {
254
397
  const armTimeout = () => {
255
398
  if (timer)
256
399
  clearTimeout(timer);
400
+ const waitMs = onToken.settled() ? dashboardTimeoutMs : timeoutMs;
257
401
  timer = setTimeout(() => {
258
402
  if (!onToken.settled()) {
259
403
  onToken.reject(new Error("timed out waiting for browser approval"));
@@ -262,16 +406,27 @@ export function startCallbackServer(opts = {}) {
262
406
  decision.resolve("timeout");
263
407
  }
264
408
  close();
265
- }, timeoutMs);
409
+ }, waitMs);
410
+ timer.unref?.();
266
411
  };
267
412
  const handleCallback = (res, token, key, nonce) => {
268
413
  if (!checkNonce(nonce))
269
414
  return void text(res, 403, "bad nonce");
270
415
  if (!token)
271
416
  return void text(res, 400, "missing token");
417
+ const firstToken = !onToken.settled();
272
418
  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 });
419
+ const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
420
+ 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>`);
421
+ const callbackToken = { token, key };
422
+ if (firstToken) {
423
+ onToken.resolve(callbackToken);
424
+ }
425
+ else if (tokenRefreshHandler) {
426
+ Promise.resolve(tokenRefreshHandler(callbackToken)).catch((e) => {
427
+ console.error(`Could not refresh local login: ${e instanceof Error ? e.message : String(e)}`);
428
+ });
429
+ }
275
430
  armTimeout();
276
431
  };
277
432
  server = http.createServer((req, res) => {
@@ -295,7 +450,7 @@ export function startCallbackServer(opts = {}) {
295
450
  if (route === "/config" && req.method === "GET") {
296
451
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
297
452
  return void text(res, 403, "bad nonce");
298
- json(res, 200, { connected, authUrl, localOnly: true });
453
+ json(res, 200, { connected, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
299
454
  return;
300
455
  }
301
456
  if (route === "/callback" && req.method === "GET") {
@@ -323,12 +478,58 @@ export function startCallbackServer(opts = {}) {
323
478
  json(res, 200, payload);
324
479
  return;
325
480
  }
481
+ if (route === "/report" && req.method === "GET") {
482
+ // Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
483
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
484
+ return void text(res, 403, "bad nonce");
485
+ const payload = opts.getReport ? opts.getReport() : null;
486
+ if (payload == null) {
487
+ // 202 carries scan progress so the page can show a live "scanned N/total" indicator.
488
+ const prog = opts.getReportProgress ? opts.getReportProgress() : { scanned: 0, total: 0 };
489
+ return void res.writeHead(202, { "Content-Type": "application/json" }).end(JSON.stringify(prog));
490
+ }
491
+ json(res, 200, payload);
492
+ return;
493
+ }
326
494
  if (route === "/progress" && req.method === "GET") {
327
495
  if (!checkNonce(url.searchParams.get("nonce") || undefined))
328
496
  return void text(res, 403, "bad nonce");
329
497
  json(res, 200, progress);
330
498
  return;
331
499
  }
500
+ if (route === "/logout" && req.method === "POST") {
501
+ let body;
502
+ try {
503
+ body = await readJsonBody(req);
504
+ }
505
+ catch {
506
+ text(res, 400, "bad json");
507
+ return;
508
+ }
509
+ if (!checkNonce(asString(body.nonce)))
510
+ return void text(res, 403, "bad nonce");
511
+ if (migrateStarted && (progress.status === "starting" || progress.status === "running")) {
512
+ json(res, 409, { error: "MIGRATION_RUNNING", message: "Wait for extraction to finish before signing out locally." });
513
+ return;
514
+ }
515
+ try {
516
+ fs.rmSync(new KeyStore().path(), { force: true });
517
+ }
518
+ catch {
519
+ /* already logged out locally */
520
+ }
521
+ connected = false;
522
+ stats = null;
523
+ migrateStarted = false;
524
+ progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
525
+ json(res, 200, { ok: true, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
526
+ Promise.resolve()
527
+ .then(() => logoutHandler?.())
528
+ .catch((e) => {
529
+ console.error(`Could not reset local login state: ${e instanceof Error ? e.message : String(e)}`);
530
+ });
531
+ return;
532
+ }
332
533
  if (route === "/migrate" && req.method === "POST") {
333
534
  let body;
334
535
  try {
@@ -385,8 +586,15 @@ export function startCallbackServer(opts = {}) {
385
586
  wait: onToken.promise,
386
587
  decision: decision.promise,
387
588
  migrateRequest: migrateRequest.promise,
388
- setAuthUrl: (url) => {
589
+ setTokenRefreshHandler: (handler) => {
590
+ tokenRefreshHandler = handler;
591
+ },
592
+ setLogoutHandler: (handler) => {
593
+ logoutHandler = handler;
594
+ },
595
+ setAuthUrl: (url, nextSwitchAccountUrl) => {
389
596
  authUrl = url;
597
+ switchAccountUrl = nextSwitchAccountUrl || url;
390
598
  },
391
599
  setStats: (s) => {
392
600
  stats = s;
@@ -408,6 +616,42 @@ function authedAxios(token) {
408
616
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
409
617
  });
410
618
  }
619
+ function formatVerificationError(error) {
620
+ if (axios.isAxiosError(error)) {
621
+ const status = typeof error.response?.status === "number" ? error.response.status : null;
622
+ const statusText = error.response?.statusText ? ` ${error.response.statusText}` : "";
623
+ const requestPath = typeof error.config?.url === "string" ? error.config.url : "";
624
+ const endpoint = requestPath
625
+ ? requestPath.startsWith("http")
626
+ ? requestPath
627
+ : `${API_BASE_URL}${requestPath}`
628
+ : API_BASE_URL;
629
+ const statusLabel = status ? `HTTP ${status}${statusText}` : error.code || error.message;
630
+ const localApi = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?/.test(API_BASE_URL);
631
+ const routeHint = status === 404 && requestPath.includes("/api/extension/account/encryption")
632
+ ? localApi
633
+ ? "That usually means ECHO_API_BASE_URL is pointing at a different local app or an older EchoMem API server."
634
+ : "That usually means the EchoMem API route is missing on the configured server."
635
+ : null;
636
+ const localHint = localApi ? "Start the EchoMem-Chrome Next API on that port, or unset ECHO_API_BASE_URL to use production." : null;
637
+ return [
638
+ `Could not verify this device token against ${endpoint}: ${statusLabel}.`,
639
+ routeHint,
640
+ localHint,
641
+ ].filter(Boolean).join(" ");
642
+ }
643
+ return `Could not verify this device token: ${error instanceof Error ? error.message : String(error)}`;
644
+ }
645
+ async function verifyAndPrint(input) {
646
+ try {
647
+ console.log(await verifyAndStore(input));
648
+ return true;
649
+ }
650
+ catch (error) {
651
+ console.error(`❌ ${formatVerificationError(error)}`);
652
+ return false;
653
+ }
654
+ }
411
655
  /**
412
656
  * Verify supplied secrets and persist them. Given a token (required), and EITHER a base64 key or a
413
657
  * passphrase (optional — only for encrypted accounts), this verifies the key against the server's
@@ -415,8 +659,8 @@ function authedAxios(token) {
415
659
  */
416
660
  export async function verifyAndStore(input) {
417
661
  const store = new KeyStore();
418
- store.saveToken(input.token);
419
662
  const config = await fetchEncryptionConfig(authedAxios(input.token));
663
+ store.saveToken(input.token);
420
664
  if (!config.enabled) {
421
665
  return input.key || input.passphrase
422
666
  ? "Token saved. (Account is not encrypted — the supplied key was ignored.)"
@@ -505,23 +749,17 @@ async function cmdSetup(flags) {
505
749
  }
506
750
  console.log("");
507
751
  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
752
  }
516
753
  async function cmdLogin(flags) {
517
754
  // Manual path (also the headless path): secrets supplied as flags.
518
755
  if (typeof flags.token === "string") {
519
- const msg = await verifyAndStore({
756
+ const ok = await verifyAndPrint({
520
757
  token: flags.token,
521
758
  key: typeof flags.key === "string" ? flags.key : undefined,
522
759
  passphrase: typeof flags.passphrase === "string" ? flags.passphrase : undefined,
523
760
  });
524
- console.log(msg);
761
+ if (!ok)
762
+ process.exitCode = 1;
525
763
  return;
526
764
  }
527
765
  // Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
@@ -529,13 +767,36 @@ async function cmdLogin(flags) {
529
767
  console.log("Opening your browser to approve this device…");
530
768
  const nonce = randomUUID();
531
769
  let stats = null;
532
- const srv = await startCallbackServer({ nonce, getStats: () => stats });
770
+ let forensicReport = null;
771
+ let forensicProgress = { scanned: 0, total: 0 };
772
+ const srv = await startCallbackServer({
773
+ nonce,
774
+ getStats: () => stats,
775
+ getReport: () => forensicReport,
776
+ getReportProgress: () => forensicProgress,
777
+ });
533
778
  const callbackUrl = `http://127.0.0.1:${srv.port}/callback`;
534
779
  const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
535
780
  const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}&return_to=${encodeURIComponent(localSetupUrl)}`;
536
- srv.setAuthUrl(connectUrl);
781
+ const switchAccountUrl = new URL(connectUrl);
782
+ // The hosted connect-device page should clear its own Supabase/browser session before minting
783
+ // the localhost token when this hint is present. Localhost cannot safely clear yeahecho.com auth.
784
+ switchAccountUrl.searchParams.set("force_signout", "1");
785
+ switchAccountUrl.searchParams.set("prompt", "login");
786
+ srv.setAuthUrl(connectUrl, switchAccountUrl.toString());
537
787
  openBrowser(localSetupUrl);
538
788
  console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
789
+ // Scan-first: build the local forensic "Context Doctor" report off-thread so the page shows it
790
+ // BEFORE the user connects an account (the scan is local-only; nothing leaves the machine).
791
+ buildForensicReportOffThread((done, total) => {
792
+ forensicProgress = { scanned: done, total };
793
+ })
794
+ .then((r) => {
795
+ forensicReport = r;
796
+ })
797
+ .catch((e) => {
798
+ console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
799
+ });
539
800
  let token;
540
801
  let key;
541
802
  try {
@@ -547,61 +808,331 @@ async function cmdLogin(flags) {
547
808
  process.exitCode = 1;
548
809
  return;
549
810
  }
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,
811
+ if (!await verifyAndPrint({ token, key })) {
812
+ srv.close();
813
+ process.exitCode = 1;
814
+ return;
815
+ }
816
+ let disc = null;
817
+ let exactDiscovery = Promise.resolve(null);
818
+ let refreshGeneration = 0;
819
+ let latestPendingEstimate = 0;
820
+ const resetLocalLoginState = () => {
821
+ refreshGeneration++;
822
+ stats = null;
823
+ disc = null;
824
+ exactDiscovery = Promise.resolve(null);
825
+ latestPendingEstimate = 0;
826
+ srv.setStats(null);
827
+ srv.setProgress({
828
+ status: "idle",
829
+ total: 0,
830
+ completed: 0,
831
+ running: 0,
832
+ queued: 0,
833
+ failed: 0,
834
+ extracted: 0,
835
+ });
568
836
  };
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(), {
837
+ const refreshLocalStatsForToken = async (activeToken) => {
838
+ const generation = ++refreshGeneration;
839
+ let lastProcessedImportKeys = null;
840
+ let importStatusUnavailable = false;
841
+ const quickDiscovery = discoverMigratableFastDiscovery();
842
+ const quick = summarizeFastMigratableDiscovery(quickDiscovery);
843
+ let migratable = migratableFromFastSummary(quick);
844
+ latestPendingEstimate = migratable.pending;
845
+ let sessionSummary = {
846
+ total: quick.sessions,
847
+ codex: quick.codexCount,
848
+ claudeCode: quick.claudeCount,
849
+ };
850
+ stats = await buildStatsPayload([], {
851
+ partial: true,
852
+ skipMemoryCount: true,
578
853
  sessions: sessionSummary,
579
854
  migratable,
580
- }).then((payload) => {
581
- stats = payload;
582
- srv.setStats(payload);
583
- }).catch(() => {
584
- /* best effort — the fast local counts are already available */
855
+ discovery: { phase: "quick", exact: false },
856
+ });
857
+ if (generation !== refreshGeneration)
858
+ return;
859
+ srv.setStats(stats);
860
+ srv.setProgress({
861
+ status: "idle",
862
+ total: quick.pending,
863
+ completed: 0,
864
+ running: 0,
865
+ queued: quick.pending,
866
+ failed: 0,
867
+ extracted: 0,
868
+ });
869
+ const fastAccountController = new AbortController();
870
+ const fastAccountCheck = withTimeout(fetchProcessedImportKeys(activeToken, quickDiscovery.sessions, fastAccountController.signal), 8_000, "ACCOUNT_STATUS_TIMEOUT", () => fastAccountController.abort()).then(async (processedKeys) => {
871
+ if (generation !== refreshGeneration)
872
+ return;
873
+ lastProcessedImportKeys = processedKeys;
874
+ const cloudQuick = applyFastAccountImportStatus(quickDiscovery, processedKeys);
875
+ const currentPhase = stats?.discovery?.phase;
876
+ if (currentPhase !== "quick")
877
+ return;
878
+ const cloudSummary = summarizeFastMigratableDiscovery(cloudQuick);
879
+ migratable = migratableFromFastSummary(cloudSummary);
880
+ latestPendingEstimate = migratable.pending;
881
+ sessionSummary = {
882
+ total: cloudSummary.sessions,
883
+ codex: cloudSummary.codexCount,
884
+ claudeCode: cloudSummary.claudeCount,
885
+ };
886
+ const cloudPayload = await buildStatsPayload([], {
887
+ partial: true,
888
+ skipMemoryCount: true,
889
+ sessions: sessionSummary,
890
+ migratable,
891
+ discovery: { phase: "account", exact: false },
892
+ });
893
+ if (generation !== refreshGeneration)
894
+ return;
895
+ stats = cloudPayload;
896
+ srv.setStats(cloudPayload);
897
+ srv.setProgress({
898
+ status: "idle",
899
+ total: cloudSummary.pending,
900
+ completed: 0,
901
+ running: 0,
902
+ queued: cloudSummary.pending,
903
+ failed: 0,
904
+ extracted: 0,
905
+ });
906
+ }).catch(async (e) => {
907
+ if (generation !== refreshGeneration)
908
+ return;
909
+ if (isImportStatusUnsupported(e)) {
910
+ importStatusUnavailable = true;
911
+ const unavailableQuick = markFastAccountImportStatusUnavailable(quickDiscovery);
912
+ const currentPhase = stats?.discovery?.phase;
913
+ if (currentPhase !== "quick")
914
+ return;
915
+ const unavailableSummary = summarizeFastMigratableDiscovery(unavailableQuick);
916
+ migratable = migratableFromFastSummary(unavailableSummary);
917
+ latestPendingEstimate = migratable.pending;
918
+ sessionSummary = {
919
+ total: unavailableSummary.sessions,
920
+ codex: unavailableSummary.codexCount,
921
+ claudeCode: unavailableSummary.claudeCount,
922
+ };
923
+ const unavailablePayload = await buildStatsPayload([], {
924
+ partial: true,
925
+ skipMemoryCount: true,
926
+ sessions: sessionSummary,
927
+ migratable,
928
+ discovery: { phase: "account", exact: false },
929
+ });
930
+ if (generation !== refreshGeneration)
931
+ return;
932
+ stats = unavailablePayload;
933
+ srv.setStats(unavailablePayload);
934
+ srv.setProgress({
935
+ status: "idle",
936
+ total: unavailableSummary.pending,
937
+ completed: 0,
938
+ running: 0,
939
+ queued: unavailableSummary.pending,
940
+ failed: 0,
941
+ extracted: 0,
942
+ });
943
+ return;
944
+ }
945
+ if (generation === refreshGeneration) {
946
+ console.error(`Could not check this EchoMem account's import status quickly: ${e instanceof Error ? e.message : String(e)}`);
947
+ }
585
948
  });
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,
949
+ exactDiscovery = new Promise((resolve, reject) => {
950
+ const timer = setTimeout(() => {
951
+ void (async () => {
952
+ try {
953
+ // Start the local sizing pass without waiting on cloud/account status. The
954
+ // account check is useful for tighter counts, but extraction can safely start
955
+ // from local candidates because the import path skips true duplicates.
956
+ await delay(250);
957
+ resolve(await discoverMigratableSessionsOffThread());
958
+ }
959
+ catch (e) {
960
+ reject(e instanceof Error ? e : new Error(String(e)));
961
+ }
962
+ })();
963
+ }, 250);
964
+ timer.unref?.();
965
+ }).then(async (exact) => {
966
+ if (generation !== refreshGeneration)
967
+ return disc;
968
+ const initialExact = lastProcessedImportKeys
969
+ ? applyAccountImportStatus(exact, lastProcessedImportKeys)
970
+ : importStatusUnavailable
971
+ ? markAccountImportStatusUnavailable(exact)
972
+ : exact;
973
+ disc = initialExact;
974
+ migratable = migratableFromDiscovery(initialExact);
975
+ latestPendingEstimate = migratable.pending;
976
+ sessionSummary = sessionsFromDiscovery(initialExact);
977
+ const partialPayload = await buildStatsPayload([], {
978
+ partial: true,
979
+ skipMemoryCount: true,
980
+ sessions: sessionSummary,
981
+ migratable,
982
+ discovery: { phase: "exact", exact: true },
983
+ });
984
+ if (generation !== refreshGeneration)
985
+ return disc;
986
+ stats = partialPayload;
987
+ srv.setStats(partialPayload);
988
+ srv.setProgress({
989
+ status: "idle",
990
+ total: initialExact.pending.length,
991
+ completed: 0,
992
+ running: 0,
993
+ queued: initialExact.pending.length,
994
+ failed: 0,
995
+ extracted: 0,
996
+ });
997
+ void (async () => {
998
+ let reconciled = initialExact;
999
+ try {
1000
+ await fastAccountCheck;
1001
+ const controller = new AbortController();
1002
+ const processedKeys = await withTimeout(fetchProcessedImportKeys(activeToken, exact.sessions, controller.signal), 15_000, "ACCOUNT_STATUS_TIMEOUT", () => controller.abort());
1003
+ lastProcessedImportKeys = processedKeys;
1004
+ reconciled = applyAccountImportStatus(exact, processedKeys);
1005
+ }
1006
+ catch (e) {
1007
+ if (lastProcessedImportKeys) {
1008
+ reconciled = applyAccountImportStatus(exact, lastProcessedImportKeys);
1009
+ if (!isImportStatusUnsupported(e)) {
1010
+ 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)}`);
1011
+ }
1012
+ }
1013
+ else if (isImportStatusUnsupported(e)) {
1014
+ reconciled = markAccountImportStatusUnavailable(exact);
1015
+ }
1016
+ else {
1017
+ reconciled = markAccountImportStatusFailed(exact);
1018
+ console.error(`Could not check this EchoMem account's import status: ${e instanceof Error ? e.message : String(e)}`);
1019
+ }
1020
+ }
1021
+ if (generation !== refreshGeneration)
1022
+ return;
1023
+ disc = reconciled;
1024
+ migratable = migratableFromDiscovery(reconciled);
1025
+ latestPendingEstimate = migratable.pending;
1026
+ sessionSummary = sessionsFromDiscovery(reconciled);
1027
+ const reconciledPayload = await buildStatsPayload([], {
1028
+ partial: true,
1029
+ skipMemoryCount: true,
1030
+ sessions: sessionSummary,
1031
+ migratable,
1032
+ discovery: { phase: "exact", exact: true },
1033
+ });
1034
+ if (generation !== refreshGeneration)
1035
+ return;
1036
+ stats = reconciledPayload;
1037
+ srv.setStats(reconciledPayload);
1038
+ srv.setProgress({
1039
+ status: "idle",
1040
+ total: reconciled.pending.length,
1041
+ completed: 0,
1042
+ running: 0,
1043
+ queued: reconciled.pending.length,
1044
+ failed: 0,
1045
+ extracted: 0,
1046
+ });
1047
+ const fullPayload = await buildStatsPayload(collect(), {
1048
+ sessions: sessionSummary,
1049
+ migratable,
1050
+ discovery: { phase: "full", exact: true },
1051
+ });
1052
+ if (generation !== refreshGeneration)
1053
+ return;
1054
+ stats = fullPayload;
1055
+ srv.setStats(fullPayload);
1056
+ })();
1057
+ return initialExact;
1058
+ }).catch((e) => {
1059
+ if (generation === refreshGeneration) {
1060
+ console.error(`Could not finish exact local extraction estimate: ${e instanceof Error ? e.message : String(e)}`);
1061
+ }
1062
+ return disc;
1063
+ });
1064
+ };
1065
+ srv.setLogoutHandler(resetLocalLoginState);
1066
+ srv.setTokenRefreshHandler(async ({ token: nextToken, key: nextKey }) => {
1067
+ if (!await verifyAndPrint({ token: nextToken, key: nextKey }))
1068
+ return;
1069
+ await refreshLocalStatsForToken(nextToken);
596
1070
  });
1071
+ await refreshLocalStatsForToken(token);
597
1072
  const choice = await srv.decision;
598
1073
  if (choice === "migrate") {
599
1074
  const { res } = await srv.migrateRequest;
1075
+ let migrateResponded = false;
1076
+ const sendMigrate = (body, status = 200) => {
1077
+ if (migrateResponded)
1078
+ return;
1079
+ migrateResponded = true;
1080
+ respondMigrate(res, body, status);
1081
+ };
600
1082
  let activeSessionId = "";
601
- let activeJobCount = disc.pending.length;
1083
+ let activeJobCount = latestPendingEstimate;
602
1084
  let progressDone = 0;
603
1085
  let progressFailed = 0;
604
1086
  let progressExtracted = 0;
1087
+ if (!disc) {
1088
+ srv.setProgress({
1089
+ status: "starting",
1090
+ total: activeJobCount,
1091
+ completed: 0,
1092
+ running: 0,
1093
+ queued: activeJobCount,
1094
+ failed: 0,
1095
+ extracted: 0,
1096
+ latest: "Finishing local job sizing before import starts.",
1097
+ });
1098
+ sendMigrate({
1099
+ status: "preparing",
1100
+ jobCount: activeJobCount,
1101
+ message: "Finishing local job sizing before import starts.",
1102
+ });
1103
+ }
1104
+ let exact = disc;
1105
+ if (!exact) {
1106
+ // Off-thread sizing normally resolves in ~20s; cap the wait and fall back to an in-process pass so
1107
+ // a stalled/contended worker can never leave extraction stuck at "finishing local job sizing".
1108
+ exact = await withTimeout(exactDiscovery, 40_000, "SIZING_TIMEOUT").catch(() => null);
1109
+ if (!exact) {
1110
+ srv.setProgress({ status: "starting", total: activeJobCount, completed: 0, running: 0, queued: activeJobCount, failed: 0, extracted: 0, latest: "Sizing your sessions…" });
1111
+ try {
1112
+ exact = discoverMigratableSessions();
1113
+ }
1114
+ catch (e) {
1115
+ console.error(`Direct local sizing failed: ${e instanceof Error ? e.message : String(e)}`);
1116
+ }
1117
+ }
1118
+ }
1119
+ if (!exact) {
1120
+ srv.setProgress({
1121
+ status: "failed",
1122
+ total: activeJobCount,
1123
+ completed: progressDone,
1124
+ running: 0,
1125
+ queued: Math.max(0, activeJobCount - progressDone - progressFailed),
1126
+ failed: progressFailed || 1,
1127
+ extracted: progressExtracted,
1128
+ error: "Local session discovery did not finish.",
1129
+ });
1130
+ sendMigrate({ error: "IMPORT_START_FAILED", message: "Local session discovery did not finish." }, 500);
1131
+ srv.close();
1132
+ process.exitCode = 1;
1133
+ return;
1134
+ }
1135
+ activeJobCount = exact.pending.length;
605
1136
  const updateProgress = (patch) => {
606
1137
  srv.setProgress({
607
1138
  status: "running",
@@ -609,15 +1140,15 @@ async function cmdLogin(flags) {
609
1140
  jobCount: activeJobCount,
610
1141
  total: activeJobCount,
611
1142
  completed: progressDone,
612
- running: progressDone + progressFailed < activeJobCount ? 1 : 0,
613
- queued: Math.max(0, activeJobCount - progressDone - progressFailed - 1),
1143
+ running: Math.min(MIGRATE_CONCURRENCY, Math.max(0, activeJobCount - progressDone - progressFailed)),
1144
+ queued: Math.max(0, activeJobCount - progressDone - progressFailed - MIGRATE_CONCURRENCY),
614
1145
  failed: progressFailed,
615
1146
  extracted: progressExtracted,
616
1147
  ...patch,
617
1148
  });
618
1149
  };
619
1150
  try {
620
- if (disc.pending.length === 0) {
1151
+ if (exact.pending.length === 0) {
621
1152
  srv.setProgress({
622
1153
  status: "completed",
623
1154
  total: 0,
@@ -628,15 +1159,15 @@ async function cmdLogin(flags) {
628
1159
  extracted: 0,
629
1160
  latest: "No unprocessed local conversations found.",
630
1161
  });
631
- respondMigrate(res, { error: "NO_PENDING_SESSIONS" }, 409);
1162
+ sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
632
1163
  srv.close();
633
1164
  console.log("Setup complete — no unprocessed local conversations to extract.");
634
1165
  return;
635
1166
  }
636
- updateProgress({ status: "starting", running: 0, queued: disc.pending.length, latest: "Creating import session." });
1167
+ updateProgress({ status: "starting", running: 0, queued: exact.pending.length, latest: "Creating import session." });
637
1168
  const controller = new AbortController();
638
1169
  const h = await withTimeout(startMigration({
639
- pending: disc.pending,
1170
+ pending: exact.pending,
640
1171
  signal: controller.signal,
641
1172
  onProgress: (ev) => {
642
1173
  if (ev.error) {
@@ -654,7 +1185,7 @@ async function cmdLogin(flags) {
654
1185
  activeSessionId = h.sessionId;
655
1186
  activeJobCount = h.jobCount;
656
1187
  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 } : {}) });
1188
+ sendMigrate({ sessionId: h.sessionId, jobCount: h.jobCount, ...(h.capped ? { capped: h.capped } : {}) });
658
1189
  console.log("Migrating your history… keep this terminal open until it completes.");
659
1190
  console.log(`Migration metrics: ${h.metricsFile}`);
660
1191
  const r = await h.done;
@@ -692,17 +1223,17 @@ async function cmdLogin(flags) {
692
1223
  error: String(e?.message || e),
693
1224
  });
694
1225
  if (e?.code === "NOT_LOGGED_IN")
695
- respondMigrate(res, { error: "NOT_LOGGED_IN" }, 401);
1226
+ sendMigrate({ error: "NOT_LOGGED_IN" }, 401);
696
1227
  else if (e?.code === "FORBIDDEN_SCOPE")
697
- respondMigrate(res, { error: "FORBIDDEN_SCOPE" }, 403);
1228
+ sendMigrate({ error: "FORBIDDEN_SCOPE" }, 403);
698
1229
  else if (e?.code === "VAULT_LOCKED")
699
- respondMigrate(res, { error: "VAULT_LOCKED" }, 409);
1230
+ sendMigrate({ error: "VAULT_LOCKED" }, 409);
700
1231
  else if (e?.code === "NO_PENDING_SESSIONS")
701
- respondMigrate(res, { error: "NO_PENDING_SESSIONS" }, 409);
1232
+ sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
702
1233
  else if (e?.code === "IMPORT_START_TIMEOUT")
703
- respondMigrate(res, { error: "IMPORT_START_TIMEOUT" }, 504);
1234
+ sendMigrate({ error: "IMPORT_START_TIMEOUT" }, 504);
704
1235
  else
705
- respondMigrate(res, { error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
1236
+ sendMigrate({ error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
706
1237
  await new Promise((resolve) => setTimeout(resolve, 2000));
707
1238
  srv.close();
708
1239
  process.exitCode = 1;