@echomem/mcp 1.3.0 → 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
@@ -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,10 @@ 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
+ 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
35
+ const APPROX_CHARS_PER_TOKEN = 4;
36
+ const ACTIVE_SESSION_GRACE_MS = 5 * 60_000;
32
37
  // ---------------------------------------------------------------------------
33
38
  // Assemblers — reconstruct a `## `-turn transcript from a raw session log.
34
39
  // ---------------------------------------------------------------------------
@@ -42,7 +47,7 @@ function cleanCodexUser(msg) {
42
47
  s = s.replace(/<(environment_context|user_instructions|permissions|app-context)>[\s\S]*?<\/\1>/g, "");
43
48
  return s.trim();
44
49
  }
45
- /** Codex rollout-*.jsonl → one session. session_meta is TOP-LEVEL `o.type`; turns are payload events. */
50
+ /** Codex rollout-*.jsonl → one session. User/assistant text turns are sent; tool/process events stay local. */
46
51
  export function assembleCodex(file) {
47
52
  let sessionId = null;
48
53
  let cwd = null;
@@ -87,14 +92,14 @@ export function assembleCodex(file) {
87
92
  conversationKey: `codex:${sessionId || sha16(file)}`,
88
93
  cwd: normalizeCwd(cwd),
89
94
  firstTs,
90
- title: title || "Codex session",
95
+ title: title || "Codex text turns",
91
96
  rawData: turns.join("\n"),
92
97
  turnCount: turns.length,
93
98
  size: stat.size,
94
99
  mtimeMs: stat.mtimeMs,
95
100
  };
96
101
  }
97
- /** Claude Code <uuid>.jsonl → one session. Only user/assistant lines are turns; thinking is dropped. */
102
+ /** Claude Code <uuid>.jsonl → one session. User/assistant text turns are sent; tool calls/results/thinking stay local. */
98
103
  export function assembleClaude(file) {
99
104
  let sessionId = null;
100
105
  let cwd = null;
@@ -113,7 +118,7 @@ export function assembleClaude(file) {
113
118
  if (typeof c === "string")
114
119
  body = c.trim();
115
120
  else if (Array.isArray(c)) {
116
- // tool_result-only carriers are NOT real user prompts skip; keep any genuine text blocks.
121
+ // Keep only genuine user text blocks. tool_result carriers stay local.
117
122
  body = c.filter((b) => b && b.type === "text" && b.text).map((b) => b.text).join("\n").trim();
118
123
  }
119
124
  if (!body)
@@ -132,9 +137,7 @@ export function assembleClaude(file) {
132
137
  for (const b of c) {
133
138
  if (b && b.type === "text" && b.text)
134
139
  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)
140
+ // thinking and tool_use blocks are process/code noise, not assistant output.
138
141
  }
139
142
  body = parts.join("\n").trim();
140
143
  }
@@ -147,7 +150,7 @@ export function assembleClaude(file) {
147
150
  if (!firstTs)
148
151
  firstTs = ts;
149
152
  }
150
- // queue-operation / attachment / ai-title / last-prompt / mode / summary / unknown → ignored
153
+ // tool_result carriers, queue-operation, attachment, ai-title, last-prompt, mode, summary, unknown → ignored
151
154
  });
152
155
  if (!turns.length)
153
156
  return null;
