@echomem/mcp 1.3.1 → 1.3.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/forensics.js +717 -0
- package/dist/index.js +61 -4
- package/dist/migrate.js +417 -99
- package/dist/report.js +1 -0
- package/dist/setup-page.js +852 -306
- package/dist/setup.js +606 -75
- package/package.json +1 -1
package/dist/migrate.js
CHANGED
|
@@ -30,6 +30,8 @@ import { walk, eachLine } from "./report.js";
|
|
|
30
30
|
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
31
31
|
const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
|
|
32
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
|
|
33
35
|
const APPROX_CHARS_PER_TOKEN = 4;
|
|
34
36
|
const ACTIVE_SESSION_GRACE_MS = 5 * 60_000;
|
|
35
37
|
// ---------------------------------------------------------------------------
|
|
@@ -156,7 +158,7 @@ export function assembleClaude(file) {
|
|
|
156
158
|
return {
|
|
157
159
|
filePath: file,
|
|
158
160
|
source: "claude-code",
|
|
159
|
-
conversationKey: `claude:${sessionId || sha16(file)}`,
|
|
161
|
+
conversationKey: `claude-code:${sessionId || sha16(file)}`,
|
|
160
162
|
cwd: normalizeCwd(cwd),
|
|
161
163
|
firstTs,
|
|
162
164
|
title: title || "Claude text turns",
|
|
@@ -200,6 +202,99 @@ export function discoverSessions() {
|
|
|
200
202
|
out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
|
|
201
203
|
return out;
|
|
202
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
|
+
}
|
|
203
298
|
function ledgerPath() {
|
|
204
299
|
return path.join(echoConfigDir(), "migrate-ledger.json");
|
|
205
300
|
}
|
|
@@ -223,6 +318,41 @@ function saveLedger(l) {
|
|
|
223
318
|
export function defaultMigrationMetricsPath() {
|
|
224
319
|
return path.join(echoConfigDir(), "migrate-metrics.jsonl");
|
|
225
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
|
+
}
|
|
226
356
|
function sourceMtimeIso(s) {
|
|
227
357
|
return s.mtimeMs ? new Date(s.mtimeMs).toISOString() : null;
|
|
228
358
|
}
|
|
@@ -359,6 +489,11 @@ function responseStatus(e) {
|
|
|
359
489
|
const response = e.response;
|
|
360
490
|
return typeof response?.status === "number" ? response.status : undefined;
|
|
361
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
|
+
}
|
|
362
497
|
function responseData(e) {
|
|
363
498
|
const data = e.response?.data;
|
|
364
499
|
return isRecord(data) ? data : {};
|
|
@@ -452,17 +587,22 @@ export function estimateMigration(sessions, pending) {
|
|
|
452
587
|
};
|
|
453
588
|
}
|
|
454
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 = {}) {
|
|
455
595
|
const bucketDefs = [
|
|
456
|
-
{ key: "small", label: "
|
|
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: "
|
|
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 },
|
|
462
602
|
];
|
|
463
603
|
const buckets = bucketDefs.map((b) => {
|
|
464
|
-
const xs =
|
|
465
|
-
const chars = sum(xs
|
|
604
|
+
const xs = lengths.filter((n) => n > b.min && n <= b.max);
|
|
605
|
+
const chars = sum(xs);
|
|
466
606
|
return {
|
|
467
607
|
key: b.key,
|
|
468
608
|
label: b.label,
|
|
@@ -472,13 +612,19 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
|
|
|
472
612
|
secondsPerSession: b.secondsPerSession,
|
|
473
613
|
};
|
|
474
614
|
});
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
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);
|
|
480
626
|
return {
|
|
481
|
-
pending:
|
|
627
|
+
pending: lengths.length,
|
|
482
628
|
skippedActive,
|
|
483
629
|
totalChars,
|
|
484
630
|
approxInputTokens: approxTokens(totalChars),
|
|
@@ -488,6 +634,67 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
|
|
|
488
634
|
buckets,
|
|
489
635
|
};
|
|
490
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();
|
|
656
|
+
const ledger = loadLedger();
|
|
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) => {
|
|
679
|
+
const e = ledger[s.conversationKey];
|
|
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,
|
|
693
|
+
};
|
|
694
|
+
}
|
|
695
|
+
export function discoverMigratableSummaryFast(opts = {}) {
|
|
696
|
+
return summarizeFastMigratableDiscovery(discoverMigratableFastDiscovery(opts));
|
|
697
|
+
}
|
|
491
698
|
export function applyMigrationSelection(sessions, opts = {}) {
|
|
492
699
|
let out = [...sessions];
|
|
493
700
|
if (typeof opts.minChars === "number") {
|
|
@@ -552,6 +759,100 @@ export function discoverMigratableSessions(opts = {}) {
|
|
|
552
759
|
claudeCount: sessions.length - codexCount,
|
|
553
760
|
};
|
|
554
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
|
+
}
|
|
555
856
|
export async function startMigration(opts) {
|
|
556
857
|
if (opts.pending.length === 0)
|
|
557
858
|
throw codedError("NO_PENDING_SESSIONS");
|
|
@@ -572,7 +873,10 @@ export async function startMigration(opts) {
|
|
|
572
873
|
catch (e) {
|
|
573
874
|
if (e?.code === "VAULT_LOCKED")
|
|
574
875
|
throw e;
|
|
575
|
-
|
|
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;
|
|
576
880
|
}
|
|
577
881
|
const tz = userTimeZone();
|
|
578
882
|
let toImport = opts.pending;
|
|
@@ -633,7 +937,9 @@ async function runJobs(args) {
|
|
|
633
937
|
await sleep(RATE_WINDOW_MS - (now - reqTimes[0]) + 100);
|
|
634
938
|
}
|
|
635
939
|
};
|
|
636
|
-
let migrated = 0, extracted = 0, failed = 0,
|
|
940
|
+
let migrated = 0, extracted = 0, failed = 0, done = 0;
|
|
941
|
+
let stoppedReason;
|
|
942
|
+
const total = args.session.jobs.length;
|
|
637
943
|
const recordMetric = (metric) => {
|
|
638
944
|
try {
|
|
639
945
|
appendMigrationMetric(args.metricsFile, metric);
|
|
@@ -642,93 +948,80 @@ async function runJobs(args) {
|
|
|
642
948
|
/* best effort: metrics must never block a user migration */
|
|
643
949
|
}
|
|
644
950
|
};
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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;
|
|
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();
|
|
955
|
+
try {
|
|
956
|
+
await throttle();
|
|
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);
|
|
960
|
+
extracted += r.memories;
|
|
961
|
+
migrated++;
|
|
962
|
+
done++;
|
|
963
|
+
ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
|
|
964
|
+
saveLedger(ledger);
|
|
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
|
+
});
|
|
976
|
+
}
|
|
977
|
+
catch (e) {
|
|
978
|
+
const status = responseStatus(e);
|
|
979
|
+
const errCode = responseData(e).error;
|
|
980
|
+
const message = responseMessage(e);
|
|
981
|
+
const durationMs = Date.now() - jobStartedAt;
|
|
982
|
+
if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
|
|
983
|
+
stoppedReason = "key-expired"; // the vault key expired mid-run — signal workers to stop pulling
|
|
984
|
+
done++;
|
|
712
985
|
recordMetric(buildMigrationMetric({
|
|
713
|
-
runId: args.runId,
|
|
714
|
-
|
|
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,
|
|
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,
|
|
723
988
|
}));
|
|
724
|
-
args.onProgress?.({ index:
|
|
989
|
+
args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
|
|
990
|
+
return;
|
|
725
991
|
}
|
|
992
|
+
failed++;
|
|
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 });
|
|
726
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);
|
|
1016
|
+
}
|
|
1017
|
+
};
|
|
1018
|
+
try {
|
|
1019
|
+
await Promise.all(Array.from({ length: Math.min(MIGRATE_CONCURRENCY, Math.max(1, total)) }, () => worker()));
|
|
727
1020
|
}
|
|
728
1021
|
catch {
|
|
729
1022
|
failed++;
|
|
730
1023
|
}
|
|
731
|
-
return { migrated, extracted, failed };
|
|
1024
|
+
return { migrated, extracted, failed, ...(stoppedReason ? { stoppedReason } : {}) };
|
|
732
1025
|
}
|
|
733
1026
|
export async function cmdMigrate(flags) {
|
|
734
1027
|
const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
|
|
@@ -756,7 +1049,25 @@ export async function cmdMigrate(flags) {
|
|
|
756
1049
|
const includeActive = flags["include-active"] === true;
|
|
757
1050
|
const selection = { since, limit, minChars, maxChars, largest, includeActive };
|
|
758
1051
|
const metricsFile = typeof flags["metrics-file"] === "string" ? flags["metrics-file"] : defaultMigrationMetricsPath();
|
|
759
|
-
|
|
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;
|
|
760
1071
|
const estimateSessions = includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
|
|
761
1072
|
if (!sessions.length) {
|
|
762
1073
|
console.log("No local Codex/Claude Code sessions found to migrate.");
|
|
@@ -768,12 +1079,19 @@ export async function cmdMigrate(flags) {
|
|
|
768
1079
|
}
|
|
769
1080
|
console.log("");
|
|
770
1081
|
console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
|
|
1082
|
+
const pendingName = accountChecked ? "unprocessed" : "new/changed";
|
|
771
1083
|
const pendingText = limited
|
|
772
|
-
? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))}
|
|
773
|
-
: `${c.bold(String(pending.length))}
|
|
1084
|
+
? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))} ${pendingName} selected`
|
|
1085
|
+
: `${c.bold(String(pending.length))} ${pendingName} to import`;
|
|
774
1086
|
console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexCount} Codex + ${claudeCount} Claude Code) · ${pendingText}` +
|
|
775
1087
|
(alreadyMigrated ? c.dim(`, ${alreadyMigrated} already migrated`) : "") +
|
|
776
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."));
|
|
777
1095
|
const filters = [
|
|
778
1096
|
minChars ? `min ${humanNum(minChars)} chars` : "",
|
|
779
1097
|
maxChars ? `max ${humanNum(maxChars)} chars` : "",
|
package/dist/report.js
CHANGED
|
@@ -510,6 +510,7 @@ export async function buildStatsPayload(stats, inject) {
|
|
|
510
510
|
return {
|
|
511
511
|
schemaVersion: 1,
|
|
512
512
|
...(inject?.partial ? { partial: true } : {}),
|
|
513
|
+
...(inject?.discovery ? { discovery: inject.discovery } : {}),
|
|
513
514
|
generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
|
|
514
515
|
llmCallsUsed: 0,
|
|
515
516
|
transcriptsUploaded: false,
|