@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/README.md +2 -0
- package/dist/codex-sync.js +469 -0
- package/dist/encryption.js +3 -1
- package/dist/forensics.js +717 -0
- package/dist/index.js +461 -9
- package/dist/migrate.js +950 -125
- package/dist/report.js +154 -7
- package/dist/setup-page.js +1120 -0
- package/dist/setup.js +999 -76
- 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,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.
|
|
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
|
|
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.
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
const
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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;
|
|
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
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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 (
|
|
271
|
-
|
|
272
|
-
return;
|
|
703
|
+
if (typeof opts.maxChars === "number") {
|
|
704
|
+
out = out.filter((s) => s.rawData.length <= opts.maxChars);
|
|
273
705
|
}
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
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
|
|
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;
|
|
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,
|
|
886
|
+
session = await createImportSession(client, toImport, bareId, tz, opts.signal);
|
|
319
887
|
}
|
|
320
888
|
catch (e) {
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
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
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
|
|
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,
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
366
|
-
|
|
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
|
|
370
|
-
const errCode = e
|
|
371
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
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(
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
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.
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
430
|
-
|
|
431
|
-
const
|
|
432
|
-
if (!retryable || attempt >=
|
|
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
|
-
|
|
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
|
}
|