@@ -155,10 +158,10 @@ export function assembleClaude(file) {
155
158
  return {
156
159
  filePath: file,
157
160
  source: "claude-code",
158
- conversationKey: `claude:${sessionId || sha16(file)}`,
161
+ conversationKey: `claude-code:${sessionId || sha16(file)}`,
159
162
  cwd: normalizeCwd(cwd),
160
163
  firstTs,
161
- title: title || "Claude Code session",
164
+ title: title || "Claude text turns",
162
165
  rawData: turns.join("\n"),
163
166
  turnCount: turns.length,
164
167
  size: stat.size,
@@ -199,6 +202,99 @@ export function discoverSessions() {
199
202
  out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
200
203
  return out;
201
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
+ }
202
298
  function ledgerPath() {
203
299
  return path.join(echoConfigDir(), "migrate-ledger.json");
204
300
  }
@@ -219,6 +315,102 @@ function saveLedger(l) {
219
315
  /* best effort */
220
316
  }
221
317
  }
318
+ export function defaultMigrationMetricsPath() {
319
+ return path.join(echoConfigDir(), "migrate-metrics.jsonl");
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
+ }
356
+ function sourceMtimeIso(s) {
357
+ return s.mtimeMs ? new Date(s.mtimeMs).toISOString() : null;
358
+ }
359
+ function projectName(s) {
360
+ if (s.cwd)
361
+ return path.basename(s.cwd);
362
+ const parent = path.basename(path.dirname(s.filePath));
363
+ return parent || null;
364
+ }
365
+ export function buildMigrationMetric(args) {
366
+ const s = args.session;
367
+ const metric = {
368
+ schemaVersion: 1,
369
+ recordedAt: new Date().toISOString(),
370
+ runId: args.runId,
371
+ importSessionId: args.importSessionId,
372
+ jobId: args.jobId,
373
+ index: args.index,
374
+ total: args.total,
375
+ status: args.status,
376
+ apiBase: API_BASE,
377
+ source: s.source,
378
+ conversationKey: s.conversationKey,
379
+ conversationId: bareId(s),
380
+ firstTs: s.firstTs,
381
+ date: s.firstTs ? s.firstTs.slice(0, 10) : null,
382
+ cwd: s.cwd,
383
+ project: projectName(s),
384
+ filePath: s.filePath,
385
+ rawDataChars: s.rawData.length,
386
+ approxInputTokens: approxTokens(s.rawData.length),
387
+ textTurns: s.turnCount,
388
+ sourceFileBytes: s.size,
389
+ sourceMtimeMs: s.mtimeMs,
390
+ sourceMtimeIso: sourceMtimeIso(s),
391
+ };
392
+ if (args.durationMs !== undefined)
393
+ metric.durationMs = args.durationMs;
394
+ if (args.processingTimeMs !== undefined)
395
+ metric.processingTimeMs = args.processingTimeMs;
396
+ if (args.ttfmMs !== undefined)
397
+ metric.ttfmMs = args.ttfmMs;
398
+ if (args.memories !== undefined)
399
+ metric.memories = args.memories;
400
+ if (args.alreadyDone !== undefined)
401
+ metric.alreadyDone = args.alreadyDone;
402
+ if (args.duplicate !== undefined)
403
+ metric.duplicate = args.duplicate;
404
+ if (args.error !== undefined)
405
+ metric.error = truncate(args.error, 240);
406
+ if (args.selection)
407
+ metric.selection = args.selection;
408
+ return metric;
409
+ }
410
+ export function appendMigrationMetric(filePath, metric) {
411
+ fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
412
+ fs.appendFileSync(filePath, JSON.stringify(metric) + "\n", { mode: 0o600 });
413
+ }
222
414
  // ---------------------------------------------------------------------------
223
415
  // CLI
224
416
  // ---------------------------------------------------------------------------
@@ -227,114 +419,511 @@ const color = (enabled) => {
227
419
  return { bold: w("1"), dim: w("2"), red: w("31"), green: w("32"), cyan: w("36"), yellow: w("33") };
228
420
  };
229
421
  const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
422
+ const humanNum = (n) => Math.round(n).toLocaleString();
423
+ function humanDuration(ms) {
424
+ if (!Number.isFinite(ms) || ms <= 0)
425
+ return "0s";
426
+ const sec = Math.round(ms / 1000);
427
+ if (sec < 90)
428
+ return `${sec}s`;
429
+ const min = Math.floor(sec / 60);
430
+ const rem = sec % 60;
431
+ if (min < 90)
432
+ return rem ? `${min}m ${rem}s` : `${min}m`;
433
+ const hours = Math.floor(min / 60);
434
+ const mins = min % 60;
435
+ return mins ? `${hours}h ${mins}m` : `${hours}h`;
436
+ }
437
+ export function formatEta(seconds) {
438
+ if (!Number.isFinite(seconds) || seconds <= 0)
439
+ return "complete";
440
+ if (seconds < 120)
441
+ return "under 2 minutes";
442
+ const minutes = Math.ceil(seconds / 60);
443
+ if (minutes < 60)
444
+ return `about ${minutes} minutes`;
445
+ const hours = Math.floor(minutes / 60);
446
+ const mins = minutes % 60;
447
+ return mins ? `about ${hours} hr ${mins} min` : `about ${hours} hr`;
448
+ }
449
+ function printMigrationEstimate(e, useJson) {
450
+ if (useJson) {
451
+ console.log(JSON.stringify(e, null, 2));
452
+ return;
453
+ }
454
+ console.log("");
455
+ console.log("Migration estimate (local metadata only; no transcripts uploaded)");
456
+ console.log(` Sessions: ${humanNum(e.sessions)} total · ${humanNum(e.pending)} pending · ${humanNum(e.alreadyMigrated)} already processed`);
457
+ console.log(` Sources: ${humanNum(e.pendingCodex)} Codex + ${humanNum(e.pendingClaudeCode)} Claude Code pending`);
458
+ console.log(` Assembled transcript size: ${humanNum(e.chars.total)} chars ≈ ${humanNum(e.approxInputTokens.total)} input tokens`);
459
+ 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)}`);
460
+ console.log(` Turns: ${humanNum(e.turns.total)} total · p50 ${humanNum(e.turns.p50)} · p90 ${humanNum(e.turns.p90)} · max ${humanNum(e.turns.max)}`);
461
+ console.log("");
462
+ console.log(" Buckets:");
463
+ for (const b of e.buckets) {
464
+ console.log(` ${b.label.padEnd(22)} ${String(b.count).padStart(4)} sessions · ${humanNum(b.chars).padStart(12)} chars · ≈${humanNum(b.approxInputTokens)} tokens`);
465
+ }
466
+ console.log("");
467
+ console.log(` Queue floor from request throttle: ${humanDuration(e.queue.throttleFloorMinutes * 60_000)} (${e.queue.runRequestLimitPerMinute}/min, client-sequential).`);
468
+ 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.`);
469
+ console.log(" For a measured ETA, run a small real sample: echomem-mcp migrate --limit 5 --yes");
470
+ }
230
471
  function promptYesNo(question) {
231
472
  const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
232
473
  return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(!/^n/i.test(a.trim())); }));
