@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/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,99 @@ 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
+ for (const obj of initialJsonObjects(file)) {
253
+ if (!isRecord(obj))
254
+ continue;
255
+ if (source === "codex" && obj.type === "session_meta") {
256
+ const payload = isRecord(obj.payload) ? obj.payload : {};
257
+ if (typeof payload.id === "string" && payload.id)
258
+ conversationKey = `codex:${payload.id}`;
259
+ continue;
260
+ }
261
+ if (source === "codex") {
262
+ const p = codexPayload(obj);
263
+ if (p.type === "user_message" && typeof p.message === "string" && cleanCodexUser(p.message))
264
+ hasTextTurn = true;
265
+ if (p.type === "agent_message" && typeof p.message === "string" && p.message.trim())
266
+ hasTextTurn = true;
267
+ continue;
268
+ }
269
+ if (source === "claude-code" && typeof obj.sessionId === "string" && obj.sessionId) {
270
+ conversationKey = `claude-code:${obj.sessionId}`;
271
+ }
272
+ if (source === "claude-code" && (obj.type === "user" || obj.type === "assistant")) {
273
+ const message = isRecord(obj.message) ? obj.message : {};
274
+ if (hasClaudeText(message.content))
275
+ hasTextTurn = true;
276
+ }
277
+ }
278
+ return { conversationKey, hasTextTurn };
279
+ }
280
+ function fastSessionEntries(opts = {}) {
281
+ const out = [];
282
+ const codexRoot = opts.codexRoot ?? path.join(os.homedir(), ".codex", "sessions");
283
+ for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
284
+ const stat = statSafe(filePath);
285
+ const info = fastSessionInfo(filePath, "codex");
286
+ if (info.hasTextTurn)
287
+ out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
288
+ }
289
+ const claudeRoot = opts.claudeRoot ?? path.join(os.homedir(), ".claude", "projects");
290
+ for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
291
+ const stat = statSafe(filePath);
292
+ const info = fastSessionInfo(filePath, "claude-code");
293
+ if (info.hasTextTurn)
294
+ out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
295
+ }
296
+ return out;
297
+ }
203
298
  function ledgerPath() {
204
299
  return path.join(echoConfigDir(), "migrate-ledger.json");
205
300
  }
