@echomem/mcp 1.4.44 → 1.4.45

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 (50) hide show
  1. package/README.md +28 -25
  2. package/dist/city/README.md +9 -0
  3. package/dist/city/echo-ai-city-only.html +2232 -0
  4. package/dist/city/echo-extraction-plate.html +330 -0
  5. package/dist/city/echo-face-cutout.png +0 -0
  6. package/dist/city/personality_stickers/bossy.png +0 -0
  7. package/dist/city/personality_stickers/ghosty.png +0 -0
  8. package/dist/city/personality_stickers/loopy.png +0 -0
  9. package/dist/city/personality_stickers/lusty.png +0 -0
  10. package/dist/city/personality_stickers/maxxy.png +0 -0
  11. package/dist/city/personality_stickers/tabby.png +0 -0
  12. package/dist/city/vendor/OrbitControls.js +1417 -0
  13. package/dist/city/vendor/RoundedBoxGeometry.js +155 -0
  14. package/dist/city/vendor/echo_general-file-21.riv +0 -0
  15. package/dist/city/vendor/rive.js +8139 -0
  16. package/dist/city/vendor/rive.wasm +0 -0
  17. package/dist/city/vendor/three.module.min.js +6 -0
  18. package/dist/context-analysis/claude-native-canonical.js +2 -2
  19. package/dist/context-analysis/vendored-canonical.js +2 -2
  20. package/dist/context-analysis/workspace-report.js +3 -3
  21. package/dist/forensics.js +1531 -0
  22. package/dist/hud/hooks.js +31 -43
  23. package/dist/index.js +79 -15
  24. package/dist/local-data-paths.js +38 -0
  25. package/dist/migrate.js +140 -70
  26. package/dist/report.js +721 -0
  27. package/dist/save-checkpoint-hook.js +1 -1
  28. package/dist/setup-page/client-core.js +372 -18
  29. package/dist/setup-page/client-extraction.js +204 -37
  30. package/dist/setup-page/client-lifecycle.js +101 -31
  31. package/dist/setup-page/client-report-audit.js +819 -0
  32. package/dist/setup-page/client-report-city.js +356 -0
  33. package/dist/setup-page/client-report.js +6 -0
  34. package/dist/setup-page/client.js +2 -0
  35. package/dist/setup-page/styles-city-report.js +880 -0
  36. package/dist/setup-page/styles-context-audit.js +470 -0
  37. package/dist/setup-page/styles-extraction.js +31 -1
  38. package/dist/setup-page/styles-foundation.js +89 -0
  39. package/dist/setup-page/styles-mvp.js +155 -10
  40. package/dist/setup-page/styles-website-alignment.js +204 -0
  41. package/dist/setup-page/styles.js +4 -0
  42. package/dist/setup-page.js +4 -4
  43. package/dist/setup-preview.js +212 -4
  44. package/dist/setup.js +702 -321
  45. package/dist/source-session.js +3 -9
  46. package/dist/v1-contract.js +8 -0
  47. package/package.json +7 -9
  48. package/dist/config-files.js +0 -63
  49. package/dist/local-jsonl.js +0 -87
  50. package/dist/onboarding-stats.js +0 -16
