@echomem/mcp 1.3.0 → 1.3.1

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
@@ -4,17 +4,18 @@
4
4
  *
5
5
  * Why this lives in the bridge (client-side): the session logs exist ONLY on the user's machine
6
6
  * (~/.codex/sessions, ~/.claude/projects). The bridge discovers each session, assembles it into a
7
- * `## `-turn transcript, and feeds it to the EXISTING durable import queue (the same one the extension
8
- * uses): POST /api/extension/import-sessions creates one import_jobs row per session, then the bridge
7
+ * text-turn-only `## ` transcript, and feeds it to the EXISTING durable import queue
8
+ * (the same one the extension uses): POST /api/extension/import-sessions creates one import_jobs row per session, then the bridge
9
9
  * POSTs each transcript to /api/extension/import-jobs/{id}/run. The web dashboard polls
10
10
  * GET /import-sessions/{id} for live progress — the bridge owns local-file access, the server owns the
11
11
  * queue + status. (Headless CLI also works; it just shows progress in the terminal.)
12
12
  *
13
13
  * Idempotency: the queue dedups by (platform, conversationId) → source_hash (a repeat run hits a
14
14
  * non-fatal DUPLICATE_SOURCE), and a local ledger (~/.echomem/migrate-ledger.json) skips done+unchanged
15
- * files WITHOUT a round-trip. Turns are joined with a SINGLE "\n" so the transcript stays byte-stable
16
- * across runs (guarded by test/migrate.test.mjs). The run is sequential (throttled < 30/min to the /run
17
- * limit) and stops cleanly if the encrypted vault's key expires mid-run.
15
+ * files WITHOUT a round-trip. A local metrics log (~/.echomem/migrate-metrics.jsonl) records per-job
16
+ * timings and sizes, but never transcript text. Turns are joined with a SINGLE "\n" so the transcript
17
+ * stays byte-stable across runs (guarded by test/migrate.test.mjs). The run is sequential (throttled
18
+ * < 30/min to the /run limit) and stops cleanly if the encrypted vault's key expires mid-run.
18
19
  * NOTE: client-pull — the queue advances only while the bridge runs; there is no server-side worker.
19
20
  */
20
21
  import fs from "node:fs";
@@ -29,6 +30,8 @@ import { walk, eachLine } from "./report.js";
29
30
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
30
31
  const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
31
32
  const RATE_WINDOW_MS = 60_000;
33
+ const APPROX_CHARS_PER_TOKEN = 4;
34
+ const ACTIVE_SESSION_GRACE_MS = 5 * 60_000;
32
35
  // ---------------------------------------------------------------------------
33
36
  // Assemblers — reconstruct a `## `-turn transcript from a raw session log.
34
37
  // ---------------------------------------------------------------------------
@@ -42,7 +45,7 @@ function cleanCodexUser(msg) {
42
45
  s = s.replace(/<(environment_context|user_instructions|permissions|app-context)>[\s\S]*?<\/\1>/g, "");
43
46
  return s.trim();
44
47
  }
45
- /** Codex rollout-*.jsonl → one session. session_meta is TOP-LEVEL `o.type`; turns are payload events. */
48
+ /** Codex rollout-*.jsonl → one session. User/assistant text turns are sent; tool/process events stay local. */
46
49
  export function assembleCodex(file) {
47
50
  let sessionId = null;
48
51
  let cwd = null;
@@ -87,14 +90,14 @@ export function assembleCodex(file) {
87
90
  conversationKey: `codex:${sessionId || sha16(file)}`,
88
91
  cwd: normalizeCwd(cwd),
89
92
  firstTs,
90
- title: title || "Codex session",
93
+ title: title || "Codex text turns",
91
94
  rawData: turns.join("\n"),
92
95
  turnCount: turns.length,
93
96
  size: stat.size,
94
97
  mtimeMs: stat.mtimeMs,
95
98
  };
96
99
  }
97
- /** Claude Code <uuid>.jsonl → one session. Only user/assistant lines are turns; thinking is dropped. */
100
+ /** Claude Code <uuid>.jsonl → one session. User/assistant text turns are sent; tool calls/results/thinking stay local. */
98
101
  export function assembleClaude(file) {
99
102
  let sessionId = null;
100
103
  let cwd = null;
@@ -113,7 +116,7 @@ export function assembleClaude(file) {
113
116
  if (typeof c === "string")
114
117
  body = c.trim();
115
118
  else if (Array.isArray(c)) {
116
- // tool_result-only carriers are NOT real user prompts skip; keep any genuine text blocks.
119
+ // Keep only genuine user text blocks. tool_result carriers stay local.
117
120
  body = c.filter((b) => b && b.type === "text" && b.text).map((b) => b.text).join("\n").trim();
118
121
  }
119
122
  if (!body)
@@ -132,9 +135,7 @@ export function assembleClaude(file) {
132
135
  for (const b of c) {
133
136
  if (b && b.type === "text" && b.text)
134
137
  parts.push(b.text);
135
- else if (b && b.type === "tool_use")
136
- parts.push(`[tool: ${b.name || "?"} ${truncate(JSON.stringify(b.input || {}), 120)}]`);
137
- // thinking blocks dropped (bulk of tokens, low memory value)
138
+ // thinking and tool_use blocks are process/code noise, not assistant output.
138
139
  }
139
140
  body = parts.join("\n").trim();
140
141
  }
@@ -147,7 +148,7 @@ export function assembleClaude(file) {
147
148
  if (!firstTs)
148
149
  firstTs = ts;
149
150
  }
150
- // queue-operation / attachment / ai-title / last-prompt / mode / summary / unknown → ignored
151
+ // tool_result carriers, queue-operation, attachment, ai-title, last-prompt, mode, summary, unknown → ignored
151
152
  });
