@echomem/mcp 1.4.8 → 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 (36) hide show
  1. package/README.md +23 -3
  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/dist/city/chaos-to-clarity-pencil.html +582 -0
  8. package/dist/city/echo-ai-city-only.html +1104 -105
  9. package/dist/city/echo-ai-city-only.template.html +1104 -105
  10. package/dist/city/pencil-pie-generator.html +883 -0
  11. package/dist/city/pencil-webgl-landscape.html +1239 -0
  12. package/dist/city/spatial-fan-story.html +479 -0
  13. package/dist/codex-session-files.js +283 -0
  14. package/dist/codex-sync.js +7 -2
  15. package/dist/context-analysis/canonical-golden.js +47 -0
  16. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  17. package/dist/context-analysis/vendored-canonical.js +793 -0
  18. package/dist/context-analysis/workspace-report.js +1838 -0
  19. package/dist/context-metrics/calculate.js +56 -0
  20. package/dist/context-metrics/model-limits.js +26 -0
  21. package/dist/context-metrics/types.js +1 -0
  22. package/dist/forensics-10-problems.js +7 -6
  23. package/dist/forensics.js +863 -132
  24. package/dist/hud/adapters.js +8 -4
  25. package/dist/hud/metric.js +13 -4
  26. package/dist/hud/monitor.js +135 -16
  27. package/dist/hud/web.js +344 -298
  28. package/dist/index.js +7 -3
  29. package/dist/local-data-paths.js +87 -0
  30. package/dist/migrate.js +37 -29
  31. package/dist/report.js +101 -40
  32. package/dist/setup-page.js +3290 -196
  33. package/dist/setup-preview.js +245 -0
  34. package/dist/setup.js +432 -34
  35. package/package.json +5 -4
  36. package/templates/echomem-recall.md +2 -2
package/dist/index.js CHANGED
@@ -1637,8 +1637,12 @@ Details: ${m.details || "N/A"}`;
1637
1637
  async function main() {
1638
1638
  // Subcommands (setup/login/unlock/status/logout/help) run and exit; no subcommand → serve.
1639
1639
  const handled = await runCli(process.argv.slice(2));
1640
- if (handled)
1641
- 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
+ }
1642
1646
  const store = new KeyStore();
1643
1647
  // Serve mode is meant to be SPAWNED by the MCP client (editor), which drives us over a piped stdin.
1644
1648
  // A human who runs the bare server in a terminal instead gets a process that blocks forever on stdio
@@ -1657,7 +1661,7 @@ async function main() {
1657
1661
  connected
1658
1662
  ? " Your editor launches this for you; you don't need to run it by hand."
1659
1663
  : " To connect this device, press Ctrl+C and run:",
1660
- connected ? " Press Ctrl+C to stop it." : " echomem-mcp setup",
1664
+ connected ? " Press Ctrl+C to stop it." : " echomem-mcp init",
1661
1665
  "",
1662
1666
  ].join("\n"));
1663
1667
  }
@@ -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
  }
@@ -1244,7 +1252,7 @@ export async function cmdMigrate(flags) {
1244
1252
  : "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
1245
1253
  }
1246
1254
  else if (code === "FORBIDDEN_SCOPE") {
1247
- 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`.");
1248
1256
  }
