@echomem/mcp 1.4.7 → 1.4.9

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.
Files changed (46) hide show
  1. package/README.md +35 -9
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/assets/hud/claude.svg +1 -0
  8. package/assets/hud/codex.svg +1 -0
  9. package/assets/hud/session-viewer.html +35 -0
  10. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  11. package/dist/city/echo-ai-city-only.html +1126 -109
  12. package/dist/city/echo-ai-city-only.template.html +1126 -109
  13. package/dist/city/echo-face-cutout.png +0 -0
  14. package/dist/city/pencil-pie-generator.html +883 -0
  15. package/dist/city/pencil-webgl-landscape.html +1239 -0
  16. package/dist/city/spatial-fan-story.html +479 -0
  17. package/dist/codex-session-files.js +283 -0
  18. package/dist/codex-sync.js +7 -2
  19. package/dist/context-analysis/canonical-golden.js +47 -0
  20. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  21. package/dist/context-analysis/vendored-canonical.js +793 -0
  22. package/dist/context-analysis/workspace-report.js +1838 -0
  23. package/dist/context-metrics/calculate.js +56 -0
  24. package/dist/context-metrics/model-limits.js +26 -0
  25. package/dist/context-metrics/types.js +1 -0
  26. package/dist/forensics-10-problems.js +7 -6
  27. package/dist/forensics.js +863 -132
  28. package/dist/hud/adapters.js +8 -4
  29. package/dist/hud/autostart.js +66 -0
  30. package/dist/hud/cli.js +31 -0
  31. package/dist/hud/electron-main.js +182 -19
  32. package/dist/hud/metric.js +13 -4
  33. package/dist/hud/monitor.js +171 -84
  34. package/dist/hud/preload.cjs +3 -0
  35. package/dist/hud/server.js +321 -4
  36. package/dist/hud/web.js +880 -270
  37. package/dist/index.js +122 -24
  38. package/dist/local-data-paths.js +87 -0
  39. package/dist/migrate.js +55 -29
  40. package/dist/report.js +101 -40
  41. package/dist/setup-page.js +4257 -245
  42. package/dist/setup-preview.js +245 -0
  43. package/dist/setup.js +786 -75
  44. package/dist/v1-contract.js +20 -2
  45. package/package.json +6 -4
  46. package/templates/echomem-recall.md +2 -2
package/dist/index.js CHANGED
@@ -273,6 +273,23 @@ function lastUserMessageFromConversationText(value) {
273
273
  flush();
274
274
  return lastUser || undefined;
275
275
  }