233
474
  }
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
- }
475
+ function parsePositiveIntFlag(value, name) {
476
+ if (value == null)
477
+ return undefined;
478
+ if (value === true)
479
+ throw new Error(`${name} requires a number.`);
480
+ const n = Number(value);
481
+ if (!Number.isInteger(n) || n <= 0)
482
+ throw new Error(`${name} must be a positive integer.`);
483
+ return n;
484
+ }
485
+ function isRecord(value) {
486
+ return typeof value === "object" && value !== null && !Array.isArray(value);
487
+ }
488
+ function responseStatus(e) {
489
+ const response = e.response;
490
+ return typeof response?.status === "number" ? response.status : undefined;
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
+ }
497
+ function responseData(e) {
498
+ const data = e.response?.data;
499
+ return isRecord(data) ? data : {};
500
+ }
501
+ function responseMessage(e) {
502
+ const data = responseData(e);
503
+ const message = data.message || data.error;
504
+ if (typeof message === "string")
505
+ return message;
506
+ return e instanceof Error ? e.message : String(e);
507
+ }
508
+ function codedError(code) {
509
+ const e = new Error(code);
510
+ e.code = code;
511
+ return e;
512
+ }
513
+ const bareId = (s) => s.conversationKey.slice(s.conversationKey.indexOf(":") + 1);
514
+ const mapKey = (platform, conv) => `${platform}:${conv}`;
515
+ const userTimeZone = () => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
516
+ const approxTokens = (chars) => Math.ceil(chars / APPROX_CHARS_PER_TOKEN);
517
+ const jobLockMessage = (message) => /already running|not retryable|job did not start/i.test(message);
518
+ function sum(nums) {
519
+ return nums.reduce((n, x) => n + x, 0);
520
+ }
521
+ function percentile(nums, p) {
522
+ if (!nums.length)
523
+ return 0;
524
+ const s = [...nums].sort((a, b) => a - b);
525
+ return s[Math.min(s.length - 1, Math.floor((s.length - 1) * p))] || 0;
526
+ }
527
+ export function estimateMigration(sessions, pending) {
528
+ const chars = pending.map((s) => s.rawData.length);
529
+ const turns = pending.map((s) => s.turnCount);
530
+ const pendingCodex = pending.filter((s) => s.source === "codex").length;
531
+ const bucketDefs = [
532
+ { label: "small <=10k chars", min: 0, max: 10_000 },
533
+ { label: "routine 10k-30k", min: 10_000, max: 30_000 },
534
+ { label: "medium 30k-60k", min: 30_000, max: 60_000 },
535
+ { label: "large 60k-120k", min: 60_000, max: 120_000 },
536
+ { label: "very large 120k-240k", min: 120_000, max: 240_000 },
537
+ { label: "huge >240k", min: 240_000, max: Number.POSITIVE_INFINITY },
538
+ ];
539
+ const buckets = bucketDefs.map((b) => {
540
+ const xs = pending.filter((s) => s.rawData.length > b.min && s.rawData.length <= b.max);
541
+ const bucketChars = sum(xs.map((s) => s.rawData.length));
542
+ return {
543
+ label: b.label,
544
+ count: xs.length,
545
+ chars: bucketChars,
546
+ approxInputTokens: approxTokens(bucketChars),
547
+ };
548
+ });
549
+ return {
550
+ sessions: sessions.length,
551
+ pending: pending.length,
552
+ alreadyMigrated: sessions.length - pending.length,
553
+ codex: sessions.filter((s) => s.source === "codex").length,
554
+ claudeCode: sessions.filter((s) => s.source === "claude-code").length,
555
+ pendingCodex,
556
+ pendingClaudeCode: pending.length - pendingCodex,
557
+ chars: {
558
+ total: sum(chars),
559
+ p50: percentile(chars, 0.5),
560
+ p75: percentile(chars, 0.75),
561
+ p90: percentile(chars, 0.9),
562
+ p95: percentile(chars, 0.95),
563
+ max: percentile(chars, 1),
564
+ },
565
+ approxInputTokens: {
566
+ total: approxTokens(sum(chars)),
567
+ p50: approxTokens(percentile(chars, 0.5)),
568
+ p90: approxTokens(percentile(chars, 0.9)),
569
+ p95: approxTokens(percentile(chars, 0.95)),
570
+ max: approxTokens(percentile(chars, 1)),
571
+ },
572
+ turns: {
573
+ total: sum(turns),
574
+ p50: percentile(turns, 0.5),
575
+ p90: percentile(turns, 0.9),
576
+ max: percentile(turns, 1),
577
+ },
578
+ buckets,
579
+ queue: {
580
+ mode: "client-sequential",
581
+ runRequestLimitPerMinute: RATE_MAX,
582
+ throttleFloorMinutes: pending.length ? Math.ceil(pending.length / RATE_MAX) : 0,
583
+ largeSessionCount: pending.filter((s) => s.rawData.length > 120_000).length,
584
+ hugeSessionCount: pending.filter((s) => s.rawData.length > 240_000).length,
585
+ needsChunkingAboveChars: 240_000,
586
+ },
587
+ };
588
+ }
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 = {}) {
595
+ const bucketDefs = [
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 },
602
+ ];
603
+ const buckets = bucketDefs.map((b) => {
604
+ const xs = lengths.filter((n) => n > b.min && n <= b.max);
605
+ const chars = sum(xs);
606
+ return {
607
+ key: b.key,
608
+ label: b.label,
609
+ count: xs.length,
610
+ chars,
611
+ approxInputTokens: approxTokens(chars),
612
+ secondsPerSession: b.secondsPerSession,
613
+ };
614
+ });
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);
626
+ return {
627
+ pending: lengths.length,
628
+ skippedActive,
629
+ totalChars,
630
+ approxInputTokens: approxTokens(totalChars),
631
+ estimatedSeconds,
632
+ estimatedLabel: formatEta(estimatedSeconds),
633
+ throttleFloorSeconds,
634
+ buckets,
635
+ };
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();
249
656
  const ledger = loadLedger();
