@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/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();