276
+ function formatMessagesForIngest(messages) {
277
+ return messages
278
+ .map((message) => {
279
+ const content = message.content.trim();
280
+ if (!content)
281
+ return "";
282
+ const role = message.role.toLowerCase();
283
+ const label = role === "user" || role === "human"
284
+ ? "User"
285
+ : role === "system"
286
+ ? "System"
287
+ : "Assistant";
288
+ return `## ${label}\n\n${content}`;
289
+ })
290
+ .filter(Boolean)
291
+ .join("\n\n---\n\n");
292
+ }
276
293
  function normalizeRetrievalCandidate(value, fallbackRank) {
277
294
  if (!isRecord(value))
278
295
  return null;
@@ -645,7 +662,7 @@ class EchoMemApiClient {
645
662
  return { success: true, tuned: true, answer: String(data.answer || data.response || "").trim(), memories };
646
663
  }
647
664
  async searchMemories(args) {
648
- const parsed = searchMemoriesSchema.parse(args);
665
+ const parsed = searchMemoriesSchema.parse(args ?? {});
649
666
  const query = parsed.query?.trim();
650
667
  const limit = parsed.limit ?? parsed.k ?? 10;
651
668
  const threshold = parsed.threshold ?? 0.1;
@@ -693,10 +710,10 @@ class EchoMemApiClient {
693
710
  return data;
694
711
  }
695
712
  async saveConversation(args) {
696
- const parsed = saveConversationSchema.parse(args);
713
+ const parsed = saveConversationSchema.parse(args ?? {});
697
714
  let rawData = parsed.conversation?.trim() || "";
698
715
  if (!rawData && parsed.messages?.length) {
699
- rawData = parsed.messages.map((m) => `${m.role}: ${m.content}`).join("\n\n");
716
+ rawData = formatMessagesForIngest(parsed.messages);
700
717
  }
701
718
  if (!rawData) {
702
719
  throw new McpError(ErrorCode.InvalidParams, "Either conversation or messages is required.");
@@ -724,7 +741,7 @@ class EchoMemApiClient {
724
741
  return response.data;
725
742
  }
726
743
  async deleteMemory(args) {
727
- const parsed = deleteMemorySchema.parse(args);
744
+ const parsed = deleteMemorySchema.parse(args ?? {});
728
745
  const enc = await this.encState();
729
746
  const memory = await this.fetchMemoryById(parsed.memoryId, enc);
730
747
  if (!memory) {
@@ -768,7 +785,7 @@ class EchoMemApiClient {
768
785
  };
769
786
  }
770
787
  async getMemoriesByTimeRange(args) {
771
- const parsed = timeRangeSchema.parse(args);
788
+ const parsed = timeRangeSchema.parse(args ?? {});
772
789
  const enc = await this.encState();
773
790
  const response = await this.axios.post("/api/extension/memories/time-range", {
774
791
  startDate: parsed.startDate,
@@ -778,7 +795,7 @@ class EchoMemApiClient {
778
795
  return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
779
796
  }
780
797
  async getMemoriesByContext(args) {
781
- const parsed = getByContextSchema.parse(args);
798
+ const parsed = getByContextSchema.parse(args ?? {});
782
799
  const enc = await this.encState();
783
800
  const response = await this.axios.post("/api/extension/memories/by-context", {
784
801
  contextId: parsed.contextId,
@@ -787,7 +804,7 @@ class EchoMemApiClient {
787
804
  return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
788
805
  }
789
806
  async searchMemoriesByKeywords(args) {
790
- const parsed = keywordsSchema.parse(args);
807
+ const parsed = keywordsSchema.parse(args ?? {});
791
808
  const enc = await this.encState();
792
809
  const response = await this.axios.post("/api/extension/memories/keywords", {
793
810
  keywords: parsed.keywords,
@@ -796,7 +813,7 @@ class EchoMemApiClient {
796
813
  return enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
797
814
  }
798
815
  async listFriends(args) {
799
- listFriendsSchema.parse(args);
816
+ listFriendsSchema.parse(args ?? {});
800
817
  try {
801
818
  const response = await this.axios.get("/api/extension/social/friends");
802
819
  return response.data;
@@ -806,7 +823,7 @@ class EchoMemApiClient {
806
823
  }
807
824
  }
808
825
  async searchUsers(args) {
809
- const parsed = searchUsersSchema.parse(args);
826
+ const parsed = searchUsersSchema.parse(args ?? {});
810
827
  try {
811
828
  const response = await this.axios.post("/api/extension/social/users/search", {
812
829
  query: parsed.query,
@@ -819,7 +836,7 @@ class EchoMemApiClient {
819
836
  }
820
837
  }
821
838
  async sendFriendRequest(args) {
822
- const parsed = sendFriendRequestSchema.parse(args);
839
+ const parsed = sendFriendRequestSchema.parse(args ?? {});
823
840
  try {
824
841
  const response = await this.axios.post("/api/extension/social/friend-requests", {
825
842
  receiverUserId: parsed.targetUserId,
@@ -831,7 +848,7 @@ class EchoMemApiClient {
831
848
  }
832
849
  }
833
850
  async searchOthersMemories(args) {
834
- const parsed = othersSchema.parse(args);
851
+ const parsed = othersSchema.parse(args ?? {});
835
852
  try {
836
853
  const response = await this.axios.post("/api/extension/social/public-memories/search", {
837
854
  query: parsed.query,
@@ -855,7 +872,7 @@ class EchoMemApiClient {
855
872
  }
856
873
  }
857
874
  async getPublicMemory(args) {
858
- const parsed = publicMemorySchema.parse(args);
875
+ const parsed = publicMemorySchema.parse(args ?? {});
859
876
  try {
860
877
  const response = await this.axios.get(`/api/extension/social/public-memories/${encodeURIComponent(parsed.memoryId)}?requestId=${encodeURIComponent(this.sessionId)}&source=mcp_friend_public_memory_fetch`);
861
878
  return response.data;
@@ -1026,6 +1043,8 @@ class EchoMemMCPServer {
1026
1043
  return await this.handleTimeRange(request.params.arguments);
1027
1044
  case canonicalToolNames.getByContext:
1028
1045
  return await this.handleGetByContext(request.params.arguments);
1046
+ case canonicalToolNames.checkpointByContext:
1047
+ return await this.handleGetCheckpointByContext(request.params.arguments);
1029
1048
  case canonicalToolNames.keywords:
1030
1049
  return await this.handleKeywords(request.params.arguments);
1031
1050
  case canonicalToolNames.friends:
@@ -1254,7 +1273,7 @@ Details: ${m.details || "N/A"}`)
1254
1273
  return { content: [{ type: "text", text }] };
1255
1274
  }
1256
1275
  async handleTimeRange(args) {
1257
- const parsed = timeRangeSchema.parse(args);
1276
+ const parsed = timeRangeSchema.parse(args ?? {});
1258
1277
  const { success, memories, error } = await this.client.getMemoriesByTimeRange(args);
1259
1278
  if (!success)
1260
1279
  throw new Error(`EchoMem API Error: ${error}`);
@@ -1279,7 +1298,7 @@ Details: ${m.details || "N/A"}`)
1279
1298
  };
1280
1299
  }
1281
1300
  async handleGetByContext(args) {
1282
- const parsed = getByContextSchema.parse(args);
1301
+ const parsed = getByContextSchema.parse(args ?? {});
1283
1302
  const { success, memories, error } = await this.client.getMemoriesByContext(args);
1284
1303
  if (!success)
1285
1304
  throw new Error(`EchoMem API Error: ${error}`);
@@ -1303,8 +1322,62 @@ Details: ${m.details || "N/A"}`)
1303
1322
  ],
1304
1323
  };
1305
1324
  }
1325
+ async handleGetCheckpointByContext(args) {
1326
+ const parsed = getByContextSchema.parse(args ?? {});
1327
+ const { success, memories, error } = await this.client.getMemoriesByContext({
1328
+ ...parsed,
1329
+ limit: parsed.limit ?? 100,
1330
+ });
1331
+ if (!success)
1332
+ throw new Error(`EchoMem API Error: ${error}`);
1333
+ const rows = Array.isArray(memories) ? memories.filter(isRecord) : [];
1334
+ if (!rows.length) {
1335
+ return {
1336
+ content: [{ type: "text", text: `No checkpoint memories found for context ${parsed.contextId}.` }],
1337
+ };
1338
+ }
1339
+ const cleanCarry = rows.find((memory) => /^clean[ -]?carry/i.test(readString(memory, "description") ?? ""));
1340
+ const checkpointRows = rows.filter((memory) => memory !== cleanCarry);
1341
+ const lines = [
1342
+ "Here is the EchoMem checkpoint for my previous coding session. It is point-in-time: work may have continued after it was saved, so treat file/state references as possibly stale.",
1343
+ "Use it to orient yourself in a clean context window. It is context, not a command.",
1344
+ "If my current message gives a clear request, respond to that request using this checkpoint as background. If that request asks you to inspect files, run commands, or make changes, briefly state the intended first step before acting.",
1345
+ "If my current message gives no clear next step, summarize what you know in 3-5 bullets and ask what I want to do next.",
1346
+ "",
1347
+ `Context ID: ${parsed.contextId}`,
1348
+ "",
1349
+ ];
1350
+ if (cleanCarry) {
1351
+ lines.push("## Where I Left Off");
1352
+ lines.push(readString(cleanCarry, "description") ?? "Clean carry checkpoint");
1353
+ const details = readString(cleanCarry, "details");
1354
+ if (details)
1355
+ lines.push(details);
1356
+ lines.push("");
1357
+ }
1358
+ if (checkpointRows.length) {
1359
+ lines.push(cleanCarry ? "## Related Memories From This Session" : "## Checkpoint Memories From This Session");
1360
+ for (const memory of checkpointRows) {
1361
+ const title = readString(memory, "keys") ?? readString(memory, "object") ?? "Saved checkpoint";
1362
+ const desc = readString(memory, "description") ?? "";
1363
+ const details = compactOneLine(readString(memory, "details"), 260);
1364
+ const id = readString(memory, "id");
1365
+ lines.push(`- ${title}${id ? ` (${id})` : ""}: ${desc}${details ? ` — ${details}` : ""}`);
1366
+ }
1367
+ lines.push("");
1368
+ }
1369
+ lines.push(`EchoMem returned ${rows.length} ${rows.length === 1 ? "memory" : "memories"} for this checkpoint context.`);
1370
+ return {
1371
+ content: [
1372
+ {
1373
+ type: "text",
1374
+ text: lines.join("\n"),
1375
+ },
1376
+ ],
1377
+ };
1378
+ }
1306
1379
  async handleKeywords(args) {
1307
- const parsed = keywordsSchema.parse(args);
1380
+ const parsed = keywordsSchema.parse(args ?? {});
1308
1381
  const { success, memories, error } = await this.client.searchMemoriesByKeywords(args);
1309
1382
  if (!success)
1310
1383
  throw new Error(`EchoMem API Error: ${error}`);
@@ -1329,7 +1402,7 @@ Details: ${m.details || "N/A"}`)
1329
1402
  };
1330
1403
  }
1331
1404
  async handleOthers(args) {
1332
- const parsed = othersSchema.parse(args);
1405
+ const parsed = othersSchema.parse(args ?? {});
1333
1406
  const payload = await this.client.searchOthersMemories(args);
1334
1407
  const memories = payload?.memories ?? [];
1335
1408
  if (!memories.length) {
@@ -1380,7 +1453,7 @@ Details: ${m.details || "N/A"}`;
1380
1453
  };
1381
1454
  }
1382
1455
  async handleFriends(args) {
1383
- listFriendsSchema.parse(args);
1456
+ listFriendsSchema.parse(args ?? {});
1384
1457
  const payload = await this.client.listFriends(args);
1385
1458
  const friends = Array.isArray(payload?.friends) ? payload.friends : [];
1386
1459
  if (!friends.length) {
@@ -1411,7 +1484,7 @@ Details: ${m.details || "N/A"}`;
1411
1484
  };
1412
1485
  }
1413
1486
  async handleSearchUsers(args) {
1414
- const parsed = searchUsersSchema.parse(args);
1487
+ const parsed = searchUsersSchema.parse(args ?? {});
1415
1488
  const payload = await this.client.searchUsers(args);
1416
1489
  const users = Array.isArray(payload?.users) ? payload.users : [];
1417
1490
  if (!users.length) {
@@ -1444,7 +1517,7 @@ Details: ${m.details || "N/A"}`;
1444
1517
  };
1445
1518
  }
1446
1519
  async handleSendFriendRequest(args) {
1447
- sendFriendRequestSchema.parse(args);
1520
+ sendFriendRequestSchema.parse(args ?? {});
1448
1521
  const payload = await this.client.sendFriendRequest(args);
1449
1522
  const result = isRecord(payload) ? payload : {};
1450
1523
  const targetUser = isRecord(result.targetUser) ? result.targetUser : {};
@@ -1477,7 +1550,7 @@ Details: ${m.details || "N/A"}`;
1477
1550
  };
1478
1551
  }
1479
1552
  async handlePublicMemory(args) {
1480
- const parsed = publicMemorySchema.parse(args);
1553
+ const parsed = publicMemorySchema.parse(args ?? {});
1481
1554
  const payload = await this.client.getPublicMemory(args);
1482
1555
  const memory = payload?.memory;
1483
1556
  if (!memory) {
@@ -1505,7 +1578,7 @@ Details: ${m.details || "N/A"}`;
1505
1578
  };
1506
1579
  }
1507
1580
  async handleDelete(args, rec) {
1508
- const parsed = deleteMemorySchema.parse(args);
1581
+ const parsed = deleteMemorySchema.parse(args ?? {});
1509
1582
  if (rec) {
1510
1583
  rec.memory_id_hash = hashText(parsed.memoryId);
1511
1584
  rec.delete_confirmed = parsed.confirmed;
@@ -1564,10 +1637,35 @@ Details: ${m.details || "N/A"}`;
1564
1637
  async function main() {
1565
1638
  // Subcommands (setup/login/unlock/status/logout/help) run and exit; no subcommand → serve.
1566
1639
  const handled = await runCli(process.argv.slice(2));
1567
- if (handled)
1568
- return;
1640
+ if (handled) {
1641
+ // These are one-shot commands: the work is finished here. Force exit so a stray open handle —
1642
+ // e.g. a not-yet-timed-out keep-alive socket from the local setup server, or a detached child's
1643
+ // inherited descriptor — can't leave the terminal hanging after setup/migrate completes.
1644
+ process.exit(process.exitCode ?? 0);
1645
+ }
1569
1646
  const store = new KeyStore();
1570
- if (!store.getToken()) {
1647
+ // Serve mode is meant to be SPAWNED by the MCP client (editor), which drives us over a piped stdin.
1648
+ // A human who runs the bare server in a terminal instead gets a process that blocks forever on stdio
1649
+ // with no output — indistinguishable from a hang ("wtf, it's stuck"). A TTY on stdin means no editor
1650
+ // is on the other end: say so loudly and point at the command they almost certainly meant.
1651
+ if (process.stdin.isTTY) {
1652
+ const connected = Boolean(store.getToken());
1653
+ console.error([
1654
+ "",
1655
+ "⚠️ You started the EchoMem MCP server directly in a terminal.",
1656
+ "",
1657
+ " This is a background server that waits for your editor to connect over",
1658
+ " stdio. It is NOT frozen — a blank, unresponsive terminal is exactly what",
1659
+ " a running stdio server looks like.",
1660
+ "",
1661
+ connected
1662
+ ? " Your editor launches this for you; you don't need to run it by hand."
1663
+ : " To connect this device, press Ctrl+C and run:",
1664
+ connected ? " Press Ctrl+C to stop it." : " echomem-mcp init",
1665
+ "",
1666
+ ].join("\n"));
1667
+ }
1668
+ else if (!store.getToken()) {
1571
1669
  // No token yet — DON'T exit. Start the server so the editor keeps the bridge alive; tools return
1572
1670
  // a "run login" nudge until `login` writes the token, then the next call picks it up (no restart).
1573
1671
  console.error("EchoMem: not connected yet — run `echomem-mcp login` to connect this device.");
@@ -0,0 +1,87 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ function normalizedHomeDir(homeDir) {
5
+ const value = homeDir.trim();
6
+ if (!value || !path.isAbsolute(value))
7
+ return null;
8
+ return path.normalize(value);
9
+ }
10
+ function expandCurrentUserHome(value, homeDir) {
11
+ const trimmed = value.trim();
12
+ if (!trimmed)
13
+ return null;
14
+ if (trimmed === "~")
15
+ return homeDir;
16
+ if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
17
+ return path.join(homeDir, trimmed.slice(2));
18
+ }
19
+ // Do not guess another user's home (~alice) or resolve a relative path against the MCP process cwd.
20
+ if (trimmed.startsWith("~") || !path.isAbsolute(trimmed))
21
+ return null;
22
+ return path.normalize(trimmed);
23
+ }
24
+ function configuredRoot(envKey, defaultDirName, opts = {}) {
25
+ const homeDir = normalizedHomeDir(opts.homeDir ?? os.homedir());
26
+ if (!homeDir)
27
+ return null;
28
+ const env = opts.env ?? process.env;
29
+ const configured = env[envKey];
30
+ if (typeof configured === "string" && configured.trim()) {
31
+ // An explicit profile override is authoritative. If it is invalid, callers must not silently
32
+ // scan the default profile, which may belong to a different account or contain stale history.
33
+ return expandCurrentUserHome(configured, homeDir);
34
+ }
35
+ return path.join(homeDir, defaultDirName);
36
+ }
37
+ /** Resolve an existing directory only after confirming it is a readable/searchable directory. */
38
+ export function resolveReadableDirectory(candidate) {
39
+ if (!candidate)
40
+ return null;
41
+ try {
42
+ const real = fs.realpathSync(candidate);
43
+ if (!fs.statSync(real).isDirectory())
44
+ return null;
45
+ fs.accessSync(real, fs.constants.R_OK | fs.constants.X_OK);
46
+ return real;
47
+ }
48
+ catch {
49
+ return null;
50
+ }
51
+ }
52
+ /**
53
+ * Resolve every readable Codex transcript root under one authoritative CODEX_HOME profile.
54
+ * Active sessions come first. archived_sessions is additive historical data, not a fallback to a
55
+ * different profile. If both names resolve to the same directory, keep it once as active.
56
+ */
57
+ export function resolveCodexSessionRoots(opts = {}) {
58
+ const root = configuredRoot("CODEX_HOME", ".codex", opts);
59
+ if (!root)
60
+ return [];
61
+ const candidates = [
62
+ { kind: "active", child: "sessions", priority: 0 },
63
+ { kind: "archived", child: "archived_sessions", priority: 1 },
64
+ ];
65
+ const roots = [];
66
+ const seenRealpaths = new Set();
67
+ for (const candidate of candidates) {
68
+ const resolved = resolveReadableDirectory(path.join(root, candidate.child));
69
+ if (!resolved || seenRealpaths.has(resolved))
70
+ continue;
71
+ seenRealpaths.add(resolved);
72
+ roots.push({ kind: candidate.kind, path: resolved, priority: candidate.priority });
73
+ }
74
+ return roots;
75
+ }
76
+ /**
77
+ * Backward-compatible active-only resolver. Historical scanners should use
78
+ * resolveCodexSessionRoots/discoverCodexSessionFiles so archived sessions are not omitted.
79
+ */
80
+ export function resolveCodexSessionsDir(opts = {}) {
81
+ return resolveCodexSessionRoots(opts).find((root) => root.kind === "active")?.path ?? null;
82
+ }
83
+ /** Claude Code state lives under CLAUDE_CONFIG_DIR (default ~/.claude); transcripts are in projects. */
84
+ export function resolveClaudeProjectsDir(opts = {}) {
85
+ const root = configuredRoot("CLAUDE_CONFIG_DIR", ".claude", opts);
86
+ return resolveReadableDirectory(root ? path.join(root, "projects") : null);
87
+ }
package/dist/migrate.js CHANGED
@@ -19,13 +19,13 @@
19
19
  * NOTE: client-pull — the queue advances only while the bridge runs; there is no server-side worker.
20
20
  */
21
21
  import fs from "node:fs";
22
- import os from "node:os";
23
22
  import path from "node:path";
24
23
  import crypto from "node:crypto";
25
24
  import readline from "node:readline";
26
25
  import axios from "axios";
27
26
  import { KeyStore, echoConfigDir } from "./keystore.js";
28
27
  import { fetchEncryptionConfig } from "./encryption.js";
28
+ import { resolveClaudeProjectsDir, resolveCodexSessionsDir } from "./local-data-paths.js";
29
29
  import { walk, eachLine } from "./report.js";
30
30
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
31
31
  const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
@@ -187,17 +187,21 @@ export function normalizeCwd(cwd) {
187
187
  /** Discover every local session, newest first (by first-turn timestamp). */
188
188
  export function discoverSessions() {
189
189
  const out = [];
190
- const codexRoot = path.join(os.homedir(), ".codex", "sessions");
191
- for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
192
- const s = assembleCodex(f);
193
- if (s)
194
- out.push(s);
190
+ const codexRoot = resolveCodexSessionsDir();
191
+ if (codexRoot) {
192
+ for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
193
+ const s = assembleCodex(f);
194
+ if (s)
195
+ out.push(s);
196
+ }
195
197
  }
196
- const claudeRoot = path.join(os.homedir(), ".claude", "projects");
197
- for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
198
- const s = assembleClaude(f);
199
- if (s)
200
- out.push(s);
198
+ const claudeRoot = resolveClaudeProjectsDir();
199
+ if (claudeRoot) {
200
+ for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
201
+ const s = assembleClaude(f);
202
+ if (s)
203
+ out.push(s);
204
+ }
201
205
  }
202
206
  out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
203
207
  return out;
@@ -283,24 +287,28 @@ function fastSessionInfo(file, source) {
283
287
  }
284
288
  function fastSessionEntries(opts = {}) {
285
289
  const out = [];
286
- const codexRoot = opts.codexRoot ?? path.join(os.homedir(), ".codex", "sessions");
287
- for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
288
- const stat = statSafe(filePath);
289
- const info = fastSessionInfo(filePath, "codex");
290
- // Include if we found text OR a real session id (big sessions can have their first text turn beyond
291
- // the 1MB probe window gating only on text dropped them entirely; exact discovery refines later).
292
- // We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
293
- if (info.hasTextTurn || info.hasRealKey)
294
- out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
290
+ const codexRoot = opts.codexRoot ?? resolveCodexSessionsDir();
291
+ if (codexRoot) {
292
+ for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
293
+ const stat = statSafe(filePath);
294
+ const info = fastSessionInfo(filePath, "codex");
295
+ // Include if we found text OR a real session id (big sessions can have their first text turn beyond
296
+ // the 1MB probe window gating only on text dropped them entirely; exact discovery refines later).
297
+ // We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
298
+ if (info.hasTextTurn || info.hasRealKey)
299
+ out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
300
+ }
295
301
  }
296
- const claudeRoot = opts.claudeRoot ?? path.join(os.homedir(), ".claude", "projects");
297
- for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
298
- const stat = statSafe(filePath);
299
- const info = fastSessionInfo(filePath, "claude-code");
300
- // Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
301
- // while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
302
- if (info.hasTextTurn || info.hasRealKey)
303
- out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
302
+ const claudeRoot = opts.claudeRoot ?? resolveClaudeProjectsDir();
303
+ if (claudeRoot) {
304
+ for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
305
+ const stat = statSafe(filePath);
306
+ const info = fastSessionInfo(filePath, "claude-code");
307
+ // Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
308
+ // while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
309
+ if (info.hasTextTurn || info.hasRealKey)
310
+ out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
311
+ }
304
312
  }
305
313
  return out;
306
314
  }
@@ -644,6 +652,7 @@ export function estimateMigrationEtaFromLengths(lengths, skippedActive = 0, opts
644
652
  };
645
653
  }
646
654
  export function summarizeFastMigratableDiscovery(discovery) {
655
+ const pendingCodex = discovery.pendingCodex ?? discovery.pending.filter((s) => s.source === "codex").length;
647
656
  return {
648
657
  sessions: discovery.sessions.length,
649
658
  pending: discovery.pending.length,
@@ -652,6 +661,8 @@ export function summarizeFastMigratableDiscovery(discovery) {
652
661
  skippedActive: discovery.skippedActive,
653
662
  codexCount: discovery.codexCount,
654
663
  claudeCount: discovery.claudeCount,
664
+ pendingCodex,
665
+ pendingClaudeCode: discovery.pendingClaudeCode ?? discovery.pending.length - pendingCodex,
655
666
  eta: estimateMigrationEtaFromLengths(discovery.pending.map((s) => s.size), discovery.skippedActive, {
656
667
  secondsPerSession: measuredSecondsPerSession() ?? undefined,
657
668
  }),
@@ -690,6 +701,7 @@ export function discoverMigratableFastDiscovery(opts = {}) {
690
701
  });
691
702
  const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
692
703
  const codexCount = sessions.filter((s) => s.source === "codex").length;
704
+ const pendingCodex = pending.filter((s) => s.source === "codex").length;
693
705
  return {
694
706
  sessions,
695
707
  pending,
@@ -699,6 +711,8 @@ export function discoverMigratableFastDiscovery(opts = {}) {
699
711
  limited: pending.length < pendingAll.length,
700
712
  codexCount,
701
713
  claudeCount: sessions.length - codexCount,
714
+ pendingCodex,
715
+ pendingClaudeCode: pending.length - pendingCodex,
702
716
  };
703
717
  }
704
718
  export function discoverMigratableSummaryFast(opts = {}) {
@@ -757,6 +771,7 @@ export function discoverMigratableSessions(opts = {}) {
757
771
  });
758
772
  const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
759
773
  const codexCount = sessions.filter((s) => s.source === "codex").length;
774
+ const pendingCodex = pending.filter((s) => s.source === "codex").length;
760
775
  return {
761
776
  sessions,
762
777
  pending,
@@ -766,6 +781,8 @@ export function discoverMigratableSessions(opts = {}) {
766
781
  limited: pending.length < pendingAll.length,
767
782
  codexCount,
768
783
  claudeCount: sessions.length - codexCount,
784
+ pendingCodex,
785
+ pendingClaudeCode: pending.length - pendingCodex,
769
786
  };
770
787
  }
771
788
  /**
@@ -787,6 +804,7 @@ export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
787
804
  if (s)
788
805
  pending.push(s);
789
806
  }
807
+ const pendingCodex = pending.filter((s) => s.source === "codex").length;
790
808
  return {
791
809
  sessions: pending,
792
810
  pending,
@@ -796,6 +814,8 @@ export function discoverPendingSessionsTargeted(processedKeys, opts = {}) {
796
814
  limited: filtered.limited,
797
815
  codexCount: filtered.codexCount,
798
816
  claudeCount: filtered.claudeCount,
817
+ pendingCodex,
818
+ pendingClaudeCode: pending.length - pendingCodex,
799
819
  accountChecked: filtered.accountChecked,
800
820
  accountCheckFailed: filtered.accountCheckFailed,
801
821
  accountCheckUnavailable: filtered.accountCheckUnavailable,
@@ -808,6 +828,7 @@ export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
808
828
  const skippedActive = discovery.sessions.length - selectableSessions.length;
809
829
  const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
810
830
  const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
831
+ const pendingCodex = pending.filter((s) => s.source === "codex").length;
811
832
  return {
812
833
  ...discovery,
813
834
  pending,
@@ -815,6 +836,8 @@ export function applyAccountImportStatus(discovery, processedKeys, opts = {}) {
815
836
  alreadyMigrated: selectableSessions.length - pendingAll.length,
816
837
  skippedActive,
817
838
  limited: pending.length < pendingAll.length,
839
+ pendingCodex,
840
+ pendingClaudeCode: pending.length - pendingCodex,
818
841
  accountChecked: true,
819
842
  accountCheckFailed: false,
820
843
  accountCheckUnavailable: false,
@@ -827,6 +850,7 @@ export function applyFastAccountImportStatus(discovery, processedKeys, opts = {}
827
850
  const skippedActive = discovery.sessions.length - selectableSessions.length;
828
851
  const pendingAll = selectableSessions.filter((s) => !processedKeys.has(s.conversationKey));
829
852
  const pending = opts.limit && opts.limit > 0 ? pendingAll.slice(0, opts.limit) : pendingAll;
853
+ const pendingCodex = pending.filter((s) => s.source === "codex").length;
830
854
  return {
831
855
  ...discovery,
832
856
  pending,
@@ -834,6 +858,8 @@ export function applyFastAccountImportStatus(discovery, processedKeys, opts = {}
834
858
  alreadyMigrated: selectableSessions.length - pendingAll.length,
835
859
  skippedActive,
836
860
  limited: pending.length < pendingAll.length,
861
+ pendingCodex,
862
+ pendingClaudeCode: pending.length - pendingCodex,
837
863
  accountChecked: true,
838
864
  accountCheckFailed: false,
839
865
  accountCheckUnavailable: false,
@@ -1226,7 +1252,7 @@ export async function cmdMigrate(flags) {
1226
1252
  : "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
1227
1253
  }
1228
1254
  else if (code === "FORBIDDEN_SCOPE") {
1229
- console.error("This device token cannot import history. Re-connect this device with `echomem-mcp setup`.");
1255
+ console.error("This device token cannot import history. Re-connect this device with `echomem-mcp login`.");
1230
1256
  }
1231
1257
  else {
1232
1258
  console.error(c.red(`Could not start the import: ${responseMessage(e)}`));