152
153
  if (!turns.length)
153
154
  return null;
@@ -158,7 +159,7 @@ export function assembleClaude(file) {
158
159
  conversationKey: `claude:${sessionId || sha16(file)}`,
159
160
  cwd: normalizeCwd(cwd),
160
161
  firstTs,
161
- title: title || "Claude Code session",
162
+ title: title || "Claude text turns",
162
163
  rawData: turns.join("\n"),
163
164
  turnCount: turns.length,
164
165
  size: stat.size,
@@ -219,6 +220,67 @@ function saveLedger(l) {
219
220
  /* best effort */
220
221
  }
221
222
  }
223
+ export function defaultMigrationMetricsPath() {
224
+ return path.join(echoConfigDir(), "migrate-metrics.jsonl");
225
+ }
226
+ function sourceMtimeIso(s) {
227
+ return s.mtimeMs ? new Date(s.mtimeMs).toISOString() : null;
228
+ }
229
+ function projectName(s) {
230
+ if (s.cwd)
231
+ return path.basename(s.cwd);
232
+ const parent = path.basename(path.dirname(s.filePath));
233
+ return parent || null;
234
+ }
235
+ export function buildMigrationMetric(args) {
236
+ const s = args.session;
237
+ const metric = {
238
+ schemaVersion: 1,
239
+ recordedAt: new Date().toISOString(),
240
+ runId: args.runId,
241
+ importSessionId: args.importSessionId,
242
+ jobId: args.jobId,
243
+ index: args.index,
244
+ total: args.total,
245
+ status: args.status,
246
+ apiBase: API_BASE,
247
+ source: s.source,
248
+ conversationKey: s.conversationKey,
249
+ conversationId: bareId(s),
250
+ firstTs: s.firstTs,
251
+ date: s.firstTs ? s.firstTs.slice(0, 10) : null,
252
+ cwd: s.cwd,
253
+ project: projectName(s),
254
+ filePath: s.filePath,
255
+ rawDataChars: s.rawData.length,
256
+ approxInputTokens: approxTokens(s.rawData.length),
257
+ textTurns: s.turnCount,
258
+ sourceFileBytes: s.size,
259
+ sourceMtimeMs: s.mtimeMs,
260
+ sourceMtimeIso: sourceMtimeIso(s),
261
+ };
262
+ if (args.durationMs !== undefined)
263
+ metric.durationMs = args.durationMs;
264
+ if (args.processingTimeMs !== undefined)
265
+ metric.processingTimeMs = args.processingTimeMs;
266
+ if (args.ttfmMs !== undefined)
267
+ metric.ttfmMs = args.ttfmMs;
268
+ if (args.memories !== undefined)
269
+ metric.memories = args.memories;
270
+ if (args.alreadyDone !== undefined)
271
+ metric.alreadyDone = args.alreadyDone;
272
+ if (args.duplicate !== undefined)
273
+ metric.duplicate = args.duplicate;
274
+ if (args.error !== undefined)
275
+ metric.error = truncate(args.error, 240);
276
+ if (args.selection)
277
+ metric.selection = args.selection;
278
+ return metric;
279
+ }
280
+ export function appendMigrationMetric(filePath, metric) {
281
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
282
+ fs.appendFileSync(filePath, JSON.stringify(metric) + "\n", { mode: 0o600 });
283
+ }
222
284
  // ---------------------------------------------------------------------------
223
285
  // CLI
224
286
  // ---------------------------------------------------------------------------
@@ -227,114 +289,337 @@ const color = (enabled) => {
227
289
  return { bold: w("1"), dim: w("2"), red: w("31"), green: w("32"), cyan: w("36"), yellow: w("33") };
228
290
  };