package/dist/report.js ADDED
@@ -0,0 +1,721 @@
1
+ /**
2
+ * `echomem-mcp report` — the orientation audit (deterministic, $0 LLM).
3
+ *
4
+ * Reads the user's LOCAL coding-agent transcripts (Codex rollout logs + Claude Code session files)
5
+ * and shows how rarely the agent RECALLS prior context versus re-gathering it from scratch — the
6
+ * thing EchoMem actually changes. No model is called; transcripts never leave the machine.
7
+ *
8
+ * Honesty notes (the team will sanity-check this — see the fresh-review findings):
9
+ * - HERO metric = recalls vs context-gathering tool calls. These are real tool invocations counted
10
+ * from the logs; this is the metric that maps to what EchoMem replaces (cross-session re-reading).
11
+ * - The token total is CUMULATIVE billing — every turn re-sends the growing context window, so it
12
+ * double-counts the conversation. We label it "processed across all turns" and explicitly say most
13
+ * of it is the conversation re-loading itself (intrinsic to agents), NOT something memory removes.
14
+ * We do NOT multiply that billing total by the experiment ratio (different unit).
15
+ * - The ~1.8× figure is an EARLY signal from a small controlled test (N=3, a stand-in agent), clearly
16
+ * labeled — not a measurement on this account.
17
+ */
18
+ import fs from "node:fs";
19
+ import path from "node:path";
20
+ import { StringDecoder } from "node:string_decoder";
21
+ import axios from "axios";
22
+ import { discoverCodexSessionFiles } from "./codex-session-files.js";
23
+ import { resolveClaudeProjectsDir } from "./local-data-paths.js";
24
+ import { KeyStore } from "./keystore.js";
25
+ const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
26
+ // Early signal from the controlled sub-agent experiment, NOT measured on this account.
27
+ // (docs/mcp-feature/LIVE-measured-orientation-experiment-2026-06-12.md — N=3 per arm, proxy agent.)
28
+ const PROJ_CONTEXT = "~1.8×";
29
+ const PROJ_SPEED = "~1.5×";
30
+ const PROJ_CORRECT = "0/3 → 3/3";
31
+ // ---------------------------------------------------------------------------
32
+ // Tool classification — only count true retrieval as "context-gathering", so the hero metric
33
+ // doesn't conflate edits/builds/tests with reading. Conservative: anything ambiguous → "act".
34
+ // ---------------------------------------------------------------------------
35
+ const CLAUDE_READ = new Set(["read", "grep", "glob", "ls", "notebookread", "webfetch", "websearch"]);
36
+ const SHELL_READ = new Set([
37
+ "cat", "head", "tail", "less", "more", "bat", "grep", "egrep", "fgrep", "rg", "ag", "ack",
38
+ "ls", "find", "fd", "tree", "wc", "stat", "file", "sed", "awk", "cut", "nl", "column",
39
+ "readlink", "realpath", "od", "strings",
40
+ ]);
41
+ const GIT_READ = new Set(["log", "show", "diff", "status", "blame", "ls-files", "ls-tree", "cat-file", "grep"]);
42
+ /**
43
+ * Classify a shell command as a read (retrieval) or an act. Splits on shell operators so chains like
44
+ * `cd src && cat x` and pipes like `cat x | grep y` are judged by their real commands; navigation
45
+ * (cd/pushd/popd) is ignored; a chain containing any non-read command is an act (conservative).
46
+ */
47
+ export function classifyShellCmd(cmd) {
48
+ const segments = String(cmd).split(/&&|\|\||[;|\n]/).map((s) => s.trim()).filter(Boolean);
49
+ let sawRead = false;
50
+ for (const seg of segments) {
51
+ const t = seg.split(/\s+/);
52
+ let i = 0;
53
+ while (i < t.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[i]))
54
+ i++; // skip FOO=bar env prefixes
55
+ let bin = (t[i] || "").split("/").pop() || "";
56
+ if (bin === "sudo") {
57
+ i++;
58
+ bin = (t[i] || "").split("/").pop() || "";
59
+ }
60
+ if (bin === "cd" || bin === "pushd" || bin === "popd")
61
+ continue; // navigation — ignore
62
+ if (bin === "git") {
63
+ let j = i + 1;
64
+ while (j < t.length && t[j].startsWith("-")) {
65
+ if (t[j] === "-C" || t[j] === "-c")
66
+ j++;
67
+ j++;
68
+ } // skip global flags (+arg)
69
+ if (GIT_READ.has(t[j] || "")) {
70
+ sawRead = true;
71
+ continue;
72
+ }
73
+ return "act";
74
+ }
75
+ if (SHELL_READ.has(bin)) {
76
+ sawRead = true;
77
+ continue;
78
+ }
79
+ return "act"; // a real non-read command → act
80
+ }
81
+ return sawRead ? "read" : "act";
82
+ }
83
+ /** Classify a Codex function_call (non-echomem) as read or act. */
84
+ function classifyCodexCall(name, argsRaw) {
85
+ const n = String(name || "").toLowerCase();
86
+ if (n.includes("github_fetch_file") || n.includes("github_search") || n === "read_thread_terminal" || n === "view_image")
87
+ return "read";
88
+ if (n === "exec_command" || n === "shell") {
89
+ try {
90
+ const cmd = JSON.parse(typeof argsRaw === "string" ? argsRaw : "{}").cmd;
91
+ if (typeof cmd === "string")
92
+ return classifyShellCmd(cmd);
93
+ }
94
+ catch {
95
+ /* fall through */
96
+ }
97
+ return "act";
98
+ }
99
+ return "act";
100
+ }
101
+ /** Classify a Claude tool_use (non-echomem) as read or act. */
102
+ function classifyClaudeTool(name) {
103
+ const base = String(name || "").toLowerCase().replace(/^mcp__.+?__/, "");
104
+ return CLAUDE_READ.has(base) ? "read" : "act";
105
+ }
106
+ function normalizeCwd(cwd) {
107
+ if (!cwd)
108
+ return null;
109
+ const m = cwd.match(/worktrees\/[^/]+\/(.+)$/);
110
+ return m ? m[1] : cwd;
111
+ }
112
+ function basenameProject(cwd) {
113
+ if (!cwd)
114
+ return null;
115
+ const name = path.basename(cwd);
116
+ return name && name !== "." && name !== "/" ? name : null;
117
+ }
118
+ function epochMs(ts) {
119
+ if (!ts)
120
+ return null;
121
+ const n = Date.parse(ts);
122
+ return Number.isFinite(n) ? n : null;
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
+ }
132
+ function durationBetween(startTs, lastTs) {
133
+ const start = epochMs(startTs);
134
+ const last = epochMs(lastTs);
135
+ if (start == null || last == null)
136
+ return 0;
137
+ return Math.max(0, Math.round((last - start) / 1000));
138
+ }
139
+ // ---------------------------------------------------------------------------
140
+ // Parsing
141
+ // ---------------------------------------------------------------------------
142
+ /**
143
+ * Stream a .jsonl file line-by-line, decoding in 1 MB chunks. Streaming (rather than
144
+ * readFileSync().split()) avoids both loading a multi-hundred-MB session into one string and the
145
+ * V8 ~512 MB max-string cliff that would silently drop a large session. Never throws.
146
+ */
147
+ export function eachLine(file, fn) {
148
+ let fd;
149
+ try {
150
+ fd = fs.openSync(file, "r");
151
+ }
152
+ catch {
153
+ return;
154
+ }
155
+ try {
156
+ const decoder = new StringDecoder("utf8");
157
+ const buf = Buffer.allocUnsafe(1 << 20);
158
+ let leftover = "";
159
+ let bytes;
160
+ const flush = (chunk) => {
161
+ const lines = (leftover + chunk).split("\n");
162
+ leftover = lines.pop() ?? "";
163
+ for (const line of lines) {
164
+ const s = line.trim();
165
+ if (!s)
166
+ continue;
167
+ try {
168
+ fn(JSON.parse(s));
169
+ }
170
+ catch {
171
+ /* skip malformed line */
172
+ }
173
+ }
174
+ };
175
+ while ((bytes = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
176
+ flush(decoder.write(buf.subarray(0, bytes)));
177
+ }
178
+ const last = (leftover + decoder.end()).trim();
179
+ if (last) {
180
+ try {
181
+ fn(JSON.parse(last));
182
+ }
183
+ catch {
184
+ /* skip */
185
+ }
186
+ }
187
+ }
188
+ finally {
189
+ try {
190
+ fs.closeSync(fd);
191
+ }
192
+ catch {
193
+ /* best effort */
194
+ }
195
+ }
196
+ }
197
+ /** Codex rollout: token_count is CUMULATIVE (take the last); function_call is per tool invocation. */
198
+ export function parseCodex(file) {
199
+ let tot = null;
200
+ const meta = { first: null, last: null, cwd: null };
201
+ let echo = 0;
202
+ let reads = 0;
203
+ let acts = 0;
204
+ eachLine(file, (o) => {
205
+ if (typeof o.timestamp === "string") {
206
+ const ms = epochMs(o.timestamp);
207
+ if (ms != null && (meta.first === null || ms < (epochMs(meta.first) ?? Number.POSITIVE_INFINITY)))
208
+ meta.first = o.timestamp;
209
+ if (ms != null && (meta.last === null || ms > (epochMs(meta.last) ?? Number.NEGATIVE_INFINITY)))
210
+ meta.last = o.timestamp;
211
+ }
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
+ }
221
+ return;
222
+ }
223
+ const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
224
+ if (!p || typeof p !== "object")
225
+ return;
226
+ if (p.type === "token_count") {
227
+ const u = p.info && p.info.total_token_usage;
228
+ if (u)
229
+ tot = u;
230
+ }
231
+ else if (p.type === "function_call") {
232
+ const ns = (String(p.namespace || "") + String(p.name || "")).toLowerCase();
233
+ if (ns.includes("echomem"))
234
+ echo++;
235
+ else if (classifyCodexCall(p.name, p.arguments) === "read")
236
+ reads++;
237
+ else
238
+ acts++;
239
+ }
240
+ });
241
+ if (!tot || !(tot.total_tokens > 0))
242
+ return null;
243
+ return {
244
+ source: "codex",
245
+ date: localDate(meta.first),
246
+ month: localDate(meta.first)?.slice(0, 7) ?? null,
247
+ startTs: meta.first,
248
+ lastTs: meta.last,
249
+ durationSec: durationBetween(meta.first, meta.last),
250
+ cwd: normalizeCwd(meta.cwd),
251
+ total: tot.total_tokens || 0,
252
+ cached: tot.cached_input_tokens || 0,
253
+ output: tot.output_tokens || 0,
254
+ toolsRead: reads,
255
+ toolsAct: acts,
256
+ toolsEcho: echo,
257
+ };
258
+ }
259
+ /** Claude Code: usage is PER assistant turn (SUM); tool_use blocks live in message.content. */
260
+ export function parseClaude(file) {
261
+ const usageByRequest = new Map();
262
+ const seenToolUses = new Set();
263
+ let anonymousRequestSeq = 0;
264
+ let echo = 0;
265
+ let reads = 0;
266
+ let acts = 0;
267
+ const meta = {
268
+ first: null,
269
+ last: null,
270
+ cwd: null,
271
+ durationSec: 0,
272
+ prevMs: null,
273
+ };
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
+ };
284
+ eachLine(file, (o) => {
285
+ if (!meta.cwd && typeof o.cwd === "string")
286
+ meta.cwd = o.cwd;
287
+ if (typeof o.timestamp === "string") {
288
+ const ms = epochMs(o.timestamp);
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;
294
+ if (meta.prevMs != null) {
295
+ meta.durationSec += Math.min(300, Math.max(0, Math.round((ms - meta.prevMs) / 1000)));
296
+ }
297
+ meta.prevMs = ms;
298
+ }
299
+ }
300
+ if (o.type !== "assistant" || !o.message)
301
+ return;
302
+ const key = requestKey(o);
303
+ const u = o.message.usage;
304
+ if (u) {
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
+ }
320
+ }
321
+ const content = o.message.content;
322
+ if (Array.isArray(content)) {
323
+ for (let index = 0; index < content.length; index += 1) {
324
+ const b = content[index];
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);
330
+ if (String(b.name || "").toLowerCase().includes("echomem"))
331
+ echo++;
332
+ else if (classifyClaudeTool(b.name) === "read")
333
+ reads++;
334
+ else
335
+ acts++;
336
+ }
337
+ }
338
+ }
339
+ });
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)
345
+ return null;
346
+ return {
347
+ source: "claude-code",
348
+ date: localDate(meta.first),
349
+ month: localDate(meta.first)?.slice(0, 7) ?? null,
350
+ startTs: meta.first,
351
+ lastTs: meta.last,
352
+ durationSec: meta.durationSec,
353
+ cwd: normalizeCwd(meta.cwd),
354
+ total,
355
+ cached,
356
+ output,
357
+ toolsRead: reads,
358
+ toolsAct: acts,
359
+ toolsEcho: echo,
360
+ };
361
+ }
362
+ export function walk(dir, match, skipDir, out = []) {
363
+ let entries;
364
+ try {
365
+ entries = fs.readdirSync(dir, { withFileTypes: true });
366
+ }
367
+ catch {
368
+ return out;
369
+ }
370
+ for (const e of entries) {
371
+ const full = path.join(dir, e.name);
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()) {
384
+ if (!skipDir(e.name))
385
+ walk(full, match, skipDir, out);
386
+ }
387
+ else if (stat.isFile() && match(full)) {
388
+ out.push(full);
389
+ }
390
+ }
391
+ return out;
392
+ }
393
+ export function collect() {
394
+ const stats = [];
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
+ }
402
+ }
403
+ // Claude Code: top-level session files only (skip subagent/workflow dirs to avoid double-counting).
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
+ }
411
+ }
412
+ return stats;
413
+ }
414
+ // ---------------------------------------------------------------------------
415
+ // Aggregation (pure — unit-tested in test/report.test.mjs)
416
+ // ---------------------------------------------------------------------------
417
+ function median(nums) {
418
+ if (!nums.length)
419
+ return 0;
420
+ const s = [...nums].sort((a, b) => a - b);
421
+ return s[Math.floor(s.length / 2)];
422
+ }
423
+ export function aggregate(stats) {
424
+ const total = stats.reduce((n, s) => n + s.total, 0);
425
+ const cached = stats.reduce((n, s) => n + s.cached, 0);
426
+ const output = stats.reduce((n, s) => n + s.output, 0);
427
+ const reads = stats.reduce((n, s) => n + s.toolsRead, 0);
428
+ const acts = stats.reduce((n, s) => n + s.toolsAct, 0);
429
+ const recalls = stats.reduce((n, s) => n + s.toolsEcho, 0);
430
+ const toolTotal = reads + acts + recalls;
431
+ const fresh = total - cached;
432
+ const noncached = Math.max(0, total - cached - output);
433
+ const dates = stats.map((s) => s.date).filter(Boolean).sort();
434
+ const lastDates = stats.map((s) => localDate(s.lastTs)).filter(Boolean).sort();
435
+ const byMonth = new Map();
436
+ for (const s of stats) {
437
+ if (!s.month)
438
+ continue;
439
+ const m = byMonth.get(s.month) || { t: 0, c: 0, n: 0 };
440
+ m.t += s.total;
441
+ m.c += s.cached;
442
+ m.n += 1;
443
+ byMonth.set(s.month, m);
444
+ }
445
+ const months = [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([month, d]) => ({
446
+ month, tokens: d.t, sessions: d.n, rereadPct: d.t ? Math.round((d.c / d.t) * 100) : 0,
447
+ }));
448
+ const byDay = new Map();
449
+ for (const d of dates)
450
+ byDay.set(d, (byDay.get(d) || 0) + 1);
451
+ const busiestDayEntry = [...byDay.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0];
452
+ const hourHistogram = Array.from({ length: 24 }, () => 0);
453
+ for (const s of stats) {
454
+ const ms = epochMs(s.startTs);
455
+ if (ms == null)
456
+ continue;
457
+ const hour = new Date(ms).getHours();
458
+ if (hour >= 0 && hour < 24)
459
+ hourHistogram[hour] += 1;
460
+ }
461
+ const hourMax = Math.max(...hourHistogram);
462
+ const hourLocal = hourMax > 0 ? hourHistogram.findIndex((n) => n === hourMax) : null;
463
+ const approxHours = stats.reduce((n, s) => n + s.durationSec, 0) / 3600;
464
+ const longestSessionMin = Math.max(0, ...stats.map((s) => s.durationSec)) / 60;
465
+ const byProject = new Map();
466
+ for (const s of stats) {
467
+ const name = basenameProject(s.cwd);
468
+ if (!name)
469
+ continue;
470
+ byProject.set(name, (byProject.get(name) || 0) + 1);
471
+ }
472
+ const topProjects = [...byProject.entries()]
473
+ .sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
474
+ .slice(0, 10)
475
+ .map(([name, sessions]) => ({ name, sessions }));
476
+ const totals = stats.map((s) => s.total).sort((a, b) => b - a);
477
+ const topN = Math.max(1, Math.floor(totals.length * 0.1));
478
+ const topShare = total ? Math.round((totals.slice(0, topN).reduce((n, x) => n + x, 0) / total) * 100) : 0;
479
+ // Rough $ — cache priced CHEAP at both ends (~0.1–0.5×); never bill cache at the full input rate.
480
+ const costLow = noncached / 1e6 * 1.25 + cached / 1e6 * 0.1 + output / 1e6 * 10;
481
+ const costHigh = noncached / 1e6 * 5 + cached / 1e6 * 0.5 + output / 1e6 * 15;
482
+ return {
483
+ sessions: stats.length,
484
+ codexN: stats.filter((s) => s.source === "codex").length,
485
+ claudeN: stats.filter((s) => s.source === "claude-code").length,
486
+ total, cached, output, fresh, noncached, reads, acts, recalls, toolTotal,
487
+ rereadPct: total ? Math.round((cached / total) * 100) : 0,
488
+ ratio: recalls > 0 ? Math.round(reads / recalls) : null,
489
+ first: dates[0] || null,
490
+ last: lastDates[lastDates.length - 1] || dates[dates.length - 1] || null,
491
+ days: new Set(dates).size,
492
+ months,
493
+ busiestDay: { date: busiestDayEntry?.[0] || null, sessions: busiestDayEntry?.[1] || 0 },
494
+ hourHistogram,
495
+ hourLocal,
496
+ approxHours,
497
+ longestSessionMin,
498
+ topProjects,
499
+ topShare,
500
+ medianSession: median(stats.map((s) => s.total)),
501
+ costLow, costHigh,
502
+ };
503
+ }
504
+ // ---------------------------------------------------------------------------
505
+ // Rendering
506
+ // ---------------------------------------------------------------------------
507
+ function makeColor(enabled) {
508
+ const w = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s));
509
+ return { bold: w("1"), dim: w("2"), red: w("31"), green: w("32"), blue: w("34"), magenta: w("35"), cyan: w("36") };
510
+ }
511
+ function human(n) {
512
+ const a = Math.abs(n);
513
+ if (a >= 1e9)
514
+ return (n / 1e9).toFixed(2) + "B";
515
+ if (a >= 1e6)
516
+ return (n / 1e6).toFixed(2) + "M";
517
+ if (a >= 1e3)
518
+ return (n / 1e3).toFixed(1) + "K";
519
+ return String(Math.round(n));
520
+ }
521
+ /**
522
+ * How many memories the EchoMem platform has captured for this account. Best-effort: needs a stored
523
+ * token; returns null otherwise. Reads the FEED route on purpose — `time-range`/`graph` still filter
524
+ * out encrypted memories (a deployed bug), but the feed route has no such filter. Never throws.
525
+ */
526
+ async function fetchMemoryCount() {
527
+ const token = new KeyStore().getToken();
528
+ if (!token)
529
+ return null;
530
+ try {
531
+ const res = await axios.get(`${API_BASE}/api/extension/memories`, {
532
+ headers: { Authorization: `Bearer ${token}` },
533
+ timeout: 5000,
534
+ });
535
+ const count = res.data?.count;
536
+ return typeof count === "number" ? count : null;
537
+ }
538
+ catch {
539
+ return null;
540
+ }
541
+ }
542
+ function renderJson(a, memCount) {
543
+ return JSON.stringify({
544
+ generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
545
+ llmCallsUsed: 0,
546
+ transcriptsUploaded: false,
547
+ window: { first: a.first, last: a.last, activeDays: a.days },
548
+ sessions: { total: a.sessions, codex: a.codexN, claudeCode: a.claudeN },
549
+ contextGathering: { reads: a.reads, memoryRecalls: a.recalls, readsPerRecall: a.ratio, otherActions: a.acts, note: "reads = file reads + searches only; edits/writes/builds/tests excluded (memory doesn't replace them)" },
550
+ memoriesCaptured: memCount,
551
+ tokensProcessedCumulative: { total: a.total, cacheReadShare: a.rereadPct, fresh: a.fresh, output: a.output, note: "cumulative across turns; most cache-read is the conversation re-loading itself, not EchoMem-addressable" },
552
+ concentration: { topTenPctShare: a.topShare, medianSession: a.medianSession },
553
+ months: a.months,
554
+ earlySignal: { source: "controlled test, N=3 per arm, proxy agent", context: PROJ_CONTEXT, speed: PROJ_SPEED, correctness: PROJ_CORRECT, measuredOnThisAccount: false },
555
+ estSpendUsdRough: { low: Math.round(a.costLow), high: Math.round(a.costHigh), note: "cache priced at ~0.1-0.5x; rough" },
556
+ }, null, 2);
557
+ }
558
+ function round1(n) {
559
+ return Math.round(n * 10) / 10;
560
+ }
561
+ /** Build the localhost onboarding dashboard payload. This is intentionally separate from report --json. */
562
+ export async function buildStatsPayload(stats, inject) {
563
+ const a = aggregate(stats);
564
+ const memCount = inject?.skipMemoryCount
565
+ ? null
566
+ : Object.prototype.hasOwnProperty.call(inject || {}, "memoriesCaptured")
567
+ ? inject?.memoriesCaptured ?? null
568
+ : await fetchMemoryCount();
569
+ const busiestMonth = [...a.months].sort((x, y) => y.sessions - x.sessions || x.month.localeCompare(y.month))[0];
570
+ const sessionCounts = inject?.sessions ?? { total: a.sessions, codex: a.codexN, claudeCode: a.claudeN };
571
+ return {
572
+ schemaVersion: 1,
573
+ ...(inject?.partial ? { partial: true } : {}),
574
+ ...(inject?.discovery ? { discovery: inject.discovery } : {}),
575
+ generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
576
+ llmCallsUsed: 0,
577
+ transcriptsUploaded: false,
578
+ window: { first: a.first, last: a.last, activeDays: a.days },
579
+ sessions: sessionCounts,
580
+ migratable: {
581
+ pending: inject?.migratable?.pending ?? 0,
582
+ alreadyMigrated: inject?.migratable?.alreadyMigrated ?? 0,
583
+ ...inject?.migratable,
584
+ },
585
+ busiest: {
586
+ month: { month: busiestMonth?.month || null, sessions: busiestMonth?.sessions || 0 },
587
+ day: a.busiestDay,
588
+ hourLocal: a.hourLocal,
589
+ hourHistogram: a.hourHistogram,
590
+ },
591
+ time: { approxHours: round1(a.approxHours), approx: true, longestSessionMin: Math.round(a.longestSessionMin) },
592
+ contextGathering: {
593
+ reads: a.reads,
594
+ memoryRecalls: a.recalls,
595
+ readsPerRecall: a.ratio,
596
+ otherActions: a.acts,
597
+ note: "reads = file reads + searches only; edits/writes/builds/tests excluded (memory doesn't replace them)",
598
+ },
599
+ tokens: {
600
+ totalCumulative: a.total,
601
+ rereadPctOfTokens: a.rereadPct,
602
+ fresh: a.fresh,
603
+ output: a.output,
604
+ note: "cumulative across turns; re-read % is a TOKEN share (context re-sent each turn), NOT time",
605
+ },
606
+ concentration: { topTenPctShare: a.topShare, medianSession: a.medianSession },
607
+ topProjects: a.topProjects,
608
+ memoriesCaptured: memCount,
609
+ };
610
+ }
611
+ /** Render the report to a string. `useColor=false` for the MCP tool (clean text); true for the terminal. */
612
+ function renderText(a, memCount, useColor) {
613
+ const c = makeColor(useColor);
614
+ const out = [];
615
+ const L = (s = "") => out.push(" " + s);
616
+ const NL = () => out.push("");
617
+ const sources = [a.codexN ? `${a.codexN} Codex` : "", a.claudeN ? `${a.claudeN} Claude Code` : ""].filter(Boolean).join(" + ");
618
+ NL();
619
+ L(c.bold(c.magenta("EchoMem · your AI coding memory audit")));
620
+ L(c.dim(`${sources} sessions · ${a.first} → ${a.last} · ${a.days} active days`));
621
+ L(c.dim(`computed locally from your own logs — transcripts never leave your machine`));
622
+ NL();
623
+ // ---- HERO: reads/searches (what recall replaces) vs recalls — edits/builds excluded ----
624
+ L(c.bold("THE HEADLINE"));
625
+ L(`Across ${c.bold(a.sessions + " sessions")}, your agents read or searched your code`);
626
+ L(`${c.bold(a.reads.toLocaleString())} times to rebuild context — and recalled it from memory ${c.bold(c.red(String(a.recalls)))} ${a.recalls === 1 ? "time" : "times"}.`);
627
+ NL();
628
+ const rw = 40;
629
+ L(`reads & searches ${c.red("█".repeat(rw))} ${c.bold(a.reads.toLocaleString())}`);
630
+ const recallBar = a.recalls > 0 ? "█".repeat(Math.max(1, Math.round((a.recalls / Math.max(a.reads, 1)) * rw))) : "·";
631
+ L(`memory recalls ${c.green(recallBar)} ${c.bold(String(a.recalls))}`);
632
+ if (a.ratio)
633
+ L(c.dim(`≈ ${a.ratio.toLocaleString()} : 1 — the agent re-reads instead of remembering. EchoMem flips this.`));
634
+ else
635
+ L(c.dim(`the agent rebuilds context from scratch every time. EchoMem turns that into recall.`));
636
+ L(c.dim(`(plus ${a.acts.toLocaleString()} edits, builds & other actions — memory doesn't change those.)`));
637
+ NL();
638
+ // ---- memories / zero-state ----
639
+ if (memCount && memCount > 0) {
640
+ const word = memCount === 1 ? "memory" : "memories";
641
+ L(`You've started: EchoMem holds ${c.bold(c.cyan(memCount + " " + word))}, so every ${c.bold("new")} session can`);
642
+ L(`now recall instead of re-gathering. (The history above predates them.)`);
643
+ }
644
+ else {
645
+ L(`And ${c.bold(c.red("none of it was kept"))} — ${c.bold(a.sessions + " sessions")}, ${c.bold("0 memories")} saved for the next one.`);
646
+ L(c.dim(`Each session started cold and re-gathered everything from scratch.`));
647
+ }
648
+ NL();
649
+ // ---- SCALE (token total — honestly labeled, with the within-session caveat stated up front) ----
650
+ L(c.bold("THE SCALE OF IT") + c.dim(" (tokens processed across all turns)"));
651
+ L(`Your agents processed ${c.bold(human(a.total))} tokens; ${c.bold(a.rereadPct + "%")} was context re-sent every turn.`);
652
+ L(c.dim(`Most of that is the conversation re-loading itself — how agents bill, not something`));
653
+ L(c.dim(`memory removes. What memory removes is the re-gathering and cold starts above.`));
654
+ NL();
655
+ if (a.months.length > 1) {
656
+ L(c.bold("BY MONTH") + c.dim(" (tokens processed)"));
657
+ const maxT = Math.max(...a.months.map((m) => m.tokens));
658
+ for (const m of a.months) {
659
+ const w = Math.max(1, Math.round((m.tokens / maxT) * 24));
660
+ L(`${m.month} ${c.blue("█".repeat(w))}${c.dim("░".repeat(24 - w))} ${human(m.tokens).padStart(8)} ${c.dim(m.sessions + " sess")}`);
661
+ }
662
+ NL();
663
+ }
664
+ // ---- WHAT CHANGES (plain language — the things a dev feels) ----
665
+ L(c.bold(c.green("WHAT CHANGES WHEN YOU CONNECT ECHOMEM")));
666
+ NL();
667
+ const pair = (bad, good) => {
668
+ L(c.red("✗ ") + c.dim("today: ") + c.dim(bad));
669
+ L(c.green("✓ ") + good);
670
+ NL();
671
+ };
672
+ pair("the agent re-reads your codebase every session just to catch up", "it recalls what it already learned — no re-gathering from scratch");
673
+ pair("it starts cold, guessing your setup from old files and stale docs", "it picks up from your latest decisions and where you actually left off");
674
+ if (memCount && memCount > 0)
675
+ pair("context is thrown away when the session ends", `it keeps compounding — every session adds to your ${c.bold(String(memCount))} memories`);
676
+ else
677
+ pair("nothing is saved — every session vanishes when it ends", "every session starts building a memory that compounds over time");
678
+ L(c.dim(`Early signal: in a small controlled test (3 runs, a stand-in agent), giving the agent`));
679
+ L(c.dim(`memory cut the context it needed to orient by ${PROJ_CONTEXT} and stopped it acting on stale code.`));
680
+ L(c.dim(`That's a directional test, not a measurement on your account — connect to get your real number.`));
681
+ NL();
682
+ // ---- CTA ----
683
+ if (memCount && memCount > 0) {
684
+ L(c.green(`✓ You're connected — every new session now builds on your ${memCount} memories.`));
685
+ }
686
+ else {
687
+ L(c.green("→ Start now (no signup wall): ") + c.bold("npm i -g @echomem/mcp@latest && echomem-mcp init"));
688
+ L(c.dim(` ~1 minute. Your next coding session recalls instead of re-reading.`));
689
+ }
690
+ NL();
691
+ // ---- trust line ----
692
+ L(c.dim("─".repeat(62)));
693
+ L(`${c.bold(c.green("$0"))} to produce this — ${c.bold("no AI model was called")}.`);
694
+ L(c.dim(`Transcripts never leave your machine${memCount !== null ? " (only your memory count was fetched)" : ""}. Re-run anytime.`));
695
+ NL();
696
+ return out.join("\n");
697
+ }
698
+ const NO_HISTORY = "EchoMem report: no coding-agent history found yet.\n" +
699
+ "Looked in ~/.codex/sessions and ~/.claude/projects. Use a coding agent, then run `echomem-mcp report`.";
700
+ /** Build the report as a plain string — used by the MCP `usage_report` tool (no color) and `setup`. */
701
+ export async function buildReportText(useColor = false) {
702
+ const stats = collect();
703
+ if (stats.length === 0)
704
+ return NO_HISTORY;
705
+ return renderText(aggregate(stats), await fetchMemoryCount(), useColor);
706
+ }
707
+ export async function runReport(flags) {
708
+ const stats = collect();
709
+ if (stats.length === 0) {
710
+ console.log(NO_HISTORY);
711
+ return;
712
+ }
713
+ const a = aggregate(stats);
714
+ const memCount = await fetchMemoryCount();
715
+ if (flags.json === true) {
716
+ console.log(renderJson(a, memCount));
717
+ return;
718
+ }
719
+ const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true;
720
+ console.log(renderText(a, memCount, useColor));
721
+ }