1249
1257
  else {
1250
1258
  console.error(c.red(`Could not start the import: ${responseMessage(e)}`));
package/dist/report.js CHANGED
@@ -16,10 +16,11 @@
16
16
  * labeled — not a measurement on this account.
17
17
  */
18
18
  import fs from "node:fs";
19
- import os from "node:os";
20
19
  import path from "node:path";
21
20
  import { StringDecoder } from "node:string_decoder";
22
21
  import axios from "axios";
22
+ import { discoverCodexSessionFiles } from "./codex-session-files.js";
23
+ import { resolveClaudeProjectsDir } from "./local-data-paths.js";
23
24
  import { KeyStore } from "./keystore.js";
24
25
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
25
26
  // Early signal from the controlled sub-agent experiment, NOT measured on this account.
@@ -120,6 +121,14 @@ function epochMs(ts) {
120
121
  const n = Date.parse(ts);
121
122
  return Number.isFinite(n) ? n : null;
122
123
  }
124
+ function localDate(ts) {
125
+ const ms = epochMs(ts);
126
+ if (ms == null)
127
+ return null;
128
+ const d = new Date(ms);
129
+ const pad = (value) => String(value).padStart(2, "0");
130
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
131
+ }
123
132
  function durationBetween(startTs, lastTs) {
124
133
  const start = epochMs(startTs);
125
134
  const last = epochMs(lastTs);
@@ -194,12 +203,21 @@ export function parseCodex(file) {
194
203
  let acts = 0;
195
204
  eachLine(file, (o) => {
196
205
  if (typeof o.timestamp === "string") {
197
- if (meta.first === null)
206
+ const ms = epochMs(o.timestamp);
207
+ if (ms != null && (meta.first === null || ms < (epochMs(meta.first) ?? Number.POSITIVE_INFINITY)))
198
208
  meta.first = o.timestamp;
199
- meta.last = o.timestamp;
209
+ if (ms != null && (meta.last === null || ms > (epochMs(meta.last) ?? Number.NEGATIVE_INFINITY)))
210
+ meta.last = o.timestamp;
200
211
  }
201
- if (o && o.type === "session_meta" && o.payload && typeof o.payload.cwd === "string") {
202
- meta.cwd = o.payload.cwd;
212
+ if (o && o.type === "session_meta" && o.payload && typeof o.payload === "object") {
213
+ if (typeof o.payload.cwd === "string")
214
+ meta.cwd = o.payload.cwd;
215
+ if (typeof o.payload.timestamp === "string") {
216
+ const launchMs = epochMs(o.payload.timestamp);
217
+ if (launchMs != null && (meta.first === null || launchMs < (epochMs(meta.first) ?? Number.POSITIVE_INFINITY))) {
218
+ meta.first = o.payload.timestamp;
219
+ }
220
+ }
203
221
  return;
204
222
  }
205
223
  const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
@@ -224,8 +242,8 @@ export function parseCodex(file) {
224
242
  return null;
225
243
  return {
226
244
  source: "codex",
227
- date: meta.first ? meta.first.slice(0, 10) : null,
228
- month: meta.first ? meta.first.slice(0, 7) : null,
245
+ date: localDate(meta.first),
246
+ month: localDate(meta.first)?.slice(0, 7) ?? null,
229
247
  startTs: meta.first,
230
248
  lastTs: meta.last,
231
249
  durationSec: durationBetween(meta.first, meta.last),
@@ -240,9 +258,9 @@ export function parseCodex(file) {
240
258
  }
241
259
  /** Claude Code: usage is PER assistant turn (SUM); tool_use blocks live in message.content. */
242
260
  export function parseClaude(file) {
243
- let total = 0;
244
- let cached = 0;
245
- let output = 0;
261
+ const usageByRequest = new Map();
262
+ const seenToolUses = new Set();
263
+ let anonymousRequestSeq = 0;
246
264
  let echo = 0;
247
265
  let reads = 0;
248
266
  let acts = 0;
@@ -253,16 +271,26 @@ export function parseClaude(file) {
253
271
  durationSec: 0,
254
272
  prevMs: null,
255
273
  };
256
- let sawUsage = false;
274
+ const requestKey = (row) => {
275
+ const messageId = typeof row.message?.id === "string" ? row.message.id.trim() : "";
276
+ if (messageId)
277
+ return `message:${messageId}`;
278
+ const requestId = typeof row.requestId === "string" ? row.requestId.trim() : "";
279
+ if (requestId)
280
+ return `request:${requestId}`;
281
+ const uuid = typeof row.uuid === "string" ? row.uuid.trim() : "";
282
+ return uuid ? `event:${uuid}` : `anonymous:${++anonymousRequestSeq}`;
283
+ };
257
284
  eachLine(file, (o) => {
258
285
  if (!meta.cwd && typeof o.cwd === "string")
259
286
  meta.cwd = o.cwd;
260
287
  if (typeof o.timestamp === "string") {
261
- if (meta.first === null)
262
- meta.first = o.timestamp;
263
- meta.last = o.timestamp;
264
288
  const ms = epochMs(o.timestamp);
265
289
  if (ms != null) {
290
+ if (meta.first === null || ms < (epochMs(meta.first) ?? Number.POSITIVE_INFINITY))
291
+ meta.first = o.timestamp;
292
+ if (meta.last === null || ms > (epochMs(meta.last) ?? Number.NEGATIVE_INFINITY))
293
+ meta.last = o.timestamp;
266
294
  if (meta.prevMs != null) {
267
295
  meta.durationSec += Math.min(300, Math.max(0, Math.round((ms - meta.prevMs) / 1000)));
268
296
  }
@@ -271,21 +299,34 @@ export function parseClaude(file) {
271
299
  }
272
300
  if (o.type !== "assistant" || !o.message)
273
301
  return;
302
+ const key = requestKey(o);
274
303
  const u = o.message.usage;
275
304
  if (u) {
276
- sawUsage = true;
277
- const inp = u.input_tokens || 0;
278
- const cr = u.cache_read_input_tokens || 0;
279
- const cc = u.cache_creation_input_tokens || 0;
280
- const out = u.output_tokens || 0;
281
- total += inp + cr + cc + out;
282
- cached += cr; // re-read only (cache_creation is a first-time write, not a re-read)
283
- output += out;
305
+ const candidate = {
306
+ input: Math.max(0, Number(u.input_tokens) || 0),
307
+ cacheRead: Math.max(0, Number(u.cache_read_input_tokens) || 0),
308
+ cacheCreate: Math.max(0, Number(u.cache_creation_input_tokens) || 0),
309
+ output: Math.max(0, Number(u.output_tokens) || 0),
310
+ };
311
+ const existing = usageByRequest.get(key);
312
+ if (!existing || candidate.input + candidate.cacheRead + candidate.cacheCreate > existing.input + existing.cacheRead + existing.cacheCreate) {
313
+ if (existing)
314
+ candidate.output = Math.max(candidate.output, existing.output);
315
+ usageByRequest.set(key, candidate);
316
+ }
317
+ else if (candidate.output > existing.output) {
318
+ existing.output = candidate.output;
319
+ }
284
320
  }
285
321
  const content = o.message.content;
286
322
  if (Array.isArray(content)) {
287
- for (const b of content) {
323
+ for (let index = 0; index < content.length; index += 1) {
324
+ const b = content[index];
288
325
  if (b && b.type === "tool_use") {
326
+ const toolKey = typeof b.id === "string" && b.id ? `id:${b.id}` : `${key}:${index}:${String(b.name || "")}`;
327
+ if (seenToolUses.has(toolKey))
328
+ continue;
329
+ seenToolUses.add(toolKey);
289
330
  if (String(b.name || "").toLowerCase().includes("echomem"))
290
331
  echo++;
291
332
  else if (classifyClaudeTool(b.name) === "read")
@@ -296,12 +337,16 @@ export function parseClaude(file) {
296
337
  }
297
338
  }
298
339
  });
299
- if (!sawUsage || total === 0)
340
+ const usage = [...usageByRequest.values()];
341
+ const cached = usage.reduce((sum, row) => sum + row.cacheRead, 0);
342
+ const output = usage.reduce((sum, row) => sum + row.output, 0);
343
+ const total = usage.reduce((sum, row) => sum + row.input + row.cacheRead + row.cacheCreate + row.output, 0);
344
+ if (!usage.length || total === 0)
300
345
  return null;
301
346
  return {
302
347
  source: "claude-code",
303
- date: meta.first ? meta.first.slice(0, 10) : null,
304
- month: meta.first ? meta.first.slice(0, 7) : null,
348
+ date: localDate(meta.first),
349
+ month: localDate(meta.first)?.slice(0, 7) ?? null,
305
350
  startTs: meta.first,
306
351
  lastTs: meta.last,
307
352
  durationSec: meta.durationSec,
@@ -324,11 +369,22 @@ export function walk(dir, match, skipDir, out = []) {
324
369
  }
325
370
  for (const e of entries) {
326
371
  const full = path.join(dir, e.name);
327
- if (e.isDirectory()) {
372
+ let stat;
373
+ try {
374
+ // Never follow entries that changed into symlinks/special files after readdir. A validated root
375
+ // may itself be a user-controlled symlink, but traversal stays inside regular child entries.
376
+ stat = fs.lstatSync(full);
377
+ }
378
+ catch {
379
+ continue;
380
+ }
381
+ if (stat.isSymbolicLink())
382
+ continue;
383
+ if (stat.isDirectory()) {
328
384
  if (!skipDir(e.name))
329
385
  walk(full, match, skipDir, out);
330
386
  }
331
- else if (match(full)) {
387
+ else if (stat.isFile() && match(full)) {
332
388
  out.push(full);
333
389
  }
334
390
  }
@@ -336,18 +392,22 @@ export function walk(dir, match, skipDir, out = []) {
336
392
  }
337
393
  export function collect() {
338
394
  const stats = [];
339
- const codexRoot = path.join(os.homedir(), ".codex", "sessions");
340
- for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
341
- const s = parseCodex(f);
342
- if (s)
343
- stats.push(s);
395
+ const codexDiscovery = discoverCodexSessionFiles({ includeArchived: true });
396
+ if (codexDiscovery.files.length) {
397
+ for (const { path: f } of codexDiscovery.files) {
398
+ const s = parseCodex(f);
399
+ if (s)
400
+ stats.push(s);
401
+ }
344
402
  }
345
403
  // Claude Code: top-level session files only (skip subagent/workflow dirs to avoid double-counting).
346
- const claudeRoot = path.join(os.homedir(), ".claude", "projects");
347
- for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
348
- const s = parseClaude(f);
349
- if (s)
350
- stats.push(s);
404
+ const claudeRoot = resolveClaudeProjectsDir();
405
+ if (claudeRoot) {
406
+ for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
407
+ const s = parseClaude(f);
408
+ if (s)
409
+ stats.push(s);
410
+ }
351
411
  }
352
412
  return stats;
353
413
  }
@@ -371,6 +431,7 @@ export function aggregate(stats) {
371
431
  const fresh = total - cached;
372
432
  const noncached = Math.max(0, total - cached - output);
373
433
  const dates = stats.map((s) => s.date).filter(Boolean).sort();
434
+ const lastDates = stats.map((s) => localDate(s.lastTs)).filter(Boolean).sort();
374
435
  const byMonth = new Map();
375
436
  for (const s of stats) {
376
437
  if (!s.month)
@@ -426,7 +487,7 @@ export function aggregate(stats) {
426
487
  rereadPct: total ? Math.round((cached / total) * 100) : 0,
427
488
  ratio: recalls > 0 ? Math.round(reads / recalls) : null,
428
489
  first: dates[0] || null,
429
- last: dates[dates.length - 1] || null,
490
+ last: lastDates[lastDates.length - 1] || dates[dates.length - 1] || null,
430
491
  days: new Set(dates).size,
431
492
  months,
432
493
  busiestDay: { date: busiestDayEntry?.[0] || null, sessions: busiestDayEntry?.[1] || 0 },
@@ -623,7 +684,7 @@ function renderText(a, memCount, useColor) {
623
684
  L(c.green(`✓ You're connected — every new session now builds on your ${memCount} memories.`));
624
685
  }
625
686
  else {
626
- L(c.green("→ Start now (no signup wall): ") + c.bold("npm i -g @echomem/mcp@latest && echomem-mcp setup"));
687
+ L(c.green("→ Start now (no signup wall): ") + c.bold("npm i -g @echomem/mcp@latest && echomem-mcp init"));
627
688
  L(c.dim(` ~1 minute. Your next coding session recalls instead of re-reading.`));
628
689
  }
629
690
  NL();