229
291
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
292
+ const humanNum = (n) => Math.round(n).toLocaleString();
293
+ function humanDuration(ms) {
294
+ if (!Number.isFinite(ms) || ms <= 0)
295
+ return "0s";
296
+ const sec = Math.round(ms / 1000);
297
+ if (sec < 90)
298
+ return `${sec}s`;
299
+ const min = Math.floor(sec / 60);
300
+ const rem = sec % 60;
301
+ if (min < 90)
302
+ return rem ? `${min}m ${rem}s` : `${min}m`;
303
+ const hours = Math.floor(min / 60);
304
+ const mins = min % 60;
305
+ return mins ? `${hours}h ${mins}m` : `${hours}h`;
306
+ }
307
+ export function formatEta(seconds) {
308
+ if (!Number.isFinite(seconds) || seconds <= 0)
309
+ return "complete";
310
+ if (seconds < 120)
311
+ return "under 2 minutes";
312
+ const minutes = Math.ceil(seconds / 60);
313
+ if (minutes < 60)
314
+ return `about ${minutes} minutes`;
315
+ const hours = Math.floor(minutes / 60);
316
+ const mins = minutes % 60;
317
+ return mins ? `about ${hours} hr ${mins} min` : `about ${hours} hr`;
318
+ }
319
+ function printMigrationEstimate(e, useJson) {
320
+ if (useJson) {
321
+ console.log(JSON.stringify(e, null, 2));
322
+ return;
323
+ }
324
+ console.log("");
325
+ console.log("Migration estimate (local metadata only; no transcripts uploaded)");
326
+ console.log(` Sessions: ${humanNum(e.sessions)} total · ${humanNum(e.pending)} pending · ${humanNum(e.alreadyMigrated)} already processed`);
327
+ console.log(` Sources: ${humanNum(e.pendingCodex)} Codex + ${humanNum(e.pendingClaudeCode)} Claude Code pending`);
328
+ console.log(` Assembled transcript size: ${humanNum(e.chars.total)} chars ≈ ${humanNum(e.approxInputTokens.total)} input tokens`);
329
+ console.log(` Size percentiles: p50 ${humanNum(e.chars.p50)} chars · p90 ${humanNum(e.chars.p90)} · p95 ${humanNum(e.chars.p95)} · max ${humanNum(e.chars.max)}`);
330
+ console.log(` Turns: ${humanNum(e.turns.total)} total · p50 ${humanNum(e.turns.p50)} · p90 ${humanNum(e.turns.p90)} · max ${humanNum(e.turns.max)}`);
331
+ console.log("");
332
+ console.log(" Buckets:");
333
+ for (const b of e.buckets) {
334
+ console.log(` ${b.label.padEnd(22)} ${String(b.count).padStart(4)} sessions · ${humanNum(b.chars).padStart(12)} chars · ≈${humanNum(b.approxInputTokens)} tokens`);
335
+ }
336
+ console.log("");
337
+ console.log(` Queue floor from request throttle: ${humanDuration(e.queue.throttleFloorMinutes * 60_000)} (${e.queue.runRequestLimitPerMinute}/min, client-sequential).`);
338
+ console.log(` Large-session risk: ${e.queue.largeSessionCount} sessions >120k chars; ${e.queue.hugeSessionCount} >${humanNum(e.queue.needsChunkingAboveChars)} chars should be chunked or sampled before full migration.`);
339
+ console.log(" For a measured ETA, run a small real sample: echomem-mcp migrate --limit 5 --yes");
340
+ }
230
341
  function promptYesNo(question) {
231
342
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
232
343
  return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(!/^n/i.test(a.trim())); }));
233
344
  }
