@echomem/mcp 1.0.1 → 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 +118 -11
- package/dist/keystore.js +5 -1
- package/dist/report.js +513 -0
- package/dist/setup.js +12 -0
- package/dist/v1-contract.js +12 -2
- 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,9 @@ 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";
|
|
11
|
+
import { randomUUID } from "node:crypto";
|
|
9
12
|
import { fetchEncryptionConfig, decryptMemoryFields } from "./encryption.js";
|
|
10
13
|
import { runCli } from "./setup.js";
|
|
11
14
|
const ECHO_API_BASE_URL = process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app";
|
|
@@ -75,10 +78,22 @@ function describeError(error) {
|
|
|
75
78
|
const fallback = stringifyErrorValue(error);
|
|
76
79
|
return fallback || "Unknown error";
|
|
77
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
|
+
}
|
|
78
91
|
class EchoMemApiClient {
|
|
79
92
|
store;
|
|
80
93
|
axios;
|
|
81
94
|
whoamiCache = null;
|
|
95
|
+
/** One id per bridge process — groups all saves from this coding session under a single EchoMem context. */
|
|
96
|
+
sessionId = randomUUID();
|
|
82
97
|
encConfigPromise = null;
|
|
83
98
|
constructor(store) {
|
|
84
99
|
this.store = store;
|
|
@@ -101,6 +116,31 @@ class EchoMemApiClient {
|
|
|
101
116
|
hasToken() {
|
|
102
117
|
return !!this.store.getToken();
|
|
103
118
|
}
|
|
119
|
+
/** The per-process session id — the join key telemetry shares with grouped saves. */
|
|
120
|
+
getSessionId() {
|
|
121
|
+
return this.sessionId;
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* Compact topic map of the user's memory for the search-tool description — a cheap "what's in here"
|
|
125
|
+
* index built from memory keys so the agent knows the boundary up front and recalls proactively.
|
|
126
|
+
* Best-effort: returns undefined on any failure (locked vault, network) rather than blocking tools.
|
|
127
|
+
*/
|
|
128
|
+
async fetchMemoryMap() {
|
|
129
|
+
try {
|
|
130
|
+
const enc = await this.encState(); // unencrypted → {enabled:false}; encrypted+locked → throws
|
|
131
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
132
|
+
const response = await this.axios.post("/api/extension/memories/time-range", { startDate: "2000-01-01", endDate: today, limit: 200 }, { timeout: 6000 });
|
|
133
|
+
const data = enc.enabled ? await this.decryptResult(response.data, enc.key) : response.data;
|
|
134
|
+
const memories = Array.isArray(data?.memories) ? data.memories : [];
|
|
135
|
+
const keys = [...new Set(memories.map((m) => String(m?.keys || "").trim()).filter(Boolean))];
|
|
136
|
+
if (!keys.length)
|
|
137
|
+
return undefined;
|
|
138
|
+
return keys.slice(0, 40).map((k) => `- ${k}`).join("\n");
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
return undefined;
|
|
142
|
+
}
|
|
143
|
+
}
|
|
104
144
|
/** Encryption config for the account, fetched once and cached. Failures are not cached. */
|
|
105
145
|
async getEncryptionConfig() {
|
|
106
146
|
if (!this.encConfigPromise) {
|
|
@@ -118,13 +158,18 @@ class EchoMemApiClient {
|
|
|
118
158
|
* handing the model ciphertext. For unencrypted accounts it returns `{ enabled: false }`.
|
|
119
159
|
*/
|
|
120
160
|
async encState() {
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
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.
|
|
124
165
|
const key = this.store.getKey();
|
|
125
|
-
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)
|
|
126
171
|
throw new LockedError(this.store.isKeyExpired());
|
|
127
|
-
return { enabled:
|
|
172
|
+
return { enabled: false };
|
|
128
173
|
}
|
|
129
174
|
async whoami() {
|
|
130
175
|
if (!this.whoamiCache) {
|
|
@@ -241,6 +286,8 @@ class EchoMemApiClient {
|
|
|
241
286
|
sourceUrl: parsed.url,
|
|
242
287
|
source: parsed.source || "mcp_server",
|
|
243
288
|
title: parsed.title,
|
|
289
|
+
// Stable per-session id so multiple saves in this coding session group under one context.
|
|
290
|
+
conversationKey: this.sessionId,
|
|
244
291
|
}, config);
|
|
245
292
|
return response.data;
|
|
246
293
|
}
|
|
@@ -283,19 +330,25 @@ class EchoMemApiClient {
|
|
|
283
330
|
}
|
|
284
331
|
}
|
|
285
332
|
}
|
|
333
|
+
const SERVER_VERSION = "1.1.0";
|
|
286
334
|
class EchoMemMCPServer {
|
|
287
335
|
server;
|
|
288
336
|
client;
|
|
337
|
+
mapCache = null;
|
|
338
|
+
events;
|
|
339
|
+
/** Whether the most recent ListTools response carried the memory map (per-session recall signal). */
|
|
340
|
+
mapInjected = false;
|
|
289
341
|
constructor(store) {
|
|
290
342
|
this.server = new Server({
|
|
291
343
|
name: "echomem-mcp",
|
|
292
|
-
version:
|
|
344
|
+
version: SERVER_VERSION,
|
|
293
345
|
}, {
|
|
294
346
|
capabilities: {
|
|
295
347
|
tools: {},
|
|
296
348
|
},
|
|
297
349
|
});
|
|
298
350
|
this.client = new EchoMemApiClient(store);
|
|
351
|
+
this.events = new EventLogger({ session_id: this.client.getSessionId(), app_version: SERVER_VERSION });
|
|
299
352
|
this.setupToolHandlers();
|
|
300
353
|
this.server.onerror = (error) => console.error("[MCP Error]", error);
|
|
301
354
|
process.on("SIGINT", async () => {
|
|
@@ -304,17 +357,37 @@ class EchoMemMCPServer {
|
|
|
304
357
|
});
|
|
305
358
|
}
|
|
306
359
|
setupToolHandlers() {
|
|
307
|
-
this.server.setRequestHandler(ListToolsRequestSchema, async () =>
|
|
360
|
+
this.server.setRequestHandler(ListToolsRequestSchema, async () => {
|
|
361
|
+
this.events.setClient(this.server.getClientVersion()?.name);
|
|
362
|
+
// Inject a compact topic map of the user's memory into the search-tool description so the agent
|
|
363
|
+
// knows the boundary up front and recalls proactively (cached; best-effort — no map on failure).
|
|
364
|
+
if (this.client.hasToken() && !this.mapCache)
|
|
365
|
+
this.mapCache = this.client.fetchMemoryMap();
|
|
366
|
+
const map = this.mapCache ? await this.mapCache : undefined;
|
|
367
|
+
this.mapInjected = !!map;
|
|
368
|
+
return { tools: listToolSpecs({ map }) };
|
|
369
|
+
});
|
|
308
370
|
this.server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
|
309
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
|
+
};
|
|
310
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
|
+
}
|
|
311
384
|
if (!this.client.hasToken())
|
|
312
385
|
throw new NoTokenError();
|
|
313
386
|
switch (canonicalName) {
|
|
314
387
|
case canonicalToolNames.search:
|
|
315
|
-
return await this.handleSearch(request.params.arguments);
|
|
388
|
+
return await this.handleSearch(request.params.arguments, rec);
|
|
316
389
|
case canonicalToolNames.save:
|
|
317
|
-
return await this.handleSave(request.params.arguments);
|
|
390
|
+
return await this.handleSave(request.params.arguments, rec);
|
|
318
391
|
case canonicalToolNames.timeRange:
|
|
319
392
|
return await this.handleTimeRange(request.params.arguments);
|
|
320
393
|
case canonicalToolNames.keywords:
|
|
@@ -326,6 +399,7 @@ class EchoMemMCPServer {
|
|
|
326
399
|
}
|
|
327
400
|
}
|
|
328
401
|
catch (error) {
|
|
402
|
+
rec.error_kind = classifyError(error);
|
|
329
403
|
if (error instanceof NoTokenError) {
|
|
330
404
|
// Not isError: a normal "not connected yet" state. Once `login` runs, the next call works.
|
|
331
405
|
return {
|
|
@@ -361,10 +435,30 @@ class EchoMemMCPServer {
|
|
|
361
435
|
isError: true,
|
|
362
436
|
};
|
|
363
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
|
+
}
|
|
364
445
|
});
|
|
365
446
|
}
|
|
366
|
-
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
|
+
}
|
|
367
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
|
+
}
|
|
368
462
|
// Tuned two-phase path: synthesized brief + ranked source memories.
|
|
369
463
|
if (result?.tuned) {
|
|
370
464
|
const { answer, memories } = result;
|
|
@@ -395,10 +489,22 @@ Details: ${m.details || "N/A"}`)
|
|
|
395
489
|
.join("\n\n");
|
|
396
490
|
return { content: [{ type: "text", text: `Found ${memories.length} relevant memories:\n\n${formattedResults}` }] };
|
|
397
491
|
}
|
|
398
|
-
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
|
+
}
|
|
399
503
|
const { success, memoriesExtracted, error } = await this.client.saveConversation(args);
|
|
400
504
|
if (!success)
|
|
401
505
|
throw new Error(`EchoMem API Error: ${error}`);
|
|
506
|
+
if (rec)
|
|
507
|
+
rec.memories_extracted = typeof memoriesExtracted === "number" ? memoriesExtracted : undefined;
|
|
402
508
|
return {
|
|
403
509
|
content: [{ type: "text", text: `Successfully ingested conversation. Extracted ${memoriesExtracted} memory distinct events.` }],
|
|
404
510
|
};
|
|
@@ -481,6 +587,7 @@ Details: ${m.details || "N/A"}`)
|
|
|
481
587
|
async run() {
|
|
482
588
|
const transport = new StdioServerTransport();
|
|
483
589
|
await this.server.connect(transport);
|
|
590
|
+
this.events.record({ type: "session_start" });
|
|
484
591
|
console.error("EchoMem MCP server running on stdio");
|
|
485
592
|
}
|
|
486
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,
|
|
@@ -45,12 +46,16 @@ export const keywordsSchema = z.object({
|
|
|
45
46
|
export const othersSchema = z.object({
|
|
46
47
|
query: z.string(),
|
|
47
48
|
});
|
|
48
|
-
export function listToolSpecs() {
|
|
49
|
+
export function listToolSpecs(opts = {}) {
|
|
49
50
|
const currentTime = new Date().toISOString();
|
|
51
|
+
const map = opts.map?.trim();
|
|
52
|
+
const mapSection = map
|
|
53
|
+
? `\n\nThis user's EchoMem currently covers these topics (a relevance guide — recall when the task relates to one of them):\n${map}\n`
|
|
54
|
+
: "";
|
|
50
55
|
return [
|
|
51
56
|
{
|
|
52
57
|
name: canonicalToolNames.search,
|
|
53
|
-
description: `Recall the user's prior decisions, preferences, constraints, and context from EchoMem — their long-term memory
|
|
58
|
+
description: `Recall the user's prior decisions, preferences, constraints, and project context from EchoMem — their long-term memory across ALL their AI tools (Claude.ai, ChatGPT, other agents), not just this session.${mapSection}\nCall this when the task plausibly relates to that remembered context — a topic above, or when the user refers to past work ("what did we decide", "like before", "the usual") — so you don't re-derive or re-ask what they already settled. Skip it for self-contained tasks with no link to their history (e.g. a generic algorithm question). Returns ranked memories with provenance. Current time: ${currentTime}.`,
|
|
54
59
|
inputSchema: {
|
|
55
60
|
type: "object",
|
|
56
61
|
properties: {
|
|
@@ -134,6 +139,11 @@ export function listToolSpecs() {
|
|
|
134
139
|
required: ["query"],
|
|
135
140
|
},
|
|
136
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
|
+
},
|
|
137
147
|
{
|
|
138
148
|
name: "search_memories_by_time_range",
|
|
139
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": {
|