@echomem/mcp 1.0.3 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/events.js ADDED
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Local, privacy-preserving telemetry for the MCP bridge — the "what EchoMem did" half of the
3
+ * with/without comparison dataset (the token/time half lives in the agent's own transcript files).
4
+ *
5
+ * Design goals (see docs/mcp-feature/mcp-telemetry-spec-2026-06-15.md):
6
+ * - Record one structured event per tool call: which tool fired, what it returned, which memories,
7
+ * latency, outcome. This is the data the agent transcript does NOT cleanly expose.
8
+ * - NEVER store raw conversation/query text — only a length + a salted-free sha256 prefix so we can
9
+ * detect repeats without keeping content. Privacy is a feature (mirrors Pieces' local-first posture).
10
+ * - Append-only JSONL at <config-dir>/events.jsonl, alongside credentials. Local only — never sent
11
+ * over the network by the bridge. A future opt-in syncs *aggregates* (not raw events) for the web report.
12
+ * - Best-effort: recording must never throw, never block, never change tool behavior.
13
+ *
14
+ * Opt out with ECHO_TELEMETRY=0 (or ECHO_DISABLE_TELEMETRY=1).
15
+ */
16
+ import fs from "node:fs";
17
+ import path from "node:path";
18
+ import { createHash } from "node:crypto";
19
+ import { echoConfigDir } from "./keystore.js";
20
+ export const TELEMETRY_SCHEMA_VERSION = 1;
21
+ /** sha256 prefix of free text — lets us count repeats of the same query without storing the query. */
22
+ export function hashText(text) {
23
+ return createHash("sha256").update(text).digest("hex").slice(0, 12);
24
+ }
25
+ export class EventLogger {
26
+ base;
27
+ file;
28
+ enabled;
29
+ client = "unknown";
30
+ constructor(base) {
31
+ this.base = base;
32
+ this.enabled = !(process.env.ECHO_TELEMETRY === "0" || process.env.ECHO_DISABLE_TELEMETRY);
33
+ this.file = path.join(echoConfigDir(), "events.jsonl");
34
+ }
35
+ /** Set the detected MCP client once known (after the initialize handshake). */
36
+ setClient(name) {
37
+ if (name)
38
+ this.client = name;
39
+ }
40
+ /** Append one event. Best-effort: swallows every error so telemetry can never break a tool call. */
41
+ record(partial) {
42
+ if (!this.enabled)
43
+ return;
44
+ try {
45
+ const event = {
46
+ v: TELEMETRY_SCHEMA_VERSION,
47
+ ts: new Date().toISOString(),
48
+ session_id: this.base.session_id,
49
+ client: this.client,
50
+ app_version: this.base.app_version,
51
+ ...partial,
52
+ };
53
+ fs.mkdirSync(path.dirname(this.file), { recursive: true, mode: 0o700 });
54
+ fs.appendFileSync(this.file, JSON.stringify(event) + "\n");
55
+ }
56
+ catch {
57
+ /* telemetry is best-effort; never surface */
58
+ }
59
+ }
60
+ }
package/dist/index.js CHANGED
@@ -6,6 +6,8 @@ import axios from "axios";
6
6
  import { ZodError } from "zod";
7
7
  import { canonicalToolNames, keywordsSchema, listToolSpecs, othersSchema, resolveCanonicalToolName, saveConversationSchema, searchMemoriesSchema, timeRangeSchema, } from "./v1-contract.js";
8
8
  import { KeyStore } from "./keystore.js";
9
+ import { EventLogger, hashText } from "./events.js";
10
+ import { buildReportText } from "./report.js";
9
11
  import { randomUUID } from "node:crypto";
10
12
  import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
11
13
  import { runCli } from "./setup.js";
@@ -76,6 +78,16 @@ function describeError(error) {
76
78
  const fallback = stringifyErrorValue(error);
77
79
  return fallback || "Unknown error";
78
80
  }
81
+ /** Map a thrown error to the telemetry error_kind taxonomy. */
82
+ function classifyError(error) {
83
+ if (error instanceof NoTokenError)
84
+ return "no_token";
85
+ if (error instanceof LockedError)
86
+ return "locked";
87
+ if (error instanceof ZodError)
88
+ return "invalid_args";
89
+ return "api_error";
90
+ }
79
91
  class EchoMemApiClient {
80
92
  store;
81
93
  axios;
@@ -104,6 +116,10 @@ class EchoMemApiClient {
104
116
  hasToken() {
105
117
  return !!this.store.getToken();
106
118
  }
119
+ /** The per-process session id — the join key telemetry shares with grouped saves. */
120
+ getSessionId() {
121
+ return this.sessionId;
122
+ }
107
123
  /**
108
124
  * Compact topic map of the user's memory for the search-tool description — a cheap "what's in here"
109
125
  * index built from memory keys so the agent knows the boundary up front and recalls proactively.
@@ -142,13 +158,18 @@ class EchoMemApiClient {
142
158
  * handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
143
159
  */
144
160
  async encState() {
145
- const cfg = await this.getEncryptionConfig();
146
- if (!cfg.enabled)
147
- return { enabled: false };
161
+ // A stored key ONLY exists because the user unlocked an ENCRYPTED vault, so treat it as
162
+ // authoritative: a flaky/failed `/account/encryption` fetch (which defaults to enabled:false) must
163
+ // NOT downgrade an unlocked encrypted account to plaintext — that causes a 422 on save and leaks
164
+ // ciphertext on read. So if we hold a usable key, we're encrypted-and-unlocked, period.
148
165
  const key = this.store.getKey();
149
- if (!key)
166
+ if (key)
167
+ return { enabled: true, key };
168
+ // No usable key: consult the server to tell "unencrypted" apart from "encrypted but locked/expired".
169
+ const cfg = await this.getEncryptionConfig();
170
+ if (cfg.enabled)
150
171
  throw new LockedError(this.store.isKeyExpired());
151
- return { enabled: true, key };
172
+ return { enabled: false };
152
173
  }
153
174
  async whoami() {
154
175
  if (!this.whoamiCache) {
@@ -309,20 +330,25 @@ class EchoMemApiClient {
309
330
  }
310
331
  }
311
332
  }
333
+ const SERVER_VERSION = "1.1.0";
312
334
  class EchoMemMCPServer {
313
335
  server;
314
336
  client;
315
337
  mapCache = null;
338
+ events;
339
+ /** Whether the most recent ListTools response carried the memory map (per-session recall signal). */
340
+ mapInjected = false;
316
341
  constructor(store) {
317
342
  this.server = new Server({
318
343
  name: "echomem-mcp",
319
- version: "1.1.0",
344
+ version: SERVER_VERSION,
320
345
  }, {
321
346
  capabilities: {
322
347
  tools: {},
323
348
  },
324
349
  });
325
350
  this.client = new EchoMemApiClient(store);
351
+ this.events = new EventLogger({ session_id: this.client.getSessionId(), app_version: SERVER_VERSION });
326
352
  this.setupToolHandlers();
327
353
  this.server.onerror = (error) => console.error("[MCP Error]", error);
328
354
  process.on("SIGINT", async () => {
@@ -332,23 +358,36 @@ class EchoMemMCPServer {
332
358
  }
333
359
  setupToolHandlers() {
334
360
  this.server.setRequestHandler(ListToolsRequestSchema, async () => {
361
+ this.events.setClient(this.server.getClientVersion()?.name);
335
362
  // Inject a compact topic map of the user's memory into the search-tool description so the agent
336
363
  // knows the boundary up front and recalls proactively (cached; best-effort — no map on failure).
337
364
  if (this.client.hasToken() && !this.mapCache)
338
365
  this.mapCache = this.client.fetchMemoryMap();
339
366
  const map = this.mapCache ? await this.mapCache : undefined;
367
+ this.mapInjected = !!map;
340
368
  return { tools: listToolSpecs({ map }) };
341
369
  });
342
370
  this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
343
371
  const canonicalName = resolveCanonicalToolName(request.params.name);
372
+ const t0 = Date.now();
373
+ // One event per call. Handlers enrich `rec` with tool-specific detail; we finalize + log in `finally`.
374
+ const rec = {
375
+ type: "tool_call",
376
+ tool: canonicalName,
377
+ map_injected: this.mapInjected,
378
+ };
344
379
  try {
380
+ // The usage report is a local, $0 audit — works with no login (value before signup).
381
+ if (canonicalName === canonicalToolNames.report) {
382
+ return { content: [{ type: "text", text: await buildReportText(false) }] };
383
+ }
345
384
  if (!this.client.hasToken())
346
385
  throw new NoTokenError();
347
386
  switch (canonicalName) {
348
387
  case canonicalToolNames.search:
349
- return await this.handleSearch(request.params.arguments);
388
+ return await this.handleSearch(request.params.arguments, rec);
350
389
  case canonicalToolNames.save:
351
- return await this.handleSave(request.params.arguments);
390
+ return await this.handleSave(request.params.arguments, rec);
352
391
  case canonicalToolNames.timeRange:
353
392
  return await this.handleTimeRange(request.params.arguments);
354
393
  case canonicalToolNames.keywords:
@@ -360,6 +399,7 @@ class EchoMemMCPServer {
360
399
  }
361
400
  }
362
401
  catch (error) {
402
+ rec.error_kind = classifyError(error);
363
403
  if (error instanceof NoTokenError) {
364
404
  // Not isError: a normal "not connected yet" state. Once `login` runs, the next call works.
365
405
  return {
@@ -395,10 +435,30 @@ class EchoMemMCPServer {
395
435
  isError: true,
396
436
  };
397
437
  }
438
+ finally {
439
+ if (!rec.error_kind)
440
+ rec.error_kind = "none";
441
+ rec.ok = rec.error_kind === "none";
442
+ rec.latency_ms = Date.now() - t0;
443
+ this.events.record(rec);
444
+ }
398
445
  });
399
446
  }
400
- async handleSearch(args) {
447
+ async handleSearch(args, rec) {
448
+ const query = typeof args?.query === "string" ? args.query.trim() : "";
449
+ if (rec && query) {
450
+ rec.query_len = query.length;
451
+ rec.query_hash = hashText(query);
452
+ }
401
453
  const result = await this.client.searchMemories(args);
454
+ if (rec) {
455
+ // "Which memories were used": topic keys + delivered context size, for the comparison dataset.
456
+ const mems = Array.isArray(result?.memories) ? result.memories : [];
457
+ rec.tuned = !!result?.tuned;
458
+ rec.results_count = mems.length;
459
+ rec.results_chars = mems.reduce((n, m) => n + String(m?.description ?? "").length, 0);
460
+ rec.memory_keys = mems.map((m) => String(m?.key ?? m?.keys ?? "").trim()).filter(Boolean).slice(0, 40);
461
+ }
402
462
  // Tuned two-phase path: synthesized brief + ranked source memories.
403
463
  if (result?.tuned) {
404
464
  const { answer, memories } = result;
@@ -429,10 +489,22 @@ Details: ${m.details || "N/A"}`)
429
489
  .join("\n\n");
430
490
  return { content: [{ type: "text", text: `Found ${memories.length} relevant memories:\n\n${formattedResults}` }] };
431
491
  }
432
- async handleSave(args) {
492
+ async handleSave(args, rec) {
493
+ if (rec) {
494
+ const a = args;
495
+ const text = typeof a?.conversation === "string"
496
+ ? a.conversation
497
+ : Array.isArray(a?.messages)
498
+ ? a.messages.map((m) => String(m?.content ?? "")).join("\n")
499
+ : "";
500
+ rec.conversation_chars = text.length;
501
+ rec.save_source = typeof a?.source === "string" ? a.source : "mcp_server";
502
+ }
433
503
  const { success, memoriesExtracted, error } = await this.client.saveConversation(args);
434
504
  if (!success)
435
505
  throw new Error(`EchoMem API Error: ${error}`);
506
+ if (rec)
507
+ rec.memories_extracted = typeof memoriesExtracted === "number" ? memoriesExtracted : undefined;
436
508
  return {
437
509
  content: [{ type: "text", text: `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.` }],
438
510
  };
@@ -515,6 +587,7 @@ Details: ${m.details || "N/A"}`)
515
587
  async run() {
516
588
  const transport = new StdioServerTransport();
517
589
  await this.server.connect(transport);
590
+ this.events.record({ type: "session_start" });
518
591
  console.error("EchoMem MCP server running on stdio");
519
592
  }
520
593
  }
package/dist/keystore.js CHANGED
@@ -17,9 +17,13 @@ import fs from "node:fs";
17
17
  import os from "node:os";
18
18
  import path from "node:path";
19
19
  export const DEFAULT_KEY_TTL_MS = 7 * 24 * 60 * 60 * 1000;
20
- function configDir() {
20
+ /** The bridge's local config dir (`~/.echomem` or `$ECHO_CONFIG_DIR`) — home for credentials + telemetry. */
21
+ export function echoConfigDir() {
21
22
  return process.env.ECHO_CONFIG_DIR || path.join(os.homedir(), ".echomem");
22
23
  }
24
+ function configDir() {
25
+ return echoConfigDir();
26
+ }
23
27
  function credentialsPath() {
24
28
  return path.join(configDir(), "credentials.json");
25
29
  }
package/dist/report.js ADDED
@@ -0,0 +1,513 @@
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 os from "node:os";
20
+ import path from "node:path";
21
+ import { StringDecoder } from "node:string_decoder";
22
+ import axios from "axios";
23
+ import { KeyStore } from "./keystore.js";
24
+ const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
25
+ // Early signal from the controlled sub-agent experiment, NOT measured on this account.
26
+ // (docs/mcp-feature/LIVE-measured-orientation-experiment-2026-06-12.md — N=3 per arm, proxy agent.)
27
+ const PROJ_CONTEXT = "~1.8×";
28
+ const PROJ_SPEED = "~1.5×";
29
+ const PROJ_CORRECT = "0/3 → 3/3";
30
+ // ---------------------------------------------------------------------------
31
+ // Tool classification — only count true retrieval as "context-gathering", so the hero metric
32
+ // doesn't conflate edits/builds/tests with reading. Conservative: anything ambiguous → "act".
33
+ // ---------------------------------------------------------------------------
34
+ const CLAUDE_READ = new Set(["read", "grep", "glob", "ls", "notebookread", "webfetch", "websearch"]);
35
+ const SHELL_READ = new Set([
36
+ "cat", "head", "tail", "less", "more", "bat", "grep", "egrep", "fgrep", "rg", "ag", "ack",
37
+ "ls", "find", "fd", "tree", "wc", "stat", "file", "sed", "awk", "cut", "nl", "column",
38
+ "readlink", "realpath", "od", "strings",
39
+ ]);
40
+ const GIT_READ = new Set(["log", "show", "diff", "status", "blame", "ls-files", "ls-tree", "cat-file", "grep"]);
41
+ /**
42
+ * Classify a shell command as a read (retrieval) or an act. Splits on shell operators so chains like
43
+ * `cd src && cat x` and pipes like `cat x | grep y` are judged by their real commands; navigation
44
+ * (cd/pushd/popd) is ignored; a chain containing any non-read command is an act (conservative).
45
+ */
46
+ export function classifyShellCmd(cmd) {
47
+ const segments = String(cmd).split(/&&|\|\||[;|\n]/).map((s) => s.trim()).filter(Boolean);
48
+ let sawRead = false;
49
+ for (const seg of segments) {
50
+ const t = seg.split(/\s+/);
51
+ let i = 0;
52
+ while (i < t.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(t[i]))
53
+ i++; // skip FOO=bar env prefixes
54
+ let bin = (t[i] || "").split("/").pop() || "";
55
+ if (bin === "sudo") {
56
+ i++;
57
+ bin = (t[i] || "").split("/").pop() || "";
58
+ }
59
+ if (bin === "cd" || bin === "pushd" || bin === "popd")
60
+ continue; // navigation — ignore
61
+ if (bin === "git") {
62
+ let j = i + 1;
63
+ while (j < t.length && t[j].startsWith("-")) {
64
+ if (t[j] === "-C" || t[j] === "-c")
65
+ j++;
66
+ j++;
67
+ } // skip global flags (+arg)
68
+ if (GIT_READ.has(t[j] || "")) {
69
+ sawRead = true;
70
+ continue;
71
+ }
72
+ return "act";
73
+ }
74
+ if (SHELL_READ.has(bin)) {
75
+ sawRead = true;
76
+ continue;
77
+ }
78
+ return "act"; // a real non-read command → act
79
+ }
80
+ return sawRead ? "read" : "act";
81
+ }
82
+ /** Classify a Codex function_call (non-echomem) as read or act. */
83
+ function classifyCodexCall(name, argsRaw) {
84
+ const n = String(name || "").toLowerCase();
85
+ if (n.includes("github_fetch_file") || n.includes("github_search") || n === "read_thread_terminal" || n === "view_image")
86
+ return "read";
87
+ if (n === "exec_command" || n === "shell") {
88
+ try {
89
+ const cmd = JSON.parse(typeof argsRaw === "string" ? argsRaw : "{}").cmd;
90
+ if (typeof cmd === "string")
91
+ return classifyShellCmd(cmd);
92
+ }
93
+ catch {
94
+ /* fall through */
95
+ }
96
+ return "act";
97
+ }
98
+ return "act";
99
+ }
100
+ /** Classify a Claude tool_use (non-echomem) as read or act. */
101
+ function classifyClaudeTool(name) {
102
+ const base = String(name || "").toLowerCase().replace(/^mcp__.+?__/, "");
103
+ return CLAUDE_READ.has(base) ? "read" : "act";
104
+ }
105
+ // ---------------------------------------------------------------------------
106
+ // Parsing
107
+ // ---------------------------------------------------------------------------
108
+ /**
109
+ * Stream a .jsonl file line-by-line, decoding in 1 MB chunks. Streaming (rather than
110
+ * readFileSync().split()) avoids both loading a multi-hundred-MB session into one string and the
111
+ * V8 ~512 MB max-string cliff that would silently drop a large session. Never throws.
112
+ */
113
+ function eachLine(file, fn) {
114
+ let fd;
115
+ try {
116
+ fd = fs.openSync(file, "r");
117
+ }
118
+ catch {
119
+ return;
120
+ }
121
+ try {
122
+ const decoder = new StringDecoder("utf8");
123
+ const buf = Buffer.allocUnsafe(1 << 20);
124
+ let leftover = "";
125
+ let bytes;
126
+ const flush = (chunk) => {
127
+ const lines = (leftover + chunk).split("\n");
128
+ leftover = lines.pop() ?? "";
129
+ for (const line of lines) {
130
+ const s = line.trim();
131
+ if (!s)
132
+ continue;
133
+ try {
134
+ fn(JSON.parse(s));
135
+ }
136
+ catch {
137
+ /* skip malformed line */
138
+ }
139
+ }
140
+ };
141
+ while ((bytes = fs.readSync(fd, buf, 0, buf.length, null)) > 0) {
142
+ flush(decoder.write(buf.subarray(0, bytes)));
143
+ }
144
+ const last = (leftover + decoder.end()).trim();
145
+ if (last) {
146
+ try {
147
+ fn(JSON.parse(last));
148
+ }
149
+ catch {
150
+ /* skip */
151
+ }
152
+ }
153
+ }
154
+ finally {
155
+ try {
156
+ fs.closeSync(fd);
157
+ }
158
+ catch {
159
+ /* best effort */
160
+ }
161
+ }
162
+ }
163
+ /** Codex rollout: token_count is CUMULATIVE (take the last); function_call is per tool invocation. */
164
+ export function parseCodex(file) {
165
+ let tot = null;
166
+ const meta = { first: null };
167
+ let echo = 0;
168
+ let reads = 0;
169
+ let acts = 0;
170
+ eachLine(file, (o) => {
171
+ if (meta.first === null && typeof o.timestamp === "string")
172
+ meta.first = o.timestamp;
173
+ const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
174
+ if (!p || typeof p !== "object")
175
+ return;
176
+ if (p.type === "token_count") {
177
+ const u = p.info && p.info.total_token_usage;
178
+ if (u)
179
+ tot = u;
180
+ }
181
+ else if (p.type === "function_call") {
182
+ const ns = (String(p.namespace || "") + String(p.name || "")).toLowerCase();
183
+ if (ns.includes("echomem"))
184
+ echo++;
185
+ else if (classifyCodexCall(p.name, p.arguments) === "read")
186
+ reads++;
187
+ else
188
+ acts++;
189
+ }
190
+ });
191
+ if (!tot || !(tot.total_tokens > 0))
192
+ return null;
193
+ return {
194
+ source: "codex",
195
+ date: meta.first ? meta.first.slice(0, 10) : null,
196
+ month: meta.first ? meta.first.slice(0, 7) : null,
197
+ total: tot.total_tokens || 0,
198
+ cached: tot.cached_input_tokens || 0,
199
+ output: tot.output_tokens || 0,
200
+ toolsRead: reads,
201
+ toolsAct: acts,
202
+ toolsEcho: echo,
203
+ };
204
+ }
205
+ /** Claude Code: usage is PER assistant turn (SUM); tool_use blocks live in message.content. */
206
+ export function parseClaude(file) {
207
+ let total = 0;
208
+ let cached = 0;
209
+ let output = 0;
210
+ let echo = 0;
211
+ let reads = 0;
212
+ let acts = 0;
213
+ const meta = { first: null };
214
+ let sawUsage = false;
215
+ eachLine(file, (o) => {
216
+ if (meta.first === null && typeof o.timestamp === "string")
217
+ meta.first = o.timestamp;
218
+ if (o.type !== "assistant" || !o.message)
219
+ return;
220
+ const u = o.message.usage;
221
+ if (u) {
222
+ sawUsage = true;
223
+ const inp = u.input_tokens || 0;
224
+ const cr = u.cache_read_input_tokens || 0;
225
+ const cc = u.cache_creation_input_tokens || 0;
226
+ const out = u.output_tokens || 0;
227
+ total += inp + cr + cc + out;
228
+ cached += cr; // re-read only (cache_creation is a first-time write, not a re-read)
229
+ output += out;
230
+ }
231
+ const content = o.message.content;
232
+ if (Array.isArray(content)) {
233
+ for (const b of content) {
234
+ if (b && b.type === "tool_use") {
235
+ if (String(b.name || "").toLowerCase().includes("echomem"))
236
+ echo++;
237
+ else if (classifyClaudeTool(b.name) === "read")
238
+ reads++;
239
+ else
240
+ acts++;
241
+ }
242
+ }
243
+ }
244
+ });
245
+ if (!sawUsage || total === 0)
246
+ return null;
247
+ return {
248
+ source: "claude-code",
249
+ date: meta.first ? meta.first.slice(0, 10) : null,
250
+ month: meta.first ? meta.first.slice(0, 7) : null,
251
+ total,
252
+ cached,
253
+ output,
254
+ toolsRead: reads,
255
+ toolsAct: acts,
256
+ toolsEcho: echo,
257
+ };
258
+ }
259
+ function walk(dir, match, skipDir, out = []) {
260
+ let entries;
261
+ try {
262
+ entries = fs.readdirSync(dir, { withFileTypes: true });
263
+ }
264
+ catch {
265
+ return out;
266
+ }
267
+ for (const e of entries) {
268
+ const full = path.join(dir, e.name);
269
+ if (e.isDirectory()) {
270
+ if (!skipDir(e.name))
271
+ walk(full, match, skipDir, out);
272
+ }
273
+ else if (match(full)) {
274
+ out.push(full);
275
+ }
276
+ }
277
+ return out;
278
+ }
279
+ export function collect() {
280
+ const stats = [];
281
+ const codexRoot = path.join(os.homedir(), ".codex", "sessions");
282
+ for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
283
+ const s = parseCodex(f);
284
+ if (s)
285
+ stats.push(s);
286
+ }
287
+ // Claude Code: top-level session files only (skip subagent/workflow dirs to avoid double-counting).
288
+ const claudeRoot = path.join(os.homedir(), ".claude", "projects");
289
+ for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
290
+ const s = parseClaude(f);
291
+ if (s)
292
+ stats.push(s);
293
+ }
294
+ return stats;
295
+ }
296
+ // ---------------------------------------------------------------------------
297
+ // Aggregation (pure — unit-tested in test/report.test.mjs)
298
+ // ---------------------------------------------------------------------------
299
+ function median(nums) {
300
+ if (!nums.length)
301
+ return 0;
302
+ const s = [...nums].sort((a, b) => a - b);
303
+ return s[Math.floor(s.length / 2)];
304
+ }
305
+ export function aggregate(stats) {
306
+ const total = stats.reduce((n, s) => n + s.total, 0);
307
+ const cached = stats.reduce((n, s) => n + s.cached, 0);
308
+ const output = stats.reduce((n, s) => n + s.output, 0);
309
+ const reads = stats.reduce((n, s) => n + s.toolsRead, 0);
310
+ const acts = stats.reduce((n, s) => n + s.toolsAct, 0);
311
+ const recalls = stats.reduce((n, s) => n + s.toolsEcho, 0);
312
+ const toolTotal = reads + acts + recalls;
313
+ const fresh = total - cached;
314
+ const noncached = Math.max(0, total - cached - output);
315
+ const dates = stats.map((s) => s.date).filter(Boolean).sort();
316
+ const byMonth = new Map();
317
+ for (const s of stats) {
318
+ if (!s.month)
319
+ continue;
320
+ const m = byMonth.get(s.month) || { t: 0, c: 0, n: 0 };
321
+ m.t += s.total;
322
+ m.c += s.cached;
323
+ m.n += 1;
324
+ byMonth.set(s.month, m);
325
+ }
326
+ const months = [...byMonth.entries()].sort((a, b) => a[0].localeCompare(b[0])).map(([month, d]) => ({
327
+ month, tokens: d.t, sessions: d.n, rereadPct: d.t ? Math.round((d.c / d.t) * 100) : 0,
328
+ }));
329
+ const totals = stats.map((s) => s.total).sort((a, b) => b - a);
330
+ const topN = Math.max(1, Math.floor(totals.length * 0.1));
331
+ const topShare = total ? Math.round((totals.slice(0, topN).reduce((n, x) => n + x, 0) / total) * 100) : 0;
332
+ // Rough $ — cache priced CHEAP at both ends (~0.1–0.5×); never bill cache at the full input rate.
333
+ const costLow = noncached / 1e6 * 1.25 + cached / 1e6 * 0.1 + output / 1e6 * 10;
334
+ const costHigh = noncached / 1e6 * 5 + cached / 1e6 * 0.5 + output / 1e6 * 15;
335
+ return {
336
+ sessions: stats.length,
337
+ codexN: stats.filter((s) => s.source === "codex").length,
338
+ claudeN: stats.filter((s) => s.source === "claude-code").length,
339
+ total, cached, output, fresh, noncached, reads, acts, recalls, toolTotal,
340
+ rereadPct: total ? Math.round((cached / total) * 100) : 0,
341
+ ratio: recalls > 0 ? Math.round(reads / recalls) : null,
342
+ first: dates[0] || null,
343
+ last: dates[dates.length - 1] || null,
344
+ days: new Set(dates).size,
345
+ months, topShare, medianSession: median(stats.map((s) => s.total)),
346
+ costLow, costHigh,
347
+ };
348
+ }
349
+ // ---------------------------------------------------------------------------
350
+ // Rendering
351
+ // ---------------------------------------------------------------------------
352
+ function makeColor(enabled) {
353
+ const w = (code) => (s) => (enabled ? `\x1b[${code}m${s}\x1b[0m` : String(s));
354
+ return { bold: w("1"), dim: w("2"), red: w("31"), green: w("32"), blue: w("34"), magenta: w("35"), cyan: w("36") };
355
+ }
356
+ function human(n) {
357
+ const a = Math.abs(n);
358
+ if (a >= 1e9)
359
+ return (n / 1e9).toFixed(2) + "B";
360
+ if (a >= 1e6)
361
+ return (n / 1e6).toFixed(2) + "M";
362
+ if (a >= 1e3)
363
+ return (n / 1e3).toFixed(1) + "K";
364
+ return String(Math.round(n));
365
+ }
366
+ /**
367
+ * How many memories the EchoMem platform has captured for this account. Best-effort: needs a stored
368
+ * token; returns null otherwise. Reads the FEED route on purpose — `time-range`/`graph` still filter
369
+ * out encrypted memories (a deployed bug), but the feed route has no such filter. Never throws.
370
+ */
371
+ async function fetchMemoryCount() {
372
+ const token = new KeyStore().getToken();
373
+ if (!token)
374
+ return null;
375
+ try {
376
+ const res = await axios.get(`${API_BASE}/api/extension/memories`, {
377
+ headers: { Authorization: `Bearer ${token}` },
378
+ timeout: 5000,
379
+ });
380
+ const count = res.data?.count;
381
+ return typeof count === "number" ? count : null;
382
+ }
383
+ catch {
384
+ return null;
385
+ }
386
+ }
387
+ function renderJson(a, memCount) {
388
+ return JSON.stringify({
389
+ generatedFrom: ["~/.codex/sessions", "~/.claude/projects"],
390
+ llmCallsUsed: 0,
391
+ transcriptsUploaded: false,
392
+ window: { first: a.first, last: a.last, activeDays: a.days },
393
+ sessions: { total: a.sessions, codex: a.codexN, claudeCode: a.claudeN },
394
+ 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)" },
395
+ memoriesCaptured: memCount,
396
+ 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" },
397
+ concentration: { topTenPctShare: a.topShare, medianSession: a.medianSession },
398
+ months: a.months,
399
+ earlySignal: { source: "controlled test, N=3 per arm, proxy agent", context: PROJ_CONTEXT, speed: PROJ_SPEED, correctness: PROJ_CORRECT, measuredOnThisAccount: false },
400
+ estSpendUsdRough: { low: Math.round(a.costLow), high: Math.round(a.costHigh), note: "cache priced at ~0.1-0.5x; rough" },
401
+ }, null, 2);
402
+ }
403
+ /** Render the report to a string. `useColor=false` for the MCP tool (clean text); true for the terminal. */
404
+ function renderText(a, memCount, useColor) {
405
+ const c = makeColor(useColor);
406
+ const out = [];
407
+ const L = (s = "") => out.push(" " + s);
408
+ const NL = () => out.push("");
409
+ const sources = [a.codexN ? `${a.codexN} Codex` : "", a.claudeN ? `${a.claudeN} Claude Code` : ""].filter(Boolean).join(" + ");
410
+ NL();
411
+ L(c.bold(c.magenta("EchoMem · your AI coding memory audit")));
412
+ L(c.dim(`${sources} sessions · ${a.first} → ${a.last} · ${a.days} active days`));
413
+ L(c.dim(`computed locally from your own logs — transcripts never leave your machine`));
414
+ NL();
415
+ // ---- HERO: reads/searches (what recall replaces) vs recalls — edits/builds excluded ----
416
+ L(c.bold("THE HEADLINE"));
417
+ L(`Across ${c.bold(a.sessions + " sessions")}, your agents read or searched your code`);
418
+ 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"}.`);
419
+ NL();
420
+ const rw = 40;
421
+ L(`reads & searches ${c.red("█".repeat(rw))} ${c.bold(a.reads.toLocaleString())}`);
422
+ const recallBar = a.recalls > 0 ? "█".repeat(Math.max(1, Math.round((a.recalls / Math.max(a.reads, 1)) * rw))) : "·";
423
+ L(`memory recalls ${c.green(recallBar)} ${c.bold(String(a.recalls))}`);
424
+ if (a.ratio)
425
+ L(c.dim(`≈ ${a.ratio.toLocaleString()} : 1 — the agent re-reads instead of remembering. EchoMem flips this.`));
426
+ else
427
+ L(c.dim(`the agent rebuilds context from scratch every time. EchoMem turns that into recall.`));
428
+ L(c.dim(`(plus ${a.acts.toLocaleString()} edits, builds & other actions — memory doesn't change those.)`));
429
+ NL();
430
+ // ---- memories / zero-state ----
431
+ if (memCount && memCount > 0) {
432
+ const word = memCount === 1 ? "memory" : "memories";
433
+ L(`You've started: EchoMem holds ${c.bold(c.cyan(memCount + " " + word))}, so every ${c.bold("new")} session can`);
434
+ L(`now recall instead of re-gathering. (The history above predates them.)`);
435
+ }
436
+ else {
437
+ 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.`);
438
+ L(c.dim(`Each session started cold and re-gathered everything from scratch.`));
439
+ }
440
+ NL();
441
+ // ---- SCALE (token total — honestly labeled, with the within-session caveat stated up front) ----
442
+ L(c.bold("THE SCALE OF IT") + c.dim(" (tokens processed across all turns)"));
443
+ L(`Your agents processed ${c.bold(human(a.total))} tokens; ${c.bold(a.rereadPct + "%")} was context re-sent every turn.`);
444
+ L(c.dim(`Most of that is the conversation re-loading itself — how agents bill, not something`));
445
+ L(c.dim(`memory removes. What memory removes is the re-gathering and cold starts above.`));
446
+ NL();
447
+ if (a.months.length > 1) {
448
+ L(c.bold("BY MONTH") + c.dim(" (tokens processed)"));
449
+ const maxT = Math.max(...a.months.map((m) => m.tokens));
450
+ for (const m of a.months) {
451
+ const w = Math.max(1, Math.round((m.tokens / maxT) * 24));
452
+ L(`${m.month} ${c.blue("█".repeat(w))}${c.dim("░".repeat(24 - w))} ${human(m.tokens).padStart(8)} ${c.dim(m.sessions + " sess")}`);
453
+ }
454
+ NL();
455
+ }
456
+ // ---- WHAT CHANGES (plain language — the things a dev feels) ----
457
+ L(c.bold(c.green("WHAT CHANGES WHEN YOU CONNECT ECHOMEM")));
458
+ NL();
459
+ const pair = (bad, good) => {
460
+ L(c.red("✗ ") + c.dim("today: ") + c.dim(bad));
461
+ L(c.green("✓ ") + good);
462
+ NL();
463
+ };
464
+ pair("the agent re-reads your codebase every session just to catch up", "it recalls what it already learned — no re-gathering from scratch");
465
+ 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");
466
+ if (memCount && memCount > 0)
467
+ pair("context is thrown away when the session ends", `it keeps compounding — every session adds to your ${c.bold(String(memCount))} memories`);
468
+ else
469
+ pair("nothing is saved — every session vanishes when it ends", "every session starts building a memory that compounds over time");
470
+ L(c.dim(`Early signal: in a small controlled test (3 runs, a stand-in agent), giving the agent`));
471
+ L(c.dim(`memory cut the context it needed to orient by ${PROJ_CONTEXT} and stopped it acting on stale code.`));
472
+ L(c.dim(`That's a directional test, not a measurement on your account — connect to get your real number.`));
473
+ NL();
474
+ // ---- CTA ----
475
+ if (memCount && memCount > 0) {
476
+ L(c.green(`✓ You're connected — every new session now builds on your ${memCount} memories.`));
477
+ }
478
+ else {
479
+ L(c.green("→ Start now (no signup wall): ") + c.bold("npx -y @echomem/mcp setup"));
480
+ L(c.dim(` ~1 minute. Your next coding session recalls instead of re-reading.`));
481
+ }
482
+ NL();
483
+ // ---- trust line ----
484
+ L(c.dim("─".repeat(62)));
485
+ L(`${c.bold(c.green("$0"))} to produce this — ${c.bold("no AI model was called")}.`);
486
+ L(c.dim(`Transcripts never leave your machine${memCount !== null ? " (only your memory count was fetched)" : ""}. Re-run anytime.`));
487
+ NL();
488
+ return out.join("\n");
489
+ }
490
+ const NO_HISTORY = "EchoMem report: no coding-agent history found yet.\n" +
491
+ "Looked in ~/.codex/sessions and ~/.claude/projects. Use a coding agent, then run `echomem-mcp report`.";
492
+ /** Build the report as a plain string — used by the MCP `usage_report` tool (no color) and `setup`. */
493
+ export async function buildReportText(useColor = false) {
494
+ const stats = collect();
495
+ if (stats.length === 0)
496
+ return NO_HISTORY;
497
+ return renderText(aggregate(stats), await fetchMemoryCount(), useColor);
498
+ }
499
+ export async function runReport(flags) {
500
+ const stats = collect();
501
+ if (stats.length === 0) {
502
+ console.log(NO_HISTORY);
503
+ return;
504
+ }
505
+ const a = aggregate(stats);
506
+ const memCount = await fetchMemoryCount();
507
+ if (flags.json === true) {
508
+ console.log(renderJson(a, memCount));
509
+ return;
510
+ }
511
+ const useColor = process.stdout.isTTY === true && !process.env.NO_COLOR && flags["no-color"] !== true;
512
+ console.log(renderText(a, memCount, useColor));
513
+ }
package/dist/setup.js CHANGED
@@ -22,6 +22,7 @@ import readline from "node:readline";
22
22
  import axios from "axios";
23
23
  import { KeyStore } from "./keystore.js";
24
24
  import { fetchEncryptionConfig, deriveAndVerifyKey, verifyKeyB64 } from "./encryption.js";
25
+ import { runReport, buildReportText } from "./report.js";
25
26
  // The connect-device page lives on the main site (WebPageReactVersion → yeahecho.com), where the
26
27
  // user already has a session. It calls the EchoMem API cross-origin. Override with ECHO_WEB_URL.
27
28
  const WEB_URL = (process.env.ECHO_WEB_URL || "https://yeahecho.com").replace(/\/$/, "");
@@ -291,6 +292,13 @@ async function cmdSetup(flags) {
291
292
  }
292
293
  console.log("");
293
294
  await cmdLogin(flags);
295
+ // Onboarding reveal: show the local usage audit right after connecting (proactive trigger).
296
+ try {
297
+ console.log("\n" + (await buildReportText(true)));
298
+ }
299
+ catch {
300
+ /* report is best-effort — never block setup */
301
+ }
294
302
  }
295
303
  async function cmdLogin(flags) {
296
304
  // Manual path (also the headless path): secrets supplied as flags.
@@ -378,6 +386,7 @@ Usage:
378
386
  echomem-mcp unlock Re-derive the encryption key after its TTL (or --passphrase)
379
387
  echomem-mcp status Show token/key/clients
380
388
  echomem-mcp logout Remove stored credentials
389
+ echomem-mcp report [--json] Your AI coding memory audit (local, no login, $0)
381
390
 
382
391
  Manual / headless:
383
392
  echomem-mcp login --token ec_xxx [--passphrase <vault pass> | --key <base64>]
@@ -403,6 +412,9 @@ export async function runCli(argv) {
403
412
  case "logout":
404
413
  cmdLogout();
405
414
  return true;
415
+ case "report":
416
+ await runReport(flags);
417
+ return true;
406
418
  case "help":
407
419
  case "--help":
408
420
  case "-h":
@@ -5,6 +5,7 @@ export const canonicalToolNames = {
5
5
  timeRange: "get_memories_by_time_range",
6
6
  keywords: "search_memories_by_keywords",
7
7
  others: "search_others_memories",
8
+ report: "echomem_usage_report",
8
9
  };
9
10
  export const legacyAliasToCanonical = {
10
11
  search_memories_by_description_semantic: canonicalToolNames.search,
@@ -138,6 +139,11 @@ export function listToolSpecs(opts = {}) {
138
139
  required: ["query"],
139
140
  },
140
141
  },
142
+ {
143
+ name: canonicalToolNames.report,
144
+ description: "Show the user a one-screen audit of THEIR OWN AI coding usage — computed locally from their Codex/Claude Code logs ($0, nothing uploaded): how many tokens their agents spent, how much was re-reading context, reads vs memory recalls, and what changes with EchoMem. Call this when the user asks about their usage, token spend, cost, how much they're wasting, or wants a summary of their agent activity — and you may offer it once right after EchoMem is first connected. Returns formatted text to show the user verbatim. Needs no login.",
145
+ inputSchema: { type: "object", properties: {} },
146
+ },
141
147
  {
142
148
  name: "search_memories_by_time_range",
143
149
  description: "Legacy alias for get_memories_by_time_range.",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@echomem/mcp",
3
- "version": "1.0.3",
3
+ "version": "1.2.0",
4
4
  "description": "EchoMem Cloud-First MCP Server",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
@@ -17,7 +17,7 @@
17
17
  "start": "node dist/index.js",
18
18
  "dev": "tsx src/index.ts",
19
19
  "smoke": "node smoke.mjs",
20
- "test": "npm run build && node test/crypto.test.mjs && node test/integration.test.mjs && node test/no-restart.test.mjs",
20
+ "test": "npm run build && node test/crypto.test.mjs && node test/integration.test.mjs && node test/no-restart.test.mjs && node test/report.test.mjs && node test/tools.test.mjs",
21
21
  "prepack": "npm run build"
22
22
  },
23
23
  "dependencies": {