234
- export async function cmdMigrate(flags) {
235
- const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
236
- const store = new KeyStore();
237
- // Discover + filter (pure local — discovery and --dry-run need no login).
238
- let sessions = discoverSessions();
239
- const since = typeof flags.since === "string" ? flags.since : undefined;
240
- if (since)
241
- sessions = sessions.filter((s) => (s.firstTs || "").slice(0, 10) >= since);
242
- const limit = typeof flags.limit === "string" ? parseInt(flags.limit, 10) : undefined;
243
- if (limit && limit > 0)
244
- sessions = sessions.slice(0, limit);
245
- if (!sessions.length) {
246
- console.log("No local Codex/Claude Code sessions found to migrate.");
247
- return;
248
- }
249
- const ledger = loadLedger();
250
- const isNew = (s) => {
251
- const e = ledger[s.conversationKey];
252
- return !e || e.size !== s.size; // unseen, or the file grew/changed since last migrate
345
+ function parsePositiveIntFlag(value, name) {
346
+ if (value == null)
347
+ return undefined;
348
+ if (value === true)
349
+ throw new Error(`${name} requires a number.`);
350
+ const n = Number(value);
351
+ if (!Number.isInteger(n) || n <= 0)
352
+ throw new Error(`${name} must be a positive integer.`);
353
+ return n;
354
+ }
355
+ function isRecord(value) {
356
+ return typeof value === "object" && value !== null && !Array.isArray(value);
357
+ }
358
+ function responseStatus(e) {
359
+ const response = e.response;
360
+ return typeof response?.status === "number" ? response.status : undefined;
361
+ }
362
+ function responseData(e) {
363
+ const data = e.response?.data;
364
+ return isRecord(data) ? data : {};
365
+ }
366
+ function responseMessage(e) {
367
+ const data = responseData(e);
368
+ const message = data.message || data.error;
369
+ if (typeof message === "string")
370
+ return message;
371
+ return e instanceof Error ? e.message : String(e);
372
+ }
373
+ function codedError(code) {
374
+ const e = new Error(code);
375
+ e.code = code;
376
+ return e;
377
+ }
378
+ const bareId = (s) => s.conversationKey.slice(s.conversationKey.indexOf(":") + 1);
379
+ const mapKey = (platform, conv) => `${platform}:${conv}`;
380
+ const userTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
381
+ const approxTokens = (chars) => Math.ceil(chars / APPROX_CHARS_PER_TOKEN);
382
+ const jobLockMessage = (message) => /already running|not retryable|job did not start/i.test(message);
383
+ function sum(nums) {
384
+ return nums.reduce((n, x) => n + x, 0);
385
+ }
386
+ function percentile(nums, p) {
387
+ if (!nums.length)
388
+ return 0;
389
+ const s = [...nums].sort((a, b) => a - b);
390
+ return s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
391
+ }
392
+ export function estimateMigration(sessions, pending) {
393
+ const chars = pending.map((s) => s.rawData.length);
394
+ const turns = pending.map((s) => s.turnCount);
395
+ const pendingCodex = pending.filter((s) => s.source === "codex").length;
396
+ const bucketDefs = [
397
+ { label: "small <=10k chars", min: 0, max: 10_000 },
398
+ { label: "routine 10k-30k", min: 10_000, max: 30_000 },
399
+ { label: "medium 30k-60k", min: 30_000, max: 60_000 },
400
+ { label: "large 60k-120k", min: 60_000, max: 120_000 },
401
+ { label: "very large 120k-240k", min: 120_000, max: 240_000 },
402
+ { label: "huge >240k", min: 240_000, max: Number.POSITIVE_INFINITY },
403
+ ];
404
+ const buckets = bucketDefs.map((b) => {
405
+ const xs = pending.filter((s) => s.rawData.length > b.min && s.rawData.length <= b.max);
406
+ const bucketChars = sum(xs.map((s) => s.rawData.length));
407
+ return {
408
+ label: b.label,
409
+ count: xs.length,
410
+ chars: bucketChars,
411
+ approxInputTokens: approxTokens(bucketChars),
412
+ };
413
+ });
414
+ return {
415
+ sessions: sessions.length,
416
+ pending: pending.length,
417
+ alreadyMigrated: sessions.length - pending.length,
418
+ codex: sessions.filter((s) => s.source === "codex").length,
419
+ claudeCode: sessions.filter((s) => s.source === "claude-code").length,
420
+ pendingCodex,
421
+ pendingClaudeCode: pending.length - pendingCodex,
422
+ chars: {
423
+ total: sum(chars),
424
+ p50: percentile(chars, 0.5),
425
+ p75: percentile(chars, 0.75),
426
+ p90: percentile(chars, 0.9),
427
+ p95: percentile(chars, 0.95),
428
+ max: percentile(chars, 1),
429
+ },
430
+ approxInputTokens: {
431
+ total: approxTokens(sum(chars)),
432
+ p50: approxTokens(percentile(chars, 0.5)),
433
+ p90: approxTokens(percentile(chars, 0.9)),
434
+ p95: approxTokens(percentile(chars, 0.95)),
435
+ max: approxTokens(percentile(chars, 1)),
436
+ },
437
+ turns: {
438
+ total: sum(turns),
439
+ p50: percentile(turns, 0.5),
440
+ p90: percentile(turns, 0.9),
441
+ max: percentile(turns, 1),
442
+ },
443
+ buckets,
444
+ queue: {
445
+ mode: "client-sequential",
446
+ runRequestLimitPerMinute: RATE_MAX,
447
+ throttleFloorMinutes: pending.length ? Math.ceil(pending.length / RATE_MAX) : 0,
448
+ largeSessionCount: pending.filter((s) => s.rawData.length > 120_000).length,
449
+ hugeSessionCount: pending.filter((s) => s.rawData.length > 240_000).length,
450
+ needsChunkingAboveChars: 240_000,
451
+ },
253
452
  };
254
- const pending = sessions.filter(isNew);
255
- const codexN = sessions.filter((s) => s.source === "codex").length;
256
- const claudeN = sessions.length - codexN;
257
- console.log("");
258
- console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
259
- console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexN} Codex + ${claudeN} Claude Code) · ${c.bold(String(pending.length))} new/changed to import` +
260
- (sessions.length - pending.length ? c.dim(`, ${sessions.length - pending.length} already migrated`) : ""));
261
- if (flags["dry-run"] === true) {
262
- console.log(c.dim("\n--dry-run: discovered + assembled only, nothing sent.\n"));
263
- for (const s of pending.slice(0, 50)) {
264
- console.log(` ${s.source === "codex" ? "codex " : "claude"} ${(s.firstTs || "").slice(0, 10)} ${s.turnCount} turns ${c.dim(truncate(s.title, 60))}`);
265
- }
266
- if (pending.length > 50)
267
- console.log(c.dim(` … and ${pending.length - 50} more`));
268
- return;
453
+ }
454
+ export function estimateMigrationEta(pending, skippedActive = 0) {
455
+ 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 },
462
+ ];
463
+ 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));
466
+ return {
467
+ key: b.key,
468
+ label: b.label,
469
+ count: xs.length,
470
+ chars,
471
+ approxInputTokens: approxTokens(chars),
472
+ secondsPerSession: b.secondsPerSession,
473
+ };
474
+ });
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));
480
+ return {
481
+ pending: pending.length,
482
+ skippedActive,
483
+ totalChars,
484
+ approxInputTokens: approxTokens(totalChars),
485
+ estimatedSeconds,
486
+ estimatedLabel: formatEta(estimatedSeconds),
487
+ throttleFloorSeconds,
488
+ buckets,
489
+ };
490
+ }
491
+ export function applyMigrationSelection(sessions, opts = {}) {
492
+ let out = [...sessions];
493
+ if (typeof opts.minChars === "number") {
494
+ out = out.filter((s) => s.rawData.length >= opts.minChars);
269
495
  }
270
- if (!pending.length) {
271
- console.log(c.green("\n✓ Everything is already migrated — nothing to do.\n"));
272
- return;
496
+ if (typeof opts.maxChars === "number") {
497
+ out = out.filter((s) => s.rawData.length <= opts.maxChars);
273
498
  }
274
- // --- Below here we actually send → require a token + (if encrypted) an unlocked key. ---
275
- const token = store.getToken();
276
- if (!token) {
277
- console.error("Not logged in. Run `echomem-mcp login` first, then re-run migrate.");
278
- process.exitCode = 1;
279
- return;
499
+ if (opts.largest) {
500
+ out.sort((a, b) => b.rawData.length - a.rawData.length || String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
280
501
  }
281
- const client = axios.create({
502
+ if (opts.limit && opts.limit > 0) {
503
+ out = out.slice(0, opts.limit);
504
+ }
505
+ return out;
506
+ }
507
+ export function dedupeSessionsByConversation(sessions) {
508
+ const byKey = new Map();
509
+ for (const s of sessions) {
510
+ const prev = byKey.get(s.conversationKey);
511
+ if (!prev ||
512
+ s.rawData.length > prev.rawData.length ||
513
+ (s.rawData.length === prev.rawData.length && s.mtimeMs > prev.mtimeMs)) {
514
+ byKey.set(s.conversationKey, s);
515
+ }
516
+ }
517
+ return [...byKey.values()].sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
518
+ }
519
+ export function isActiveMigrationSession(session, nowMs = Date.now()) {
520
+ return !!session.mtimeMs && nowMs - session.mtimeMs >= 0 && nowMs - session.mtimeMs < ACTIVE_SESSION_GRACE_MS;
521
+ }
522
+ function authedClient(token) {
523
+ return axios.create({
282
524
  baseURL: API_BASE,
283
525
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
284
526
  });
527
+ }
528
+ export function discoverMigratableSessions(opts = {}) {
529
+ let sessions = discoverSessions();
530
+ const since = opts.since;
531
+ if (since)
532
+ sessions = sessions.filter((s) => (s.firstTs || "").slice(0, 10) >= since);
533
+ sessions = dedupeSessionsByConversation(sessions);
534
+ sessions = applyMigrationSelection(sessions, { minChars: opts.minChars, maxChars: opts.maxChars, largest: opts.largest });
535
+ const selectableSessions = opts.includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
536
+ const skippedActive = sessions.length - selectableSessions.length;
537
+ const ledger = loadLedger();
538
+ const pendingAll = selectableSessions.filter((s) => {
539
+ const e = ledger[s.conversationKey];
540
+ return !e || e.size !== s.size;
541
+ });
542
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
543
+ const codexCount = sessions.filter((s) => s.source === "codex").length;
544
+ return {
545
+ sessions,
546
+ pending,
547
+ pendingTotal: pendingAll.length,
548
+ alreadyMigrated: selectableSessions.length - pendingAll.length,
549
+ skippedActive,
550
+ limited: pending.length < pendingAll.length,
551
+ codexCount,
552
+ claudeCount: sessions.length - codexCount,
553
+ };
554
+ }
555
+ export async function startMigration(opts) {
556
+ if (opts.pending.length === 0)
557
+ throw codedError("NO_PENDING_SESSIONS");
558
+ const store = new KeyStore();
559
+ const token = store.getToken();
560
+ if (!token)
561
+ throw codedError("NOT_LOGGED_IN");
562
+ const client = authedClient(token);
285
563
  let encKey;
286
564
  try {
287
565
  const cfg = await fetchEncryptionConfig(client);
288
566
  if (cfg.enabled) {
289
567
  encKey = store.getKey();
290
- if (!encKey) {
291
- console.error(store.isKeyExpired()
292
- ? "Vault key expired. Run `echomem-mcp unlock`, then re-run migrate."
293
- : "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
294
- process.exitCode = 1;
295
- return;
296
- }
568
+ if (!encKey)
569
+ throw codedError("VAULT_LOCKED");
297
570
  }
298
571
  }
299
- catch {
300
- /* config fetch failed → proceed as unencrypted (server enforces 422 if it's actually encrypted) */
301
- }
302
- // Consent: one keypress (skipped with --yes or when non-interactive).
303
- if (flags.yes !== true && process.stdin.isTTY) {
304
- const ok = await promptYesNo(`Import ${pending.length} session(s) into your EchoMem memory? [Y/n] `);
305
- if (!ok) {
306
- console.log("Aborted. Nothing was sent.");
307
- return;
308
- }
572
+ catch (e) {
573
+ if (e?.code === "VAULT_LOCKED")
574
+ throw e;
575
+ /* config fetch failed proceed as unencrypted; server enforces if actually encrypted */
309
576
  }
310
- const userTz = Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
311
- const bareId = (s) => s.conversationKey.slice(s.conversationKey.indexOf(":") + 1);
312
- const mapKey = (platform, conv) => `${platform}:${conv}`;
313
- // 1) Create the import session — descriptors only, NO transcript. The server fans this out to one
314
- // import_jobs row per session, which is what the web dashboard polls (GET /import-sessions/{id}).
315
- let toImport = pending;
577
+ const tz = userTimeZone();
578
+ let toImport = opts.pending;
579
+ let capped;
316
580
  let session;
317
581
  try {
318
- session = await createImportSession(client, toImport, bareId, userTz);
582
+ session = await createImportSession(client, toImport, bareId, tz, opts.signal);
319
583
  }
320
584
  catch (e) {
321
- // Plan caps the batch → import the newest N and tell the user to re-run for the rest.
322
- const max = Number(e?.response?.data?.maxConversations);
323
- if (e?.response?.status === 422 && e?.response?.data?.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
324
- console.log(c.yellow(`Your plan imports up to ${max} at a time — importing the newest ${max}; re-run migrate for older sessions.`));
325
- toImport = pending.slice(0, max);
326
- session = await createImportSession(client, toImport, bareId, userTz);
585
+ if (responseStatus(e) === 403)
586
+ throw codedError("FORBIDDEN_SCOPE");
587
+ const data = responseData(e);
588
+ const max = Number(data.maxConversations);
589
+ if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
590
+ capped = max;
591
+ toImport = opts.pending.slice(0, max);
592
+ try {
593
+ session = await createImportSession(client, toImport, bareId, tz, opts.signal);
594
+ }
595
+ catch (retryError) {
596
+ if (responseStatus(retryError) === 403)
597
+ throw codedError("FORBIDDEN_SCOPE");
598
+ throw retryError;
599
+ }
327
600
  }
328
601
  else {
329
- console.error(c.red(`Could not start the import: ${e?.response?.data?.message || e?.response?.data?.error || e?.message || e}`));
330
- process.exitCode = 1;
331
- return;
602
+ throw e;
332
603
  }
333
604
  }
334
605
  const byConv = new Map(toImport.map((s) => [mapKey(s.source, bareId(s)), s]));
335
- console.log(c.dim(`Import session ${session.id} — ${session.jobs.length} jobs queued (the web dashboard can watch this live).`));
336
- // 2) Drive each job: the queue lives server-side, but only THIS machine can read the logs, so the
337
- // bridge streams each transcript to /import-jobs/{id}/run. The web just polls. Throttle < 30/min.
606
+ const runId = crypto.randomUUID();
607
+ const metricsFile = opts.metricsFile || defaultMigrationMetricsPath();
608
+ const done = runJobs({
609
+ client,
610
+ session,
611
+ byConv,
612
+ userTz: tz,
613
+ encKey,
614
+ onProgress: opts.onProgress,
615
+ runId,
616
+ metricsFile,
617
+ selection: opts.selection,
618
+ });
619
+ return { sessionId: session.id, runId, jobCount: session.jobs.length, metricsFile, ...(capped ? { capped } : {}), done };
620
+ }
621
+ async function runJobs(args) {
622
+ const ledger = loadLedger();
338
623
  const reqTimes = [];
339
624
  const throttle = async () => {
340
625
  for (;;) {
@@ -349,44 +634,248 @@ export async function cmdMigrate(flags) {
349
634
  }
350
635
  };
351
636
  let migrated = 0, extracted = 0, failed = 0, i = 0;
352
- for (const job of session.jobs) {
353
- const s = byConv.get(mapKey(job.platform, job.conversation_id));
354
- if (!s)
355
- continue;
356
- i++;
357
- const tag = `${c.dim(`[${i}/${session.jobs.length}]`)} ${s.source === "codex" ? "codex " : "claude"} ${(s.firstTs || "").slice(0, 10)}`;
637
+ const recordMetric = (metric) => {
358
638
  try {
359
- await throttle();
360
- const r = await runImportJob(client, job.id, s, userTz, encKey);
361
- extracted += r.memories;
362
- migrated++;
363
- ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
364
- saveLedger(ledger);
365
- const note = r.alreadyDone || r.duplicate ? c.dim("already imported") : c.green(`+${r.memories} ${r.memories === 1 ? "memory" : "memories"}`);
366
- console.log(`${tag} ${note} ${c.dim(truncate(s.title, 48))}`);
639
+ appendMigrationMetric(args.metricsFile, metric);
367
640
  }
368
- catch (e) {
369
- const status = e?.response?.status;
370
- const errCode = e?.response?.data?.error;
371
- // Vault key expired mid-run (TTL crossed) → stop cleanly; the rest is resumable on re-run.
372
- if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
373
- console.error(c.yellow(`\n⚠ Vault locked mid-run. Run \`echomem-mcp unlock\` and re-run migrate to resume (${migrated} done so far).`));
374
- process.exitCode = 1;
375
- break;
641
+ catch {
642
+ /* best effort: metrics must never block a user migration */
643
+ }
644
+ };
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;
712
+ 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,
723
+ }));
724
+ args.onProgress?.({ index: i, total: args.session.jobs.length, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
376
725
  }
377
- failed++;
378
- console.log(`${tag} ${c.red("✗ failed")} ${c.dim(truncate(String(e?.response?.data?.message || errCode || e?.message || e), 60))}`);
379
726
  }
