@echomem/mcp 1.3.0 → 1.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/setup.js CHANGED
@@ -22,10 +22,13 @@ import readline from "node:readline";
22
22
  import axios from "axios";
23
23
  import { KeyStore } from "./keystore.js";
24
24
  import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
25
- import { runReport, buildReportText } from "./report.js";
26
- import { cmdMigrate } from "./migrate.js";
27
- // The connect-device page lives on the main site (WebPageReactVersion → yeahecho.com), where the
28
- // user already has a session. It calls the EchoMem API cross-origin. Override with ECHO_WEB_URL.
25
+ import { collect, runReport, buildReportText, buildStatsPayload } from "./report.js";
26
+ import { cmdMigrate, discoverMigratableSessions, estimateMigrationEta, startMigration } from "./migrate.js";
27
+ import { syncCodexUsage } from "./codex-sync.js";
28
+ import { renderSetupPage } from "./setup-page.js";
29
+ // The hosted connect-device page is now only the account-auth/token courier. The dashboard itself is
30
+ // served by this localhost bridge, where local logs and processed/unprocessed counts never leave the
31
+ // device unless the user explicitly starts migration. Override the hosted auth origin with ECHO_WEB_URL.
29
32
  const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
30
33
  const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
31
34
  function home(...p) {
@@ -63,7 +66,24 @@ export function buildServerEntry(opts = {}) {
63
66
  if (opts.devEntryPath) {
64
67
  return { command: "node", args: [opts.devEntryPath] };
65
68
  }
66
- return { command: "npx", args: ["-y", "@echomem/mcp"] };
69
+ // Spawn the ALREADY-INSTALLED bridge directly (this node + this script's real path) instead of
70
+ // `npx -y @echomem/mcp`. `npx -y` re-resolves and, on a cache miss, NETWORK-fetches from the npm
71
+ // registry on EVERY client start — on a slow/flaky network that delays the MCP handshake past the
72
+ // client's timeout, so the agent hangs ("connection timed out after 30000ms") and Codex can SIGTERM.
73
+ // Using process.execPath (the running node) + the realpath'd entry also dodges the GUI-PATH trap:
74
+ // GUI/IDE-launched clients don't inherit nvm's PATH, so a bare `npx`/`echomem-mcp` may not resolve.
75
+ // Trade-off: the node path is version-specific under nvm — re-run `setup` after a Node upgrade.
76
+ try {
77
+ const entry = fs.realpathSync(process.argv[1] || "");
78
+ if (entry && fs.existsSync(entry)) {
79
+ return { command: process.execPath, args: [entry] };
80
+ }
81
+ }
82
+ catch {
83
+ /* couldn't resolve a local install — fall through to npx */
84
+ }
85
+ // Fallback (unresolved local install): at least drop `-y` so npx doesn't auto-INSTALL on every start.
86
+ return { command: "npx", args: ["@echomem/mcp"] };
67
87
  }
68
88
  /** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
69
89
  export function codexTomlBlock(entry) {
@@ -71,8 +91,12 @@ export function codexTomlBlock(entry) {
71
91
  const args = (Array.isArray(entry.args) ? entry.args : []).map((a) => JSON.stringify(String(a))).join(", ");
72
92
  return `[mcp_servers.echomem]\ncommand = ${command}\nargs = [${args}]\n`;
73
93
  }
74
- /** Write/merge the EchoMem entry straight into Codex's config.toml — no `codex` CLI needed. Idempotent. */
75
- function writeCodexConfig(configPath, entry) {
94
+ /**
95
+ * Write/merge the EchoMem entry straight into Codex's config.toml — no `codex` CLI needed. Idempotent.
96
+ * If an `[mcp_servers.echomem]` block already exists it is REPLACED (so re-running `setup` upgrades a
97
+ * stale `npx -y` entry to the direct path); identical entries are left untouched.
98
+ */
99
+ export function writeCodexConfig(configPath, entry) {
76
100
  let content = "";
77
101
  try {
78
102
  content = fs.readFileSync(configPath, "utf8");
@@ -80,11 +104,23 @@ function writeCodexConfig(configPath, entry) {
80
104
  catch {
81
105
  /* fresh config */
82
106
  }
83
- if (/^\s*\[mcp_servers\.echomem\]/m.test(content))
84
- return "exists";
107
+ const block = codexTomlBlock(entry).trimEnd();
108
+ const lines = content.split("\n");
109
+ const start = lines.findIndex((l) => /^\s*\[mcp_servers\.echomem\]\s*$/.test(l));
85
110
  fs.mkdirSync(path.dirname(configPath), { recursive: true });
111
+ if (start >= 0) {
112
+ // The block runs from its header to the next top-level [section] (or EOF).
113
+ let end = start + 1;
114
+ while (end < lines.length && !/^\s*\[/.test(lines[end]))
115
+ end++;
116
+ if (lines.slice(start, end).join("\n").trimEnd() === block)
117
+ return "exists"; // already correct
118
+ const next = [...lines.slice(0, start), ...block.split("\n"), ...lines.slice(end)];
119
+ fs.writeFileSync(configPath, next.join("\n").replace(/\n{3,}/g, "\n\n").replace(/^\n+/, ""));
120
+ return "wrote"; // replaced a stale entry → caller tells the user to restart Codex
121
+ }
86
122
  const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
87
- fs.appendFileSync(configPath, sep + codexTomlBlock(entry));
123
+ fs.appendFileSync(configPath, sep + block + "\n");
88
124
  return "wrote";
89
125
  }
90
126
  /** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
@@ -113,23 +149,132 @@ function openBrowser(url) {
113
149
  /* headless — caller prints the URL */
114
150
  }
115
151
  }
152
+ function deferred() {
153
+ let done = false;
154
+ let resolveInner;
155
+ let rejectInner;
156
+ const promise = new Promise((resolve, reject) => {
157
+ resolveInner = resolve;
158
+ rejectInner = reject;
159
+ });
160
+ return {
161
+ promise,
162
+ resolve: (value) => {
163
+ if (done)
164
+ return;
165
+ done = true;
166
+ resolveInner(value);
167
+ },
168
+ reject: (reason) => {
169
+ if (done)
170
+ return;
171
+ done = true;
172
+ rejectInner(reason);
173
+ },
174
+ settled: () => done,
175
+ };
176
+ }
177
+ function isObjectRecord(value) {
178
+ return typeof value === "object" && value !== null && !Array.isArray(value);
179
+ }
180
+ function asString(value) {
181
+ return typeof value === "string" && value ? value : undefined;
182
+ }
183
+ function readJsonBody(req) {
184
+ return new Promise((resolve, reject) => {
185
+ let body = "";
186
+ req.on("data", (chunk) => {
187
+ body += chunk.toString();
188
+ });
189
+ req.on("end", () => {
190
+ try {
191
+ const parsed = JSON.parse(body || "{}");
192
+ resolve(isObjectRecord(parsed) ? parsed : {});
193
+ }
194
+ catch {
195
+ reject(new Error("bad json"));
196
+ }
197
+ });
198
+ req.on("error", (e) => reject(e instanceof Error ? e : new Error(String(e))));
199
+ });
200
+ }
201
+ function withTimeout(promise, ms, code, onTimeout) {
202
+ let timer;
203
+ const timeout = new Promise((_, reject) => {
204
+ timer = setTimeout(() => {
205
+ onTimeout?.();
206
+ const e = new Error(code);
207
+ e.code = code;
208
+ reject(e);
209
+ }, ms);
210
+ timer.unref?.();
211
+ });
212
+ return Promise.race([promise, timeout]).finally(() => {
213
+ if (timer)
214
+ clearTimeout(timer);
215
+ });
216
+ }
217
+ export function respondMigrate(res, body, status = 200) {
218
+ if (res.writableEnded)
219
+ return;
220
+ res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
221
+ }
116
222
  /**
117
- * Start a localhost callback server. Resolves immediately with the bound `port` and a `wait` promise
118
- * that settles when the connect-device page delivers `{ token, key? }` by POSTing JSON to
119
- * `/callback` or hitting `/callback?token=…&key=…`. Splitting port-acquisition from the token-wait
120
- * lets the caller build the callback URL before the user approves.
223
+ * Start the persistent localhost bridge used by the connect-device page. It accepts the token,
224
+ * serves local Wrapped stats, and holds the /migrate response until cmdLogin has created a cloud
225
+ * import session.
121
226
  */
122
227
  export function startCallbackServer(opts = {}) {
123
- const timeoutMs = opts.timeoutMs ?? 180_000;
228
+ const timeoutMs = opts.timeoutMs ?? 300_000;
124
229
  const expectedNonce = opts.nonce;
125
230
  return new Promise((resolveOuter, rejectOuter) => {
126
- let settle;
127
- let fail;
128
- const wait = new Promise((res, rej) => {
129
- settle = res;
130
- fail = rej;
131
- });
132
- const server = http.createServer((req, res) => {
231
+ const onToken = deferred();
232
+ const decision = deferred();
233
+ const migrateRequest = deferred();
234
+ let stats = null;
235
+ let authUrl = "";
236
+ let connected = false;
237
+ let progress = { status: "idle", total: 0, completed: 0, running: 0, queued: 0, failed: 0, extracted: 0 };
238
+ let migrateStarted = false;
239
+ let timer;
240
+ let closed = false;
241
+ let server;
242
+ const checkNonce = (nonce) => !expectedNonce || nonce === expectedNonce;
243
+ const text = (res, status, body = "") => res.writeHead(status, { "Content-Type": "text/plain" }).end(body);
244
+ const json = (res, status, body) => res.writeHead(status, { "Content-Type": "application/json" }).end(JSON.stringify(body));
245
+ const close = () => {
246
+ if (timer)
247
+ clearTimeout(timer);
248
+ timer = undefined;
249
+ if (closed)
250
+ return;
251
+ closed = true;
252
+ server.close();
253
+ };
254
+ const armTimeout = () => {
255
+ if (timer)
256
+ clearTimeout(timer);
257
+ timer = setTimeout(() => {
258
+ if (!onToken.settled()) {
259
+ onToken.reject(new Error("timed out waiting for browser approval"));
260
+ }
261
+ else if (!decision.settled()) {
262
+ decision.resolve("timeout");
263
+ }
264
+ close();
265
+ }, timeoutMs);
266
+ };
267
+ const handleCallback = (res, token, key, nonce) => {
268
+ if (!checkNonce(nonce))
269
+ return void text(res, 403, "bad nonce");
270
+ if (!token)
271
+ return void text(res, 400, "missing token");
272
+ connected = true;
273
+ res.writeHead(200, { "Content-Type": "text/html" }).end(`<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1"></head><body style="font-family:system-ui;padding:3rem;text-align:center"><h2>EchoMem connected</h2><p>Returning to the local dashboard...</p><p><a href="/setup?nonce=${encodeURIComponent(nonce || "")}&connected=1">Continue</a></p></body></html>`);
274
+ onToken.resolve({ token, key });
275
+ armTimeout();
276
+ };
277
+ server = http.createServer((req, res) => {
133
278
  res.setHeader("Access-Control-Allow-Origin", "*");
134
279
  res.setHeader("Access-Control-Allow-Headers", "Content-Type");
135
280
  res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
@@ -138,51 +283,118 @@ export function startCallbackServer(opts = {}) {
138
283
  if (req.method === "OPTIONS")
139
284
  return void res.writeHead(204).end();
140
285
  const url = new URL(req.url || "/", "http://127.0.0.1");
141
- if (!url.pathname.startsWith("/callback"))
142
- return void res.writeHead(404).end();
143
- const finish = (token, key, nonce) => {
144
- // Nonce gates the callback: only the page we opened (which carries the nonce) can post here.
145
- if (expectedNonce && nonce !== expectedNonce)
146
- return void res.writeHead(403, { "Content-Type": "text/plain" }).end("bad nonce");
147
- if (!token)
148
- return void res.writeHead(400, { "Content-Type": "text/plain" }).end("missing token");
149
- res.writeHead(200, { "Content-Type": "text/html" }).end("<html><body style='font-family:system-ui;padding:3rem;text-align:center'><h2>✅ EchoMem connected</h2><p>You can close this tab and return to your editor.</p></body></html>");
150
- clearTimeout(timer);
151
- server.close();
152
- settle({ token, key });
153
- };
154
- if (req.method === "GET") {
155
- finish(url.searchParams.get("token") || undefined, url.searchParams.get("key") || undefined, url.searchParams.get("nonce") || undefined);
156
- }
157
- else {
158
- let body = "";
159
- req.on("data", (c) => (body += c));
160
- req.on("end", () => {
286
+ const route = url.pathname;
287
+ const run = async () => {
288
+ if (route === "/setup" && req.method === "GET") {
289
+ res.writeHead(200, {
290
+ "Content-Type": "text/html; charset=utf-8",
291
+ "Cache-Control": "no-store",
292
+ }).end(renderSetupPage());
293
+ return;
294
+ }
295
+ if (route === "/config" && req.method === "GET") {
296
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
297
+ return void text(res, 403, "bad nonce");
298
+ json(res, 200, { connected, authUrl, localOnly: true });
299
+ return;
300
+ }
301
+ if (route === "/callback" && req.method === "GET") {
302
+ handleCallback(res, url.searchParams.get("token") || undefined, url.searchParams.get("key") || undefined, url.searchParams.get("nonce") || undefined);
303
+ return;
304
+ }
305
+ if (route === "/callback" && req.method === "POST") {
306
+ let body;
161
307
  try {
162
- const parsed = JSON.parse(body || "{}");
163
- finish(parsed.token, parsed.key, parsed.nonce);
308
+ body = await readJsonBody(req);
164
309
  }
165
310
  catch {
166
- res.writeHead(400).end("bad json");
311
+ text(res, 400, "bad json");
312
+ return;
167
313
  }
168
- });
169
- }
314
+ handleCallback(res, asString(body.token), asString(body.key), asString(body.nonce));
315
+ return;
316
+ }
317
+ if (route === "/stats" && req.method === "GET") {
318
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
319
+ return void text(res, 403, "bad nonce");
320
+ const payload = opts.getStats ? opts.getStats() : stats;
321
+ if (payload == null)
322
+ return void res.writeHead(202).end();
323
+ json(res, 200, payload);
324
+ return;
325
+ }
326
+ if (route === "/progress" && req.method === "GET") {
327
+ if (!checkNonce(url.searchParams.get("nonce") || undefined))
328
+ return void text(res, 403, "bad nonce");
329
+ json(res, 200, progress);
330
+ return;
331
+ }
332
+ if (route === "/migrate" && req.method === "POST") {
333
+ let body;
334
+ try {
335
+ body = await readJsonBody(req);
336
+ }
337
+ catch {
338
+ text(res, 400, "bad json");
339
+ return;
340
+ }
341
+ if (!checkNonce(asString(body.nonce)))
342
+ return void text(res, 403, "bad nonce");
343
+ if (migrateStarted)
344
+ return void json(res, 409, { error: "MIGRATE_IN_PROGRESS" });
345
+ migrateStarted = true;
346
+ const safety = setTimeout(() => {
347
+ respondMigrate(res, { error: "IMPORT_START_TIMEOUT" }, 504);
348
+ }, 30_000);
349
+ safety.unref?.();
350
+ migrateRequest.resolve({ res });
351
+ decision.resolve("migrate");
352
+ return;
353
+ }
354
+ if (route === "/skip" && req.method === "POST") {
355
+ let body;
356
+ try {
357
+ body = await readJsonBody(req);
358
+ }
359
+ catch {
360
+ text(res, 400, "bad json");
361
+ return;
362
+ }
363
+ if (!checkNonce(asString(body.nonce)))
364
+ return void text(res, 403, "bad nonce");
365
+ json(res, 200, { ok: true });
366
+ decision.resolve("skip");
367
+ close();
368
+ return;
369
+ }
370
+ res.writeHead(404).end();
371
+ };
372
+ run().catch((e) => {
373
+ if (!res.writableEnded)
374
+ text(res, 500, e instanceof Error ? e.message : String(e));
375
+ });
170
376
  });
171
- const timer = setTimeout(() => {
172
- server.close();
173
- fail(new Error("timed out waiting for browser approval"));
174
- }, timeoutMs);
377
+ armTimeout();
175
378
  server.on("error", (e) => rejectOuter(e));
176
379
  server.listen(0, "127.0.0.1", () => {
177
380
  const addr = server.address();
178
381
  const port = typeof addr === "object" && addr ? addr.port : 0;
179
382
  resolveOuter({
180
383
  port,
181
- wait,
182
- close: () => {
183
- clearTimeout(timer);
184
- server.close();
384
+ onToken: onToken.promise,
385
+ wait: onToken.promise,
386
+ decision: decision.promise,
387
+ migrateRequest: migrateRequest.promise,
388
+ setAuthUrl: (url) => {
389
+ authUrl = url;
390
+ },
391
+ setStats: (s) => {
392
+ stats = s;
185
393
  },
394
+ setProgress: (p) => {
395
+ progress = p;
396
+ },
397
+ close,
186
398
  });
187
399
  });
188
400
  });
@@ -312,24 +524,193 @@ async function cmdLogin(flags) {
312
524
  console.log(msg);
313
525
  return;
314
526
  }
315
- // Browser path: open the connect-device page pointed at our localhost callback. The nonce gates
316
- // the callback so only the page we opened can deliver the token+key.
527
+ // Browser path: open a localhost dashboard. It briefly leaves for hosted auth, then returns here
528
+ // after the web page has delivered the token+key to the callback. The nonce gates every local route.
317
529
  console.log("Opening your browser to approve this device…");
318
530
  const nonce = randomUUID();
319
- const { port, wait, close } = await startCallbackServer({ nonce });
320
- const callbackUrl = `http://127.0.0.1:${port}/callback`;
321
- const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}`;
322
- openBrowser(connectUrl);
323
- console.log(`If it didn't open, visit:\n ${connectUrl}\n`);
531
+ let stats = null;
532
+ const srv = await startCallbackServer({ nonce, getStats: () => stats });
533
+ const callbackUrl = `http://127.0.0.1:${srv.port}/callback`;
534
+ const localSetupUrl = `http://127.0.0.1:${srv.port}/setup?nonce=${nonce}`;
535
+ const connectUrl = `${WEB_URL}/connect-device?callback=${encodeURIComponent(callbackUrl)}&nonce=${nonce}&return_to=${encodeURIComponent(localSetupUrl)}`;
536
+ srv.setAuthUrl(connectUrl);
537
+ openBrowser(localSetupUrl);
538
+ console.log(`If it didn't open, visit:\n ${localSetupUrl}\n`);
539
+ let token;
540
+ let key;
324
541
  try {
325
- const { token, key } = await wait;
326
- const msg = await verifyAndStore({ token, key });
327
- console.log(msg);
542
+ ({ token, key } = await srv.onToken);
328
543
  }
329
544
  catch (e) {
330
- close();
545
+ srv.close();
331
546
  console.error(`❌ ${e?.message || e}. You can instead run: echomem-mcp login --token ec_… [--passphrase <vault pass>]`);
332
547
  process.exitCode = 1;
548
+ return;
549
+ }
550
+ console.log(await verifyAndStore({ token, key }));
551
+ const disc = discoverMigratableSessions();
552
+ const eta = estimateMigrationEta(disc.pending, disc.skippedActive);
553
+ const migratable = {
554
+ pending: disc.pending.length,
555
+ alreadyMigrated: disc.alreadyMigrated,
556
+ skippedActive: disc.skippedActive,
557
+ eta,
558
+ buckets: eta.buckets,
559
+ totalChars: eta.totalChars,
560
+ approxInputTokens: eta.approxInputTokens,
561
+ estimatedSeconds: eta.estimatedSeconds,
562
+ estimatedLabel: eta.estimatedLabel,
563
+ };
564
+ const sessionSummary = {
565
+ total: disc.sessions.length,
566
+ codex: disc.codexCount,
567
+ claudeCode: disc.claudeCount,
568
+ };
569
+ stats = await buildStatsPayload([], {
570
+ partial: true,
571
+ skipMemoryCount: true,
572
+ sessions: sessionSummary,
573
+ migratable,
574
+ });
575
+ srv.setStats(stats);
576
+ const fullStatsTimer = setTimeout(() => {
577
+ void buildStatsPayload(collect(), {
578
+ sessions: sessionSummary,
579
+ migratable,
580
+ }).then((payload) => {
581
+ stats = payload;
582
+ srv.setStats(payload);
583
+ }).catch(() => {
584
+ /* best effort — the fast local counts are already available */
585
+ });
586
+ }, 1500);
587
+ fullStatsTimer.unref?.();
588
+ srv.setProgress({
589
+ status: "idle",
590
+ total: disc.pending.length,
591
+ completed: 0,
592
+ running: 0,
593
+ queued: disc.pending.length,
594
+ failed: 0,
595
+ extracted: 0,
596
+ });
597
+ const choice = await srv.decision;
598
+ if (choice === "migrate") {
599
+ const { res } = await srv.migrateRequest;
600
+ let activeSessionId = "";
601
+ let activeJobCount = disc.pending.length;
602
+ let progressDone = 0;
603
+ let progressFailed = 0;
604
+ let progressExtracted = 0;
605
+ const updateProgress = (patch) => {
606
+ srv.setProgress({
607
+ status: "running",
608
+ sessionId: activeSessionId || undefined,
609
+ jobCount: activeJobCount,
610
+ total: activeJobCount,
611
+ completed: progressDone,
612
+ running: progressDone + progressFailed < activeJobCount ? 1 : 0,
613
+ queued: Math.max(0, activeJobCount - progressDone - progressFailed - 1),
614
+ failed: progressFailed,
615
+ extracted: progressExtracted,
616
+ ...patch,
617
+ });
618
+ };
619
+ try {
620
+ if (disc.pending.length === 0) {
621
+ srv.setProgress({
622
+ status: "completed",
623
+ total: 0,
624
+ completed: 0,
625
+ running: 0,
626
+ queued: 0,
627
+ failed: 0,
628
+ extracted: 0,
629
+ latest: "No unprocessed local conversations found.",
630
+ });
631
+ respondMigrate(res, { error: "NO_PENDING_SESSIONS" }, 409);
632
+ srv.close();
633
+ console.log("Setup complete — no unprocessed local conversations to extract.");
634
+ return;
635
+ }
636
+ updateProgress({ status: "starting", running: 0, queued: disc.pending.length, latest: "Creating import session." });
637
+ const controller = new AbortController();
638
+ const h = await withTimeout(startMigration({
639
+ pending: disc.pending,
640
+ signal: controller.signal,
641
+ onProgress: (ev) => {
642
+ if (ev.error) {
643
+ progressFailed += 1;
644
+ }
645
+ else {
646
+ progressDone += 1;
647
+ progressExtracted += ev.memories ?? 0;
648
+ }
649
+ updateProgress({
650
+ latest: `${ev.session.source} ${(ev.session.firstTs || "").slice(0, 10)}${ev.error ? ` failed: ${ev.error}` : ""}`,
651
+ });
652
+ },
653
+ }), 30_000, "IMPORT_START_TIMEOUT", () => controller.abort());
654
+ activeSessionId = h.sessionId;
655
+ activeJobCount = h.jobCount;
656
+ updateProgress({ status: "running", sessionId: h.sessionId, jobCount: h.jobCount, total: h.jobCount, capped: h.capped, latest: "Import session created." });
657
+ respondMigrate(res, { sessionId: h.sessionId, jobCount: h.jobCount, ...(h.capped ? { capped: h.capped } : {}) });
658
+ console.log("Migrating your history… keep this terminal open until it completes.");
659
+ console.log(`Migration metrics: ${h.metricsFile}`);
660
+ const r = await h.done;
661
+ srv.setProgress({
662
+ status: r.failed || r.stoppedReason ? "failed" : "completed",
663
+ sessionId: h.sessionId,
664
+ jobCount: h.jobCount,
665
+ capped: h.capped,
666
+ total: h.jobCount,
667
+ completed: r.migrated,
668
+ running: 0,
669
+ queued: 0,
670
+ failed: r.failed,
671
+ extracted: r.extracted,
672
+ latest: r.stoppedReason ? `Stopped: ${r.stoppedReason}` : "Import complete.",
673
+ ...(r.stoppedReason ? { error: r.stoppedReason } : {}),
674
+ });
675
+ console.log(`Import finished: ${r.migrated} imported, ${r.extracted} memories, ${r.failed} failed.`);
676
+ if (r.stoppedReason || r.failed)
677
+ process.exitCode = 1;
678
+ await new Promise((resolve) => setTimeout(resolve, 2000));
679
+ srv.close();
680
+ }
681
+ catch (e) {
682
+ srv.setProgress({
683
+ status: "failed",
684
+ sessionId: activeSessionId || undefined,
685
+ jobCount: activeJobCount,
686
+ total: activeJobCount,
687
+ completed: progressDone,
688
+ running: 0,
689
+ queued: Math.max(0, activeJobCount - progressDone - progressFailed),
690
+ failed: progressFailed || 1,
691
+ extracted: progressExtracted,
692
+ error: String(e?.message || e),
693
+ });
694
+ if (e?.code === "NOT_LOGGED_IN")
695
+ respondMigrate(res, { error: "NOT_LOGGED_IN" }, 401);
696
+ else if (e?.code === "FORBIDDEN_SCOPE")
697
+ respondMigrate(res, { error: "FORBIDDEN_SCOPE" }, 403);
698
+ else if (e?.code === "VAULT_LOCKED")
699
+ respondMigrate(res, { error: "VAULT_LOCKED" }, 409);
700
+ else if (e?.code === "NO_PENDING_SESSIONS")
701
+ respondMigrate(res, { error: "NO_PENDING_SESSIONS" }, 409);
702
+ else if (e?.code === "IMPORT_START_TIMEOUT")
703
+ respondMigrate(res, { error: "IMPORT_START_TIMEOUT" }, 504);
704
+ else
705
+ respondMigrate(res, { error: "IMPORT_START_FAILED", message: String(e?.message || e) }, 500);
706
+ await new Promise((resolve) => setTimeout(resolve, 2000));
707
+ srv.close();
708
+ process.exitCode = 1;
709
+ }
710
+ }
711
+ else {
712
+ console.log("Setup complete — run `echomem-mcp migrate` later to back-fill your history.");
713
+ srv.close();
333
714
  }
334
715
  }
335
716
  async function cmdUnlock(flags) {
@@ -389,10 +770,18 @@ Usage:
389
770
  echomem-mcp logout Remove stored credentials
390
771
  echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
391
772
  echomem-mcp migrate [--since D] Import your existing Codex/Claude history into your memory
773
+ echomem-mcp migrate --estimate Estimate migration size/time metadata without uploading transcripts
774
+ echomem-mcp migrate --max-chars N Import only sessions up to N assembled text chars
775
+ echomem-mcp migrate --min-chars N Import only sessions at least N assembled text chars
776
+ echomem-mcp migrate --largest --limit 1 Sample the largest pending session first
777
+ echomem-mcp migrate --include-active Include sessions modified in the last 5 minutes
778
+ echomem-mcp migrate --metrics-file PATH Write local per-session timing metadata JSONL
779
+ echomem-mcp sync-usage Sync safe Codex JSONL usage summaries to EchoMem/Amplitude
392
780
 
393
781
  Manual / headless:
394
782
  echomem-mcp login --token ec_xxx [--passphrase <vault pass> | --key <base64>]
395
783
  echomem-mcp setup --dev /abs/path/dist/index.js # point clients at a local checkout
784
+ echomem-mcp sync-usage --days 7 --limit 50 --dry-run
396
785
  `;
397
786
  /** Returns true if argv was a recognized subcommand (and was handled). */
398
787
  export async function runCli(argv) {
@@ -420,6 +809,9 @@ export async function runCli(argv) {
420
809
  case "migrate":
421
810
  await cmdMigrate(flags);
422
811
  return true;
812
+ case "sync-usage":
813
+ await syncCodexUsage(argv.slice(1));
814
+ return true;
423
815
  case "help":
424
816
  case "--help":
425
817
  case "-h":