250
- const isNew = (s) => {
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) => {
251
679
  const e = ledger[s.conversationKey];
252
- return !e || e.size !== s.size; // unseen, or the file grew/changed since last migrate
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,
253
693
  };
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;
694
+ }
695
+ export function discoverMigratableSummaryFast(opts = {}) {
696
+ return summarizeFastMigratableDiscovery(discoverMigratableFastDiscovery(opts));
697
+ }
698
+ export function applyMigrationSelection(sessions, opts = {}) {
699
+ let out = [...sessions];
700
+ if (typeof opts.minChars === "number") {
701
+ out = out.filter((s) => s.rawData.length >= opts.minChars);
269
702
  }
270
- if (!pending.length) {
271
- console.log(c.green("\n✓ Everything is already migrated — nothing to do.\n"));
272
- return;
703
+ if (typeof opts.maxChars === "number") {
704
+ out = out.filter((s) => s.rawData.length <= opts.maxChars);
273
705
  }
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;
706
+ if (opts.largest) {
707
+ out.sort((a, b) => b.rawData.length - a.rawData.length || String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
708
+ }
709
+ if (opts.limit && opts.limit > 0) {
710
+ out = out.slice(0, opts.limit);
711
+ }
712
+ return out;
713
+ }
714
+ export function dedupeSessionsByConversation(sessions) {
715
+ const byKey = new Map();
716
+ for (const s of sessions) {
717
+ const prev = byKey.get(s.conversationKey);
718
+ if (!prev ||
719
+ s.rawData.length > prev.rawData.length ||
720
+ (s.rawData.length === prev.rawData.length && s.mtimeMs > prev.mtimeMs)) {
721
+ byKey.set(s.conversationKey, s);
722
+ }
280
723
  }
281
- const client = axios.create({
724
+ return [...byKey.values()].sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
725
+ }
726
+ export function isActiveMigrationSession(session, nowMs = Date.now()) {
727
+ return !!session.mtimeMs && nowMs - session.mtimeMs >= 0 && nowMs - session.mtimeMs < ACTIVE_SESSION_GRACE_MS;
728
+ }
729
+ function authedClient(token) {
730
+ return axios.create({
282
731
  baseURL: API_BASE,
283
732
  headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
284
733
  });
734
+ }
735
+ export function discoverMigratableSessions(opts = {}) {
736
+ let sessions = discoverSessions();
737
+ const since = opts.since;
738
+ if (since)
739
+ sessions = sessions.filter((s) => (s.firstTs || "").slice(0, 10) >= since);
740
+ sessions = dedupeSessionsByConversation(sessions);
741
+ sessions = applyMigrationSelection(sessions, { minChars: opts.minChars, maxChars: opts.maxChars, largest: opts.largest });
742
+ const selectableSessions = opts.includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
743
+ const skippedActive = sessions.length - selectableSessions.length;
744
+ const ledger = loadLedger();
745
+ const pendingAll = selectableSessions.filter((s) => {
746
+ const e = ledger[s.conversationKey];
747
+ return !e || e.size !== s.size;
748
+ });
749
+ const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
750
+ const codexCount = sessions.filter((s) => s.source === "codex").length;
751
+ return {
752
+ sessions,
753
+ pending,
754
+ pendingTotal: pendingAll.length,
755
+ alreadyMigrated: selectableSessions.length - pendingAll.length,
756
+ skippedActive,
757
+ limited: pending.length < pendingAll.length,
758
+ codexCount,
759
+ claudeCount: sessions.length - codexCount,
760
+ };
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
+ }
856
+ export async function startMigration(opts) {
857
+ if (opts.pending.length === 0)
858
+ throw codedError("NO_PENDING_SESSIONS");
859
+ const store = new KeyStore();
860
+ const token = store.getToken();
861
+ if (!token)
862
+ throw codedError("NOT_LOGGED_IN");
863
+ const client = authedClient(token);
285
864
  let encKey;
286
865
  try {
287
866
  const cfg = await fetchEncryptionConfig(client);
288
867
  if (cfg.enabled) {
289
868
  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
- }
869
+ if (!encKey)
870
+ throw codedError("VAULT_LOCKED");
297
871
  }
298
872
  }
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
- }
873
+ catch (e) {
874
+ if (e?.code === "VAULT_LOCKED")
875
+ throw e;
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;
309
880
  }
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;
881
+ const tz = userTimeZone();
882
+ let toImport = opts.pending;
883
+ let capped;
316
884
  let session;
317
885
  try {
318
- session = await createImportSession(client, toImport, bareId, userTz);
886
+ session = await createImportSession(client, toImport, bareId, tz, opts.signal);
319
887
  }
320
888
  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);
889
+ if (responseStatus(e) === 403)
890
+ throw codedError("FORBIDDEN_SCOPE");
891
+ const data = responseData(e);
892
+ const max = Number(data.maxConversations);
893
+ if (responseStatus(e) === 422 && data.error === "IMPORT_LIMIT_EXCEEDED" && max > 0) {
894
+ capped = max;
895
+ toImport = opts.pending.slice(0, max);
896
+ try {
897
+ session = await createImportSession(client, toImport, bareId, tz, opts.signal);
898
+ }
899
+ catch (retryError) {
900
+ if (responseStatus(retryError) === 403)
901
+ throw codedError("FORBIDDEN_SCOPE");
902
+ throw retryError;
903
+ }
327
904
  }
328
905
  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;
906
+ throw e;
332
907
  }
333
908
  }
