@echomem/mcp 1.2.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/README.md +9 -5
- package/dist/codex-sync.js +469 -0
- package/dist/encryption.js +3 -1
- package/dist/index.js +576 -22
- package/dist/migrate.js +945 -0
- package/dist/report.js +155 -9
- package/dist/setup-page.js +574 -0
- package/dist/setup.js +462 -65
- package/dist/v1-contract.js +80 -1
- package/package.json +2 -2
package/dist/setup.js
CHANGED
|
@@ -22,9 +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
|
-
|
|
27
|
-
|
|
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.
|
|
28
32
|
const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
|
|
29
33
|
const API_BASE_URL = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
30
34
|
function home(...p) {
|
|
@@ -62,7 +66,24 @@ export function buildServerEntry(opts = {}) {
|
|
|
62
66
|
if (opts.devEntryPath) {
|
|
63
67
|
return { command: "node", args: [opts.devEntryPath] };
|
|
64
68
|
}
|
|
65
|
-
|
|
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"] };
|
|
66
87
|
}
|
|
67
88
|
/** The TOML block EchoMem adds to ~/.codex/config.toml. No secret — the bridge reads the keystore. */
|
|
68
89
|
export function codexTomlBlock(entry) {
|
|
@@ -70,8 +91,12 @@ export function codexTomlBlock(entry) {
|
|
|
70
91
|
const args = (Array.isArray(entry.args) ? entry.args : []).map((a) => JSON.stringify(String(a))).join(", ");
|
|
71
92
|
return `[mcp_servers.echomem]\ncommand = ${command}\nargs = [${args}]\n`;
|
|
72
93
|
}
|
|
73
|
-
/**
|
|
74
|
-
|
|
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) {
|
|
75
100
|
let content = "";
|
|
76
101
|
try {
|
|
77
102
|
content = fs.readFileSync(configPath, "utf8");
|
|
@@ -79,11 +104,23 @@ function writeCodexConfig(configPath, entry) {
|
|
|
79
104
|
catch {
|
|
80
105
|
/* fresh config */
|
|
81
106
|
}
|
|
82
|
-
|
|
83
|
-
|
|
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));
|
|
84
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
|
+
}
|
|
85
122
|
const sep = content ? (content.endsWith("\n") ? "\n" : "\n\n") : "";
|
|
86
|
-
fs.appendFileSync(configPath, sep +
|
|
123
|
+
fs.appendFileSync(configPath, sep + block + "\n");
|
|
87
124
|
return "wrote";
|
|
88
125
|
}
|
|
89
126
|
/** Merge the EchoMem entry into a JSON client's `mcpServers` map without clobbering siblings. */
|
|
@@ -112,23 +149,132 @@ function openBrowser(url) {
|
|
|
112
149
|
/* headless — caller prints the URL */
|
|
113
150
|
}
|
|
114
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
|
+
}
|
|
115
222
|
/**
|
|
116
|
-
* Start
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
* 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.
|
|
120
226
|
*/
|
|
121
227
|
export function startCallbackServer(opts = {}) {
|
|
122
|
-
const timeoutMs = opts.timeoutMs ??
|
|
228
|
+
const timeoutMs = opts.timeoutMs ?? 300_000;
|
|
123
229
|
const expectedNonce = opts.nonce;
|
|
124
230
|
return new Promise((resolveOuter, rejectOuter) => {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
const
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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) => {
|
|
132
278
|
res.setHeader("Access-Control-Allow-Origin", "*");
|
|
133
279
|
res.setHeader("Access-Control-Allow-Headers", "Content-Type");
|
|
134
280
|
res.setHeader("Access-Control-Allow-Methods", "GET, POST, OPTIONS");
|
|
@@ -137,51 +283,118 @@ export function startCallbackServer(opts = {}) {
|
|
|
137
283
|
if (req.method === "OPTIONS")
|
|
138
284
|
return void res.writeHead(204).end();
|
|
139
285
|
const url = new URL(req.url || "/", "http://127.0.0.1");
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
return
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
req.
|
|
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;
|
|
160
307
|
try {
|
|
161
|
-
|
|
162
|
-
finish(parsed.token, parsed.key, parsed.nonce);
|
|
308
|
+
body = await readJsonBody(req);
|
|
163
309
|
}
|
|
164
310
|
catch {
|
|
165
|
-
res
|
|
311
|
+
text(res, 400, "bad json");
|
|
312
|
+
return;
|
|
166
313
|
}
|
|
167
|
-
|
|
168
|
-
|
|
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
|
+
});
|
|
169
376
|
});
|
|
170
|
-
|
|
171
|
-
server.close();
|
|
172
|
-
fail(new Error("timed out waiting for browser approval"));
|
|
173
|
-
}, timeoutMs);
|
|
377
|
+
armTimeout();
|
|
174
378
|
server.on("error", (e) => rejectOuter(e));
|
|
175
379
|
server.listen(0, "127.0.0.1", () => {
|
|
176
380
|
const addr = server.address();
|
|
177
381
|
const port = typeof addr === "object" && addr ? addr.port : 0;
|
|
178
382
|
resolveOuter({
|
|
179
383
|
port,
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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;
|
|
393
|
+
},
|
|
394
|
+
setProgress: (p) => {
|
|
395
|
+
progress = p;
|
|
184
396
|
},
|
|
397
|
+
close,
|
|
185
398
|
});
|
|
186
399
|
});
|
|
187
400
|
});
|
|
@@ -311,24 +524,193 @@ async function cmdLogin(flags) {
|
|
|
311
524
|
console.log(msg);
|
|
312
525
|
return;
|
|
313
526
|
}
|
|
314
|
-
// Browser path: open
|
|
315
|
-
// the
|
|
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.
|
|
316
529
|
console.log("Opening your browser to approve this device…");
|
|
317
530
|
const nonce = randomUUID();
|
|
318
|
-
|
|
319
|
-
const
|
|
320
|
-
const
|
|
321
|
-
|
|
322
|
-
|
|
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;
|
|
323
541
|
try {
|
|
324
|
-
|
|
325
|
-
const msg = await verifyAndStore({ token, key });
|
|
326
|
-
console.log(msg);
|
|
542
|
+
({ token, key } = await srv.onToken);
|
|
327
543
|
}
|
|
328
544
|
catch (e) {
|
|
329
|
-
close();
|
|
545
|
+
srv.close();
|
|
330
546
|
console.error(`❌ ${e?.message || e}. You can instead run: echomem-mcp login --token ec_… [--passphrase <vault pass>]`);
|
|
331
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();
|
|
332
714
|
}
|
|
333
715
|
}
|
|
334
716
|
async function cmdUnlock(flags) {
|
|
@@ -387,10 +769,19 @@ Usage:
|
|
|
387
769
|
echomem-mcp status Show token/key/clients
|
|
388
770
|
echomem-mcp logout Remove stored credentials
|
|
389
771
|
echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
|
|
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
|
|
390
780
|
|
|
391
781
|
Manual / headless:
|
|
392
782
|
echomem-mcp login --token ec_xxx [--passphrase <vault pass> | --key <base64>]
|
|
393
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
|
|
394
785
|
`;
|
|
395
786
|
/** Returns true if argv was a recognized subcommand (and was handled). */
|
|
396
787
|
export async function runCli(argv) {
|
|
@@ -415,6 +806,12 @@ export async function runCli(argv) {
|
|
|
415
806
|
case "report":
|
|
416
807
|
await runReport(flags);
|
|
417
808
|
return true;
|
|
809
|
+
case "migrate":
|
|
810
|
+
await cmdMigrate(flags);
|
|
811
|
+
return true;
|
|
812
|
+
case "sync-usage":
|
|
813
|
+
await syncCodexUsage(argv.slice(1));
|
|
814
|
+
return true;
|
|
418
815
|
case "help":
|
|
419
816
|
case "--help":
|
|
420
817
|
case "-h":
|