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