@@ -223,6 +318,41 @@ function saveLedger(l) {
223
318
  export function defaultMigrationMetricsPath() {
224
319
  return path.join(echoConfigDir(), "migrate-metrics.jsonl");
225
320
  }
321
+ /**
322
+ * Per-session extraction time (seconds) measured from THIS user's past runs, so the ETA reflects their
323
+ * real server/network throughput instead of fixed guesses. Reads the local metrics log, keeps recent
324
+ * genuinely-extracted jobs (completed, not duplicate/already-done), and returns a trimmed mean (drops
325
+ * the slowest 10% so a rare stalled job doesn't dominate). Returns null when there isn't enough data.
326
+ */
327
+ export function measuredSecondsPerSession(metricsFile = defaultMigrationMetricsPath()) {
328
+ let lines;
329
+ try {
330
+ lines = fs.readFileSync(metricsFile, "utf8").split("\n");
331
+ }
332
+ catch {
333
+ return null; // never run a real migrate yet
334
+ }
335
+ const secs = [];
336
+ for (const line of lines.slice(-400)) {
337
+ const s = line.trim();
338
+ if (!s)
339
+ continue;
340
+ try {
341
+ const m = JSON.parse(s);
342
+ if (m.status === "completed" && !m.duplicate && !m.alreadyDone && typeof m.durationMs === "number" && m.durationMs > 0) {
343
+ secs.push(m.durationMs / 1000);
344
+ }
345
+ }
346
+ catch {
347
+ /* skip malformed line */
348
+ }
349
+ }
350
+ if (secs.length < 8)
351
+ return null;
352
+ secs.sort((a, b) => a - b);
353
+ const kept = secs.slice(0, Math.max(1, Math.floor(secs.length * 0.9)));
354
+ return kept.reduce((n, x) => n + x, 0) / kept.length;
355
+ }
226
356
  function sourceMtimeIso(s) {
227
357
  return s.mtimeMs ? new Date(s.mtimeMs).toISOString() : null;
228
358
  }
@@ -359,6 +489,11 @@ function responseStatus(e) {
359
489
  const response = e.response;
360
490
  return typeof response?.status === "number" ? response.status : undefined;
361
491
  }
492
+ export function isImportStatusUnsupported(e) {
493
+ const status = responseStatus(e);
494
+ const message = e instanceof Error ? e.message : String(e);
495
+ return status === 404 || status === 405 || /status code (404|405)\b/i.test(message);
496
+ }
362
497
  function responseData(e) {
363
498
  const data = e.response?.data;
364
499
  return isRecord(data) ? data : {};
@@ -452,17 +587,22 @@ export function estimateMigration(sessions, pending) {
452
587
  };
453
588
  }
454
589
  export function estimateMigrationEta(pending, skippedActive = 0) {
590
+ return estimateMigrationEtaFromLengths(pending.map((s) => s.rawData.length), skippedActive, {
591
+ secondsPerSession: measuredSecondsPerSession() ?? undefined,
592
+ });
593
+ }
594
+ export function estimateMigrationEtaFromLengths(lengths, skippedActive = 0, opts = {}) {
455
595
  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 },
596
+ { key: "small", label: "Up to 30k chars", min: 0, max: 30_000, secondsPerSession: 10 },
597
+ { key: "routine", label: "30k-120k chars", min: 30_000, max: 120_000, secondsPerSession: 16 },
598
+ { key: "large", label: "120k-350k chars", min: 120_000, max: 350_000, secondsPerSession: 16 },
599
+ { key: "veryLarge", label: "350k-1M chars", min: 350_000, max: 1_000_000, secondsPerSession: 40 },
600
+ { key: "huge", label: "1M-2M chars", min: 1_000_000, max: 2_000_000, secondsPerSession: 60 },
601
+ { key: "massive", label: "Over 2M chars", min: 2_000_000, max: Number.POSITIVE_INFINITY, secondsPerSession: 90 },
462
602
  ];
463
603
  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));
604
+ const xs = lengths.filter((n) => n > b.min && n <= b.max);
605
+ const chars = sum(xs);
466
606
  return {
467
607
  key: b.key,
468
608
  label: b.label,
@@ -472,13 +612,19 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
472
612
  secondsPerSession: b.secondsPerSession,
473
613
  };
474
614
  });
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));
615
+ // Jobs run concurrently (up to MIGRATE_CONCURRENCY in flight), but request STARTS are capped at
616
+ // RATE_MAX/min. Effective throughput is the lesser of what the worker pool sustains (concurrency /
617
+ // per-session time) and the server rate cap so for large batches the rate cap dominates and the
618
+ // wall-clock is far shorter than the old one-by-one sum. perSession comes from measured metrics.
619
+ const pending = lengths.length;
620
+ const perSession = opts.secondsPerSession && opts.secondsPerSession > 0 ? opts.secondsPerSession : FALLBACK_SECONDS_PER_SESSION;
621
+ const concurrency = opts.concurrency && opts.concurrency > 0 ? opts.concurrency : MIGRATE_CONCURRENCY;
622
+ const throughputPerSec = pending ? Math.min(concurrency / perSession, RATE_MAX / 60) : 0;
623
+ const estimatedSeconds = pending && throughputPerSec ? Math.ceil((pending / throughputPerSec + perSession) * 1.15) : 0;
624
+ const throttleFloorSeconds = pending ? Math.ceil(pending / RATE_MAX) * 60 : 0;
625
+ const totalChars = sum(lengths);
480
626
  return {
481
- pending: pending.length,
627
+ pending: lengths.length,
482
628
  skippedActive,
483
629
  totalChars,
484
630
  approxInputTokens: approxTokens(totalChars),
@@ -488,6 +634,67 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
488
634
  buckets,
489
635
  };