334
909
  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.
910
+ const runId = crypto.randomUUID();
911
+ const metricsFile = opts.metricsFile || defaultMigrationMetricsPath();
912
+ const done = runJobs({
913
+ client,
914
+ session,
915
+ byConv,
916
+ userTz: tz,
917
+ encKey,
918
+ onProgress: opts.onProgress,
919
+ runId,
920
+ metricsFile,
921
+ selection: opts.selection,
922
+ });
923
+ return { sessionId: session.id, runId, jobCount: session.jobs.length, metricsFile, ...(capped ? { capped } : {}), done };
924
+ }
925
+ async function runJobs(args) {
926
+ const ledger = loadLedger();
338
927
  const reqTimes = [];
339
928
  const throttle = async () => {
340
929
  for (;;) {
@@ -348,45 +937,263 @@ export async function cmdMigrate(flags) {
348
937
  await sleep(RATE_WINDOW_MS - (now - reqTimes[0]) + 100);
349
938
  }
350
939
  };
351
- 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)}`;
940
+ let migrated = 0, extracted = 0, failed = 0, done = 0;
941
+ let stoppedReason;
942
+ const total = args.session.jobs.length;
943
+ const recordMetric = (metric) => {
944
+ try {
945
+ appendMigrationMetric(args.metricsFile, metric);
946
+ }
947
+ catch {
948
+ /* best effort: metrics must never block a user migration */
949
+ }
950
+ };
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();
358
955
  try {
359
956
  await throttle();
360
- const r = await runImportJob(client, job.id, s, userTz, encKey);
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);
361
960
  extracted += r.memories;
362
961
  migrated++;
962
+ done++;
363
963
  ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
364
964
  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))}`);
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
+ });
367
976
  }
