@echomem/mcp 1.0.3 → 1.1.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 +60 -0
- package/dist/index.js +83 -10
- package/dist/keystore.js +5 -1
- package/dist/report.js +513 -0
- package/dist/setup.js +12 -0
- package/dist/v1-contract.js +6 -0
- package/package.json +2 -2
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
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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 (
|
|
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:
|
|
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:
|
|
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
|
-
|
|
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":
|
package/dist/v1-contract.js
CHANGED
|
@@ -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
|
+
"version": "1.1.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",
|
|
21
21
|
"prepack": "npm run build"
|
|
22
22
|
},
|
|
23
23
|
"dependencies": {
|