490
636
  }
637
+ export function summarizeFastMigratableDiscovery(discovery) {
638
+ return {
639
+ sessions: discovery.sessions.length,
640
+ pending: discovery.pending.length,
641
+ pendingTotal: discovery.pendingTotal,
642
+ alreadyMigrated: discovery.alreadyMigrated,
643
+ skippedActive: discovery.skippedActive,
644
+ codexCount: discovery.codexCount,
645
+ claudeCount: discovery.claudeCount,
646
+ eta: estimateMigrationEtaFromLengths(discovery.pending.map((s) => s.size), discovery.skippedActive, {
647
+ secondsPerSession: measuredSecondsPerSession() ?? undefined,
648
+ }),
649
+ accountChecked: discovery.accountChecked,
650
+ accountCheckFailed: discovery.accountCheckFailed,
651
+ accountCheckUnavailable: discovery.accountCheckUnavailable,
652
+ };
653
+ }
654
+ export function discoverMigratableFastDiscovery(opts = {}) {
655
+ const byKey = new Map();
656
+ const ledger = loadLedger();
657
+ for (const s of fastSessionEntries(opts)) {
658
+ const prev = byKey.get(s.conversationKey);
659
+ const ledgerEntry = ledger[s.conversationKey];
660
+ const matchesLedger = !!ledgerEntry && ledgerEntry.size === s.size;
661
+ const prevMatchesLedger = !!prev && !!ledgerEntry && ledgerEntry.size === prev.size;
662
+ if (!prev ||
663
+ (matchesLedger && !prevMatchesLedger) ||
664
+ (matchesLedger === prevMatchesLedger && (s.size > prev.size || (s.size === prev.size && s.mtimeMs > prev.mtimeMs)))) {
665
+ byKey.set(s.conversationKey, s);
666
+ }
667
+ }
668
+ let sessions = [...byKey.values()];
669
+ if (opts.since) {
670
+ const since = opts.since;
671
+ sessions = sessions.filter((s) => {
672
+ const iso = sourceMtimeIso({ ...s, rawData: "", turnCount: 0, cwd: null, firstTs: null, title: "" });
673
+ return !!iso && iso.slice(0, 10) >= since;
674
+ });
675
+ }
676
+ const selectable = opts.includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
677
+ const skippedActive = sessions.length - selectable.length;
678
+ const pendingAll = selectable.filter((s) => {
679
+ const e = ledger[s.conversationKey];
680
+ return !e || e.size !== s.size;
681
+ });
682
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
683
+ const codexCount = sessions.filter((s) => s.source === "codex").length;
684
+ return {
685
+ sessions,
686
+ pending,
687
+ pendingTotal: pendingAll.length,
688
+ alreadyMigrated: selectable.length - pendingAll.length,
689
+ skippedActive,
690
+ limited: pending.length < pendingAll.length,
691
+ codexCount,
692
+ claudeCount: sessions.length - codexCount,
693
+ };
694
+ }
695
+ export function discoverMigratableSummaryFast(opts = {}) {
696
+ return summarizeFastMigratableDiscovery(discoverMigratableFastDiscovery(opts));
697
+ }
491
698
  export function applyMigrationSelection(sessions, opts = {}) {
492
699
  let out = [...sessions];
493
700
  if (typeof opts.minChars === "number") {
@@ -552,6 +759,100 @@ export function discoverMigratableSessions(opts = {}) {
552
759
  claudeCount: sessions.length - codexCount,
553
760
  };
554
761
  }
762
+ export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
763
+ const selectableSessions = opts.includeActive
764
+ ? discovery.sessions
765
+ : discovery.sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
766
+ const skippedActive = discovery.sessions.length - selectableSessions.length;
767
+ const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
768
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
769
+ return {
770
+ ...discovery,
771
+ pending,
772
+ pendingTotal: pendingAll.length,
773
+ alreadyMigrated: selectableSessions.length - pendingAll.length,
774
+ skippedActive,
775
+ limited: pending.length < pendingAll.length,
776
+ accountChecked: true,
777
+ accountCheckFailed: false,
778
+ accountCheckUnavailable: false,
779
+ };
780
+ }
781
+ export function applyFastAccountImportStatus(discovery, processedKeys, opts = {}) {
782
+ const selectableSessions = opts.includeActive
783
+ ? discovery.sessions
784
+ : discovery.sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
785
+ const skippedActive = discovery.sessions.length - selectableSessions.length;
786
+ const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
787
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
788
+ return {
789
+ ...discovery,
790
+ pending,
791
+ pendingTotal: pendingAll.length,
792
+ alreadyMigrated: selectableSessions.length - pendingAll.length,
793
+ skippedActive,
794
+ limited: pending.length < pendingAll.length,
795
+ accountChecked: true,
796
+ accountCheckFailed: false,
797
+ accountCheckUnavailable: false,
798
+ };
799
+ }
800
+ export function markAccountImportStatusFailed(discovery) {
801
+ return {
802
+ ...discovery,
803
+ accountChecked: false,
804
+ accountCheckFailed: true,
805
+ accountCheckUnavailable: false,
806
+ };
807
+ }
808
+ export function markAccountImportStatusUnavailable(discovery) {
809
+ return {
810
+ ...discovery,
811
+ accountChecked: false,
812
+ accountCheckFailed: false,
813
+ accountCheckUnavailable: true,
814
+ };
815
+ }
816
+ export function markFastAccountImportStatusUnavailable(discovery) {
817
+ return {
818
+ ...discovery,
819
+ accountChecked: false,
820
+ accountCheckFailed: false,
821
+ accountCheckUnavailable: true,
822
+ };
823
+ }
824
+ export async function fetchProcessedImportKeys(token, sessions, signal) {
825
+ const client = authedClient(token);
826
+ const processedKeys = new Set();
827
+ for (let i = 0; i < sessions.length; i += IMPORT_STATUS_CHUNK_SIZE) {
828
+ const chunk = sessions.slice(i, i + IMPORT_STATUS_CHUNK_SIZE);
829
+ const items = chunk.map((s) => ({
830
+ conversationId: bareId(s),
831
+ platform: s.source,
832
+ sourceDate: s.firstTs,
833
+ }));
834
+ const res = await client.post("/api/extension/import-sessions/status", { items }, signal ? { signal } : undefined);
835
+ const data = (res.data && typeof res.data === "object" ? res.data : {});
836
+ const statusItems = Array.isArray(data.items) ? data.items : [];
837
+ for (const item of statusItems) {
838
+ if (item.processed === true &&
839
+ typeof item.platform === "string" &&
840
+ typeof item.conversationId === "string" &&
841
+ item.conversationId) {
842
+ processedKeys.add(mapKey(item.platform, item.conversationId));
843
+ }
844
+ }
845
+ }
846
+ return processedKeys;
847
+ }
848
+ export async function reconcileMigratableWithAccount(token, discovery, opts = {}) {
849
+ const processedKeys = await fetchProcessedImportKeys(token, discovery.sessions, opts.signal);
850
+ return applyAccountImportStatus(discovery, processedKeys, opts);
851
+ }
852
+ export async function reconcileFastMigratableWithAccount(token, discovery, opts = {}) {
853
+ const processedKeys = await fetchProcessedImportKeys(token, discovery.sessions, opts.signal);
854
+ return applyFastAccountImportStatus(discovery, processedKeys, opts);
855
+ }
555
856
  export async function startMigration(opts) {
556
857
  if (opts.pending.length === 0)
557
858
  throw codedError("NO_PENDING_SESSIONS");
@@ -572,7 +873,10 @@ export async function startMigration(opts) {
572
873
  catch (e) {
573
874
  if (e?.code === "VAULT_LOCKED")
574
875
  throw e;
575
- /* config fetch failed proceed as unencrypted; server enforces if actually encrypted */
876
+ // Config fetch failed (e.g., a transient DNS/network timeout). If a verified key is stored, send it
877
+ // anyway — the server uses it iff the account is encrypted and ignores it otherwise. Dropping it here
878
+ // made an encrypted account fail every job with ENCRYPTION_KEY_REQUIRED on a single flaky request.
879
+ encKey = store.getKey() || undefined;
576
880
  }
577
881
  const tz = userTimeZone();
578
882
  let toImport = opts.pending;
@@ -633,7 +937,9 @@ async function runJobs(args) {
633
937
  await sleep(RATE_WINDOW_MS - (now - reqTimes[0]) + 100);
634
938
  }
635
939
  };
636
- let migrated = 0, extracted = 0, failed = 0, i = 0;
940
+ let migrated = 0, extracted = 0, failed = 0, done = 0;
941
+ let stoppedReason;
942
+ const total = args.session.jobs.length;
637
943
  const recordMetric = (metric) => {
638
944
  try {
639
945
  appendMigrationMetric(args.metricsFile, metric);
@@ -642,93 +948,80 @@ async function runJobs(args) {
642
948
  /* best effort: metrics must never block a user migration */
643
949
  }
644
950
  };
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;
951
+ // Run a single job: throttle the request START, extract, then record the outcome. Counters are only
952
+ // mutated between awaits, so single-threaded interleaving keeps them consistent without locks.
953
+ const runOne = async (job, s) => {
954
+ const jobStartedAt = Date.now();
955
+ try {
956
+ await throttle();
957
+ if (stoppedReason)
958
+ return; // an earlier job hit an unrecoverable stop — don't start more work
959
+ const r = await runImportJob(args.client, job.id, s, args.userTz, args.encKey);
960
+ extracted += r.memories;
961
+ migrated++;
962
+ done++;
963
+ ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
964
+ saveLedger(ledger);
965
+ recordMetric(buildMigrationMetric({
966
+ runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
967
+ session: s, status: "completed", memories: r.memories, durationMs: r.durationMs,
968
+ processingTimeMs: r.processingTimeMs, ttfmMs: r.ttfmMs, alreadyDone: r.alreadyDone,
969
+ duplicate: r.duplicate, selection: args.selection,
970
+ }));
971
+ args.onProgress?.({
972
+ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s,
973
+ memories: r.memories, durationMs: r.durationMs, processingTimeMs: r.processingTimeMs,
974
+ ttfmMs: r.ttfmMs, alreadyDone: r.alreadyDone, duplicate: r.duplicate,
975
+ });
976
+ }
977
+ catch (e) {
978
+ const status = responseStatus(e);
979
+ const errCode = responseData(e).error;
980
+ const message = responseMessage(e);
981
+ const durationMs = Date.now() - jobStartedAt;
982
+ if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
983
+ stoppedReason = "key-expired"; // the vault key expired mid-run — signal workers to stop pulling
984
+ done++;
712
985
  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,
986
+ runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
987
+ session: s, status: "stopped", durationMs, error: message, selection: args.selection,
723
988
  }));
724
- args.onProgress?.({ index: i, total: args.session.jobs.length, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
989
+ args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
990
+ return;
725
991
  }
992
+ failed++;
993
+ done++;
994
+ recordMetric(buildMigrationMetric({
995
+ runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
996
+ session: s, status: "failed", durationMs, error: message, selection: args.selection,
997
+ }));
998
+ args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
726
999
  }
1000
+ };
1001
+ // Worker pool: each worker pulls the next job until the list drains or a stop is signalled. The
1002
+ // shared throttle keeps request STARTS under RATE_MAX/min regardless of how many run concurrently.
1003
+ let next = 0;
1004
+ const worker = async () => {
1005
+ for (;;) {
1006
+ if (stoppedReason)
1007
+ return;
1008
+ const myIdx = next++;
1009
+ if (myIdx >= total)
1010
+ return;
1011
+ const job = args.session.jobs[myIdx];
1012
+ const s = args.byConv.get(mapKey(job.platform, job.conversation_id));
1013
+ if (!s)
1014
+ continue;
1015
+ await runOne(job, s);
1016
+ }
1017
+ };
1018
+ try {
1019
+ await Promise.all(Array.from({ length: Math.min(MIGRATE_CONCURRENCY, Math.max(1, total)) }, () => worker()));
727
1020
  }
