@echomem/mcp 1.2.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.
@@ -0,0 +1,945 @@
1
+ /**
2
+ * `echomem-mcp migrate` — one-shot bulk back-fill of the user's EXISTING local coding-agent history
3
+ * (Codex + Claude Code) into their Echo cloud. The onboarding "see it instantly know my work" moment.
4
+ *
5
+ * Why this lives in the bridge (client-side): the session logs exist ONLY on the user's machine
6
+ * (~/.codex/sessions, ~/.claude/projects). The bridge discovers each session, assembles it into a
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
+ * POSTs each transcript to /api/extension/import-jobs/{id}/run. The web dashboard polls
10
+ * GET /import-sessions/{id} for live progress — the bridge owns local-file access, the server owns the
11
+ * queue + status. (Headless CLI also works; it just shows progress in the terminal.)
12
+ *
13
+ * Idempotency: the queue dedups by (platform, conversationId) → source_hash (a repeat run hits a
14
+ * non-fatal DUPLICATE_SOURCE), and a local ledger (~/.echomem/migrate-ledger.json) skips done+unchanged
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.
19
+ * NOTE: client-pull — the queue advances only while the bridge runs; there is no server-side worker.
20
+ */
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+ import crypto from "node:crypto";
25
+ import readline from "node:readline";
26
+ import axios from "axios";
27
+ import { KeyStore, echoConfigDir } from "./keystore.js";
28
+ import { fetchEncryptionConfig } from "./encryption.js";
29
+ import { walk, eachLine } from "./report.js";
30
+ const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
31
+ const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
32
+ const RATE_WINDOW_MS = 60_000;
33
+ const APPROX_CHARS_PER_TOKEN = 4;
34
+ const ACTIVE_SESSION_GRACE_MS = 5 * 60_000;
35
+ // ---------------------------------------------------------------------------
36
+ // Assemblers — reconstruct a `## `-turn transcript from a raw session log.
37
+ // ---------------------------------------------------------------------------
38
+ const sha16 = (s) => crypto.createHash("sha256").update(s).digest("hex").slice(0, 16);
39
+ const turn = (role, ts, body) => `## ${role}${ts ? " — " + ts : ""}\n${body}`;
40
+ const firstLine = (s) => (s.split("\n").find((l) => l.trim()) || "").trim().slice(0, 120);
41
+ const truncate = (s, n) => (s.length > n ? s.slice(0, n) + "…" : s);
42
+ /** Strip Codex's synthetic scaffolding blocks from a user message; "" if nothing real remains. */
43
+ function cleanCodexUser(msg) {
44
+ let s = String(msg || "");
45
+ s = s.replace(/<(environment_context|user_instructions|permissions|app-context)>[\s\S]*?<\/\1>/g, "");
46
+ return s.trim();
47
+ }
48
+ /** Codex rollout-*.jsonl → one session. User/assistant text turns are sent; tool/process events stay local. */
49
+ export function assembleCodex(file) {
50
+ let sessionId = null;
51
+ let cwd = null;
52
+ let firstTs = null;
53
+ let title = "";
54
+ const turns = [];
55
+ eachLine(file, (o) => {
56
+ if (o && o.type === "session_meta") {
57
+ sessionId = (o.payload && typeof o.payload.id === "string" && o.payload.id) || sessionId;
58
+ cwd = (o.payload && typeof o.payload.cwd === "string" && o.payload.cwd) || cwd;
59
+ return;
60
+ }
61
+ const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
62
+ if (!p || typeof p !== "object")
63
+ return;
64
+ const ts = typeof o.timestamp === "string" ? o.timestamp : "";
65
+ if (p.type === "user_message" && typeof p.message === "string") {
66
+ const body = cleanCodexUser(p.message);
67
+ if (!body)
68
+ return;
69
+ turns.push(turn("User", ts, body));
70
+ if (!firstTs)
71
+ firstTs = ts;
72
+ if (!title)
73
+ title = firstLine(body);
74
+ }
75
+ else if (p.type === "agent_message" && typeof p.message === "string") {
76
+ const body = p.message.trim();
77
+ if (!body)
78
+ return;
79
+ turns.push(turn("Assistant", ts, body));
80
+ if (!firstTs)
81
+ firstTs = ts;
82
+ }
83
+ });
84
+ if (!turns.length)
85
+ return null;
86
+ const stat = statSafe(file);
87
+ return {
88
+ filePath: file,
89
+ source: "codex",
90
+ conversationKey: `codex:${sessionId || sha16(file)}`,
91
+ cwd: normalizeCwd(cwd),
92
+ firstTs,
93
+ title: title || "Codex text turns",
94
+ rawData: turns.join("\n"),
95
+ turnCount: turns.length,
96
+ size: stat.size,
97
+ mtimeMs: stat.mtimeMs,
98
+ };
99
+ }
100
+ /** Claude Code <uuid>.jsonl → one session. User/assistant text turns are sent; tool calls/results/thinking stay local. */
101
+ export function assembleClaude(file) {
102
+ let sessionId = null;
103
+ let cwd = null;
104
+ let firstTs = null;
105
+ let title = "";
106
+ const turns = [];
107
+ eachLine(file, (o) => {
108
+ if (!cwd && typeof o.cwd === "string")
109
+ cwd = o.cwd;
110
+ if (!sessionId && typeof o.sessionId === "string")
111
+ sessionId = o.sessionId;
112
+ const ts = typeof o.timestamp === "string" ? o.timestamp : "";
113
+ if (o.type === "user") {
114
+ const c = o.message && o.message.content;
115
+ let body = "";
116
+ if (typeof c === "string")
117
+ body = c.trim();
118
+ else if (Array.isArray(c)) {
119
+ // Keep only genuine user text blocks. tool_result carriers stay local.
120
+ body = c.filter((b) => b && b.type === "text" && b.text).map((b) => b.text).join("\n").trim();
121
+ }
122
+ if (!body)
123
+ return;
124
+ turns.push(turn("User", ts, body));
125
+ if (!firstTs)
126
+ firstTs = ts;
127
+ if (!title)
128
+ title = firstLine(body);
129
+ }
130
+ else if (o.type === "assistant") {
131
+ const c = o.message && o.message.content;
132
+ let body = "";
133
+ if (Array.isArray(c)) {
134
+ const parts = [];
135
+ for (const b of c) {
136
+ if (b && b.type === "text" && b.text)
137
+ parts.push(b.text);
138
+ // thinking and tool_use blocks are process/code noise, not assistant output.
139
+ }
140
+ body = parts.join("\n").trim();
141
+ }
142
+ else if (typeof c === "string") {
143
+ body = c.trim();
144
+ }
145
+ if (!body)
146
+ return; // thinking-only turn → no header
147
+ turns.push(turn("Assistant", ts, body));
148
+ if (!firstTs)
149
+ firstTs = ts;
150
+ }
151
+ // tool_result carriers, queue-operation, attachment, ai-title, last-prompt, mode, summary, unknown → ignored
152
+ });
153
+ if (!turns.length)
154
+ return null;
155
+ const stat = statSafe(file);
156
+ return {
157
+ filePath: file,
158
+ source: "claude-code",
159
+ conversationKey: `claude:${sessionId || sha16(file)}`,
160
+ cwd: normalizeCwd(cwd),
161
+ firstTs,
162
+ title: title || "Claude text turns",
163
+ rawData: turns.join("\n"),
164
+ turnCount: turns.length,
165
+ size: stat.size,
166
+ mtimeMs: stat.mtimeMs,
167
+ };
168
+ }
169
+ function statSafe(file) {
170
+ try {
171
+ const s = fs.statSync(file);
172
+ return { size: s.size, mtimeMs: Math.round(s.mtimeMs) };
173
+ }
174
+ catch {
175
+ return { size: 0, mtimeMs: 0 };
176
+ }
177
+ }
178
+ /** Strip agent/git worktree wrappers so worktree sessions group under their real project root. */
179
+ export function normalizeCwd(cwd) {
180
+ if (!cwd)
181
+ return null;
182
+ const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
183
+ return m ? m[1] : cwd;
184
+ }
185
+ /** Discover every local session, newest first (by first-turn timestamp). */
186
+ export function discoverSessions() {
187
+ const out = [];
188
+ const codexRoot = path.join(os.homedir(), ".codex", "sessions");
189
+ for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
190
+ const s = assembleCodex(f);
191
+ if (s)
192
+ out.push(s);
193
+ }
194
+ const claudeRoot = path.join(os.homedir(), ".claude", "projects");
195
+ for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
196
+ const s = assembleClaude(f);
197
+ if (s)
198
+ out.push(s);
199
+ }
200
+ out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
201
+ return out;
202
+ }
203
+ function ledgerPath() {
204
+ return path.join(echoConfigDir(), "migrate-ledger.json");
205
+ }
206
+ function loadLedger() {
207
+ try {
208
+ return JSON.parse(fs.readFileSync(ledgerPath(), "utf8"));
209
+ }
210
+ catch {
211
+ return {};
212
+ }
213
+ }
214
+ function saveLedger(l) {
215
+ try {
216
+ fs.mkdirSync(echoConfigDir(), { recursive: true, mode: 0o700 });
217
+ fs.writeFileSync(ledgerPath(), JSON.stringify(l, null, 2));
218
+ }
219
+ catch {
220
+ /* best effort */
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
+ }
284
+ // ---------------------------------------------------------------------------
285
+ // CLI
286
+ // ---------------------------------------------------------------------------
287
+ const color = (enabled) => {
288
+ const w = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s));
289
+ return { bold: w("1"), dim: w("2"), red: w("31"), green: w("32"), cyan: w("36"), yellow: w("33") };
290
+ };
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
+ }
341
+ function promptYesNo(question) {
342
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
343
+ return new Promise((resolve) => rl.question(question, (a) => { rl.close(); resolve(!/^n/i.test(a.trim())); }));
344
+ }
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
+ },
452
+ };
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);
495
+ }
496
+ if (typeof opts.maxChars === "number") {
497
+ out = out.filter((s) => s.rawData.length <= opts.maxChars);
498
+ }
499
+ if (opts.largest) {
500
+ out.sort((a, b) => b.rawData.length - a.rawData.length || String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
501
+ }
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({
524
+ baseURL: API_BASE,
525
+ headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
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);
563
+ let encKey;
564
+ try {
565
+ const cfg = await fetchEncryptionConfig(client);
566
+ if (cfg.enabled) {
567
+ encKey = store.getKey();
568
+ if (!encKey)
569
+ throw codedError("VAULT_LOCKED");
570
+ }
571
+ }
572
+ catch (e) {
573
+ if (e?.code === "VAULT_LOCKED")
574
+ throw e;
575
+ /* config fetch failed → proceed as unencrypted; server enforces if actually encrypted */
576
+ }
577
+ const tz = userTimeZone();
578
+ let toImport = opts.pending;
579
+ let capped;
580
+ let session;
581
+ try {
582
+ session = await createImportSession(client, toImport, bareId, tz, opts.signal);
583
+ }
584
+ catch (e) {
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
+ }
600
+ }
601
+ else {
602
+ throw e;
603
+ }
604
+ }
605
+ const byConv = new Map(toImport.map((s) => [mapKey(s.source, bareId(s)), s]));
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();
623
+ const reqTimes = [];
624
+ const throttle = async () => {
625
+ for (;;) {
626
+ const now = Date.now();
627
+ while (reqTimes.length && now - reqTimes[0] > RATE_WINDOW_MS)
628
+ reqTimes.shift();
629
+ if (reqTimes.length < RATE_MAX) {
630
+ reqTimes.push(Date.now());
631
+ return;
632
+ }
633
+ await sleep(RATE_WINDOW_MS - (now - reqTimes[0]) + 100);
634
+ }
635
+ };
636
+ let migrated = 0, extracted = 0, failed = 0, i = 0;
637
+ const recordMetric = (metric) => {
638
+ try {
639
+ appendMigrationMetric(args.metricsFile, metric);
640
+ }
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 });
725
+ }
726
+ }
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
+ }
769
+ console.log("");
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;
875
+ }
876
+ }
877
+ /** Create an import session (lightweight descriptors only — NO transcript). Returns the session id + jobs. */
878
+ async function createImportSession(client, sessions, bareId, userTz, signal) {
879
+ const items = sessions.map((s) => ({
880
+ conversationId: bareId(s),
881
+ platform: s.source, // free-text label; also half of the (session, platform, conversation) key
882
+ title: s.title,
883
+ sourceDate: s.firstTs,
884
+ userTz,
885
+ }));
886
+ const res = await client.post("/api/extension/import-sessions", { items }, signal ? { signal } : undefined);
887
+ const data = res.data || {};
888
+ return { id: String(data.session?.id || ""), jobs: Array.isArray(data.jobs) ? data.jobs : [] };
889
+ }
890
+ /** Run one queued job: stream its transcript to the server, which extracts it. Retries transient locks/rate limits. */
891
+ async function runImportJob(client, jobId, s, userTz, encKey) {
892
+ const startedAt = Date.now();
893
+ const body = {
894
+ rawData: s.rawData,
895
+ source: s.source, // NOT containing "mcp" (avoids the route's MCP title truncation)
896
+ title: s.title,
897
+ sourceDate: s.firstTs, // back-fill memories on the session's real date
898
+ userTz,
899
+ sessionType: "coding", // routes to the coding-checkpoint extraction prompt (ignored if unsupported)
900
+ };
901
+ const cfg = encKey ? { headers: { "X-Encryption-Key": encKey } } : undefined;
902
+ for (let attempt = 0;; attempt++) {
903
+ try {
904
+ const res = await client.post(`/api/extension/import-jobs/${jobId}/run`, body, cfg);
905
+ const data = res.data || {};
906
+ if (data.claimed === false) {
907
+ // Already completed on a prior run → idempotent, not an error; otherwise it didn't start.
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
+ }
916
+ throw new Error(data.message || "job did not start");
917
+ }
918
+ const result = data.result || {};
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
+ };
927
+ }
928
+ catch (e) {
929
+ const status = e?.response?.status;
930
+ const message = e instanceof Error ? e.message : responseMessage(e);
931
+ if (status === 422 && e?.response?.data?.error === "ENCRYPTION_KEY_REQUIRED")
932
+ throw e; // not retryable
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)
937
+ throw e;
938
+ const retryAfter = Number(e?.response?.headers?.["retry-after"]) || Number(e?.response?.data?.retryAfterSeconds);
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);
943
+ }
944
+ }
945
+ }