@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/migrate.js CHANGED
@@ -30,6 +30,8 @@ import { walk, eachLine } from "./report.js";
30
30
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
31
31
  const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
32
32
  const RATE_WINDOW_MS = 60_000;
33
+ export const MIGRATE_CONCURRENCY = 6; // import jobs run concurrently; request STARTS are still throttled <RATE_MAX/min
34
+ const FALLBACK_SECONDS_PER_SESSION = 14; // per-session extract time used only until local metrics exist
33
35
  const APPROX_CHARS_PER_TOKEN = 4;
34
36
  const ACTIVE_SESSION_GRACE_MS = 5 * 60_000;
35
37
  // ---------------------------------------------------------------------------
@@ -156,7 +158,7 @@ export function assembleClaude(file) {
156
158
  return {
157
159
  filePath: file,
158
160
  source: "claude-code",
159
- conversationKey: `claude:${sessionId || sha16(file)}`,
161
+ conversationKey: `claude-code:${sessionId || sha16(file)}`,
160
162
  cwd: normalizeCwd(cwd),
161
163
  firstTs,
162
164
  title: title || "Claude text turns",
@@ -200,6 +202,108 @@ export function discoverSessions() {
200
202
  out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
201
203
  return out;
202
204
  }
205
+ const IMPORT_STATUS_CHUNK_SIZE = 1000;
206
+ function initialJsonObjects(file, maxBytes = 1024 * 1024) {
207
+ let fd;
208
+ try {
209
+ fd = fs.openSync(file, "r");
210
+ }
211
+ catch {
212
+ return [];
213
+ }
214
+ try {
215
+ const buf = Buffer.allocUnsafe(maxBytes);
216
+ const bytes = fs.readSync(fd, buf, 0, maxBytes, 0);
217
+ const text = buf.subarray(0, bytes).toString("utf8");
218
+ return text.split("\n").slice(0, 1000).map((line) => {
219
+ const s = line.trim();
220
+ if (!s)
221
+ return null;
222
+ try {
223
+ return JSON.parse(s);
224
+ }
225
+ catch {
226
+ return null;
227
+ }
228
+ }).filter(Boolean);
229
+ }
230
+ finally {
231
+ try {
232
+ fs.closeSync(fd);
233
+ }
234
+ catch {
235
+ /* best effort */
236
+ }
237
+ }
238
+ }
239
+ function codexPayload(obj) {
240
+ return isRecord(obj.payload) ? obj.payload : obj;
241
+ }
242
+ function hasClaudeText(content) {
243
+ if (typeof content === "string")
244
+ return content.trim().length > 0;
245
+ if (!Array.isArray(content))
246
+ return false;
247
+ return content.some((block) => isRecord(block) && block.type === "text" && typeof block.text === "string" && block.text.trim().length > 0);
248
+ }
249
+ function fastSessionInfo(file, source) {
250
+ let conversationKey = `${source === "codex" ? "codex" : "claude-code"}:${sha16(file)}`;
251
+ let hasTextTurn = false;
252
+ let hasRealKey = false;
253
+ for (const obj of initialJsonObjects(file)) {
254
+ if (!isRecord(obj))
255
+ continue;
256
+ if (source === "codex" && obj.type === "session_meta") {
257
+ const payload = isRecord(obj.payload) ? obj.payload : {};
258
+ if (typeof payload.id === "string" && payload.id) {
259
+ conversationKey = `codex:${payload.id}`;
260
+ hasRealKey = true;
261
+ }
262
+ continue;
263
+ }
264
+ if (source === "codex") {
265
+ const p = codexPayload(obj);
266
+ if (p.type === "user_message" && typeof p.message === "string" && cleanCodexUser(p.message))
267
+ hasTextTurn = true;
268
+ if (p.type === "agent_message" && typeof p.message === "string" && p.message.trim())
269
+ hasTextTurn = true;
270
+ continue;
271
+ }
272
+ if (source === "claude-code" && typeof obj.sessionId === "string" && obj.sessionId) {
273
+ conversationKey = `claude-code:${obj.sessionId}`;
274
+ hasRealKey = true;
275
+ }
276
+ if (source === "claude-code" && (obj.type === "user" || obj.type === "assistant")) {
277
+ const message = isRecord(obj.message) ? obj.message : {};
278
+ if (hasClaudeText(message.content))
279
+ hasTextTurn = true;
280
+ }
281
+ }
282
+ return { conversationKey, hasTextTurn, hasRealKey };
283
+ }
284
+ function fastSessionEntries(opts = {}) {
285
+ const out = [];
286
+ const codexRoot = opts.codexRoot ?? path.join(os.homedir(), ".codex", "sessions");
287
+ for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
288
+ const stat = statSafe(filePath);
289
+ const info = fastSessionInfo(filePath, "codex");
290
+ // Include if we found text OR a real session id (big sessions can have their first text turn beyond
291
+ // the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
292
+ // We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
293
+ if (info.hasTextTurn || info.hasRealKey)
294
+ out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
295
+ }
296
+ const claudeRoot = opts.claudeRoot ?? path.join(os.homedir(), ".claude", "projects");
297
+ for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
298
+ const stat = statSafe(filePath);
299
+ const info = fastSessionInfo(filePath, "claude-code");
300
+ // Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
301
+ // while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
302
+ if (info.hasTextTurn || info.hasRealKey)
303
+ out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
304
+ }
305
+ return out;
306
+ }
203
307
  function ledgerPath() {
204
308
  return path.join(echoConfigDir(), "migrate-ledger.json");
205
309
  }