368
977
  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.
978
+ const status = responseStatus(e);
979
+ const errCode = responseData(e).error;
980
+ const message = responseMessage(e);
981
+ const durationMs = Date.now() - jobStartedAt;
372
982
  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;
983
+ stoppedReason = "key-expired"; // the vault key expired mid-run signal workers to stop pulling
984
+ done++;
985
+ recordMetric(buildMigrationMetric({
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,
988
+ }));
989
+ args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
990
+ return;
376
991
  }
377
992
  failed++;
378
- console.log(`${tag} ${c.red("✗ failed")} ${c.dim(truncate(String(e?.response?.data?.message || errCode || e?.message || e), 60))}`);
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 });
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);
379
1016
  }
1017
+ };
1018
+ try {
1019
+ await Promise.all(Array.from({ length: Math.min(MIGRATE_CONCURRENCY, Math.max(1, total)) }, () => worker()));
1020
+ }
1021
+ catch {
1022
+ failed++;
1023
+ }
1024
+ return { migrated, extracted, failed, ...(stoppedReason ? { stoppedReason } : {}) };
1025
+ }
1026
+ export async function cmdMigrate(flags) {
1027
+ const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
1028
+ // Discover + filter (pure local — discovery and --dry-run need no login).
1029
+ const since = typeof flags.since === "string" ? flags.since : undefined;
1030
+ let limit;
1031
+ let minChars;
1032
+ let maxChars;
1033
+ try {
1034
+ limit = parsePositiveIntFlag(flags.limit, "--limit");
1035
+ minChars = parsePositiveIntFlag(flags["min-chars"], "--min-chars");
1036
+ maxChars = parsePositiveIntFlag(flags["max-chars"], "--max-chars");
1037
+ }
1038
+ catch (e) {
1039
+ console.error(e instanceof Error ? e.message : String(e));
1040
+ process.exitCode = 1;
1041
+ return;
1042
+ }
1043
+ if (minChars && maxChars && minChars > maxChars) {
1044
+ console.error("--min-chars cannot be greater than --max-chars.");
1045
+ process.exitCode = 1;
1046
+ return;
1047
+ }
1048
+ const largest = flags.largest === true;
1049
+ const includeActive = flags["include-active"] === true;
1050
+ const selection = { since, limit, minChars, maxChars, largest, includeActive };
1051
+ const metricsFile = typeof flags["metrics-file"] === "string" ? flags["metrics-file"] : defaultMigrationMetricsPath();
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;
1071
+ const estimateSessions = includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
1072
+ if (!sessions.length) {
1073
+ console.log("No local Codex/Claude Code sessions found to migrate.");
1074
+ return;
1075
+ }
1076
+ if (flags.estimate === true && flags.json === true) {
1077
+ printMigrationEstimate(estimateMigration(limit ? pending : estimateSessions, pending), true);
1078
+ return;
380
1079
  }
381
1080
  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"));
1081
+ console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
1082
+ const pendingName = accountChecked ? "unprocessed" : "new/changed";
1083
+ const pendingText = limited
1084
+ ? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))} ${pendingName} selected`
1085
+ : `${c.bold(String(pending.length))} ${pendingName} to import`;
1086
+ console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexCount} Codex + ${claudeCount} Claude Code) · ${pendingText}` +
1087
+ (alreadyMigrated ? c.dim(`, ${alreadyMigrated} already migrated`) : "") +
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."));
1095
+ const filters = [
1096
+ minChars ? `min ${humanNum(minChars)} chars` : "",
1097
+ maxChars ? `max ${humanNum(maxChars)} chars` : "",
1098
+ largest ? "largest first" : "",
1099
+ includeActive ? "include active" : "",
1100
+ limit ? `limit ${humanNum(limit)}` : "",
1101
+ ].filter(Boolean).join(" · ");
1102
+ if (filters)
1103
+ console.log(c.dim(`Selection: ${filters}`));
1104
+ if (flags.estimate === true) {
1105
+ printMigrationEstimate(estimateMigration(limit ? pending : estimateSessions, pending), false);
1106
+ return;
1107
+ }
1108
+ if (flags["dry-run"] === true) {
1109
+ console.log(c.dim("\n--dry-run: discovered + assembled only, nothing sent.\n"));
1110
+ for (const s of pending.slice(0, 50)) {
1111
+ console.log(` ${s.source === "codex" ? "codex " : "claude"} ${(s.firstTs || "").slice(0, 10)} ${humanNum(s.rawData.length)} chars ${s.turnCount} text turns`);
1112
+ }
1113
+ if (pending.length > 50)
1114
+ console.log(c.dim(` … and ${pending.length - 50} more`));
1115
+ return;
1116
+ }
1117
+ if (!pending.length) {
1118
+ console.log(c.green("\n✓ Everything is already migrated — nothing to do.\n"));
1119
+ return;
1120
+ }
1121
+ // Consent: one keypress (skipped with --yes or when non-interactive).
1122
+ if (flags.yes !== true && process.stdin.isTTY) {
1123
+ const ok = await promptYesNo(`Import ${pending.length} session(s) into your EchoMem memory? [Y/n] `);
1124
+ if (!ok) {
1125
+ console.log("Aborted. Nothing was sent.");
1126
+ return;
1127
+ }
1128
+ }
1129
+ try {
1130
+ const jobDurations = [];
1131
+ const h = await startMigration({
1132
+ pending,
1133
+ metricsFile,
1134
+ selection,
1135
+ onProgress: (ev) => {
1136
+ const tag = `${c.dim(`[${ev.index}/${ev.total}]`)} ${ev.session.source === "codex" ? "codex " : "claude"} ${(ev.session.firstTs || "").slice(0, 10)}`;
1137
+ if (ev.error) {
1138
+ console.log(`${tag} ${c.red("✗ failed")} ${c.dim(truncate(ev.error, 60))}` + (ev.durationMs ? c.dim(` ${humanDuration(ev.durationMs)}`) : ""));
1139
+ return;
1140
+ }
1141
+ if (ev.durationMs)
1142
+ jobDurations.push(ev.durationMs);
1143
+ const eta = jobDurations.length > 0
1144
+ ? humanDuration(percentile(jobDurations, 0.5) * Math.max(0, ev.total - ev.index))
1145
+ : null;
1146
+ const memories = ev.memories ?? 0;
1147
+ const note = ev.alreadyDone
1148
+ ? c.dim("already completed")
1149
+ : memories === 0
1150
+ ? c.dim("0 memories")
1151
+ : c.green(`+${memories} ${memories === 1 ? "memory" : "memories"}`);
1152
+ const timing = ev.durationMs
1153
+ ? c.dim(` ${humanDuration(ev.durationMs)}${ev.ttfmMs ? ` (first memory ${humanDuration(ev.ttfmMs)})` : ""}${eta ? ` · ETA ${eta}` : ""}`)
1154
+ : "";
1155
+ console.log(`${tag} ${note} ${c.dim(`${humanNum(ev.session.rawData.length)} chars · ${ev.session.turnCount} turns`)}${timing}`);
1156
+ },
1157
+ });
1158
+ if (h.capped)
1159
+ 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.`));
1160
+ console.log(c.dim(`Import session ${h.sessionId} — ${h.jobCount} jobs queued (the web dashboard can watch this live).`));
1161
+ console.log(c.dim(`Metrics: ${h.metricsFile}`));
1162
+ const r = await h.done;
1163
+ if (r.stoppedReason === "key-expired") {
1164
+ console.error(c.yellow(`\n⚠ Vault locked mid-run. Run \`echomem-mcp unlock\` and re-run migrate to resume (${r.migrated} done so far).`));
1165
+ process.exitCode = 1;
1166
+ }
1167
+ console.log("");
1168
+ console.log(c.bold("Done.") + ` ${c.green(String(r.migrated) + " imported")} · ${c.bold(String(r.extracted))} memories` +
1169
+ (r.failed ? ` · ${c.red(String(r.failed) + " failed")}` : "") + ".");
1170
+ if (r.extracted > 0) {
1171
+ console.log(c.dim("Now ask your agent about a past project — it can recall it from memory.\n"));
1172
+ }
1173
+ if (r.failed)
1174
+ process.exitCode = 1;
1175
+ }
1176
+ catch (e) {
1177
+ const code = e?.code;
1178
+ if (code === "NOT_LOGGED_IN")
1179
+ console.error("Not logged in. Run `echomem-mcp login` first, then re-run migrate.");
1180
+ else if (code === "VAULT_LOCKED") {
1181
+ const store = new KeyStore();
1182
+ console.error(store.isKeyExpired()
1183
+ ? "Vault key expired. Run `echomem-mcp unlock`, then re-run migrate."
1184
+ : "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
1185
+ }
1186
+ else if (code === "FORBIDDEN_SCOPE") {
1187
+ console.error("This device token cannot import history. Re-connect this device with `echomem-mcp setup`.");
1188
+ }
1189
+ else {
1190
+ console.error(c.red(`Could not start the import: ${responseMessage(e)}`));
1191
+ }
1192
+ process.exitCode = 1;
386
1193
  }
387
1194
  }