380
727
  }
728
+ catch {
729
+ failed++;
730
+ }
731
+ return { migrated, extracted, failed };
732
+ }
733
+ export async function cmdMigrate(flags) {
734
+ const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
735
+ // Discover + filter (pure local — discovery and --dry-run need no login).
736
+ const since = typeof flags.since === "string" ? flags.since : undefined;
737
+ let limit;
738
+ let minChars;
739
+ let maxChars;
740
+ try {
741
+ limit = parsePositiveIntFlag(flags.limit, "--limit");
742
+ minChars = parsePositiveIntFlag(flags["min-chars"], "--min-chars");
743
+ maxChars = parsePositiveIntFlag(flags["max-chars"], "--max-chars");
744
+ }
745
+ catch (e) {
746
+ console.error(e instanceof Error ? e.message : String(e));
747
+ process.exitCode = 1;
748
+ return;
749
+ }
750
+ if (minChars && maxChars && minChars > maxChars) {
751
+ console.error("--min-chars cannot be greater than --max-chars.");
752
+ process.exitCode = 1;
753
+ return;
754
+ }
755
+ const largest = flags.largest === true;
756
+ const includeActive = flags["include-active"] === true;
757
+ const selection = { since, limit, minChars, maxChars, largest, includeActive };
758
+ const metricsFile = typeof flags["metrics-file"] === "string" ? flags["metrics-file"] : defaultMigrationMetricsPath();
759
+ const { sessions, pending, pendingTotal, alreadyMigrated, skippedActive, limited, codexCount, claudeCount } = discoverMigratableSessions(selection);
760
+ const estimateSessions = includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
761
+ if (!sessions.length) {
762
+ console.log("No local Codex/Claude Code sessions found to migrate.");
763
+ return;
764
+ }
765
+ if (flags.estimate === true && flags.json === true) {
766
+ printMigrationEstimate(estimateMigration(limit ? pending : estimateSessions, pending), true);
767
+ return;
768
+ }
381
769
  console.log("");
382
- console.log(c.bold("Done.") + ` ${c.green(String(migrated) + " imported")} · ${c.bold(String(extracted))} memories` +
383
- (failed ? ` · ${c.red(String(failed) + " failed")}` : "") + ".");
384
- if (extracted > 0) {
385
- console.log(c.dim("Now ask your agent about a past project — it can recall it from memory.\n"));
770
+ console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
771
+ 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`;
774
+ console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexCount} Codex + ${claudeCount} Claude Code) · ${pendingText}` +
775
+ (alreadyMigrated ? c.dim(`, ${alreadyMigrated} already migrated`) : "") +
776
+ (skippedActive ? c.dim(`, ${skippedActive} active skipped`) : ""));
777
+ const filters = [
778
+ minChars ? `min ${humanNum(minChars)} chars` : "",
779
+ maxChars ? `max ${humanNum(maxChars)} chars` : "",
780
+ largest ? "largest first" : "",
781
+ includeActive ? "include active" : "",
782
+ limit ? `limit ${humanNum(limit)}` : "",
783
+ ].filter(Boolean).join(" · ");
784
+ if (filters)
785
+ console.log(c.dim(`Selection: ${filters}`));
786
+ if (flags.estimate === true) {
787
+ printMigrationEstimate(estimateMigration(limit ? pending : estimateSessions, pending), false);
788
+ return;
789
+ }
790
+ if (flags["dry-run"] === true) {
791
+ console.log(c.dim("\n--dry-run: discovered + assembled only, nothing sent.\n"));
792
+ for (const s of pending.slice(0, 50)) {
793
+ console.log(` ${s.source === "codex" ? "codex " : "claude"} ${(s.firstTs || "").slice(0, 10)} ${humanNum(s.rawData.length)} chars ${s.turnCount} text turns`);
794
+ }
795
+ if (pending.length > 50)
796
+ console.log(c.dim(` … and ${pending.length - 50} more`));
797
+ return;
798
+ }
799
+ if (!pending.length) {
800
+ console.log(c.green("\n✓ Everything is already migrated — nothing to do.\n"));
801
+ return;
802
+ }
803
+ // Consent: one keypress (skipped with --yes or when non-interactive).
804
+ if (flags.yes !== true && process.stdin.isTTY) {
805
+ const ok = await promptYesNo(`Import ${pending.length} session(s) into your EchoMem memory? [Y/n] `);
806
+ if (!ok) {
807
+ console.log("Aborted. Nothing was sent.");
808
+ return;
809
+ }
810
+ }
811
+ try {
812
+ const jobDurations = [];
813
+ const h = await startMigration({
814
+ pending,
815
+ metricsFile,
816
+ selection,
817
+ onProgress: (ev) => {
818
+ const tag = `${c.dim(`[${ev.index}/${ev.total}]`)} ${ev.session.source === "codex" ? "codex " : "claude"} ${(ev.session.firstTs || "").slice(0, 10)}`;
819
+ if (ev.error) {
820
+ console.log(`${tag} ${c.red("✗ failed")} ${c.dim(truncate(ev.error, 60))}` + (ev.durationMs ? c.dim(` ${humanDuration(ev.durationMs)}`) : ""));
821
+ return;
822
+ }
823
+ if (ev.durationMs)
824
+ jobDurations.push(ev.durationMs);
825
+ const eta = jobDurations.length > 0
826
+ ? humanDuration(percentile(jobDurations, 0.5) * Math.max(0, ev.total - ev.index))
827
+ : null;
828
+ const memories = ev.memories ?? 0;
829
+ const note = ev.alreadyDone
830
+ ? c.dim("already completed")
831
+ : memories === 0
832
+ ? c.dim("0 memories")
833
+ : c.green(`+${memories} ${memories === 1 ? "memory" : "memories"}`);
834
+ const timing = ev.durationMs
835
+ ? c.dim(` ${humanDuration(ev.durationMs)}${ev.ttfmMs ? ` (first memory ${humanDuration(ev.ttfmMs)})` : ""}${eta ? ` · ETA ${eta}` : ""}`)
836
+ : "";
837
+ console.log(`${tag} ${note} ${c.dim(`${humanNum(ev.session.rawData.length)} chars · ${ev.session.turnCount} turns`)}${timing}`);
838
+ },
839
+ });
840
+ if (h.capped)
841
+ console.log(c.yellow(`Your plan imports up to ${h.capped} at a time — importing the newest ${h.capped}; re-run migrate for older sessions.`));
842
+ console.log(c.dim(`Import session ${h.sessionId} — ${h.jobCount} jobs queued (the web dashboard can watch this live).`));
843
+ console.log(c.dim(`Metrics: ${h.metricsFile}`));
844
+ const r = await h.done;
845
+ if (r.stoppedReason === "key-expired") {
846
+ console.error(c.yellow(`\n⚠ Vault locked mid-run. Run \`echomem-mcp unlock\` and re-run migrate to resume (${r.migrated} done so far).`));
847
+ process.exitCode = 1;
848
+ }
849
+ console.log("");
850
+ console.log(c.bold("Done.") + ` ${c.green(String(r.migrated) + " imported")} · ${c.bold(String(r.extracted))} memories` +
851
+ (r.failed ? ` · ${c.red(String(r.failed) + " failed")}` : "") + ".");
852
+ if (r.extracted > 0) {
853
+ console.log(c.dim("Now ask your agent about a past project — it can recall it from memory.\n"));
854
+ }
855
+ if (r.failed)
856
+ process.exitCode = 1;
857
+ }
858
+ catch (e) {
859
+ const code = e?.code;
860
+ if (code === "NOT_LOGGED_IN")
861
+ console.error("Not logged in. Run `echomem-mcp login` first, then re-run migrate.");
862
+ else if (code === "VAULT_LOCKED") {
863
+ const store = new KeyStore();
864
+ console.error(store.isKeyExpired()
865
+ ? "Vault key expired. Run `echomem-mcp unlock`, then re-run migrate."
866
+ : "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
867
+ }
868
+ else if (code === "FORBIDDEN_SCOPE") {
869
+ console.error("This device token cannot import history. Re-connect this device with `echomem-mcp setup`.");
870
+ }
871
+ else {
872
+ console.error(c.red(`Could not start the import: ${responseMessage(e)}`));
873
+ }
874
+ process.exitCode = 1;
386
875
  }
387
876
  }
388
877
  /** Create an import session (lightweight descriptors only — NO transcript). Returns the session id + jobs. */
389
- async function createImportSession(client, sessions, bareId, userTz) {
878
+ async function createImportSession(client, sessions, bareId, userTz, signal) {
390
879
  const items = sessions.map((s) => ({
391
880
  conversationId: bareId(s),
392
881
  platform: s.source, // free-text label; also half of the (session, platform, conversation) key
@@ -394,12 +883,13 @@ async function createImportSession(client, sessions, bareId, userTz) {
394
883
  sourceDate: s.firstTs,
395
884
  userTz,
396
885
  }));
397
- const res = await client.post("/api/extension/import-sessions", { items });
886
+ const res = await client.post("/api/extension/import-sessions", { items }, signal ? { signal } : undefined);
398
887
  const data = res.data || {};
399
888
  return { id: String(data.session?.id || ""), jobs: Array.isArray(data.jobs) ? data.jobs : [] };
400
889
  }
401
- /** Run one queued job: stream its transcript to the server, which extracts it. One retry on 429/5xx. */
890
+ /** Run one queued job: stream its transcript to the server, which extracts it. Retries transient locks/rate limits. */
402
891
  async function runImportJob(client, jobId, s, userTz, encKey) {
892
+ const startedAt = Date.now();
403
893
  const body = {
404
894
  rawData: s.rawData,
405
895
  source: s.source, // NOT containing "mcp" (avoids the route's MCP title truncation)
@@ -415,24 +905,41 @@ async function runImportJob(client, jobId, s, userTz, encKey) {
415
905
  const data = res.data || {};
416
906
  if (data.claimed === false) {
417
907
  // Already completed on a prior run → idempotent, not an error; otherwise it didn't start.
418
- if (data.job?.status === "completed")
419
- return { memories: Number(data.job?.saved_memory_count) || 0, alreadyDone: true, duplicate: false };
908
+ if (data.job?.status === "completed") {
909
+ return {
910
+ memories: Number(data.job?.saved_memory_count) || 0,
911
+ alreadyDone: true,
912
+ duplicate: false,
913
+ durationMs: Date.now() - startedAt,
914
+ };
915
+ }
420
916
  throw new Error(data.message || "job did not start");
421
917
  }
422
918
  const result = data.result || {};
423
- return { memories: Number(result.memoriesExtracted) || 0, alreadyDone: false, duplicate: !!result.duplicate };
919
+ return {
920
+ memories: Number(result.memoriesExtracted) || 0,
921
+ alreadyDone: false,
922
+ duplicate: !!result.duplicate,
923
+ durationMs: Date.now() - startedAt,
924
+ processingTimeMs: typeof result.processingTimeMs === "number" ? result.processingTimeMs : undefined,
925
+ ttfmMs: typeof result.ttfmMs === "number" ? result.ttfmMs : undefined,
926
+ };
424
927
  }
425
928
  catch (e) {
426
929
  const status = e?.response?.status;
930
+ const message = e instanceof Error ? e.message : responseMessage(e);
427
931
  if (status === 422 && e?.response?.data?.error === "ENCRYPTION_KEY_REQUIRED")
428
932
  throw e; // not retryable
429
- if (status === 409)
430
- throw e; // claim conflict don't hammer a job another worker holds
431
- const retryable = status === 429 || (status >= 500 && status < 600) || e?.code === "ECONNABORTED" || !status;
432
- if (!retryable || attempt >= 2)
933
+ const lockConflict = status === 409 || jobLockMessage(message);
934
+ const retryable = lockConflict || status === 429 || (status >= 500 && status < 600) || e?.code === "ECONNABORTED" || !status;
935
+ const maxAttempts = lockConflict ? 5 : 2;
936
+ if (!retryable || attempt >= maxAttempts)
433
937
  throw e;
434
938
  const retryAfter = Number(e?.response?.headers?.["retry-after"]) || Number(e?.response?.data?.retryAfterSeconds);
435
- await sleep((status === 429 && retryAfter > 0 ? retryAfter : Math.pow(2, attempt) * 2) * 1000);
939
+ const backoffSeconds = lockConflict
940
+ ? Math.min(30, 5 * (attempt + 1))
941
+ : Math.pow(2, attempt) * 2;
942
+ await sleep((status === 429 && retryAfter > 0 ? retryAfter : backoffSeconds) * 1000);
436
943
  }
437
944
  }
438
945
  }