@@ -223,6 +327,41 @@ function saveLedger(l) {
223
327
  export function defaultMigrationMetricsPath() {
224
328
  return path.join(echoConfigDir(), "migrate-metrics.jsonl");
225
329
  }
330
+ /**
331
+ * Per-session extraction time (seconds) measured from THIS user's past runs, so the ETA reflects their
332
+ * real server/network throughput instead of fixed guesses. Reads the local metrics log, keeps recent
333
+ * genuinely-extracted jobs (completed, not duplicate/already-done), and returns a trimmed mean (drops
334
+ * the slowest 10% so a rare stalled job doesn't dominate). Returns null when there isn't enough data.
335
+ */
336
+ export function measuredSecondsPerSession(metricsFile = defaultMigrationMetricsPath()) {
337
+ let lines;
338
+ try {
339
+ lines = fs.readFileSync(metricsFile, "utf8").split("\n");
340
+ }
341
+ catch {
342
+ return null; // never run a real migrate yet
343
+ }
344
+ const secs = [];
345
+ for (const line of lines.slice(-400)) {
346
+ const s = line.trim();
347
+ if (!s)
348
+ continue;
349
+ try {
350
+ const m = JSON.parse(s);
351
+ if (m.status === "completed" && !m.duplicate && !m.alreadyDone && typeof m.durationMs === "number" && m.durationMs > 0) {
352
+ secs.push(m.durationMs / 1000);
353
+ }
354
+ }
355
+ catch {
356
+ /* skip malformed line */
357
+ }
358
+ }
359
+ if (secs.length < 8)
360
+ return null;
361
+ secs.sort((a, b) => a - b);
362
+ const kept = secs.slice(0, Math.max(1, Math.floor(secs.length * 0.9)));
363
+ return kept.reduce((n, x) => n + x, 0) / kept.length;
364
+ }
226
365
  function sourceMtimeIso(s) {
227
366
  return s.mtimeMs ? new Date(s.mtimeMs).toISOString() : null;
228
367
  }
@@ -359,6 +498,11 @@ function responseStatus(e) {
359
498
  const response = e.response;
360
499
  return typeof response?.status === "number" ? response.status : undefined;
361
500
  }
501
+ export function isImportStatusUnsupported(e) {
502
+ const status = responseStatus(e);
503
+ const message = e instanceof Error ? e.message : String(e);
504
+ return status === 404 || status === 405 || /status code (404|405)\b/i.test(message);
505
+ }
362
506
  function responseData(e) {
363
507
  const data = e.response?.data;
364
508
  return isRecord(data) ? data : {};
@@ -452,17 +596,22 @@ export function estimateMigration(sessions, pending) {
452
596
  };
453
597
  }
454
598
  export function estimateMigrationEta(pending, skippedActive = 0) {
599
+ return estimateMigrationEtaFromLengths(pending.map((s) => s.rawData.length), skippedActive, {
600
+ secondsPerSession: measuredSecondsPerSession() ?? undefined,
601
+ });
602
+ }
603
+ export function estimateMigrationEtaFromLengths(lengths, skippedActive = 0, opts = {}) {
455
604
  const bucketDefs = [
456
- { key: "small", label: "<=30k chars", min: 0, max: 30_000, secondsPerSession: 10 },
457
- { key: "routine", label: "30k-120k", min: 30_000, max: 120_000, secondsPerSession: 16 },
458
- { key: "large", label: "120k-350k", min: 120_000, max: 350_000, secondsPerSession: 16 },
459
- { key: "veryLarge", label: "350k-1M", min: 350_000, max: 1_000_000, secondsPerSession: 40 },
460
- { key: "huge", label: "1M-2M", min: 1_000_000, max: 2_000_000, secondsPerSession: 60 },
461
- { key: "massive", label: ">2M", min: 2_000_000, max: Number.POSITIVE_INFINITY, secondsPerSession: 90 },
605
+ { key: "small", label: "Up to 30k chars", min: 0, max: 30_000, secondsPerSession: 10 },
606
+ { key: "routine", label: "30k-120k chars", min: 30_000, max: 120_000, secondsPerSession: 16 },
607
+ { key: "large", label: "120k-350k chars", min: 120_000, max: 350_000, secondsPerSession: 16 },
608
+ { key: "veryLarge", label: "350k-1M chars", min: 350_000, max: 1_000_000, secondsPerSession: 40 },
609
+ { key: "huge", label: "1M-2M chars", min: 1_000_000, max: 2_000_000, secondsPerSession: 60 },
610
+ { key: "massive", label: "Over 2M chars", min: 2_000_000, max: Number.POSITIVE_INFINITY, secondsPerSession: 90 },
462
611
  ];
463
612
  const buckets = bucketDefs.map((b) => {
464
- const xs = pending.filter((s) => s.rawData.length > b.min && s.rawData.length <= b.max);
465
- const chars = sum(xs.map((s) => s.rawData.length));
613
+ const xs = lengths.filter((n) => n > b.min && n <= b.max);
614
+ const chars = sum(xs);
466
615
  return {
467
616
  key: b.key,
468
617
  label: b.label,
@@ -472,13 +621,19 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
472
621
  secondsPerSession: b.secondsPerSession,
473
622
  };
474
623
  });
475
- const unbufferedSeconds = sum(buckets.map((b) => b.count * b.secondsPerSession));
476
- const bufferedSeconds = Math.ceil(unbufferedSeconds * 1.25);
477
- const throttleFloorSeconds = pending.length ? Math.ceil(pending.length / RATE_MAX) * 60 : 0;
478
- const estimatedSeconds = Math.max(bufferedSeconds, throttleFloorSeconds);
479
- const totalChars = sum(pending.map((s) => s.rawData.length));
624
+ // Jobs run concurrently (up to MIGRATE_CONCURRENCY in flight), but request STARTS are capped at
625
+ // RATE_MAX/min. Effective throughput is the lesser of what the worker pool sustains (concurrency /
626
+ // per-session time) and the server rate cap so for large batches the rate cap dominates and the
627
+ // wall-clock is far shorter than the old one-by-one sum. perSession comes from measured metrics.
628
+ const pending = lengths.length;
629
+ const perSession = opts.secondsPerSession && opts.secondsPerSession > 0 ? opts.secondsPerSession : FALLBACK_SECONDS_PER_SESSION;
630
+ const concurrency = opts.concurrency && opts.concurrency > 0 ? opts.concurrency : MIGRATE_CONCURRENCY;
631
+ const throughputPerSec = pending ? Math.min(concurrency / perSession, RATE_MAX / 60) : 0;
632
+ const estimatedSeconds = pending && throughputPerSec ? Math.ceil((pending / throughputPerSec + perSession) * 1.15) : 0;
633
+ const throttleFloorSeconds = pending ? Math.ceil(pending / RATE_MAX) * 60 : 0;
634
+ const totalChars = sum(lengths);
480
635
  return {
481
- pending: pending.length,
636
+ pending: lengths.length,
482
637
  skippedActive,
483
638
  totalChars,
484
639
  approxInputTokens: approxTokens(totalChars),
@@ -488,6 +643,67 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
488
643
  buckets,
489
644
  };
490
645
  }
646
+ export function summarizeFastMigratableDiscovery(discovery) {
647
+ return {
648
+ sessions: discovery.sessions.length,
649
+ pending: discovery.pending.length,
650
+ pendingTotal: discovery.pendingTotal,
651
+ alreadyMigrated: discovery.alreadyMigrated,
652
+ skippedActive: discovery.skippedActive,
653
+ codexCount: discovery.codexCount,
654
+ claudeCount: discovery.claudeCount,
655
+ eta: estimateMigrationEtaFromLengths(discovery.pending.map((s) => s.size), discovery.skippedActive, {
656
+ secondsPerSession: measuredSecondsPerSession() ?? undefined,
657
+ }),
658
+ accountChecked: discovery.accountChecked,
659
+ accountCheckFailed: discovery.accountCheckFailed,
660
+ accountCheckUnavailable: discovery.accountCheckUnavailable,
661
+ };
662
+ }
663
+ export function discoverMigratableFastDiscovery(opts = {}) {
664
+ const byKey = new Map();
665
+ const ledger = loadLedger();
666
+ for (const s of fastSessionEntries(opts)) {
667
+ const prev = byKey.get(s.conversationKey);
668
+ const ledgerEntry = ledger[s.conversationKey];
669
+ const matchesLedger = !!ledgerEntry && ledgerEntry.size === s.size;
670
+ const prevMatchesLedger = !!prev && !!ledgerEntry && ledgerEntry.size === prev.size;
671
+ if (!prev ||
672
+ (matchesLedger && !prevMatchesLedger) ||
673
+ (matchesLedger === prevMatchesLedger && (s.size > prev.size || (s.size === prev.size && s.mtimeMs > prev.mtimeMs)))) {
674
+ byKey.set(s.conversationKey, s);
675
+ }
676
+ }
677
+ let sessions = [...byKey.values()];
678
+ if (opts.since) {
679
+ const since = opts.since;
680
+ sessions = sessions.filter((s) => {
681
+ const iso = sourceMtimeIso({ ...s, rawData: "", turnCount: 0, cwd: null, firstTs: null, title: "" });
682
+ return !!iso && iso.slice(0, 10) >= since;
683
+ });
684
+ }
685
+ const selectable = opts.includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
686
+ const skippedActive = sessions.length - selectable.length;
687
+ const pendingAll = selectable.filter((s) => {
688
+ const e = ledger[s.conversationKey];
689
+ return !e || e.size !== s.size;
690
+ });
691
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
692
+ const codexCount = sessions.filter((s) => s.source === "codex").length;
693
+ return {
694
+ sessions,
695
+ pending,
696
+ pendingTotal: pendingAll.length,
697
+ alreadyMigrated: selectable.length - pendingAll.length,
698
+ skippedActive,
699
+ limited: pending.length < pendingAll.length,
700
+ codexCount,
701
+ claudeCount: sessions.length - codexCount,
702
+ };
703
+ }
704
+ export function discoverMigratableSummaryFast(opts = {}) {
705
+ return summarizeFastMigratableDiscovery(discoverMigratableFastDiscovery(opts));
706
+ }
491
707
  export function applyMigrationSelection(sessions, opts = {}) {
492
708
  let out = [...sessions];
493
709
  if (typeof opts.minChars === "number") {
@@ -552,6 +768,133 @@ export function discoverMigratableSessions(opts = {}) {
552
768
  claudeCount: sessions.length - codexCount,
553
769
  };
554
770
  }
771
+ /**
772
+ * Targeted sizing for extraction start. The fast (stat-only) scan already knows WHICH sessions are
773
+ * pending — by conversation key + file path — without reading any content. This assembles full content
774
+ * for ONLY those pending sessions, never the whole local history. That keeps "start extraction" fast even
775
+ * when a user has gigabytes of already-processed logs (reading all of them just to prepare a few new jobs
776
+ * is what made sizing hang for 30s+).
777
+ *
778
+ * `processedKeys` (from the account import-status check) yields the accurate pending set; without it we
779
+ * fall back to the local-ledger pending. Either way we read at most the pending files, not the archive.
780
+ */
781
+ export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
782
+ const fast = discoverMigratableFastDiscovery(opts);
783
+ const filtered = processedKeys ? applyFastAccountImportStatus(fast, processedKeys, opts) : fast;
784
+ const pending = [];
785
+ for (const entry of filtered.pending) {
786
+ const s = entry.source === "codex" ? assembleCodex(entry.filePath) : assembleClaude(entry.filePath);
787
+ if (s)
788
+ pending.push(s);
789
+ }
790
+ return {
791
+ sessions: pending,
792
+ pending,
793
+ pendingTotal: filtered.pendingTotal,
794
+ alreadyMigrated: filtered.alreadyMigrated,
795
+ skippedActive: filtered.skippedActive,
796
+ limited: filtered.limited,
797
+ codexCount: filtered.codexCount,
798
+ claudeCount: filtered.claudeCount,
799
+ accountChecked: filtered.accountChecked,
800
+ accountCheckFailed: filtered.accountCheckFailed,
801
+ accountCheckUnavailable: filtered.accountCheckUnavailable,
802
+ };
803
+ }
804
+ export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
805
+ const selectableSessions = opts.includeActive
806
+ ? discovery.sessions
807
+ : discovery.sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
808
+ const skippedActive = discovery.sessions.length - selectableSessions.length;
809
+ const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
810
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
811
+ return {
812
+ ...discovery,
813
+ pending,
814
+ pendingTotal: pendingAll.length,
815
+ alreadyMigrated: selectableSessions.length - pendingAll.length,
816
+ skippedActive,
817
+ limited: pending.length < pendingAll.length,
818
+ accountChecked: true,
819
+ accountCheckFailed: false,
820
+ accountCheckUnavailable: false,
821
+ };
822
+ }
823
+ export function applyFastAccountImportStatus(discovery, processedKeys, opts = {}) {
824
+ const selectableSessions = opts.includeActive
825
+ ? discovery.sessions
826
+ : discovery.sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
827
+ const skippedActive = discovery.sessions.length - selectableSessions.length;
828
+ const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
829
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
830
+ return {
831
+ ...discovery,
832
+ pending,
833
+ pendingTotal: pendingAll.length,
834
+ alreadyMigrated: selectableSessions.length - pendingAll.length,
835
+ skippedActive,
836
+ limited: pending.length < pendingAll.length,
837
+ accountChecked: true,
838
+ accountCheckFailed: false,
839
+ accountCheckUnavailable: false,
840
+ };
841
+ }
842
+ export function markAccountImportStatusFailed(discovery) {
843
+ return {
844
+ ...discovery,
845
+ accountChecked: false,
846
+ accountCheckFailed: true,
847
+ accountCheckUnavailable: false,
848
+ };
849
+ }
850
+ export function markAccountImportStatusUnavailable(discovery) {
851
+ return {
852
+ ...discovery,
853
+ accountChecked: false,
854
+ accountCheckFailed: false,
855
+ accountCheckUnavailable: true,
856
+ };
857
+ }
858
+ export function markFastAccountImportStatusUnavailable(discovery) {
859
+ return {
860
+ ...discovery,
861
+ accountChecked: false,
862
+ accountCheckFailed: false,
863
+ accountCheckUnavailable: true,
864
+ };
865
+ }
866
+ export async function fetchProcessedImportKeys(token, sessions, signal) {
867
+ const client = authedClient(token);
868
+ const processedKeys = new Set();
869
+ for (let i = 0; i < sessions.length; i += IMPORT_STATUS_CHUNK_SIZE) {
870
+ const chunk = sessions.slice(i, i + IMPORT_STATUS_CHUNK_SIZE);
871
+ const items = chunk.map((s) => ({
872
+ conversationId: bareId(s),
873
+ platform: s.source,
874
+ sourceDate: s.firstTs,
875
+ }));
876
+ const res = await client.post("/api/extension/import-sessions/status", { items }, signal ? { signal } : undefined);
877
+ const data = (res.data && typeof res.data === "object" ? res.data : {});
878
+ const statusItems = Array.isArray(data.items) ? data.items : [];
879
+ for (const item of statusItems) {
880
+ if (item.processed === true &&
881
+ typeof item.platform === "string" &&
882
+ typeof item.conversationId === "string" &&
883
+ item.conversationId) {
884
+ processedKeys.add(mapKey(item.platform, item.conversationId));
885
+ }
886
+ }
887
+ }
888
+ return processedKeys;
889
+ }
890
+ export async function reconcileMigratableWithAccount(token, discovery, opts = {}) {
891
+ const processedKeys = await fetchProcessedImportKeys(token, discovery.sessions, opts.signal);
892
+ return applyAccountImportStatus(discovery, processedKeys, opts);
893
+ }
894
+ export async function reconcileFastMigratableWithAccount(token, discovery, opts = {}) {
895
+ const processedKeys = await fetchProcessedImportKeys(token, discovery.sessions, opts.signal);
896
+ return applyFastAccountImportStatus(discovery, processedKeys, opts);
897
+ }
555
898
  export async function startMigration(opts) {
556
899
  if (opts.pending.length === 0)
557
900
  throw codedError("NO_PENDING_SESSIONS");
@@ -572,7 +915,10 @@ export async function startMigration(opts) {
572
915
  catch (e) {
573
916
  if (e?.code === "VAULT_LOCKED")
574
917
  throw e;
575
- /* config fetch failed proceed as unencrypted; server enforces if actually encrypted */
918
+ // Config fetch failed (e.g., a transient DNS/network timeout). If a verified key is stored, send it
919
+ // anyway — the server uses it iff the account is encrypted and ignores it otherwise. Dropping it here
920
+ // made an encrypted account fail every job with ENCRYPTION_KEY_REQUIRED on a single flaky request.
921
+ encKey = store.getKey() || undefined;
576
922
  }
577
923
  const tz = userTimeZone();
578
924
  let toImport = opts.pending;
@@ -633,7 +979,9 @@ async function runJobs(args) {
633
979
  await sleep(RATE_WINDOW_MS - (now - reqTimes[0]) + 100);
634
980
  }
635
981
  };
636
- let migrated = 0, extracted = 0, failed = 0, i = 0;
982
+ let migrated = 0, extracted = 0, failed = 0, done = 0;
983
+ let stoppedReason;
984
+ const total = args.session.jobs.length;
637
985
  const recordMetric = (metric) => {
638
986
  try {
639
987
  appendMigrationMetric(args.metricsFile, metric);
@@ -642,93 +990,80 @@ async function runJobs(args) {
642
990
  /* best effort: metrics must never block a user migration */
643
991
  }
644
992
  };
645
- try {
646
- for (const job of args.session.jobs) {
647
- const s = args.byConv.get(mapKey(job.platform, job.conversation_id));
648
- if (!s)
649
- continue;
650
- i++;
651
- const jobStartedAt = Date.now();
652
- try {
653
- await throttle();
654
- const r = await runImportJob(args.client, job.id, s, args.userTz, args.encKey);
655
- extracted += r.memories;
656
- migrated++;
657
- ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
658
- saveLedger(ledger);
659
- recordMetric(buildMigrationMetric({
660
- runId: args.runId,
661
- importSessionId: args.session.id,
662
- jobId: job.id,
663
- index: i,
664
- total: args.session.jobs.length,
665
- session: s,
666
- status: "completed",
667
- memories: r.memories,
668
- durationMs: r.durationMs,
669
- processingTimeMs: r.processingTimeMs,
670
- ttfmMs: r.ttfmMs,
671
- alreadyDone: r.alreadyDone,
672
- duplicate: r.duplicate,
673
- selection: args.selection,
674
- }));
675
- args.onProgress?.({
676
- index: i,
677
- total: args.session.jobs.length,
678
- importSessionId: args.session.id,
679
- jobId: job.id,
680
- session: s,
681
- memories: r.memories,
682
- durationMs: r.durationMs,
683
- processingTimeMs: r.processingTimeMs,
684
- ttfmMs: r.ttfmMs,
685
- alreadyDone: r.alreadyDone,
686
- duplicate: r.duplicate,
687
- });
688
- }
689
- catch (e) {
690
- const status = responseStatus(e);
691
- const errCode = responseData(e).error;
692
- const message = responseMessage(e);
693
- if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
694
- const durationMs = Date.now() - jobStartedAt;
695
- recordMetric(buildMigrationMetric({
696
- runId: args.runId,
697
- importSessionId: args.session.id,
698
- jobId: job.id,
699
- index: i,
700
- total: args.session.jobs.length,
701
- session: s,
702
- status: "stopped",
703
- durationMs,
704
- error: message,
705
- selection: args.selection,
706
- }));
707
- args.onProgress?.({ index: i, total: args.session.jobs.length, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
708
- return { migrated, extracted, failed, stoppedReason: "key-expired" };
709
- }
710
- failed++;
711
- const durationMs = Date.now() - jobStartedAt;
993
+ // Run a single job: throttle the request START, extract, then record the outcome. Counters are only
994
+ // mutated between awaits, so single-threaded interleaving keeps them consistent without locks.
995
+ const runOne = async (job, s) => {
996
+ const jobStartedAt = Date.now();
997
+ try {
998
+ await throttle();
999
+ if (stoppedReason)
1000
+ return; // an earlier job hit an unrecoverable stop — don't start more work
1001
+ const r = await runImportJob(args.client, job.id, s, args.userTz, args.encKey);
1002
+ extracted += r.memories;
1003
+ migrated++;
1004
+ done++;
1005
+ ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
1006
+ saveLedger(ledger);
1007
+ recordMetric(buildMigrationMetric({
1008
+ runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
1009
+ session: s, status: "completed", memories: r.memories, durationMs: r.durationMs,
1010
+ processingTimeMs: r.processingTimeMs, ttfmMs: r.ttfmMs, alreadyDone: r.alreadyDone,
1011
+ duplicate: r.duplicate, selection: args.selection,
1012
+ }));
1013
+ args.onProgress?.({
1014
+ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s,
1015
+ memories: r.memories, durationMs: r.durationMs, processingTimeMs: r.processingTimeMs,
1016
+ ttfmMs: r.ttfmMs, alreadyDone: r.alreadyDone, duplicate: r.duplicate,
1017
+ });
1018
+ }
1019
+ catch (e) {
1020
+ const status = responseStatus(e);
1021
+ const errCode = responseData(e).error;
1022
+ const message = responseMessage(e);
1023
+ const durationMs = Date.now() - jobStartedAt;
1024
+ if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
1025
+ stoppedReason = "key-expired"; // the vault key expired mid-run — signal workers to stop pulling
1026
+ done++;
712
1027
  recordMetric(buildMigrationMetric({
713
- runId: args.runId,
714
- importSessionId: args.session.id,
715
- jobId: job.id,
716
- index: i,
717
- total: args.session.jobs.length,
718
- session: s,
719
- status: "failed",
720
- durationMs,
721
- error: message,
722
- selection: args.selection,
1028
+ runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
1029
+ session: s, status: "stopped", durationMs, error: message, selection: args.selection,
723
1030
  }));
724
- args.onProgress?.({ index: i, total: args.session.jobs.length, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
1031
+ args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
1032
+ return;
725
1033
  }
1034
+ failed++;
1035
+ done++;
1036
+ recordMetric(buildMigrationMetric({
1037
+ runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
1038
+ session: s, status: "failed", durationMs, error: message, selection: args.selection,
1039
+ }));
1040
+ args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
1041
+ }
1042
+ };
1043
+ // Worker pool: each worker pulls the next job until the list drains or a stop is signalled. The
1044
+ // shared throttle keeps request STARTS under RATE_MAX/min regardless of how many run concurrently.
1045
+ let next = 0;
1046
+ const worker = async () => {
1047
+ for (;;) {
1048
+ if (stoppedReason)
1049
+ return;
1050
+ const myIdx = next++;
1051
+ if (myIdx >= total)
1052
+ return;
1053
+ const job = args.session.jobs[myIdx];
1054
+ const s = args.byConv.get(mapKey(job.platform, job.conversation_id));
1055
+ if (!s)
1056
+ continue;
1057
+ await runOne(job, s);
726
1058
  }
1059
+ };
1060
+ try {
1061
+ await Promise.all(Array.from({ length: Math.min(MIGRATE_CONCURRENCY, Math.max(1, total)) }, () => worker()));
727
1062
  }
728
1063
  catch {
729
1064
  failed++;
730
1065
  }
731
- return { migrated, extracted, failed };
1066
+ return { migrated, extracted, failed, ...(stoppedReason ? { stoppedReason } : {}) };
732
1067
  }
733
1068
  export async function cmdMigrate(flags) {
734
1069
  const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
@@ -756,7 +1091,25 @@ export async function cmdMigrate(flags) {
756
1091
  const includeActive = flags["include-active"] === true;
757
1092
  const selection = { since, limit, minChars, maxChars, largest, includeActive };
758
1093
  const metricsFile = typeof flags["metrics-file"] === "string" ? flags["metrics-file"] : defaultMigrationMetricsPath();
759
- const { sessions, pending, pendingTotal, alreadyMigrated, skippedActive, limited, codexCount, claudeCount } = discoverMigratableSessions(selection);
1094
+ let discovery = discoverMigratableSessions(selection);
1095
+ if (flags.estimate !== true && flags["dry-run"] !== true) {
1096
+ const token = new KeyStore().getToken();
1097
+ if (token) {
1098
+ try {
1099
+ discovery = await reconcileMigratableWithAccount(token, discovery, selection);
1100
+ }
1101
+ catch (e) {
1102
+ if (isImportStatusUnsupported(e)) {
1103
+ discovery = markAccountImportStatusUnavailable(discovery);
1104
+ }
1105
+ else {
1106
+ discovery = markAccountImportStatusFailed(discovery);
1107
+ console.warn(`Could not check this EchoMem account's import status; using local migration ledger. ${responseMessage(e)}`);
1108
+ }
1109
+ }
1110
+ }
1111
+ }
1112
+ const { sessions, pending, pendingTotal, alreadyMigrated, skippedActive, limited, codexCount, claudeCount, accountChecked, accountCheckFailed, accountCheckUnavailable } = discovery;
760
1113
  const estimateSessions = includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
761
1114
  if (!sessions.length) {
762
1115
  console.log("No local Codex/Claude Code sessions found to migrate.");
@@ -768,12 +1121,19 @@ export async function cmdMigrate(flags) {
768
1121
  }
769
1122
  console.log("");
770
1123
  console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
1124
+ const pendingName = accountChecked ? "unprocessed" : "new/changed";
771
1125
  const pendingText = limited
772
- ? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))} new/changed selected`
773
- : `${c.bold(String(pending.length))} new/changed to import`;
1126
+ ? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))} ${pendingName} selected`
1127
+ : `${c.bold(String(pending.length))} ${pendingName} to import`;
774
1128
  console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexCount} Codex + ${claudeCount} Claude Code) · ${pendingText}` +
775
1129
  (alreadyMigrated ? c.dim(`, ${alreadyMigrated} already migrated`) : "") +
776
1130
  (skippedActive ? c.dim(`, ${skippedActive} active skipped`) : ""));
1131
+ if (accountChecked)
1132
+ console.log(c.dim("Checked this EchoMem account; already processed conversations are skipped."));
1133
+ else if (accountCheckFailed)
1134
+ console.log(c.dim("Using local migration ledger because the account status check failed."));
1135
+ else if (accountCheckUnavailable)
1136
+ console.log(c.dim("Using local migration ledger; this API does not expose the optional account status precheck yet."));
777
1137
  const filters = [
778
1138
  minChars ? `min ${humanNum(minChars)} chars` : "",
779
1139
  maxChars ? `max ${humanNum(maxChars)} chars` : "",