388
1195
  /** Create an import session (lightweight descriptors only — NO transcript). Returns the session id + jobs. */
389
- async function createImportSession(client, sessions, bareId, userTz) {
1196
+ async function createImportSession(client, sessions, bareId, userTz, signal) {
390
1197
  const items = sessions.map((s) => ({
391
1198
  conversationId: bareId(s),
392
1199
  platform: s.source, // free-text label; also half of the (session, platform, conversation) key
@@ -394,12 +1201,13 @@ async function createImportSession(client, sessions, bareId, userTz) {
394
1201
  sourceDate: s.firstTs,
395
1202
  userTz,
396
1203
  }));
397
- const res = await client.post("/api/extension/import-sessions", { items });
1204
+ const res = await client.post("/api/extension/import-sessions", { items }, signal ? { signal } : undefined);
398
1205
  const data = res.data || {};
399
1206
  return { id: String(data.session?.id || ""), jobs: Array.isArray(data.jobs) ? data.jobs : [] };
400
1207
  }
401
- /** Run one queued job: stream its transcript to the server, which extracts it. One retry on 429/5xx. */
1208
+ /** Run one queued job: stream its transcript to the server, which extracts it. Retries transient locks/rate limits. */
402
1209
  async function runImportJob(client, jobId, s, userTz, encKey) {
1210
+ const startedAt = Date.now();
403
1211
  const body = {
404
1212
  rawData: s.rawData,
405
1213
  source: s.source, // NOT containing "mcp" (avoids the route's MCP title truncation)
@@ -415,24 +1223,41 @@ async function runImportJob(client, jobId, s, userTz, encKey) {
415
1223
  const data = res.data || {};
416
1224
  if (data.claimed === false) {
417
1225
  // 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 };
1226
+ if (data.job?.status === "completed") {
1227
+ return {
1228
+ memories: Number(data.job?.saved_memory_count) || 0,
1229
+ alreadyDone: true,
1230
+ duplicate: false,
1231
+ durationMs: Date.now() - startedAt,
1232
+ };
1233
+ }
420
1234
  throw new Error(data.message || "job did not start");
421
1235
  }
422
1236
  const result = data.result || {};
423
- return { memories: Number(result.memoriesExtracted) || 0, alreadyDone: false, duplicate: !!result.duplicate };
1237
+ return {
1238
+ memories: Number(result.memoriesExtracted) || 0,
1239
+ alreadyDone: false,
1240
+ duplicate: !!result.duplicate,
1241
+ durationMs: Date.now() - startedAt,
1242
+ processingTimeMs: typeof result.processingTimeMs === "number" ? result.processingTimeMs : undefined,
1243
+ ttfmMs: typeof result.ttfmMs === "number" ? result.ttfmMs : undefined,
1244
+ };
424
1245
  }
425
1246
  catch (e) {
426
1247
  const status = e?.response?.status;
1248
+ const message = e instanceof Error ? e.message : responseMessage(e);
427
1249
  if (status === 422 && e?.response?.data?.error === "ENCRYPTION_KEY_REQUIRED")
428
1250
  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)
1251
+ const lockConflict = status === 409 || jobLockMessage(message);
1252
+ const retryable = lockConflict || status === 429 || (status >= 500 && status < 600) || e?.code === "ECONNABORTED" || !status;
1253
+ const maxAttempts = lockConflict ? 5 : 2;
1254
+ if (!retryable || attempt >= maxAttempts)
433
1255
  throw e;
434
1256
  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);
1257
+ const backoffSeconds = lockConflict
1258
+ ? Math.min(30, 5 * (attempt + 1))
1259
+ : Math.pow(2, attempt) * 2;
1260
+ await sleep((status === 429 && retryAfter > 0 ? retryAfter : backoffSeconds) * 1000);
436
1261
  }
437
1262
  }
438
1263
  }