728
1021
  catch {
729
1022
  failed++;
730
1023
  }
731
- return { migrated, extracted, failed };
1024
+ return { migrated, extracted, failed, ...(stoppedReason ? { stoppedReason } : {}) };
732
1025
  }
733
1026
  export async function cmdMigrate(flags) {
734
1027
  const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
@@ -756,7 +1049,25 @@ export async function cmdMigrate(flags) {
756
1049
  const includeActive = flags["include-active"] === true;
757
1050
  const selection = { since, limit, minChars, maxChars, largest, includeActive };
758
1051
  const metricsFile = typeof flags["metrics-file"] === "string" ? flags["metrics-file"] : defaultMigrationMetricsPath();
759
- const { sessions, pending, pendingTotal, alreadyMigrated, skippedActive, limited, codexCount, claudeCount } = discoverMigratableSessions(selection);
1052
+ let discovery = discoverMigratableSessions(selection);
1053
+ if (flags.estimate !== true && flags["dry-run"] !== true) {
1054
+ const token = new KeyStore().getToken();
1055
+ if (token) {
1056
+ try {
1057
+ discovery = await reconcileMigratableWithAccount(token, discovery, selection);
1058
+ }
1059
+ catch (e) {
1060
+ if (isImportStatusUnsupported(e)) {
1061
+ discovery = markAccountImportStatusUnavailable(discovery);
1062
+ }
1063
+ else {
1064
+ discovery = markAccountImportStatusFailed(discovery);
1065
+ console.warn(`Could not check this EchoMem account's import status; using local migration ledger. ${responseMessage(e)}`);
1066
+ }
1067
+ }
1068
+ }
1069
+ }
1070
+ const { sessions, pending, pendingTotal, alreadyMigrated, skippedActive, limited, codexCount, claudeCount, accountChecked, accountCheckFailed, accountCheckUnavailable } = discovery;
760
1071
  const estimateSessions = includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
761
1072
  if (!sessions.length) {
762
1073
  console.log("No local Codex/Claude Code sessions found to migrate.");
@@ -768,12 +1079,19 @@ export async function cmdMigrate(flags) {
768
1079
  }
769
1080
  console.log("");
770
1081
  console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
1082
+ const pendingName = accountChecked ? "unprocessed" : "new/changed";
771
1083
  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`;
1084
+ ? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))} ${pendingName} selected`
1085
+ : `${c.bold(String(pending.length))} ${pendingName} to import`;
774
1086
  console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexCount} Codex + ${claudeCount} Claude Code) · ${pendingText}` +
775
1087
  (alreadyMigrated ? c.dim(`, ${alreadyMigrated} already migrated`) : "") +
776
1088
  (skippedActive ? c.dim(`, ${skippedActive} active skipped`) : ""));
1089
+ if (accountChecked)
1090
+ console.log(c.dim("Checked this EchoMem account; already processed conversations are skipped."));
1091
+ else if (accountCheckFailed)
1092
+ console.log(c.dim("Using local migration ledger because the account status check failed."));
1093
+ else if (accountCheckUnavailable)
1094
+ console.log(c.dim("Using local migration ledger; this API does not expose the optional account status precheck yet."));
777
1095
  const filters = [
778
1096
  minChars ? `min ${humanNum(minChars)} chars` : "",
779
1097
  maxChars ? `max ${humanNum(maxChars)} chars` : "",
package/dist/report.js CHANGED
@@ -510,6 +510,7 @@ export async function buildStatsPayload(stats, inject) {
510
510
  return {
511
511
  schemaVersion: 1,
512
512
  ...(inject?.partial ? { partial: true } : {}),
513
+ ...(inject?.discovery ? { discovery: inject.discovery } : {}),
513
514
  generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
514
515
  llmCallsUsed: 0,
515
516
  transcriptsUploaded: false,