@echomem/mcp 1.3.1 → 1.4.0
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/city/_live.html +37 -0
- package/dist/city/_serve.mjs +45 -0
- package/dist/city/card-data.json +15 -0
- package/dist/city/city-data.json +248 -0
- package/dist/city/echo-ai-city-only.html +1254 -0
- package/dist/city/echo-ai-city-only.template.html +1254 -0
- package/dist/city/echo-extraction-plate.html +330 -0
- package/dist/city/generate-echo-city-only.mjs +112 -0
- package/dist/city/vendor/OrbitControls.js +1417 -0
- package/dist/city/vendor/RoundedBoxGeometry.js +155 -0
- package/dist/city/vendor/echo_general-file-21.riv +0 -0
- package/dist/city/vendor/rive.js +8139 -0
- package/dist/city/vendor/rive.wasm +0 -0
- package/dist/city/vendor/three.module.min.js +6 -0
- package/dist/forensics.js +753 -0
- package/dist/index.js +61 -4
- package/dist/migrate.js +459 -99
- package/dist/report.js +1 -0
- package/dist/setup-page.js +1356 -308
- package/dist/setup.js +666 -75
- package/package.json +2 -2
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,108 @@ 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
|
+
let hasRealKey = false;
|
|
253
|
+
for (const obj of initialJsonObjects(file)) {
|
|
254
|
+
if (!isRecord(obj))
|
|
255
|
+
continue;
|
|
256
|
+
if (source === "codex" && obj.type === "session_meta") {
|
|
257
|
+
const payload = isRecord(obj.payload) ? obj.payload : {};
|
|
258
|
+
if (typeof payload.id === "string" && payload.id) {
|
|
259
|
+
conversationKey = `codex:${payload.id}`;
|
|
260
|
+
hasRealKey = true;
|
|
261
|
+
}
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
if (source === "codex") {
|
|
265
|
+
const p = codexPayload(obj);
|
|
266
|
+
if (p.type === "user_message" && typeof p.message === "string" && cleanCodexUser(p.message))
|
|
267
|
+
hasTextTurn = true;
|
|
268
|
+
if (p.type === "agent_message" && typeof p.message === "string" && p.message.trim())
|
|
269
|
+
hasTextTurn = true;
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
272
|
+
if (source === "claude-code" && typeof obj.sessionId === "string" && obj.sessionId) {
|
|
273
|
+
conversationKey = `claude-code:${obj.sessionId}`;
|
|
274
|
+
hasRealKey = true;
|
|
275
|
+
}
|
|
276
|
+
if (source === "claude-code" && (obj.type === "user" || obj.type === "assistant")) {
|
|
277
|
+
const message = isRecord(obj.message) ? obj.message : {};
|
|
278
|
+
if (hasClaudeText(message.content))
|
|
279
|
+
hasTextTurn = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
return { conversationKey, hasTextTurn, hasRealKey };
|
|
283
|
+
}
|
|
284
|
+
function fastSessionEntries(opts = {}) {
|
|
285
|
+
const out = [];
|
|
286
|
+
const codexRoot = opts.codexRoot ?? path.join(os.homedir(), ".codex", "sessions");
|
|
287
|
+
for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
|
|
288
|
+
const stat = statSafe(filePath);
|
|
289
|
+
const info = fastSessionInfo(filePath, "codex");
|
|
290
|
+
// Include if we found text OR a real session id (big sessions can have their first text turn beyond
|
|
291
|
+
// the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
|
|
292
|
+
// We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
|
|
293
|
+
if (info.hasTextTurn || info.hasRealKey)
|
|
294
|
+
out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
295
|
+
}
|
|
296
|
+
const claudeRoot = opts.claudeRoot ?? path.join(os.homedir(), ".claude", "projects");
|
|
297
|
+
for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
298
|
+
const stat = statSafe(filePath);
|
|
299
|
+
const info = fastSessionInfo(filePath, "claude-code");
|
|
300
|
+
// Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
|
|
301
|
+
// while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
|
|
302
|
+
if (info.hasTextTurn || info.hasRealKey)
|
|
303
|
+
out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
304
|
+
}
|
|
305
|
+
return out;
|
|
306
|
+
}
|
|
203
307
|
function ledgerPath() {
|
|
204
308
|
return path.join(echoConfigDir(), "migrate-ledger.json");
|
|
205
309
|
}
|
|
@@ -223,6 +327,41 @@ function saveLedger(l) {
|
|
|
223
327
|
export function defaultMigrationMetricsPath() {
|
|
224
328
|
return path.join(echoConfigDir(), "migrate-metrics.jsonl");
|
|
225
329
|
}
|
|
330
|
+
/**
|
|
331
|
+
* Per-session extraction time (seconds) measured from THIS user's past runs, so the ETA reflects their
|
|
332
|
+
* real server/network throughput instead of fixed guesses. Reads the local metrics log, keeps recent
|
|
333
|
+
* genuinely-extracted jobs (completed, not duplicate/already-done), and returns a trimmed mean (drops
|
|
334
|
+
* the slowest 10% so a rare stalled job doesn't dominate). Returns null when there isn't enough data.
|
|
335
|
+
*/
|
|
336
|
+
export function measuredSecondsPerSession(metricsFile = defaultMigrationMetricsPath()) {
|
|
337
|
+
let lines;
|
|
338
|
+
try {
|
|
339
|
+
lines = fs.readFileSync(metricsFile, "utf8").split("\n");
|
|
340
|
+
}
|
|
341
|
+
catch {
|
|
342
|
+
return null; // never run a real migrate yet
|
|
343
|
+
}
|
|
344
|
+
const secs = [];
|
|
345
|
+
for (const line of lines.slice(-400)) {
|
|
346
|
+
const s = line.trim();
|
|
347
|
+
if (!s)
|
|
348
|
+
continue;
|
|
349
|
+
try {
|
|
350
|
+
const m = JSON.parse(s);
|
|
351
|
+
if (m.status === "completed" && !m.duplicate && !m.alreadyDone && typeof m.durationMs === "number" && m.durationMs > 0) {
|
|
352
|
+
secs.push(m.durationMs / 1000);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
/* skip malformed line */
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
if (secs.length < 8)
|
|
360
|
+
return null;
|
|
361
|
+
secs.sort((a, b) => a - b);
|
|
362
|
+
const kept = secs.slice(0, Math.max(1, Math.floor(secs.length * 0.9)));
|
|
363
|
+
return kept.reduce((n, x) => n + x, 0) / kept.length;
|
|
364
|
+
}
|
|
226
365
|
function sourceMtimeIso(s) {
|
|
227
366
|
return s.mtimeMs ? new Date(s.mtimeMs).toISOString() : null;
|
|
228
367
|
}
|
|
@@ -359,6 +498,11 @@ function responseStatus(e) {
|
|
|
359
498
|
const response = e.response;
|
|
360
499
|
return typeof response?.status === "number" ? response.status : undefined;
|
|
361
500
|
}
|
|
501
|
+
export function isImportStatusUnsupported(e) {
|
|
502
|
+
const status = responseStatus(e);
|
|
503
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
504
|
+
return status === 404 || status === 405 || /status code (404|405)\b/i.test(message);
|
|
505
|
+
}
|
|
362
506
|
function responseData(e) {
|
|
363
507
|
const data = e.response?.data;
|
|
364
508
|
return isRecord(data) ? data : {};
|
|
@@ -452,17 +596,22 @@ export function estimateMigration(sessions, pending) {
|
|
|
452
596
|
};
|
|
453
597
|
}
|
|
454
598
|
export function estimateMigrationEta(pending, skippedActive = 0) {
|
|
599
|
+
return estimateMigrationEtaFromLengths(pending.map((s) => s.rawData.length), skippedActive, {
|
|
600
|
+
secondsPerSession: measuredSecondsPerSession() ?? undefined,
|
|
601
|
+
});
|
|
602
|
+
}
|
|
603
|
+
export function estimateMigrationEtaFromLengths(lengths, skippedActive = 0, opts = {}) {
|
|
455
604
|
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: "
|
|
605
|
+
{ key: "small", label: "Up to 30k chars", min: 0, max: 30_000, secondsPerSession: 10 },
|
|
606
|
+
{ key: "routine", label: "30k-120k chars", min: 30_000, max: 120_000, secondsPerSession: 16 },
|
|
607
|
+
{ key: "large", label: "120k-350k chars", min: 120_000, max: 350_000, secondsPerSession: 16 },
|
|
608
|
+
{ key: "veryLarge", label: "350k-1M chars", min: 350_000, max: 1_000_000, secondsPerSession: 40 },
|
|
609
|
+
{ key: "huge", label: "1M-2M chars", min: 1_000_000, max: 2_000_000, secondsPerSession: 60 },
|
|
610
|
+
{ key: "massive", label: "Over 2M chars", min: 2_000_000, max: Number.POSITIVE_INFINITY, secondsPerSession: 90 },
|
|
462
611
|
];
|
|
463
612
|
const buckets = bucketDefs.map((b) => {
|
|
464
|
-
const xs =
|
|
465
|
-
const chars = sum(xs
|
|
613
|
+
const xs = lengths.filter((n) => n > b.min && n <= b.max);
|
|
614
|
+
const chars = sum(xs);
|
|
466
615
|
return {
|
|
467
616
|
key: b.key,
|
|
468
617
|
label: b.label,
|
|
@@ -472,13 +621,19 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
|
|
|
472
621
|
secondsPerSession: b.secondsPerSession,
|
|
473
622
|
};
|
|
474
623
|
});
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
const
|
|
624
|
+
// Jobs run concurrently (up to MIGRATE_CONCURRENCY in flight), but request STARTS are capped at
|
|
625
|
+
// RATE_MAX/min. Effective throughput is the lesser of what the worker pool sustains (concurrency /
|
|
626
|
+
// per-session time) and the server rate cap — so for large batches the rate cap dominates and the
|
|
627
|
+
// wall-clock is far shorter than the old one-by-one sum. perSession comes from measured metrics.
|
|
628
|
+
const pending = lengths.length;
|
|
629
|
+
const perSession = opts.secondsPerSession && opts.secondsPerSession > 0 ? opts.secondsPerSession : FALLBACK_SECONDS_PER_SESSION;
|
|
630
|
+
const concurrency = opts.concurrency && opts.concurrency > 0 ? opts.concurrency : MIGRATE_CONCURRENCY;
|
|
631
|
+
const throughputPerSec = pending ? Math.min(concurrency / perSession, RATE_MAX / 60) : 0;
|
|
632
|
+
const estimatedSeconds = pending && throughputPerSec ? Math.ceil((pending / throughputPerSec + perSession) * 1.15) : 0;
|
|
633
|
+
const throttleFloorSeconds = pending ? Math.ceil(pending / RATE_MAX) * 60 : 0;
|
|
634
|
+
const totalChars = sum(lengths);
|
|
480
635
|
return {
|
|
481
|
-
pending:
|
|
636
|
+
pending: lengths.length,
|
|
482
637
|
skippedActive,
|
|
483
638
|
totalChars,
|
|
484
639
|
approxInputTokens: approxTokens(totalChars),
|
|
@@ -488,6 +643,67 @@ export function estimateMigrationEta(pending, skippedActive = 0) {
|
|
|
488
643
|
buckets,
|
|
489
644
|
};
|
|
490
645
|
}
|
|
646
|
+
export function summarizeFastMigratableDiscovery(discovery) {
|
|
647
|
+
return {
|
|
648
|
+
sessions: discovery.sessions.length,
|
|
649
|
+
pending: discovery.pending.length,
|
|
650
|
+
pendingTotal: discovery.pendingTotal,
|
|
651
|
+
alreadyMigrated: discovery.alreadyMigrated,
|
|
652
|
+
skippedActive: discovery.skippedActive,
|
|
653
|
+
codexCount: discovery.codexCount,
|
|
654
|
+
claudeCount: discovery.claudeCount,
|
|
655
|
+
eta: estimateMigrationEtaFromLengths(discovery.pending.map((s) => s.size), discovery.skippedActive, {
|
|
656
|
+
secondsPerSession: measuredSecondsPerSession() ?? undefined,
|
|
657
|
+
}),
|
|
658
|
+
accountChecked: discovery.accountChecked,
|
|
659
|
+
accountCheckFailed: discovery.accountCheckFailed,
|
|
660
|
+
accountCheckUnavailable: discovery.accountCheckUnavailable,
|
|
661
|
+
};
|
|
662
|
+
}
|
|
663
|
+
export function discoverMigratableFastDiscovery(opts = {}) {
|
|
664
|
+
const byKey = new Map();
|
|
665
|
+
const ledger = loadLedger();
|
|
666
|
+
for (const s of fastSessionEntries(opts)) {
|
|
667
|
+
const prev = byKey.get(s.conversationKey);
|
|
668
|
+
const ledgerEntry = ledger[s.conversationKey];
|
|
669
|
+
const matchesLedger = !!ledgerEntry && ledgerEntry.size === s.size;
|
|
670
|
+
const prevMatchesLedger = !!prev && !!ledgerEntry && ledgerEntry.size === prev.size;
|
|
671
|
+
if (!prev ||
|
|
672
|
+
(matchesLedger && !prevMatchesLedger) ||
|
|
673
|
+
(matchesLedger === prevMatchesLedger && (s.size > prev.size || (s.size === prev.size && s.mtimeMs > prev.mtimeMs)))) {
|
|
674
|
+
byKey.set(s.conversationKey, s);
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
let sessions = [...byKey.values()];
|
|
678
|
+
if (opts.since) {
|
|
679
|
+
const since = opts.since;
|
|
680
|
+
sessions = sessions.filter((s) => {
|
|
681
|
+
const iso = sourceMtimeIso({ ...s, rawData: "", turnCount: 0, cwd: null, firstTs: null, title: "" });
|
|
682
|
+
return !!iso && iso.slice(0, 10) >= since;
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
const selectable = opts.includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
|
|
686
|
+
const skippedActive = sessions.length - selectable.length;
|
|
687
|
+
const pendingAll = selectable.filter((s) => {
|
|
688
|
+
const e = ledger[s.conversationKey];
|
|
689
|
+
return !e || e.size !== s.size;
|
|
690
|
+
});
|
|
691
|
+
const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
|
|
692
|
+
const codexCount = sessions.filter((s) => s.source === "codex").length;
|
|
693
|
+
return {
|
|
694
|
+
sessions,
|
|
695
|
+
pending,
|
|
696
|
+
pendingTotal: pendingAll.length,
|
|
697
|
+
alreadyMigrated: selectable.length - pendingAll.length,
|
|
698
|
+
skippedActive,
|
|
699
|
+
limited: pending.length < pendingAll.length,
|
|
700
|
+
codexCount,
|
|
701
|
+
claudeCount: sessions.length - codexCount,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
export function discoverMigratableSummaryFast(opts = {}) {
|
|
705
|
+
return summarizeFastMigratableDiscovery(discoverMigratableFastDiscovery(opts));
|
|
706
|
+
}
|
|
491
707
|
export function applyMigrationSelection(sessions, opts = {}) {
|
|
492
708
|
let out = [...sessions];
|
|
493
709
|
if (typeof opts.minChars === "number") {
|
|
@@ -552,6 +768,133 @@ export function discoverMigratableSessions(opts = {}) {
|
|
|
552
768
|
claudeCount: sessions.length - codexCount,
|
|
553
769
|
};
|
|
554
770
|
}
|
|
771
|
+
/**
|
|
772
|
+
* Targeted sizing for extraction start. The fast (stat-only) scan already knows WHICH sessions are
|
|
773
|
+
* pending — by conversation key + file path — without reading any content. This assembles full content
|
|
774
|
+
* for ONLY those pending sessions, never the whole local history. That keeps "start extraction" fast even
|
|
775
|
+
* when a user has gigabytes of already-processed logs (reading all of them just to prepare a few new jobs
|
|
776
|
+
* is what made sizing hang for 30s+).
|
|
777
|
+
*
|
|
778
|
+
* `processedKeys` (from the account import-status check) yields the accurate pending set; without it we
|
|
779
|
+
* fall back to the local-ledger pending. Either way we read at most the pending files, not the archive.
|
|
780
|
+
*/
|
|
781
|
+
export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
|
|
782
|
+
const fast = discoverMigratableFastDiscovery(opts);
|
|
783
|
+
const filtered = processedKeys ? applyFastAccountImportStatus(fast, processedKeys, opts) : fast;
|
|
784
|
+
const pending = [];
|
|
785
|
+
for (const entry of filtered.pending) {
|
|
786
|
+
const s = entry.source === "codex" ? assembleCodex(entry.filePath) : assembleClaude(entry.filePath);
|
|
787
|
+
if (s)
|
|
788
|
+
pending.push(s);
|
|
789
|
+
}
|
|
790
|
+
return {
|
|
791
|
+
sessions: pending,
|
|
792
|
+
pending,
|
|
793
|
+
pendingTotal: filtered.pendingTotal,
|
|
794
|
+
alreadyMigrated: filtered.alreadyMigrated,
|
|
795
|
+
skippedActive: filtered.skippedActive,
|
|
796
|
+
limited: filtered.limited,
|
|
797
|
+
codexCount: filtered.codexCount,
|
|
798
|
+
claudeCount: filtered.claudeCount,
|
|
799
|
+
accountChecked: filtered.accountChecked,
|
|
800
|
+
accountCheckFailed: filtered.accountCheckFailed,
|
|
801
|
+
accountCheckUnavailable: filtered.accountCheckUnavailable,
|
|
802
|
+
};
|
|
803
|
+
}
|
|
804
|
+
export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
|
|
805
|
+
const selectableSessions = opts.includeActive
|
|
806
|
+
? discovery.sessions
|
|
807
|
+
: discovery.sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
|
|
808
|
+
const skippedActive = discovery.sessions.length - selectableSessions.length;
|
|
809
|
+
const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
|
|
810
|
+
const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
|
|
811
|
+
return {
|
|
812
|
+
...discovery,
|
|
813
|
+
pending,
|
|
814
|
+
pendingTotal: pendingAll.length,
|
|
815
|
+
alreadyMigrated: selectableSessions.length - pendingAll.length,
|
|
816
|
+
skippedActive,
|
|
817
|
+
limited: pending.length < pendingAll.length,
|
|
818
|
+
accountChecked: true,
|
|
819
|
+
accountCheckFailed: false,
|
|
820
|
+
accountCheckUnavailable: false,
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
export function applyFastAccountImportStatus(discovery, processedKeys, opts = {}) {
|
|
824
|
+
const selectableSessions = opts.includeActive
|
|
825
|
+
? discovery.sessions
|
|
826
|
+
: discovery.sessions.filter((s) => !isActiveMigrationSession(s, opts.nowMs));
|
|
827
|
+
const skippedActive = discovery.sessions.length - selectableSessions.length;
|
|
828
|
+
const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
|
|
829
|
+
const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
|
|
830
|
+
return {
|
|
831
|
+
...discovery,
|
|
832
|
+
pending,
|
|
833
|
+
pendingTotal: pendingAll.length,
|
|
834
|
+
alreadyMigrated: selectableSessions.length - pendingAll.length,
|
|
835
|
+
skippedActive,
|
|
836
|
+
limited: pending.length < pendingAll.length,
|
|
837
|
+
accountChecked: true,
|
|
838
|
+
accountCheckFailed: false,
|
|
839
|
+
accountCheckUnavailable: false,
|
|
840
|
+
};
|
|
841
|
+
}
|
|
842
|
+
export function markAccountImportStatusFailed(discovery) {
|
|
843
|
+
return {
|
|
844
|
+
...discovery,
|
|
845
|
+
accountChecked: false,
|
|
846
|
+
accountCheckFailed: true,
|
|
847
|
+
accountCheckUnavailable: false,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
export function markAccountImportStatusUnavailable(discovery) {
|
|
851
|
+
return {
|
|
852
|
+
...discovery,
|
|
853
|
+
accountChecked: false,
|
|
854
|
+
accountCheckFailed: false,
|
|
855
|
+
accountCheckUnavailable: true,
|
|
856
|
+
};
|
|
857
|
+
}
|
|
858
|
+
export function markFastAccountImportStatusUnavailable(discovery) {
|
|
859
|
+
return {
|
|
860
|
+
...discovery,
|
|
861
|
+
accountChecked: false,
|
|
862
|
+
accountCheckFailed: false,
|
|
863
|
+
accountCheckUnavailable: true,
|
|
864
|
+
};
|
|
865
|
+
}
|
|
866
|
+
export async function fetchProcessedImportKeys(token, sessions, signal) {
|
|
867
|
+
const client = authedClient(token);
|
|
868
|
+
const processedKeys = new Set();
|
|
869
|
+
for (let i = 0; i < sessions.length; i += IMPORT_STATUS_CHUNK_SIZE) {
|
|
870
|
+
const chunk = sessions.slice(i, i + IMPORT_STATUS_CHUNK_SIZE);
|
|
871
|
+
const items = chunk.map((s) => ({
|
|
872
|
+
conversationId: bareId(s),
|
|
873
|
+
platform: s.source,
|
|
874
|
+
sourceDate: s.firstTs,
|
|
875
|
+
}));
|
|
876
|
+
const res = await client.post("/api/extension/import-sessions/status", { items }, signal ? { signal } : undefined);
|
|
877
|
+
const data = (res.data && typeof res.data === "object" ? res.data : {});
|
|
878
|
+
const statusItems = Array.isArray(data.items) ? data.items : [];
|
|
879
|
+
for (const item of statusItems) {
|
|
880
|
+
if (item.processed === true &&
|
|
881
|
+
typeof item.platform === "string" &&
|
|
882
|
+
typeof item.conversationId === "string" &&
|
|
883
|
+
item.conversationId) {
|
|
884
|
+
processedKeys.add(mapKey(item.platform, item.conversationId));
|
|
885
|
+
}
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
return processedKeys;
|
|
889
|
+
}
|
|
890
|
+
export async function reconcileMigratableWithAccount(token, discovery, opts = {}) {
|
|
891
|
+
const processedKeys = await fetchProcessedImportKeys(token, discovery.sessions, opts.signal);
|
|
892
|
+
return applyAccountImportStatus(discovery, processedKeys, opts);
|
|
893
|
+
}
|
|
894
|
+
export async function reconcileFastMigratableWithAccount(token, discovery, opts = {}) {
|
|
895
|
+
const processedKeys = await fetchProcessedImportKeys(token, discovery.sessions, opts.signal);
|
|
896
|
+
return applyFastAccountImportStatus(discovery, processedKeys, opts);
|
|
897
|
+
}
|
|
555
898
|
export async function startMigration(opts) {
|
|
556
899
|
if (opts.pending.length === 0)
|
|
557
900
|
throw codedError("NO_PENDING_SESSIONS");
|
|
@@ -572,7 +915,10 @@ export async function startMigration(opts) {
|
|
|
572
915
|
catch (e) {
|
|
573
916
|
if (e?.code === "VAULT_LOCKED")
|
|
574
917
|
throw e;
|
|
575
|
-
|
|
918
|
+
// Config fetch failed (e.g., a transient DNS/network timeout). If a verified key is stored, send it
|
|
919
|
+
// anyway — the server uses it iff the account is encrypted and ignores it otherwise. Dropping it here
|
|
920
|
+
// made an encrypted account fail every job with ENCRYPTION_KEY_REQUIRED on a single flaky request.
|
|
921
|
+
encKey = store.getKey() || undefined;
|
|
576
922
|
}
|
|
577
923
|
const tz = userTimeZone();
|
|
578
924
|
let toImport = opts.pending;
|
|
@@ -633,7 +979,9 @@ async function runJobs(args) {
|
|
|
633
979
|
await sleep(RATE_WINDOW_MS - (now - reqTimes[0]) + 100);
|
|
634
980
|
}
|
|
635
981
|
};
|
|
636
|
-
let migrated = 0, extracted = 0, failed = 0,
|
|
982
|
+
let migrated = 0, extracted = 0, failed = 0, done = 0;
|
|
983
|
+
let stoppedReason;
|
|
984
|
+
const total = args.session.jobs.length;
|
|
637
985
|
const recordMetric = (metric) => {
|
|
638
986
|
try {
|
|
639
987
|
appendMigrationMetric(args.metricsFile, metric);
|
|
@@ -642,93 +990,80 @@ async function runJobs(args) {
|
|
|
642
990
|
/* best effort: metrics must never block a user migration */
|
|
643
991
|
}
|
|
644
992
|
};
|
|
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;
|
|
993
|
+
// Run a single job: throttle the request START, extract, then record the outcome. Counters are only
|
|
994
|
+
// mutated between awaits, so single-threaded interleaving keeps them consistent without locks.
|
|
995
|
+
const runOne = async (job, s) => {
|
|
996
|
+
const jobStartedAt = Date.now();
|
|
997
|
+
try {
|
|
998
|
+
await throttle();
|
|
999
|
+
if (stoppedReason)
|
|
1000
|
+
return; // an earlier job hit an unrecoverable stop — don't start more work
|
|
1001
|
+
const r = await runImportJob(args.client, job.id, s, args.userTz, args.encKey);
|
|
1002
|
+
extracted += r.memories;
|
|
1003
|
+
migrated++;
|
|
1004
|
+
done++;
|
|
1005
|
+
ledger[s.conversationKey] = { size: s.size, mtimeMs: s.mtimeMs, status: "done", memories: r.memories };
|
|
1006
|
+
saveLedger(ledger);
|
|
1007
|
+
recordMetric(buildMigrationMetric({
|
|
1008
|
+
runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
|
|
1009
|
+
session: s, status: "completed", memories: r.memories, durationMs: r.durationMs,
|
|
1010
|
+
processingTimeMs: r.processingTimeMs, ttfmMs: r.ttfmMs, alreadyDone: r.alreadyDone,
|
|
1011
|
+
duplicate: r.duplicate, selection: args.selection,
|
|
1012
|
+
}));
|
|
1013
|
+
args.onProgress?.({
|
|
1014
|
+
index: done, total, importSessionId: args.session.id, jobId: job.id, session: s,
|
|
1015
|
+
memories: r.memories, durationMs: r.durationMs, processingTimeMs: r.processingTimeMs,
|
|
1016
|
+
ttfmMs: r.ttfmMs, alreadyDone: r.alreadyDone, duplicate: r.duplicate,
|
|
1017
|
+
});
|
|
1018
|
+
}
|
|
1019
|
+
catch (e) {
|
|
1020
|
+
const status = responseStatus(e);
|
|
1021
|
+
const errCode = responseData(e).error;
|
|
1022
|
+
const message = responseMessage(e);
|
|
1023
|
+
const durationMs = Date.now() - jobStartedAt;
|
|
1024
|
+
if (status === 422 && errCode === "ENCRYPTION_KEY_REQUIRED") {
|
|
1025
|
+
stoppedReason = "key-expired"; // the vault key expired mid-run — signal workers to stop pulling
|
|
1026
|
+
done++;
|
|
712
1027
|
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,
|
|
1028
|
+
runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
|
|
1029
|
+
session: s, status: "stopped", durationMs, error: message, selection: args.selection,
|
|
723
1030
|
}));
|
|
724
|
-
args.onProgress?.({ index:
|
|
1031
|
+
args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
|
|
1032
|
+
return;
|
|
725
1033
|
}
|
|
1034
|
+
failed++;
|
|
1035
|
+
done++;
|
|
1036
|
+
recordMetric(buildMigrationMetric({
|
|
1037
|
+
runId: args.runId, importSessionId: args.session.id, jobId: job.id, index: done, total,
|
|
1038
|
+
session: s, status: "failed", durationMs, error: message, selection: args.selection,
|
|
1039
|
+
}));
|
|
1040
|
+
args.onProgress?.({ index: done, total, importSessionId: args.session.id, jobId: job.id, session: s, error: message, durationMs });
|
|
1041
|
+
}
|
|
1042
|
+
};
|
|
1043
|
+
// Worker pool: each worker pulls the next job until the list drains or a stop is signalled. The
|
|
1044
|
+
// shared throttle keeps request STARTS under RATE_MAX/min regardless of how many run concurrently.
|
|
1045
|
+
let next = 0;
|
|
1046
|
+
const worker = async () => {
|
|
1047
|
+
for (;;) {
|
|
1048
|
+
if (stoppedReason)
|
|
1049
|
+
return;
|
|
1050
|
+
const myIdx = next++;
|
|
1051
|
+
if (myIdx >= total)
|
|
1052
|
+
return;
|
|
1053
|
+
const job = args.session.jobs[myIdx];
|
|
1054
|
+
const s = args.byConv.get(mapKey(job.platform, job.conversation_id));
|
|
1055
|
+
if (!s)
|
|
1056
|
+
continue;
|
|
1057
|
+
await runOne(job, s);
|
|
726
1058
|
}
|
|
1059
|
+
};
|
|
1060
|
+
try {
|
|
1061
|
+
await Promise.all(Array.from({ length: Math.min(MIGRATE_CONCURRENCY, Math.max(1, total)) }, () => worker()));
|
|
727
1062
|
}
|
|
728
1063
|
catch {
|
|
729
1064
|
failed++;
|
|
730
1065
|
}
|
|
731
|
-
return { migrated, extracted, failed };
|
|
1066
|
+
return { migrated, extracted, failed, ...(stoppedReason ? { stoppedReason } : {}) };
|
|
732
1067
|
}
|
|
733
1068
|
export async function cmdMigrate(flags) {
|
|
734
1069
|
const c = color(process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true);
|
|
@@ -756,7 +1091,25 @@ export async function cmdMigrate(flags) {
|
|
|
756
1091
|
const includeActive = flags["include-active"] === true;
|
|
757
1092
|
const selection = { since, limit, minChars, maxChars, largest, includeActive };
|
|
758
1093
|
const metricsFile = typeof flags["metrics-file"] === "string" ? flags["metrics-file"] : defaultMigrationMetricsPath();
|
|
759
|
-
|
|
1094
|
+
let discovery = discoverMigratableSessions(selection);
|
|
1095
|
+
if (flags.estimate !== true && flags["dry-run"] !== true) {
|
|
1096
|
+
const token = new KeyStore().getToken();
|
|
1097
|
+
if (token) {
|
|
1098
|
+
try {
|
|
1099
|
+
discovery = await reconcileMigratableWithAccount(token, discovery, selection);
|
|
1100
|
+
}
|
|
1101
|
+
catch (e) {
|
|
1102
|
+
if (isImportStatusUnsupported(e)) {
|
|
1103
|
+
discovery = markAccountImportStatusUnavailable(discovery);
|
|
1104
|
+
}
|
|
1105
|
+
else {
|
|
1106
|
+
discovery = markAccountImportStatusFailed(discovery);
|
|
1107
|
+
console.warn(`Could not check this EchoMem account's import status; using local migration ledger. ${responseMessage(e)}`);
|
|
1108
|
+
}
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
const { sessions, pending, pendingTotal, alreadyMigrated, skippedActive, limited, codexCount, claudeCount, accountChecked, accountCheckFailed, accountCheckUnavailable } = discovery;
|
|
760
1113
|
const estimateSessions = includeActive ? sessions : sessions.filter((s) => !isActiveMigrationSession(s));
|
|
761
1114
|
if (!sessions.length) {
|
|
762
1115
|
console.log("No local Codex/Claude Code sessions found to migrate.");
|
|
@@ -768,12 +1121,19 @@ export async function cmdMigrate(flags) {
|
|
|
768
1121
|
}
|
|
769
1122
|
console.log("");
|
|
770
1123
|
console.log(c.bold(c.cyan("EchoMem migration")) + c.dim(` (${API_BASE})`));
|
|
1124
|
+
const pendingName = accountChecked ? "unprocessed" : "new/changed";
|
|
771
1125
|
const pendingText = limited
|
|
772
|
-
? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))}
|
|
773
|
-
: `${c.bold(String(pending.length))}
|
|
1126
|
+
? `${c.bold(String(pending.length))} of ${c.bold(String(pendingTotal))} ${pendingName} selected`
|
|
1127
|
+
: `${c.bold(String(pending.length))} ${pendingName} to import`;
|
|
774
1128
|
console.log(`Found ${c.bold(String(sessions.length))} sessions (${codexCount} Codex + ${claudeCount} Claude Code) · ${pendingText}` +
|
|
775
1129
|
(alreadyMigrated ? c.dim(`, ${alreadyMigrated} already migrated`) : "") +
|
|
776
1130
|
(skippedActive ? c.dim(`, ${skippedActive} active skipped`) : ""));
|
|
1131
|
+
if (accountChecked)
|
|
1132
|
+
console.log(c.dim("Checked this EchoMem account; already processed conversations are skipped."));
|
|
1133
|
+
else if (accountCheckFailed)
|
|
1134
|
+
console.log(c.dim("Using local migration ledger because the account status check failed."));
|
|
1135
|
+
else if (accountCheckUnavailable)
|
|
1136
|
+
console.log(c.dim("Using local migration ledger; this API does not expose the optional account status precheck yet."));
|
|
777
1137
|
const filters = [
|
|
778
1138
|
minChars ? `min ${humanNum(minChars)} chars` : "",
|
|
779
1139
|
maxChars ? `max ${humanNum(maxChars)} chars` : "",
|