@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/setup.js
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
import http from "node:http";
|
|
16
16
|
import { randomUUID } from "node:crypto";
|
|
17
17
|
import { spawn } from "node:child_process";
|
|
18
|
+
import { Worker } from "node:worker_threads";
|
|
18
19
|
import fs from "node:fs";
|
|
19
20
|
import os from "node:os";
|
|
20
21
|
import path from "node:path";
|
|
@@ -22,10 +23,13 @@ import readline from "node:readline";
|
|
|
22
23
|
import axios from "axios";
|
|
23
24
|
import { KeyStore } from "./keystore.js";
|
|
24
25
|
import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
|
|
25
|
-
import { runReport,
|
|
26
|
-
import { cmdMigrate } from "./migrate.js";
|
|
27
|
-
|
|
28
|
-
|
|
26
|
+
import { collect, runReport, buildStatsPayload } from "./report.js";
|
|
27
|
+
import { cmdMigrate, applyAccountImportStatus, applyFastAccountImportStatus, discoverMigratableFastDiscovery, discoverMigratableSessions, estimateMigrationEta, fetchProcessedImportKeys, isImportStatusUnsupported, markAccountImportStatusFailed, markAccountImportStatusUnavailable, markFastAccountImportStatusUnavailable, startMigration, summarizeFastMigratableDiscovery, MIGRATE_CONCURRENCY, } from "./migrate.js";
|
|
28
|
+
import { syncCodexUsage } from "./codex-sync.js";
|
|
29
|
+
import { renderSetupPage } from "./setup-page.js";
|
|
30
|
+
// The hosted connect-device page is now only the account-auth/token courier. The dashboard itself is
|
|
31
|
+
// served by this localhost bridge, where local logs and processed/unprocessed counts never leave the
|
|
32
|
+
// device unless the user explicitly starts migration. Override the hosted auth origin with ECHO_WEB_URL.
|
|
29
33
|
const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
|
|
30
34
|
const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
31
35
|
function home(...p) {
|
|
@@ -63,7 +67,24 @@ export function buildServerEntry(opts = {}) {
|
|
|
63
67
|
if (opts.devEntryPath) {
|
|
64
68
|
return { command: "node", args: [opts.devEntryPath] };
|
|
65
69
|
}
|
|
66
|
-
|
|
70
|
+
// Spawn the ALREADY-INSTALLED bridge directly (this node + this script's real path) instead of
|
|
71
|
+
// `npx -y @echomem/mcp`. `npx -y` re-resolves and, on a cache miss, NETWORK-fetches from the npm
|
|
72
|
+
// registry on EVERY client start — on a slow/flaky network that delays the MCP handshake past the
|
|
73
|
+
// client's timeout, so the agent hangs ("connection timed out after 30000ms") and Codex can SIGTERM.
|
|
74
|
+
// Using process.execPath (the running node) + the realpath'd entry also dodges the GUI-PATH trap:
|
|
75
|
+
// GUI/IDE-launched clients don't inherit nvm's PATH, so a bare `npx`/`echomem-mcp` may not resolve.
|
|
76
|
+
// Trade-off: the node path is version-specific under nvm — re-run `setup` after a Node upgrade.
|
|
77
|
+
try {
|
|
78
|
+
const entry = fs.realpathSync(process.argv[1] || "");
|
|
79
|
+
if (entry && fs.existsSync(entry)) {
|
|
80
|
+
return { command: process.execPath, args: [entry] };
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
/* couldn't resolve a local install — fall through to npx */
|
|
85
|
+
}
|
|
86
|
+
// Fallback (unresolved local install): at least drop `-y` so npx doesn't auto-INSTALL on every start.
|
|
87
|
+
return { command: "npx", args: ["@echomem/mcp"] };
|
|
67
88
|
}
|
|
68
89
|
/** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
|
|
69
90
|
export function codexTomlBlock(entry) {
|
|
@@ -71,8 +92,12 @@ export function codexTomlBlock(entry) {
|
|
|
71
92
|
const args = (Array.isArray(entry.args) ? entry.args : []).map((a) => JSON.stringify(String(a))).join(", ");
|
|
72
93
|
return `[mcp_servers.echomem]\ncommand = ${command}\nargs = [${args}]\n`;
|
|
73
94
|
}
|
|
74
|
-
/**
|
|
75
|
-
|
|
95
|
+
/**
|
|
96
|
+
* Write/merge the EchoMem entry straight into Codex's config.toml — no `codex` CLI needed. Idempotent.
|
|
97
|
+
* If an `[mcp_servers.echomem]` block already exists it is REPLACED (so re-running `setup` upgrades a
|
|
98
|
+
* stale `npx -y` entry to the direct path); identical entries are left untouched.
|
|
99
|
+
*/
|
|
100
|
+
export function writeCodexConfig(configPath, entry) {
|
|
76
101
|
let content = "";
|
|
77
102
|
try {
|
|
78
103
|
content = fs.readFileSync(configPath, "utf8");
|
|
@@ -80,11 +105,23 @@ function writeCodexConfig(configPath, entry) {
|
|
|
80
105
|
catch {
|
|
81
106
|
/* fresh config */
|
|
82
107
|
}
|
|
83
|
-
|
|
84
|
-
|
|
108
|
+
const block = codexTomlBlock(entry).trimEnd();
|
|
109
|
+
const lines = content.split("\n");
|
|
110
|
+
const start = lines.findIndex((l) => /^\s*\[mcp_servers\.echomem\]\s*$/.test(l));
|
|
85
111
|
fs.mkdirSync(path.dirname(configPath), { recursive: true });
|
|
112
|
+
if (start >= 0) {
|
|
113
|
+
// The block runs from its header to the next top-level [section] (or EOF).
|
|
114
|
+
let end = start + 1;
|
|
115
|
+
while (end < lines.length && !/^\s*\[/.test(lines[end]))
|
|
116
|
+
end++;
|
|
117
|
+
if (lines.slice(start, end).join("\n").trimEnd() === block)
|
|
118
|
+
return "exists"; // already correct
|
|
119
|
+
const next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end)];
|
|
120
|
+
fs.writeFileSync(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
|
|
121
|
+
return "wrote"; // replaced a stale entry → caller tells the user to restart Codex
|
|
122
|
+
}
|
|
86
123
|
const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
|
|
87
|
-
fs.appendFileSync(configPath, sep +
|
|
124
|
+
fs.appendFileSync(configPath, sep + block + "\n");
|
|
88
125
|
return "wrote";
|
|
89
126
|
}
|
|
90
127
|
/** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
|
|
@@ -113,23 +150,286 @@ function openBrowser(url) {
|
|
|
113
150
|
/* headless — caller prints the URL */
|
|
114
151
|
}
|
|
115
152
|
}
|
|
153
|
+
function migratableFromDiscovery(disc) {
|
|
154
|
+
const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
|
|
155
|
+
return {
|
|
156
|
+
pending: disc.pending.length,
|
|
157
|
+
pendingTotal: disc.pendingTotal,
|
|
158
|
+
alreadyMigrated: disc.alreadyMigrated,
|
|
159
|
+
skippedActive: disc.skippedActive,
|
|
160
|
+
limited: disc.limited,
|
|
161
|
+
eta,
|
|
162
|
+
buckets: eta.buckets,
|
|
163
|
+
totalChars: eta.totalChars,
|
|
164
|
+
approxInputTokens: eta.approxInputTokens,
|
|
165
|
+
estimatedSeconds: eta.estimatedSeconds,
|
|
166
|
+
estimatedLabel: eta.estimatedLabel,
|
|
167
|
+
accountChecked: disc.accountChecked === true,
|
|
168
|
+
accountCheckFailed: disc.accountCheckFailed === true,
|
|
169
|
+
accountCheckUnavailable: disc.accountCheckUnavailable === true,
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
function sessionsFromDiscovery(disc) {
|
|
173
|
+
return {
|
|
174
|
+
total: disc.sessions.length,
|
|
175
|
+
codex: disc.codexCount,
|
|
176
|
+
claudeCode: disc.claudeCount,
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function migratableFromFastSummary(summary) {
|
|
180
|
+
return {
|
|
181
|
+
pending: summary.pending,
|
|
182
|
+
pendingTotal: summary.pendingTotal,
|
|
183
|
+
alreadyMigrated: summary.alreadyMigrated,
|
|
184
|
+
skippedActive: summary.skippedActive,
|
|
185
|
+
eta: summary.eta,
|
|
186
|
+
buckets: summary.eta.buckets,
|
|
187
|
+
totalChars: summary.eta.totalChars,
|
|
188
|
+
approxInputTokens: summary.eta.approxInputTokens,
|
|
189
|
+
estimatedSeconds: summary.eta.estimatedSeconds,
|
|
190
|
+
estimatedLabel: summary.eta.estimatedLabel,
|
|
191
|
+
quickEstimate: true,
|
|
192
|
+
accountChecked: summary.accountChecked === true,
|
|
193
|
+
accountCheckFailed: summary.accountCheckFailed === true,
|
|
194
|
+
accountCheckUnavailable: summary.accountCheckUnavailable === true,
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function deferred() {
|
|
198
|
+
let done = false;
|
|
199
|
+
let resolveInner;
|
|
200
|
+
let rejectInner;
|
|
201
|
+
const promise = new Promise((resolve, reject) => {
|
|
202
|
+
resolveInner = resolve;
|
|
203
|
+
rejectInner = reject;
|
|
204
|
+
});
|
|
205
|
+
return {
|
|
206
|
+
promise,
|
|
207
|
+
resolve: (value) => {
|
|
208
|
+
if (done)
|
|
209
|
+
return;
|
|
210
|
+
done = true;
|
|
211
|
+
resolveInner(value);
|
|
212
|
+
},
|
|
213
|
+
reject: (reason) => {
|
|
214
|
+
if (done)
|
|
215
|
+
return;
|
|
216
|
+
done = true;
|
|
217
|
+
rejectInner(reason);
|
|
218
|
+
},
|
|
219
|
+
settled: () => done,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
function isObjectRecord(value) {
|
|
223
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
224
|
+
}
|
|
225
|
+
function asString(value) {
|
|
226
|
+
return typeof value === "string" && value ? value : undefined;
|
|
227
|
+
}
|
|
228
|
+
function readJsonBody(req) {
|
|
229
|
+
return new Promise((resolve, reject) => {
|
|
230
|
+
let body = "";
|
|
231
|
+
req.on("data", (chunk) => {
|
|
232
|
+
body += chunk.toString();
|
|
233
|
+
});
|
|
234
|
+
req.on("end", () => {
|
|
235
|
+
try {
|
|
236
|
+
const parsed = JSON.parse(body || "{}");
|
|
237
|
+
resolve(isObjectRecord(parsed) ? parsed : {});
|
|
238
|
+
}
|
|
239
|
+
catch {
|
|
240
|
+
reject(new Error("bad json"));
|
|
241
|
+
}
|
|
242
|
+
});
|
|
243
|
+
req.on("error", (e) => reject(e instanceof Error ? e : new Error(String(e))));
|
|
244
|
+
});
|
|
245
|
+
}
|
|
246
|
+
function withTimeout(promise, ms, code, onTimeout) {
|
|
247
|
+
let timer;
|
|
248
|
+
const timeout = new Promise((_, reject) => {
|
|
249
|
+
timer = setTimeout(() => {
|
|
250
|
+
onTimeout?.();
|
|
251
|
+
const e = new Error(code);
|
|
252
|
+
e.code = code;
|
|
253
|
+
reject(e);
|
|
254
|
+
}, ms);
|
|
255
|
+
timer.unref?.();
|
|
256
|
+
});
|
|
257
|
+
return Promise.race([promise, timeout]).finally(() => {
|
|
258
|
+
if (timer)
|
|
259
|
+
clearTimeout(timer);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
function delay(ms) {
|
|
263
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
264
|
+
}
|
|
265
|
+
function discoverMigratableSessionsOffThread() {
|
|
266
|
+
const migrateUrl = new URL("./migrate.js", import.meta.url).href;
|
|
267
|
+
const code = `
|
|
268
|
+
import { parentPort } from "node:worker_threads";
|
|
269
|
+
import { discoverMigratableSessions } from ${JSON.stringify(migrateUrl)};
|
|
270
|
+
|
|
271
|
+
try {
|
|
272
|
+
parentPort?.postMessage({ ok: true, discovery: discoverMigratableSessions() });
|
|
273
|
+
} catch (error) {
|
|
274
|
+
parentPort?.postMessage({
|
|
275
|
+
ok: false,
|
|
276
|
+
message: error instanceof Error ? error.message : String(error),
|
|
277
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
`;
|
|
281
|
+
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
|
|
282
|
+
return new Promise((resolve, reject) => {
|
|
283
|
+
let settled = false;
|
|
284
|
+
worker.once("message", (message) => {
|
|
285
|
+
settled = true;
|
|
286
|
+
const msg = message;
|
|
287
|
+
if (msg.ok === true && msg.discovery && typeof msg.discovery === "object") {
|
|
288
|
+
resolve(msg.discovery);
|
|
289
|
+
return;
|
|
290
|
+
}
|
|
291
|
+
const err = new Error(typeof msg.message === "string" ? msg.message : "Exact local discovery failed");
|
|
292
|
+
if (typeof msg.stack === "string")
|
|
293
|
+
err.stack = msg.stack;
|
|
294
|
+
reject(err);
|
|
295
|
+
});
|
|
296
|
+
worker.once("error", (error) => {
|
|
297
|
+
if (settled)
|
|
298
|
+
return;
|
|
299
|
+
settled = true;
|
|
300
|
+
reject(error);
|
|
301
|
+
});
|
|
302
|
+
worker.once("exit", (code) => {
|
|
303
|
+
// Reject on ANY unsettled exit (incl. code 0): a worker that exits without posting a result must
|
|
304
|
+
// not leave this promise pending forever (that was the "stuck at finishing local job sizing" hang).
|
|
305
|
+
if (settled)
|
|
306
|
+
return;
|
|
307
|
+
settled = true;
|
|
308
|
+
reject(new Error(`Exact local discovery worker exited (code ${code}) without a result`));
|
|
309
|
+
});
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/** Build the local forensic "Context Doctor" report on a worker thread so the multi-file scan never
|
|
313
|
+
* blocks the bridge's event loop (the freeze postmortem: any unbounded sync work on this path is risky). */
|
|
314
|
+
function buildForensicReportOffThread(onProgress) {
|
|
315
|
+
const forensicsUrl = new URL("./forensics.js", import.meta.url).href;
|
|
316
|
+
const code = `
|
|
317
|
+
import { parentPort } from "node:worker_threads";
|
|
318
|
+
import { buildForensicReport } from ${JSON.stringify(forensicsUrl)};
|
|
319
|
+
try {
|
|
320
|
+
const report = buildForensicReport({ onProgress: (done, total) => parentPort?.postMessage({ progress: { done, total } }) });
|
|
321
|
+
parentPort?.postMessage({ ok: true, report });
|
|
322
|
+
} catch (error) {
|
|
323
|
+
parentPort?.postMessage({ ok: false, message: error instanceof Error ? error.message : String(error) });
|
|
324
|
+
}
|
|
325
|
+
`;
|
|
326
|
+
const worker = new Worker(new URL(`data:text/javascript;charset=utf-8,${encodeURIComponent(code)}`));
|
|
327
|
+
return new Promise((resolve, reject) => {
|
|
328
|
+
let settled = false;
|
|
329
|
+
worker.on("message", (message) => {
|
|
330
|
+
const msg = message;
|
|
331
|
+
if (msg.progress) {
|
|
332
|
+
onProgress?.(msg.progress.done, msg.progress.total);
|
|
333
|
+
return;
|
|
334
|
+
}
|
|
335
|
+
settled = true;
|
|
336
|
+
if (msg.ok === true && msg.report && typeof msg.report === "object")
|
|
337
|
+
resolve(msg.report);
|
|
338
|
+
else
|
|
339
|
+
reject(new Error(typeof msg.message === "string" ? msg.message : "Local forensic report failed"));
|
|
340
|
+
void worker.terminate();
|
|
341
|
+
});
|
|
342
|
+
worker.once("error", (error) => {
|
|
343
|
+
if (settled)
|
|
344
|
+
return;
|
|
345
|
+
settled = true;
|
|
346
|
+
reject(error);
|
|
347
|
+
});
|
|
348
|
+
worker.once("exit", (code) => {
|
|
349
|
+
if (settled)
|
|
350
|
+
return;
|
|
351
|
+
settled = true;
|
|
352
|
+
reject(new Error(`Forensic report worker exited (code ${code}) without a result`));
|
|
353
|
+
});
|
|
354
|
+
});
|
|
355
|
+
}
|
|
356
|
+
export function respondMigrate(res, body, status = 200) {
|
|
357
|
+
if (res.writableEnded)
|
|
358
|
+
return;
|
|
359
|
+
res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
|
360
|
+
}
|
|
116
361
|
/**
|
|
117
|
-
* Start
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
* lets the caller build the callback URL before the user approves.
|
|
362
|
+
* Start the persistent localhost bridge used by the connect-device page. It accepts the token,
|
|
363
|
+
* serves local Wrapped stats, and holds the /migrate response until cmdLogin has created a cloud
|
|
364
|
+
* import session.
|
|
121
365
|
*/
|
|
122
366
|
export function startCallbackServer(opts = {}) {
|
|
123
|
-
const timeoutMs = opts.timeoutMs ??
|
|
367
|
+
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
368
|
+
const dashboardTimeoutMs = 4 * 60 * 60 * 1000;
|
|
124
369
|
const expectedNonce = opts.nonce;
|
|
125
370
|
return new Promise((resolveOuter, rejectOuter) => {
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
371
|
+
const onToken = deferred();
|
|
372
|
+
const decision = deferred();
|
|
373
|
+
const migrateRequest = deferred();
|
|
374
|
+
let stats = null;
|
|
375
|
+
let authUrl = "";
|
|
376
|
+
let switchAccountUrl = "";
|
|
377
|
+
let connected = false;
|
|
378
|
+
let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
379
|
+
let migrateStarted = false;
|
|
380
|
+
let tokenRefreshHandler = null;
|
|
381
|
+
let logoutHandler = null;
|
|
382
|
+
let timer;
|
|
383
|
+
let closed = false;
|
|
384
|
+
let server;
|
|
385
|
+
const checkNonce = (nonce) => !expectedNonce || nonce === expectedNonce;
|
|
386
|
+
const text = (res, status, body = "") => res.writeHead(status, { "Content-Type": "text/plain" }).end(body);
|
|
387
|
+
const json = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
|
|
388
|
+
const close = () => {
|
|
389
|
+
if (timer)
|
|
390
|
+
clearTimeout(timer);
|
|
391
|
+
timer = undefined;
|
|
392
|
+
if (closed)
|
|
393
|
+
return;
|
|
394
|
+
closed = true;
|
|
395
|
+
server.close();
|
|
396
|
+
};
|
|
397
|
+
const armTimeout = () => {
|
|
398
|
+
if (timer)
|
|
399
|
+
clearTimeout(timer);
|
|
400
|
+
const waitMs = onToken.settled() ? dashboardTimeoutMs : timeoutMs;
|
|
401
|
+
timer = setTimeout(() => {
|
|
402
|
+
if (!onToken.settled()) {
|
|
403
|
+
onToken.reject(new Error("timed out waiting for browser approval"));
|
|
404
|
+
}
|
|
405
|
+
else if (!decision.settled()) {
|
|
406
|
+
decision.resolve("timeout");
|
|
407
|
+
}
|
|
408
|
+
close();
|
|
409
|
+
}, waitMs);
|
|
410
|
+
timer.unref?.();
|
|
411
|
+
};
|
|
412
|
+
const handleCallback = (res, token, key, nonce) => {
|
|
413
|
+
if (!checkNonce(nonce))
|
|
414
|
+
return void text(res, 403, "bad nonce");
|
|
415
|
+
if (!token)
|
|
416
|
+
return void text(res, 400, "missing token");
|
|
417
|
+
const firstToken = !onToken.settled();
|
|
418
|
+
connected = true;
|
|
419
|
+
const setupPath = `/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1`;
|
|
420
|
+
res.writeHead(200, { "Content-Type": "text/html" }).end(`<!doctype html><html><head><meta charset="utf-8"></head><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>EchoMem connected</h2><p>Returning to the local setup page...</p><p><a href="${setupPath}">Continue</a></p><script>(function(){var target=${JSON.stringify(setupPath)};try{if(window.opener&&!window.opener.closed){window.opener.postMessage({type:"echomem:connected",nonce:${JSON.stringify(nonce || "")}},window.location.origin);window.close();setTimeout(function(){window.location.href=target;},500);return;}}catch(_){}window.location.href=target;})();</script></body></html>`);
|
|
421
|
+
const callbackToken = { token, key };
|
|
422
|
+
if (firstToken) {
|
|
423
|
+
onToken.resolve(callbackToken);
|
|
424
|
+
}
|
|
425
|
+
else if (tokenRefreshHandler) {
|
|
426
|
+
Promise.resolve(tokenRefreshHandler(callbackToken)).catch((e) => {
|
|
427
|
+
console.error(`Could not refresh local login: ${e instanceof Error ? e.message : String(e)}`);
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
armTimeout();
|
|
431
|
+
};
|
|
432
|
+
server = http.createServer((req, res) => {
|
|
133
433
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
134
434
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
135
435
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -138,51 +438,171 @@ export function startCallbackServer(opts = {}) {
|
|
|
138
438
|
if (req.method === "OPTIONS")
|
|
139
439
|
return void res.writeHead(204).end();
|
|
140
440
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
req.
|
|
441
|
+
const route = url.pathname;
|
|
442
|
+
const run = async () => {
|
|
443
|
+
if (route === "/setup" && req.method === "GET") {
|
|
444
|
+
res.writeHead(200, {
|
|
445
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
446
|
+
"Cache-Control": "no-store",
|
|
447
|
+
}).end(renderSetupPage());
|
|
448
|
+
return;
|
|
449
|
+
}
|
|
450
|
+
if (route === "/config" && req.method === "GET") {
|
|
451
|
+
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
452
|
+
return void text(res, 403, "bad nonce");
|
|
453
|
+
json(res, 200, { connected, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
|
|
454
|
+
return;
|
|
455
|
+
}
|
|
456
|
+
if (route === "/callback" && req.method === "GET") {
|
|
457
|
+
handleCallback(res, url.searchParams.get("token") || undefined, url.searchParams.get("key") || undefined, url.searchParams.get("nonce") || undefined);
|
|
458
|
+
return;
|
|
459
|
+
}
|
|
460
|
+
if (route === "/callback" && req.method === "POST") {
|
|
461
|
+
let body;
|
|
161
462
|
try {
|
|
162
|
-
|
|
163
|
-
finish(parsed.token, parsed.key, parsed.nonce);
|
|
463
|
+
body = await readJsonBody(req);
|
|
164
464
|
}
|
|
165
465
|
catch {
|
|
166
|
-
res
|
|
466
|
+
text(res, 400, "bad json");
|
|
467
|
+
return;
|
|
167
468
|
}
|
|
168
|
-
|
|
169
|
-
|
|
469
|
+
handleCallback(res, asString(body.token), asString(body.key), asString(body.nonce));
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
if (route === "/stats" && req.method === "GET") {
|
|
473
|
+
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
474
|
+
return void text(res, 403, "bad nonce");
|
|
475
|
+
const payload = opts.getStats ? opts.getStats() : stats;
|
|
476
|
+
if (payload == null)
|
|
477
|
+
return void res.writeHead(202).end();
|
|
478
|
+
json(res, 200, payload);
|
|
479
|
+
return;
|
|
480
|
+
}
|
|
481
|
+
if (route === "/report" && req.method === "GET") {
|
|
482
|
+
// Local forensic "Context Doctor" report — computed locally, served BEFORE auth (scan-first).
|
|
483
|
+
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
484
|
+
return void text(res, 403, "bad nonce");
|
|
485
|
+
const payload = opts.getReport ? opts.getReport() : null;
|
|
486
|
+
if (payload == null) {
|
|
487
|
+
// 202 carries scan progress so the page can show a live "scanned N/total" indicator.
|
|
488
|
+
const prog = opts.getReportProgress ? opts.getReportProgress() : { scanned: 0, total: 0 };
|
|
489
|
+
return void res.writeHead(202, { "Content-Type": "application/json" }).end(JSON.stringify(prog));
|
|
490
|
+
}
|
|
491
|
+
json(res, 200, payload);
|
|
492
|
+
return;
|
|
493
|
+
}
|
|
494
|
+
if (route === "/progress" && req.method === "GET") {
|
|
495
|
+
if (!checkNonce(url.searchParams.get("nonce") || undefined))
|
|
496
|
+
return void text(res, 403, "bad nonce");
|
|
497
|
+
json(res, 200, progress);
|
|
498
|
+
return;
|
|
499
|
+
}
|
|
500
|
+
if (route === "/logout" && req.method === "POST") {
|
|
501
|
+
let body;
|
|
502
|
+
try {
|
|
503
|
+
body = await readJsonBody(req);
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
text(res, 400, "bad json");
|
|
507
|
+
return;
|
|
508
|
+
}
|
|
509
|
+
if (!checkNonce(asString(body.nonce)))
|
|
510
|
+
return void text(res, 403, "bad nonce");
|
|
511
|
+
if (migrateStarted && (progress.status === "starting" || progress.status === "running")) {
|
|
512
|
+
json(res, 409, { error: "MIGRATION_RUNNING", message: "Wait for extraction to finish before signing out locally." });
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
try {
|
|
516
|
+
fs.rmSync(new KeyStore().path(), { force: true });
|
|
517
|
+
}
|
|
518
|
+
catch {
|
|
519
|
+
/* already logged out locally */
|
|
520
|
+
}
|
|
521
|
+
connected = false;
|
|
522
|
+
stats = null;
|
|
523
|
+
migrateStarted = false;
|
|
524
|
+
progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
|
|
525
|
+
json(res, 200, { ok: true, authUrl, switchAccountUrl: switchAccountUrl || authUrl, localOnly: true });
|
|
526
|
+
Promise.resolve()
|
|
527
|
+
.then(() => logoutHandler?.())
|
|
528
|
+
.catch((e) => {
|
|
529
|
+
console.error(`Could not reset local login state: ${e instanceof Error ? e.message : String(e)}`);
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
if (route === "/migrate" && req.method === "POST") {
|
|
534
|
+
let body;
|
|
535
|
+
try {
|
|
536
|
+
body = await readJsonBody(req);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
text(res, 400, "bad json");
|
|
540
|
+
return;
|
|
541
|
+
}
|
|
542
|
+
if (!checkNonce(asString(body.nonce)))
|
|
543
|
+
return void text(res, 403, "bad nonce");
|
|
544
|
+
if (migrateStarted)
|
|
545
|
+
return void json(res, 409, { error: "MIGRATE_IN_PROGRESS" });
|
|
546
|
+
migrateStarted = true;
|
|
547
|
+
const safety = setTimeout(() => {
|
|
548
|
+
respondMigrate(res, { error: "IMPORT_START_TIMEOUT" }, 504);
|
|
549
|
+
}, 30_000);
|
|
550
|
+
safety.unref?.();
|
|
551
|
+
migrateRequest.resolve({ res });
|
|
552
|
+
decision.resolve("migrate");
|
|
553
|
+
return;
|
|
554
|
+
}
|
|
555
|
+
if (route === "/skip" && req.method === "POST") {
|
|
556
|
+
let body;
|
|
557
|
+
try {
|
|
558
|
+
body = await readJsonBody(req);
|
|
559
|
+
}
|
|
560
|
+
catch {
|
|
561
|
+
text(res, 400, "bad json");
|
|
562
|
+
return;
|
|
563
|
+
}
|
|
564
|
+
if (!checkNonce(asString(body.nonce)))
|
|
565
|
+
return void text(res, 403, "bad nonce");
|
|
566
|
+
json(res, 200, { ok: true });
|
|
567
|
+
decision.resolve("skip");
|
|
568
|
+
close();
|
|
569
|
+
return;
|
|
570
|
+
}
|
|
571
|
+
res.writeHead(404).end();
|
|
572
|
+
};
|
|
573
|
+
run().catch((e) => {
|
|
574
|
+
if (!res.writableEnded)
|
|
575
|
+
text(res, 500, e instanceof Error ? e.message : String(e));
|
|
576
|
+
});
|
|
170
577
|
});
|
|
171
|
-
|
|
172
|
-
server.close();
|
|
173
|
-
fail(new Error("timed out waiting for browser approval"));
|
|
174
|
-
}, timeoutMs);
|
|
578
|
+
armTimeout();
|
|
175
579
|
server.on("error", (e) => rejectOuter(e));
|
|
176
580
|
server.listen(0, "127.0.0.1", () => {
|
|
177
581
|
const addr = server.address();
|
|
178
582
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
179
583
|
resolveOuter({
|
|
180
584
|
port,
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
585
|
+
onToken: onToken.promise,
|
|
586
|
+
wait: onToken.promise,
|
|
587
|
+
decision: decision.promise,
|
|
588
|
+
migrateRequest: migrateRequest.promise,
|
|
589
|
+
setTokenRefreshHandler: (handler) => {
|
|
590
|
+
tokenRefreshHandler = handler;
|
|
591
|
+
},
|
|
592
|
+
setLogoutHandler: (handler) => {
|
|
593
|
+
logoutHandler = handler;
|
|
185
594
|
},
|
|
595
|
+
setAuthUrl: (url, nextSwitchAccountUrl) => {
|
|
596
|
+
authUrl = url;
|
|
597
|
+
switchAccountUrl = nextSwitchAccountUrl || url;
|
|
598
|
+
},
|
|
599
|
+
setStats: (s) => {
|
|
600
|
+
stats = s;
|
|
601
|
+
},
|
|
602
|
+
setProgress: (p) => {
|
|
603
|
+
progress = p;
|
|
604
|
+
},
|
|
605
|
+
close,
|
|
186
606
|
});
|
|
187
607
|
});
|
|
188
608
|
});
|
|
@@ -196,6 +616,42 @@ function authedAxios(token) {
|
|
|
196
616
|
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
197
617
|
});
|
|
198
618
|
}
|
|
619
|
+
function formatVerificationError(error) {
|
|
620
|
+
if (axios.isAxiosError(error)) {
|
|
621
|
+
const status = typeof error.response?.status === "number" ? error.response.status : null;
|
|
622
|
+
const statusText = error.response?.statusText ? ` ${error.response.statusText}` : "";
|
|
623
|
+
const requestPath = typeof error.config?.url === "string" ? error.config.url : "";
|
|
624
|
+
const endpoint = requestPath
|
|
625
|
+
? requestPath.startsWith("http")
|
|
626
|
+
? requestPath
|
|
627
|
+
: `${API_BASE_URL}${requestPath}`
|
|
628
|
+
: API_BASE_URL;
|
|
629
|
+
const statusLabel = status ? `HTTP ${status}${statusText}` : error.code || error.message;
|
|
630
|
+
const localApi = /^https?:\/\/(127\.0\.0\.1|localhost)(:\d+)?/.test(API_BASE_URL);
|
|
631
|
+
const routeHint = status === 404 && requestPath.includes("/api/extension/account/encryption")
|
|
632
|
+
? localApi
|
|
633
|
+
? "That usually means ECHO_API_BASE_URL is pointing at a different local app or an older EchoMem API server."
|
|
634
|
+
: "That usually means the EchoMem API route is missing on the configured server."
|
|
635
|
+
: null;
|
|
636
|
+
const localHint = localApi ? "Start the EchoMem-Chrome Next API on that port, or unset ECHO_API_BASE_URL to use production." : null;
|
|
637
|
+
return [
|
|
638
|
+
`Could not verify this device token against ${endpoint}: ${statusLabel}.`,
|
|
639
|
+
routeHint,
|
|
640
|
+
localHint,
|
|
641
|
+
].filter(Boolean).join(" ");
|
|
642
|
+
}
|
|
643
|
+
return `Could not verify this device token: ${error instanceof Error ? error.message : String(error)}`;
|
|
644
|
+
}
|
|
645
|
+
async function verifyAndPrint(input) {
|
|
646
|
+
try {
|
|
647
|
+
console.log(await verifyAndStore(input));
|
|
648
|
+
return true;
|
|
649
|
+
}
|
|
650
|
+
catch (error) {
|
|
651
|
+
console.error(`❌ ${formatVerificationError(error)}`);
|
|
652
|
+
return false;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
199
655
|
/**
|
|
200
656
|
* Verify supplied secrets and persist them. Given a token (required), and EITHER a base64 key or a
|
|
201
657
|
* passphrase (optional — only for encrypted accounts), this verifies the key against the server's
|
|
@@ -203,8 +659,8 @@ function authedAxios(token) {
|
|
|
203
659
|
*/
|
|
204
660
|
export async function verifyAndStore(input) {
|
|
205
661
|
const store = new KeyStore();
|
|
206
|
-
store.saveToken(input.token);
|
|
207
662
|
const config = await fetchEncryptionConfig(authedAxios(input.token));
|
|
663
|
+
store.saveToken(input.token);
|
|
208
664
|
if (!config.enabled) {
|
|
209
665
|
return input.key || input.passphrase
|
|
210
666
|
? "Token saved. (Account is not encrypted — the supplied key was ignored.)"
|
|
@@ -293,43 +749,499 @@ async function cmdSetup(flags) {
|
|
|
293
749
|
}
|
|
294
750
|
console.log("");
|
|
295
751
|
await cmdLogin(flags);
|
|
296
|
-
// Onboarding reveal: show the local usage audit right after connecting (proactive trigger).
|
|
297
|
-
try {
|
|
298
|
-
console.log("\n" + (await buildReportText(true)));
|
|
299
|
-
}
|
|
300
|
-
catch {
|
|
301
|
-
/* report is best-effort — never block setup */
|
|
302
|
-
}
|
|
303
752
|
}
|
|
304
753
|
async function cmdLogin(flags) {
|
|
305
754
|
// Manual path (also the headless path): secrets supplied as flags.
|
|
306
755
|
if (typeof flags.token === "string") {
|
|
307
|
-
const
|
|
756
|
+
const ok = await verifyAndPrint({
|
|
308
757
|
token: flags.token,
|
|
309
758
|
key: typeof flags.key === "string" ? flags.key : undefined,
|
|
310
759
|
passphrase: typeof flags.passphrase === "string" ? flags.passphrase : undefined,
|
|
311
760
|
});
|
|
312
|
-
|
|
761
|
+
if (!ok)
|
|
762
|
+
process.exitCode = 1;
|
|
313
763
|
return;
|
|
314
764
|
}
|
|
315
|
-
// Browser path: open
|
|
316
|
-
// the
|
|
765
|
+
// Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
|
|
766
|
+
// after the web page has delivered the token+key to the callback. The nonce gates every local route.
|
|
317
767
|
console.log("Opening your browser to approve this device…");
|
|
318
768
|
const nonce = randomUUID();
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
769
|
+
let stats = null;
|
|
770
|
+
let forensicReport = null;
|
|
771
|
+
let forensicProgress = { scanned: 0, total: 0 };
|
|
772
|
+
const srv = await startCallbackServer({
|
|
773
|
+
nonce,
|
|
774
|
+
getStats: () => stats,
|
|
775
|
+
getReport: () => forensicReport,
|
|
776
|
+
getReportProgress: () => forensicProgress,
|
|
777
|
+
});
|
|
778
|
+
const callbackUrl = `http://127.0.0.1:${srv.port}/callback`;
|
|
779
|
+
const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
|
|
780
|
+
const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}&return_to=${encodeURIComponent(localSetupUrl)}`;
|
|
781
|
+
const switchAccountUrl = new URL(connectUrl);
|
|
782
|
+
// The hosted connect-device page should clear its own Supabase/browser session before minting
|
|
783
|
+
// the localhost token when this hint is present. Localhost cannot safely clear yeahecho.com auth.
|
|
784
|
+
switchAccountUrl.searchParams.set("force_signout", "1");
|
|
785
|
+
switchAccountUrl.searchParams.set("prompt", "login");
|
|
786
|
+
srv.setAuthUrl(connectUrl, switchAccountUrl.toString());
|
|
787
|
+
openBrowser(localSetupUrl);
|
|
788
|
+
console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
|
|
789
|
+
// Scan-first: build the local forensic "Context Doctor" report off-thread so the page shows it
|
|
790
|
+
// BEFORE the user connects an account (the scan is local-only; nothing leaves the machine).
|
|
791
|
+
buildForensicReportOffThread((done, total) => {
|
|
792
|
+
forensicProgress = { scanned: done, total };
|
|
793
|
+
})
|
|
794
|
+
.then((r) => {
|
|
795
|
+
forensicReport = r;
|
|
796
|
+
})
|
|
797
|
+
.catch((e) => {
|
|
798
|
+
console.error(`Could not build the local report: ${e instanceof Error ? e.message : String(e)}`);
|
|
799
|
+
});
|
|
800
|
+
let token;
|
|
801
|
+
let key;
|
|
324
802
|
try {
|
|
325
|
-
|
|
326
|
-
const msg = await verifyAndStore({ token, key });
|
|
327
|
-
console.log(msg);
|
|
803
|
+
({ token, key } = await srv.onToken);
|
|
328
804
|
}
|
|
329
805
|
catch (e) {
|
|
330
|
-
close();
|
|
806
|
+
srv.close();
|
|
331
807
|
console.error(`❌ ${e?.message || e}. You can instead run: echomem-mcp login --token ec_… [--passphrase <vault pass>]`);
|
|
332
808
|
process.exitCode = 1;
|
|
809
|
+
return;
|
|
810
|
+
}
|
|
811
|
+
if (!await verifyAndPrint({ token, key })) {
|
|
812
|
+
srv.close();
|
|
813
|
+
process.exitCode = 1;
|
|
814
|
+
return;
|
|
815
|
+
}
|
|
816
|
+
let disc = null;
|
|
817
|
+
let exactDiscovery = Promise.resolve(null);
|
|
818
|
+
let refreshGeneration = 0;
|
|
819
|
+
let latestPendingEstimate = 0;
|
|
820
|
+
const resetLocalLoginState = () => {
|
|
821
|
+
refreshGeneration++;
|
|
822
|
+
stats = null;
|
|
823
|
+
disc = null;
|
|
824
|
+
exactDiscovery = Promise.resolve(null);
|
|
825
|
+
latestPendingEstimate = 0;
|
|
826
|
+
srv.setStats(null);
|
|
827
|
+
srv.setProgress({
|
|
828
|
+
status: "idle",
|
|
829
|
+
total: 0,
|
|
830
|
+
completed: 0,
|
|
831
|
+
running: 0,
|
|
832
|
+
queued: 0,
|
|
833
|
+
failed: 0,
|
|
834
|
+
extracted: 0,
|
|
835
|
+
});
|
|
836
|
+
};
|
|
837
|
+
const refreshLocalStatsForToken = async (activeToken) => {
|
|
838
|
+
const generation = ++refreshGeneration;
|
|
839
|
+
let lastProcessedImportKeys = null;
|
|
840
|
+
let importStatusUnavailable = false;
|
|
841
|
+
const quickDiscovery = discoverMigratableFastDiscovery();
|
|
842
|
+
const quick = summarizeFastMigratableDiscovery(quickDiscovery);
|
|
843
|
+
let migratable = migratableFromFastSummary(quick);
|
|
844
|
+
latestPendingEstimate = migratable.pending;
|
|
845
|
+
let sessionSummary = {
|
|
846
|
+
total: quick.sessions,
|
|
847
|
+
codex: quick.codexCount,
|
|
848
|
+
claudeCode: quick.claudeCount,
|
|
849
|
+
};
|
|
850
|
+
stats = await buildStatsPayload([], {
|
|
851
|
+
partial: true,
|
|
852
|
+
skipMemoryCount: true,
|
|
853
|
+
sessions: sessionSummary,
|
|
854
|
+
migratable,
|
|
855
|
+
discovery: { phase: "quick", exact: false },
|
|
856
|
+
});
|
|
857
|
+
if (generation !== refreshGeneration)
|
|
858
|
+
return;
|
|
859
|
+
srv.setStats(stats);
|
|
860
|
+
srv.setProgress({
|
|
861
|
+
status: "idle",
|
|
862
|
+
total: quick.pending,
|
|
863
|
+
completed: 0,
|
|
864
|
+
running: 0,
|
|
865
|
+
queued: quick.pending,
|
|
866
|
+
failed: 0,
|
|
867
|
+
extracted: 0,
|
|
868
|
+
});
|
|
869
|
+
const fastAccountController = new AbortController();
|
|
870
|
+
const fastAccountCheck = withTimeout(fetchProcessedImportKeys(activeToken, quickDiscovery.sessions, fastAccountController.signal), 8_000, "ACCOUNT_STATUS_TIMEOUT", () => fastAccountController.abort()).then(async (processedKeys) => {
|
|
871
|
+
if (generation !== refreshGeneration)
|
|
872
|
+
return;
|
|
873
|
+
lastProcessedImportKeys = processedKeys;
|
|
874
|
+
const cloudQuick = applyFastAccountImportStatus(quickDiscovery, processedKeys);
|
|
875
|
+
const currentPhase = stats?.discovery?.phase;
|
|
876
|
+
if (currentPhase !== "quick")
|
|
877
|
+
return;
|
|
878
|
+
const cloudSummary = summarizeFastMigratableDiscovery(cloudQuick);
|
|
879
|
+
migratable = migratableFromFastSummary(cloudSummary);
|
|
880
|
+
latestPendingEstimate = migratable.pending;
|
|
881
|
+
sessionSummary = {
|
|
882
|
+
total: cloudSummary.sessions,
|
|
883
|
+
codex: cloudSummary.codexCount,
|
|
884
|
+
claudeCode: cloudSummary.claudeCount,
|
|
885
|
+
};
|
|
886
|
+
const cloudPayload = await buildStatsPayload([], {
|
|
887
|
+
partial: true,
|
|
888
|
+
skipMemoryCount: true,
|
|
889
|
+
sessions: sessionSummary,
|
|
890
|
+
migratable,
|
|
891
|
+
discovery: { phase: "account", exact: false },
|
|
892
|
+
});
|
|
893
|
+
if (generation !== refreshGeneration)
|
|
894
|
+
return;
|
|
895
|
+
stats = cloudPayload;
|
|
896
|
+
srv.setStats(cloudPayload);
|
|
897
|
+
srv.setProgress({
|
|
898
|
+
status: "idle",
|
|
899
|
+
total: cloudSummary.pending,
|
|
900
|
+
completed: 0,
|
|
901
|
+
running: 0,
|
|
902
|
+
queued: cloudSummary.pending,
|
|
903
|
+
failed: 0,
|
|
904
|
+
extracted: 0,
|
|
905
|
+
});
|
|
906
|
+
}).catch(async (e) => {
|
|
907
|
+
if (generation !== refreshGeneration)
|
|
908
|
+
return;
|
|
909
|
+
if (isImportStatusUnsupported(e)) {
|
|
910
|
+
importStatusUnavailable = true;
|
|
911
|
+
const unavailableQuick = markFastAccountImportStatusUnavailable(quickDiscovery);
|
|
912
|
+
const currentPhase = stats?.discovery?.phase;
|
|
913
|
+
if (currentPhase !== "quick")
|
|
914
|
+
return;
|
|
915
|
+
const unavailableSummary = summarizeFastMigratableDiscovery(unavailableQuick);
|
|
916
|
+
migratable = migratableFromFastSummary(unavailableSummary);
|
|
917
|
+
latestPendingEstimate = migratable.pending;
|
|
918
|
+
sessionSummary = {
|
|
919
|
+
total: unavailableSummary.sessions,
|
|
920
|
+
codex: unavailableSummary.codexCount,
|
|
921
|
+
claudeCode: unavailableSummary.claudeCount,
|
|
922
|
+
};
|
|
923
|
+
const unavailablePayload = await buildStatsPayload([], {
|
|
924
|
+
partial: true,
|
|
925
|
+
skipMemoryCount: true,
|
|
926
|
+
sessions: sessionSummary,
|
|
927
|
+
migratable,
|
|
928
|
+
discovery: { phase: "account", exact: false },
|
|
929
|
+
});
|
|
930
|
+
if (generation !== refreshGeneration)
|
|
931
|
+
return;
|
|
932
|
+
stats = unavailablePayload;
|
|
933
|
+
srv.setStats(unavailablePayload);
|
|
934
|
+
srv.setProgress({
|
|
935
|
+
status: "idle",
|
|
936
|
+
total: unavailableSummary.pending,
|
|
937
|
+
completed: 0,
|
|
938
|
+
running: 0,
|
|
939
|
+
queued: unavailableSummary.pending,
|
|
940
|
+
failed: 0,
|
|
941
|
+
extracted: 0,
|
|
942
|
+
});
|
|
943
|
+
return;
|
|
944
|
+
}
|
|
945
|
+
if (generation === refreshGeneration) {
|
|
946
|
+
console.error(`Could not check this EchoMem account's import status quickly: ${e instanceof Error ? e.message : String(e)}`);
|
|
947
|
+
}
|
|
948
|
+
});
|
|
949
|
+
exactDiscovery = new Promise((resolve, reject) => {
|
|
950
|
+
const timer = setTimeout(() => {
|
|
951
|
+
void (async () => {
|
|
952
|
+
try {
|
|
953
|
+
// Start the local sizing pass without waiting on cloud/account status. The
|
|
954
|
+
// account check is useful for tighter counts, but extraction can safely start
|
|
955
|
+
// from local candidates because the import path skips true duplicates.
|
|
956
|
+
await delay(250);
|
|
957
|
+
resolve(await discoverMigratableSessionsOffThread());
|
|
958
|
+
}
|
|
959
|
+
catch (e) {
|
|
960
|
+
reject(e instanceof Error ? e : new Error(String(e)));
|
|
961
|
+
}
|
|
962
|
+
})();
|
|
963
|
+
}, 250);
|
|
964
|
+
timer.unref?.();
|
|
965
|
+
}).then(async (exact) => {
|
|
966
|
+
if (generation !== refreshGeneration)
|
|
967
|
+
return disc;
|
|
968
|
+
const initialExact = lastProcessedImportKeys
|
|
969
|
+
? applyAccountImportStatus(exact, lastProcessedImportKeys)
|
|
970
|
+
: importStatusUnavailable
|
|
971
|
+
? markAccountImportStatusUnavailable(exact)
|
|
972
|
+
: exact;
|
|
973
|
+
disc = initialExact;
|
|
974
|
+
migratable = migratableFromDiscovery(initialExact);
|
|
975
|
+
latestPendingEstimate = migratable.pending;
|
|
976
|
+
sessionSummary = sessionsFromDiscovery(initialExact);
|
|
977
|
+
const partialPayload = await buildStatsPayload([], {
|
|
978
|
+
partial: true,
|
|
979
|
+
skipMemoryCount: true,
|
|
980
|
+
sessions: sessionSummary,
|
|
981
|
+
migratable,
|
|
982
|
+
discovery: { phase: "exact", exact: true },
|
|
983
|
+
});
|
|
984
|
+
if (generation !== refreshGeneration)
|
|
985
|
+
return disc;
|
|
986
|
+
stats = partialPayload;
|
|
987
|
+
srv.setStats(partialPayload);
|
|
988
|
+
srv.setProgress({
|
|
989
|
+
status: "idle",
|
|
990
|
+
total: initialExact.pending.length,
|
|
991
|
+
completed: 0,
|
|
992
|
+
running: 0,
|
|
993
|
+
queued: initialExact.pending.length,
|
|
994
|
+
failed: 0,
|
|
995
|
+
extracted: 0,
|
|
996
|
+
});
|
|
997
|
+
void (async () => {
|
|
998
|
+
let reconciled = initialExact;
|
|
999
|
+
try {
|
|
1000
|
+
await fastAccountCheck;
|
|
1001
|
+
const controller = new AbortController();
|
|
1002
|
+
const processedKeys = await withTimeout(fetchProcessedImportKeys(activeToken, exact.sessions, controller.signal), 15_000, "ACCOUNT_STATUS_TIMEOUT", () => controller.abort());
|
|
1003
|
+
lastProcessedImportKeys = processedKeys;
|
|
1004
|
+
reconciled = applyAccountImportStatus(exact, processedKeys);
|
|
1005
|
+
}
|
|
1006
|
+
catch (e) {
|
|
1007
|
+
if (lastProcessedImportKeys) {
|
|
1008
|
+
reconciled = applyAccountImportStatus(exact, lastProcessedImportKeys);
|
|
1009
|
+
if (!isImportStatusUnsupported(e)) {
|
|
1010
|
+
console.error(`Could not refresh this EchoMem account's exact import status; keeping the last successful account check. ${e instanceof Error ? e.message : String(e)}`);
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
else if (isImportStatusUnsupported(e)) {
|
|
1014
|
+
reconciled = markAccountImportStatusUnavailable(exact);
|
|
1015
|
+
}
|
|
1016
|
+
else {
|
|
1017
|
+
reconciled = markAccountImportStatusFailed(exact);
|
|
1018
|
+
console.error(`Could not check this EchoMem account's import status: ${e instanceof Error ? e.message : String(e)}`);
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
if (generation !== refreshGeneration)
|
|
1022
|
+
return;
|
|
1023
|
+
disc = reconciled;
|
|
1024
|
+
migratable = migratableFromDiscovery(reconciled);
|
|
1025
|
+
latestPendingEstimate = migratable.pending;
|
|
1026
|
+
sessionSummary = sessionsFromDiscovery(reconciled);
|
|
1027
|
+
const reconciledPayload = await buildStatsPayload([], {
|
|
1028
|
+
partial: true,
|
|
1029
|
+
skipMemoryCount: true,
|
|
1030
|
+
sessions: sessionSummary,
|
|
1031
|
+
migratable,
|
|
1032
|
+
discovery: { phase: "exact", exact: true },
|
|
1033
|
+
});
|
|
1034
|
+
if (generation !== refreshGeneration)
|
|
1035
|
+
return;
|
|
1036
|
+
stats = reconciledPayload;
|
|
1037
|
+
srv.setStats(reconciledPayload);
|
|
1038
|
+
srv.setProgress({
|
|
1039
|
+
status: "idle",
|
|
1040
|
+
total: reconciled.pending.length,
|
|
1041
|
+
completed: 0,
|
|
1042
|
+
running: 0,
|
|
1043
|
+
queued: reconciled.pending.length,
|
|
1044
|
+
failed: 0,
|
|
1045
|
+
extracted: 0,
|
|
1046
|
+
});
|
|
1047
|
+
const fullPayload = await buildStatsPayload(collect(), {
|
|
1048
|
+
sessions: sessionSummary,
|
|
1049
|
+
migratable,
|
|
1050
|
+
discovery: { phase: "full", exact: true },
|
|
1051
|
+
});
|
|
1052
|
+
if (generation !== refreshGeneration)
|
|
1053
|
+
return;
|
|
1054
|
+
stats = fullPayload;
|
|
1055
|
+
srv.setStats(fullPayload);
|
|
1056
|
+
})();
|
|
1057
|
+
return initialExact;
|
|
1058
|
+
}).catch((e) => {
|
|
1059
|
+
if (generation === refreshGeneration) {
|
|
1060
|
+
console.error(`Could not finish exact local extraction estimate: ${e instanceof Error ? e.message : String(e)}`);
|
|
1061
|
+
}
|
|
1062
|
+
return disc;
|
|
1063
|
+
});
|
|
1064
|
+
};
|
|
1065
|
+
srv.setLogoutHandler(resetLocalLoginState);
|
|
1066
|
+
srv.setTokenRefreshHandler(async ({ token: nextToken, key: nextKey }) => {
|
|
1067
|
+
if (!await verifyAndPrint({ token: nextToken, key: nextKey }))
|
|
1068
|
+
return;
|
|
1069
|
+
await refreshLocalStatsForToken(nextToken);
|
|
1070
|
+
});
|
|
1071
|
+
await refreshLocalStatsForToken(token);
|
|
1072
|
+
const choice = await srv.decision;
|
|
1073
|
+
if (choice === "migrate") {
|
|
1074
|
+
const { res } = await srv.migrateRequest;
|
|
1075
|
+
let migrateResponded = false;
|
|
1076
|
+
const sendMigrate = (body, status = 200) => {
|
|
1077
|
+
if (migrateResponded)
|
|
1078
|
+
return;
|
|
1079
|
+
migrateResponded = true;
|
|
1080
|
+
respondMigrate(res, body, status);
|
|
1081
|
+
};
|
|
1082
|
+
let activeSessionId = "";
|
|
1083
|
+
let activeJobCount = latestPendingEstimate;
|
|
1084
|
+
let progressDone = 0;
|
|
1085
|
+
let progressFailed = 0;
|
|
1086
|
+
let progressExtracted = 0;
|
|
1087
|
+
if (!disc) {
|
|
1088
|
+
srv.setProgress({
|
|
1089
|
+
status: "starting",
|
|
1090
|
+
total: activeJobCount,
|
|
1091
|
+
completed: 0,
|
|
1092
|
+
running: 0,
|
|
1093
|
+
queued: activeJobCount,
|
|
1094
|
+
failed: 0,
|
|
1095
|
+
extracted: 0,
|
|
1096
|
+
latest: "Finishing local job sizing before import starts.",
|
|
1097
|
+
});
|
|
1098
|
+
sendMigrate({
|
|
1099
|
+
status: "preparing",
|
|
1100
|
+
jobCount: activeJobCount,
|
|
1101
|
+
message: "Finishing local job sizing before import starts.",
|
|
1102
|
+
});
|
|
1103
|
+
}
|
|
1104
|
+
let exact = disc;
|
|
1105
|
+
if (!exact) {
|
|
1106
|
+
// Off-thread sizing normally resolves in ~20s; cap the wait and fall back to an in-process pass so
|
|
1107
|
+
// a stalled/contended worker can never leave extraction stuck at "finishing local job sizing".
|
|
1108
|
+
exact = await withTimeout(exactDiscovery, 40_000, "SIZING_TIMEOUT").catch(() => null);
|
|
1109
|
+
if (!exact) {
|
|
1110
|
+
srv.setProgress({ status: "starting", total: activeJobCount, completed: 0, running: 0, queued: activeJobCount, failed: 0, extracted: 0, latest: "Sizing your sessions…" });
|
|
1111
|
+
try {
|
|
1112
|
+
exact = discoverMigratableSessions();
|
|
1113
|
+
}
|
|
1114
|
+
catch (e) {
|
|
1115
|
+
console.error(`Direct local sizing failed: ${e instanceof Error ? e.message : String(e)}`);
|
|
1116
|
+
}
|
|
1117
|
+
}
|
|
1118
|
+
}
|
|
1119
|
+
if (!exact) {
|
|
1120
|
+
srv.setProgress({
|
|
1121
|
+
status: "failed",
|
|
1122
|
+
total: activeJobCount,
|
|
1123
|
+
completed: progressDone,
|
|
1124
|
+
running: 0,
|
|
1125
|
+
queued: Math.max(0, activeJobCount - progressDone - progressFailed),
|
|
1126
|
+
failed: progressFailed || 1,
|
|
1127
|
+
extracted: progressExtracted,
|
|
1128
|
+
error: "Local session discovery did not finish.",
|
|
1129
|
+
});
|
|
1130
|
+
sendMigrate({ error: "IMPORT_START_FAILED", message: "Local session discovery did not finish." }, 500);
|
|
1131
|
+
srv.close();
|
|
1132
|
+
process.exitCode = 1;
|
|
1133
|
+
return;
|
|
1134
|
+
}
|
|
1135
|
+
activeJobCount = exact.pending.length;
|
|
1136
|
+
const updateProgress = (patch) => {
|
|
1137
|
+
srv.setProgress({
|
|
1138
|
+
status: "running",
|
|
1139
|
+
sessionId: activeSessionId || undefined,
|
|
1140
|
+
jobCount: activeJobCount,
|
|
1141
|
+
total: activeJobCount,
|
|
1142
|
+
completed: progressDone,
|
|
1143
|
+
running: Math.min(MIGRATE_CONCURRENCY, Math.max(0, activeJobCount - progressDone - progressFailed)),
|
|
1144
|
+
queued: Math.max(0, activeJobCount - progressDone - progressFailed - MIGRATE_CONCURRENCY),
|
|
1145
|
+
failed: progressFailed,
|
|
1146
|
+
extracted: progressExtracted,
|
|
1147
|
+
...patch,
|
|
1148
|
+
});
|
|
1149
|
+
};
|
|
1150
|
+
try {
|
|
1151
|
+
if (exact.pending.length === 0) {
|
|
1152
|
+
srv.setProgress({
|
|
1153
|
+
status: "completed",
|
|
1154
|
+
total: 0,
|
|
1155
|
+
completed: 0,
|
|
1156
|
+
running: 0,
|
|
1157
|
+
queued: 0,
|
|
1158
|
+
failed: 0,
|
|
1159
|
+
extracted: 0,
|
|
1160
|
+
latest: "No unprocessed local conversations found.",
|
|
1161
|
+
});
|
|
1162
|
+
sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
|
|
1163
|
+
srv.close();
|
|
1164
|
+
console.log("Setup complete — no unprocessed local conversations to extract.");
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
updateProgress({ status: "starting", running: 0, queued: exact.pending.length, latest: "Creating import session." });
|
|
1168
|
+
const controller = new AbortController();
|
|
1169
|
+
const h = await withTimeout(startMigration({
|
|
1170
|
+
pending: exact.pending,
|
|
1171
|
+
signal: controller.signal,
|
|
1172
|
+
onProgress: (ev) => {
|
|
1173
|
+
if (ev.error) {
|
|
1174
|
+
progressFailed += 1;
|
|
1175
|
+
}
|
|
1176
|
+
else {
|
|
1177
|
+
progressDone += 1;
|
|
1178
|
+
progressExtracted += ev.memories ?? 0;
|
|
1179
|
+
}
|
|
1180
|
+
updateProgress({
|
|
1181
|
+
latest: `${ev.session.source} ${(ev.session.firstTs || "").slice(0, 10)}${ev.error ? ` failed: ${ev.error}` : ""}`,
|
|
1182
|
+
});
|
|
1183
|
+
},
|
|
1184
|
+
}), 30_000, "IMPORT_START_TIMEOUT", () => controller.abort());
|
|
1185
|
+
activeSessionId = h.sessionId;
|
|
1186
|
+
activeJobCount = h.jobCount;
|
|
1187
|
+
updateProgress({ status: "running", sessionId: h.sessionId, jobCount: h.jobCount, total: h.jobCount, capped: h.capped, latest: "Import session created." });
|
|
1188
|
+
sendMigrate({ sessionId: h.sessionId, jobCount: h.jobCount, ...(h.capped ? { capped: h.capped } : {}) });
|
|
1189
|
+
console.log("Migrating your history… keep this terminal open until it completes.");
|
|
1190
|
+
console.log(`Migration metrics: ${h.metricsFile}`);
|
|
1191
|
+
const r = await h.done;
|
|
1192
|
+
srv.setProgress({
|
|
1193
|
+
status: r.failed || r.stoppedReason ? "failed" : "completed",
|
|
1194
|
+
sessionId: h.sessionId,
|
|
1195
|
+
jobCount: h.jobCount,
|
|
1196
|
+
capped: h.capped,
|
|
1197
|
+
total: h.jobCount,
|
|
1198
|
+
completed: r.migrated,
|
|
1199
|
+
running: 0,
|
|
1200
|
+
queued: 0,
|
|
1201
|
+
failed: r.failed,
|
|
1202
|
+
extracted: r.extracted,
|
|
1203
|
+
latest: r.stoppedReason ? `Stopped: ${r.stoppedReason}` : "Import complete.",
|
|
1204
|
+
...(r.stoppedReason ? { error: r.stoppedReason } : {}),
|
|
1205
|
+
});
|
|
1206
|
+
console.log(`Import finished: ${r.migrated} imported, ${r.extracted} memories, ${r.failed} failed.`);
|
|
1207
|
+
if (r.stoppedReason || r.failed)
|
|
1208
|
+
process.exitCode = 1;
|
|
1209
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1210
|
+
srv.close();
|
|
1211
|
+
}
|
|
1212
|
+
catch (e) {
|
|
1213
|
+
srv.setProgress({
|
|
1214
|
+
status: "failed",
|
|
1215
|
+
sessionId: activeSessionId || undefined,
|
|
1216
|
+
jobCount: activeJobCount,
|
|
1217
|
+
total: activeJobCount,
|
|
1218
|
+
completed: progressDone,
|
|
1219
|
+
running: 0,
|
|
1220
|
+
queued: Math.max(0, activeJobCount - progressDone - progressFailed),
|
|
1221
|
+
failed: progressFailed || 1,
|
|
1222
|
+
extracted: progressExtracted,
|
|
1223
|
+
error: String(e?.message || e),
|
|
1224
|
+
});
|
|
1225
|
+
if (e?.code === "NOT_LOGGED_IN")
|
|
1226
|
+
sendMigrate({ error: "NOT_LOGGED_IN" }, 401);
|
|
1227
|
+
else if (e?.code === "FORBIDDEN_SCOPE")
|
|
1228
|
+
sendMigrate({ error: "FORBIDDEN_SCOPE" }, 403);
|
|
1229
|
+
else if (e?.code === "VAULT_LOCKED")
|
|
1230
|
+
sendMigrate({ error: "VAULT_LOCKED" }, 409);
|
|
1231
|
+
else if (e?.code === "NO_PENDING_SESSIONS")
|
|
1232
|
+
sendMigrate({ error: "NO_PENDING_SESSIONS" }, 409);
|
|
1233
|
+
else if (e?.code === "IMPORT_START_TIMEOUT")
|
|
1234
|
+
sendMigrate({ error: "IMPORT_START_TIMEOUT" }, 504);
|
|
1235
|
+
else
|
|
1236
|
+
sendMigrate({ error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
|
|
1237
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
1238
|
+
srv.close();
|
|
1239
|
+
process.exitCode = 1;
|
|
1240
|
+
}
|
|
1241
|
+
}
|
|
1242
|
+
else {
|
|
1243
|
+
console.log("Setup complete — run `echomem-mcp migrate` later to back-fill your history.");
|
|
1244
|
+
srv.close();
|
|
333
1245
|
}
|
|
334
1246
|
}
|
|
335
1247
|
async function cmdUnlock(flags) {
|
|
@@ -389,10 +1301,18 @@ Usage:
|
|
|
389
1301
|
echomem-mcp logout Remove stored credentials
|
|
390
1302
|
echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
|
|
391
1303
|
echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
|
|
1304
|
+
echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
|
|
1305
|
+
echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
|
|
1306
|
+
echomem-mcp migrate --min-chars N Import only sessions at least N assembled text chars
|
|
1307
|
+
echomem-mcp migrate --largest --limit 1 Sample the largest pending session first
|
|
1308
|
+
echomem-mcp migrate --include-active Include sessions modified in the last 5 minutes
|
|
1309
|
+
echomem-mcp migrate --metrics-file PATH Write local per-session timing metadata JSONL
|
|
1310
|
+
echomem-mcp sync-usage Sync safe Codex JSONL usage summaries to EchoMem/Amplitude
|
|
392
1311
|
|
|
393
1312
|
Manual / headless:
|
|
394
1313
|
echomem-mcp login --token ec_xxx [--passphrase <vault pass> | --key <base64>]
|
|
395
1314
|
echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
|
|
1315
|
+
echomem-mcp sync-usage --days 7 --limit 50 --dry-run
|
|
396
1316
|
`;
|
|
397
1317
|
/** Returns true if argv was a recognized subcommand (and was handled). */
|
|
398
1318
|
export async function runCli(argv) {
|
|
@@ -420,6 +1340,9 @@ export async function runCli(argv) {
|
|
|
420
1340
|
case "migrate":
|
|
421
1341
|
await cmdMigrate(flags);
|
|
422
1342
|
return true;
|
|
1343
|
+
case "sync-usage":
|
|
1344
|
+
await syncCodexUsage(argv.slice(1));
|
|
1345
|
+
return true;
|
|
423
1346
|
case "help":
|
|
424
1347
|
case "--help":
|
|
425
1348
|
case "-h":
|