@echomem/mcp 1.4.8 → 1.4.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +23 -3
- package/assets/canonical-scorer/README.md +18 -0
- package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
- package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
- package/assets/canonical-scorer/golden_anchors.mjs +83 -0
- package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
- package/dist/city/chaos-to-clarity-pencil.html +582 -0
- package/dist/city/echo-ai-city-only.html +1104 -105
- package/dist/city/echo-ai-city-only.template.html +1104 -105
- package/dist/city/pencil-pie-generator.html +883 -0
- package/dist/city/pencil-webgl-landscape.html +1239 -0
- package/dist/city/spatial-fan-story.html +479 -0
- package/dist/codex-session-files.js +283 -0
- package/dist/codex-sync.js +7 -2
- package/dist/context-analysis/canonical-golden.js +47 -0
- package/dist/context-analysis/claude-native-canonical.js +1193 -0
- package/dist/context-analysis/vendored-canonical.js +793 -0
- package/dist/context-analysis/workspace-report.js +1838 -0
- package/dist/context-metrics/calculate.js +56 -0
- package/dist/context-metrics/model-limits.js +26 -0
- package/dist/context-metrics/types.js +1 -0
- package/dist/forensics-10-problems.js +7 -6
- package/dist/forensics.js +863 -132
- package/dist/hud/adapters.js +8 -4
- package/dist/hud/metric.js +13 -4
- package/dist/hud/monitor.js +135 -16
- package/dist/hud/web.js +344 -298
- package/dist/index.js +7 -3
- package/dist/local-data-paths.js +87 -0
- package/dist/migrate.js +37 -29
- package/dist/report.js +101 -40
- package/dist/setup-page.js +3290 -196
- package/dist/setup-preview.js +245 -0
- package/dist/setup.js +432 -34
- package/package.json +5 -4
- package/templates/echomem-recall.md +2 -2
package/dist/index.js
CHANGED
|
@@ -1637,8 +1637,12 @@ Details: ${m.details || "N/A"}`;
|
|
|
1637
1637
|
async function main() {
|
|
1638
1638
|
// Subcommands (setup/login/unlock/status/logout/help) run and exit; no subcommand → serve.
|
|
1639
1639
|
const handled = await runCli(process.argv.slice(2));
|
|
1640
|
-
if (handled)
|
|
1641
|
-
|
|
1640
|
+
if (handled) {
|
|
1641
|
+
// These are one-shot commands: the work is finished here. Force exit so a stray open handle —
|
|
1642
|
+
// e.g. a not-yet-timed-out keep-alive socket from the local setup server, or a detached child's
|
|
1643
|
+
// inherited descriptor — can't leave the terminal hanging after setup/migrate completes.
|
|
1644
|
+
process.exit(process.exitCode ?? 0);
|
|
1645
|
+
}
|
|
1642
1646
|
const store = new KeyStore();
|
|
1643
1647
|
// Serve mode is meant to be SPAWNED by the MCP client (editor), which drives us over a piped stdin.
|
|
1644
1648
|
// A human who runs the bare server in a terminal instead gets a process that blocks forever on stdio
|
|
@@ -1657,7 +1661,7 @@ async function main() {
|
|
|
1657
1661
|
connected
|
|
1658
1662
|
? " Your editor launches this for you; you don't need to run it by hand."
|
|
1659
1663
|
: " To connect this device, press Ctrl+C and run:",
|
|
1660
|
-
connected ? " Press Ctrl+C to stop it." : " echomem-mcp
|
|
1664
|
+
connected ? " Press Ctrl+C to stop it." : " echomem-mcp init",
|
|
1661
1665
|
"",
|
|
1662
1666
|
].join("\n"));
|
|
1663
1667
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
function normalizedHomeDir(homeDir) {
|
|
5
|
+
const value = homeDir.trim();
|
|
6
|
+
if (!value || !path.isAbsolute(value))
|
|
7
|
+
return null;
|
|
8
|
+
return path.normalize(value);
|
|
9
|
+
}
|
|
10
|
+
function expandCurrentUserHome(value, homeDir) {
|
|
11
|
+
const trimmed = value.trim();
|
|
12
|
+
if (!trimmed)
|
|
13
|
+
return null;
|
|
14
|
+
if (trimmed === "~")
|
|
15
|
+
return homeDir;
|
|
16
|
+
if (trimmed.startsWith(`~${path.sep}`) || trimmed.startsWith("~/") || trimmed.startsWith("~\\")) {
|
|
17
|
+
return path.join(homeDir, trimmed.slice(2));
|
|
18
|
+
}
|
|
19
|
+
// Do not guess another user's home (~alice) or resolve a relative path against the MCP process cwd.
|
|
20
|
+
if (trimmed.startsWith("~") || !path.isAbsolute(trimmed))
|
|
21
|
+
return null;
|
|
22
|
+
return path.normalize(trimmed);
|
|
23
|
+
}
|
|
24
|
+
function configuredRoot(envKey, defaultDirName, opts = {}) {
|
|
25
|
+
const homeDir = normalizedHomeDir(opts.homeDir ?? os.homedir());
|
|
26
|
+
if (!homeDir)
|
|
27
|
+
return null;
|
|
28
|
+
const env = opts.env ?? process.env;
|
|
29
|
+
const configured = env[envKey];
|
|
30
|
+
if (typeof configured === "string" && configured.trim()) {
|
|
31
|
+
// An explicit profile override is authoritative. If it is invalid, callers must not silently
|
|
32
|
+
// scan the default profile, which may belong to a different account or contain stale history.
|
|
33
|
+
return expandCurrentUserHome(configured, homeDir);
|
|
34
|
+
}
|
|
35
|
+
return path.join(homeDir, defaultDirName);
|
|
36
|
+
}
|
|
37
|
+
/** Resolve an existing directory only after confirming it is a readable/searchable directory. */
|
|
38
|
+
export function resolveReadableDirectory(candidate) {
|
|
39
|
+
if (!candidate)
|
|
40
|
+
return null;
|
|
41
|
+
try {
|
|
42
|
+
const real = fs.realpathSync(candidate);
|
|
43
|
+
if (!fs.statSync(real).isDirectory())
|
|
44
|
+
return null;
|
|
45
|
+
fs.accessSync(real, fs.constants.R_OK | fs.constants.X_OK);
|
|
46
|
+
return real;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return null;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve every readable Codex transcript root under one authoritative CODEX_HOME profile.
|
|
54
|
+
* Active sessions come first. archived_sessions is additive historical data, not a fallback to a
|
|
55
|
+
* different profile. If both names resolve to the same directory, keep it once as active.
|
|
56
|
+
*/
|
|
57
|
+
export function resolveCodexSessionRoots(opts = {}) {
|
|
58
|
+
const root = configuredRoot("CODEX_HOME", ".codex", opts);
|
|
59
|
+
if (!root)
|
|
60
|
+
return [];
|
|
61
|
+
const candidates = [
|
|
62
|
+
{ kind: "active", child: "sessions", priority: 0 },
|
|
63
|
+
{ kind: "archived", child: "archived_sessions", priority: 1 },
|
|
64
|
+
];
|
|
65
|
+
const roots = [];
|
|
66
|
+
const seenRealpaths = new Set();
|
|
67
|
+
for (const candidate of candidates) {
|
|
68
|
+
const resolved = resolveReadableDirectory(path.join(root, candidate.child));
|
|
69
|
+
if (!resolved || seenRealpaths.has(resolved))
|
|
70
|
+
continue;
|
|
71
|
+
seenRealpaths.add(resolved);
|
|
72
|
+
roots.push({ kind: candidate.kind, path: resolved, priority: candidate.priority });
|
|
73
|
+
}
|
|
74
|
+
return roots;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Backward-compatible active-only resolver. Historical scanners should use
|
|
78
|
+
* resolveCodexSessionRoots/discoverCodexSessionFiles so archived sessions are not omitted.
|
|
79
|
+
*/
|
|
80
|
+
export function resolveCodexSessionsDir(opts = {}) {
|
|
81
|
+
return resolveCodexSessionRoots(opts).find((root) => root.kind === "active")?.path ?? null;
|
|
82
|
+
}
|
|
83
|
+
/** Claude Code state lives under CLAUDE_CONFIG_DIR (default ~/.claude); transcripts are in projects. */
|
|
84
|
+
export function resolveClaudeProjectsDir(opts = {}) {
|
|
85
|
+
const root = configuredRoot("CLAUDE_CONFIG_DIR", ".claude", opts);
|
|
86
|
+
return resolveReadableDirectory(root ? path.join(root, "projects") : null);
|
|
87
|
+
}
|
package/dist/migrate.js
CHANGED
|
@@ -19,13 +19,13 @@
|
|
|
19
19
|
* NOTE: client-pull — the queue advances only while the bridge runs; there is no server-side worker.
|
|
20
20
|
*/
|
|
21
21
|
import fs from "node:fs";
|
|
22
|
-
import os from "node:os";
|
|
23
22
|
import path from "node:path";
|
|
24
23
|
import crypto from "node:crypto";
|
|
25
24
|
import readline from "node:readline";
|
|
26
25
|
import axios from "axios";
|
|
27
26
|
import { KeyStore, echoConfigDir } from "./keystore.js";
|
|
28
27
|
import { fetchEncryptionConfig } from "./encryption.js";
|
|
28
|
+
import { resolveClaudeProjectsDir, resolveCodexSessionsDir } from "./local-data-paths.js";
|
|
29
29
|
import { walk, eachLine } from "./report.js";
|
|
30
30
|
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
31
31
|
const RATE_MAX = 28; // stay under the import-jobs /run limit of 30 / 60s
|
|
@@ -187,17 +187,21 @@ export function normalizeCwd(cwd) {
|
|
|
187
187
|
/** Discover every local session, newest first (by first-turn timestamp). */
|
|
188
188
|
export function discoverSessions() {
|
|
189
189
|
const out = [];
|
|
190
|
-
const codexRoot =
|
|
191
|
-
|
|
192
|
-
const
|
|
193
|
-
|
|
194
|
-
|
|
190
|
+
const codexRoot = resolveCodexSessionsDir();
|
|
191
|
+
if (codexRoot) {
|
|
192
|
+
for (const f of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
|
|
193
|
+
const s = assembleCodex(f);
|
|
194
|
+
if (s)
|
|
195
|
+
out.push(s);
|
|
196
|
+
}
|
|
195
197
|
}
|
|
196
|
-
const claudeRoot =
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
|
|
200
|
-
|
|
198
|
+
const claudeRoot = resolveClaudeProjectsDir();
|
|
199
|
+
if (claudeRoot) {
|
|
200
|
+
for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
201
|
+
const s = assembleClaude(f);
|
|
202
|
+
if (s)
|
|
203
|
+
out.push(s);
|
|
204
|
+
}
|
|
201
205
|
}
|
|
202
206
|
out.sort((a, b) => String(b.firstTs || "").localeCompare(String(a.firstTs || "")));
|
|
203
207
|
return out;
|
|
@@ -283,24 +287,28 @@ function fastSessionInfo(file, source) {
|
|
|
283
287
|
}
|
|
284
288
|
function fastSessionEntries(opts = {}) {
|
|
285
289
|
const out = [];
|
|
286
|
-
const codexRoot = opts.codexRoot ??
|
|
287
|
-
|
|
288
|
-
const
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
290
|
+
const codexRoot = opts.codexRoot ?? resolveCodexSessionsDir();
|
|
291
|
+
if (codexRoot) {
|
|
292
|
+
for (const filePath of walk(codexRoot, (p) => /rollout-.*\.jsonl$/.test(p), () => false)) {
|
|
293
|
+
const stat = statSafe(filePath);
|
|
294
|
+
const info = fastSessionInfo(filePath, "codex");
|
|
295
|
+
// Include if we found text OR a real session id (big sessions can have their first text turn beyond
|
|
296
|
+
// the 1MB probe window — gating only on text dropped them entirely; exact discovery refines later).
|
|
297
|
+
// We require a real key so the fast/exact conversationKey match (no sha16 fallback mismatch).
|
|
298
|
+
if (info.hasTextTurn || info.hasRealKey)
|
|
299
|
+
out.push({ filePath, source: "codex", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
300
|
+
}
|
|
295
301
|
}
|
|
296
|
-
const claudeRoot = opts.claudeRoot ??
|
|
297
|
-
|
|
298
|
-
const
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
302
|
+
const claudeRoot = opts.claudeRoot ?? resolveClaudeProjectsDir();
|
|
303
|
+
if (claudeRoot) {
|
|
304
|
+
for (const filePath of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
305
|
+
const stat = statSafe(filePath);
|
|
306
|
+
const info = fastSessionInfo(filePath, "claude-code");
|
|
307
|
+
// Same rule as codex: include on text OR a real session id so large sessions aren't undercounted,
|
|
308
|
+
// while keeping the key stable (claude-code sessionId appears on every line, so hasRealKey is reliable).
|
|
309
|
+
if (info.hasTextTurn || info.hasRealKey)
|
|
310
|
+
out.push({ filePath, source: "claude-code", conversationKey: info.conversationKey, size: stat.size, mtimeMs: stat.mtimeMs });
|
|
311
|
+
}
|
|
304
312
|
}
|
|
305
313
|
return out;
|
|
306
314
|
}
|
|
@@ -1244,7 +1252,7 @@ export async function cmdMigrate(flags) {
|
|
|
1244
1252
|
: "This account is ENCRYPTED but the vault is locked. Run `echomem-mcp unlock`, then re-run migrate.");
|
|
1245
1253
|
}
|
|
1246
1254
|
else if (code === "FORBIDDEN_SCOPE") {
|
|
1247
|
-
console.error("This device token cannot import history. Re-connect this device with `echomem-mcp
|
|
1255
|
+
console.error("This device token cannot import history. Re-connect this device with `echomem-mcp login`.");
|
|
1248
1256
|
}
|
|
1249
1257
|
else {
|
|
1250
1258
|
console.error(c.red(`Could not start the import: ${responseMessage(e)}`));
|
package/dist/report.js
CHANGED
|
@@ -16,10 +16,11 @@
|
|
|
16
16
|
* labeled — not a measurement on this account.
|
|
17
17
|
*/
|
|
18
18
|
import fs from "node:fs";
|
|
19
|
-
import os from "node:os";
|
|
20
19
|
import path from "node:path";
|
|
21
20
|
import { StringDecoder } from "node:string_decoder";
|
|
22
21
|
import axios from "axios";
|
|
22
|
+
import { discoverCodexSessionFiles } from "./codex-session-files.js";
|
|
23
|
+
import { resolveClaudeProjectsDir } from "./local-data-paths.js";
|
|
23
24
|
import { KeyStore } from "./keystore.js";
|
|
24
25
|
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
25
26
|
// Early signal from the controlled sub-agent experiment, NOT measured on this account.
|
|
@@ -120,6 +121,14 @@ function epochMs(ts) {
|
|
|
120
121
|
const n = Date.parse(ts);
|
|
121
122
|
return Number.isFinite(n) ? n : null;
|
|
122
123
|
}
|
|
124
|
+
function localDate(ts) {
|
|
125
|
+
const ms = epochMs(ts);
|
|
126
|
+
if (ms == null)
|
|
127
|
+
return null;
|
|
128
|
+
const d = new Date(ms);
|
|
129
|
+
const pad = (value) => String(value).padStart(2, "0");
|
|
130
|
+
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
|
|
131
|
+
}
|
|
123
132
|
function durationBetween(startTs, lastTs) {
|
|
124
133
|
const start = epochMs(startTs);
|
|
125
134
|
const last = epochMs(lastTs);
|
|
@@ -194,12 +203,21 @@ export function parseCodex(file) {
|
|
|
194
203
|
let acts = 0;
|
|
195
204
|
eachLine(file, (o) => {
|
|
196
205
|
if (typeof o.timestamp === "string") {
|
|
197
|
-
|
|
206
|
+
const ms = epochMs(o.timestamp);
|
|
207
|
+
if (ms != null && (meta.first === null || ms < (epochMs(meta.first) ?? Number.POSITIVE_INFINITY)))
|
|
198
208
|
meta.first = o.timestamp;
|
|
199
|
-
meta.last
|
|
209
|
+
if (ms != null && (meta.last === null || ms > (epochMs(meta.last) ?? Number.NEGATIVE_INFINITY)))
|
|
210
|
+
meta.last = o.timestamp;
|
|
200
211
|
}
|
|
201
|
-
if (o && o.type === "session_meta" && o.payload && typeof o.payload
|
|
202
|
-
|
|
212
|
+
if (o && o.type === "session_meta" && o.payload && typeof o.payload === "object") {
|
|
213
|
+
if (typeof o.payload.cwd === "string")
|
|
214
|
+
meta.cwd = o.payload.cwd;
|
|
215
|
+
if (typeof o.payload.timestamp === "string") {
|
|
216
|
+
const launchMs = epochMs(o.payload.timestamp);
|
|
217
|
+
if (launchMs != null && (meta.first === null || launchMs < (epochMs(meta.first) ?? Number.POSITIVE_INFINITY))) {
|
|
218
|
+
meta.first = o.payload.timestamp;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
203
221
|
return;
|
|
204
222
|
}
|
|
205
223
|
const p = o && typeof o.payload === "object" && o.payload ? o.payload : o;
|
|
@@ -224,8 +242,8 @@ export function parseCodex(file) {
|
|
|
224
242
|
return null;
|
|
225
243
|
return {
|
|
226
244
|
source: "codex",
|
|
227
|
-
date: meta.first
|
|
228
|
-
month: meta.first
|
|
245
|
+
date: localDate(meta.first),
|
|
246
|
+
month: localDate(meta.first)?.slice(0, 7) ?? null,
|
|
229
247
|
startTs: meta.first,
|
|
230
248
|
lastTs: meta.last,
|
|
231
249
|
durationSec: durationBetween(meta.first, meta.last),
|
|
@@ -240,9 +258,9 @@ export function parseCodex(file) {
|
|
|
240
258
|
}
|
|
241
259
|
/** Claude Code: usage is PER assistant turn (SUM); tool_use blocks live in message.content. */
|
|
242
260
|
export function parseClaude(file) {
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
let
|
|
261
|
+
const usageByRequest = new Map();
|
|
262
|
+
const seenToolUses = new Set();
|
|
263
|
+
let anonymousRequestSeq = 0;
|
|
246
264
|
let echo = 0;
|
|
247
265
|
let reads = 0;
|
|
248
266
|
let acts = 0;
|
|
@@ -253,16 +271,26 @@ export function parseClaude(file) {
|
|
|
253
271
|
durationSec: 0,
|
|
254
272
|
prevMs: null,
|
|
255
273
|
};
|
|
256
|
-
|
|
274
|
+
const requestKey = (row) => {
|
|
275
|
+
const messageId = typeof row.message?.id === "string" ? row.message.id.trim() : "";
|
|
276
|
+
if (messageId)
|
|
277
|
+
return `message:${messageId}`;
|
|
278
|
+
const requestId = typeof row.requestId === "string" ? row.requestId.trim() : "";
|
|
279
|
+
if (requestId)
|
|
280
|
+
return `request:${requestId}`;
|
|
281
|
+
const uuid = typeof row.uuid === "string" ? row.uuid.trim() : "";
|
|
282
|
+
return uuid ? `event:${uuid}` : `anonymous:${++anonymousRequestSeq}`;
|
|
283
|
+
};
|
|
257
284
|
eachLine(file, (o) => {
|
|
258
285
|
if (!meta.cwd && typeof o.cwd === "string")
|
|
259
286
|
meta.cwd = o.cwd;
|
|
260
287
|
if (typeof o.timestamp === "string") {
|
|
261
|
-
if (meta.first === null)
|
|
262
|
-
meta.first = o.timestamp;
|
|
263
|
-
meta.last = o.timestamp;
|
|
264
288
|
const ms = epochMs(o.timestamp);
|
|
265
289
|
if (ms != null) {
|
|
290
|
+
if (meta.first === null || ms < (epochMs(meta.first) ?? Number.POSITIVE_INFINITY))
|
|
291
|
+
meta.first = o.timestamp;
|
|
292
|
+
if (meta.last === null || ms > (epochMs(meta.last) ?? Number.NEGATIVE_INFINITY))
|
|
293
|
+
meta.last = o.timestamp;
|
|
266
294
|
if (meta.prevMs != null) {
|
|
267
295
|
meta.durationSec += Math.min(300, Math.max(0, Math.round((ms - meta.prevMs) / 1000)));
|
|
268
296
|
}
|
|
@@ -271,21 +299,34 @@ export function parseClaude(file) {
|
|
|
271
299
|
}
|
|
272
300
|
if (o.type !== "assistant" || !o.message)
|
|
273
301
|
return;
|
|
302
|
+
const key = requestKey(o);
|
|
274
303
|
const u = o.message.usage;
|
|
275
304
|
if (u) {
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
305
|
+
const candidate = {
|
|
306
|
+
input: Math.max(0, Number(u.input_tokens) || 0),
|
|
307
|
+
cacheRead: Math.max(0, Number(u.cache_read_input_tokens) || 0),
|
|
308
|
+
cacheCreate: Math.max(0, Number(u.cache_creation_input_tokens) || 0),
|
|
309
|
+
output: Math.max(0, Number(u.output_tokens) || 0),
|
|
310
|
+
};
|
|
311
|
+
const existing = usageByRequest.get(key);
|
|
312
|
+
if (!existing || candidate.input + candidate.cacheRead + candidate.cacheCreate > existing.input + existing.cacheRead + existing.cacheCreate) {
|
|
313
|
+
if (existing)
|
|
314
|
+
candidate.output = Math.max(candidate.output, existing.output);
|
|
315
|
+
usageByRequest.set(key, candidate);
|
|
316
|
+
}
|
|
317
|
+
else if (candidate.output > existing.output) {
|
|
318
|
+
existing.output = candidate.output;
|
|
319
|
+
}
|
|
284
320
|
}
|
|
285
321
|
const content = o.message.content;
|
|
286
322
|
if (Array.isArray(content)) {
|
|
287
|
-
for (
|
|
323
|
+
for (let index = 0; index < content.length; index += 1) {
|
|
324
|
+
const b = content[index];
|
|
288
325
|
if (b && b.type === "tool_use") {
|
|
326
|
+
const toolKey = typeof b.id === "string" && b.id ? `id:${b.id}` : `${key}:${index}:${String(b.name || "")}`;
|
|
327
|
+
if (seenToolUses.has(toolKey))
|
|
328
|
+
continue;
|
|
329
|
+
seenToolUses.add(toolKey);
|
|
289
330
|
if (String(b.name || "").toLowerCase().includes("echomem"))
|
|
290
331
|
echo++;
|
|
291
332
|
else if (classifyClaudeTool(b.name) === "read")
|
|
@@ -296,12 +337,16 @@ export function parseClaude(file) {
|
|
|
296
337
|
}
|
|
297
338
|
}
|
|
298
339
|
});
|
|
299
|
-
|
|
340
|
+
const usage = [...usageByRequest.values()];
|
|
341
|
+
const cached = usage.reduce((sum, row) => sum + row.cacheRead, 0);
|
|
342
|
+
const output = usage.reduce((sum, row) => sum + row.output, 0);
|
|
343
|
+
const total = usage.reduce((sum, row) => sum + row.input + row.cacheRead + row.cacheCreate + row.output, 0);
|
|
344
|
+
if (!usage.length || total === 0)
|
|
300
345
|
return null;
|
|
301
346
|
return {
|
|
302
347
|
source: "claude-code",
|
|
303
|
-
date: meta.first
|
|
304
|
-
month: meta.first
|
|
348
|
+
date: localDate(meta.first),
|
|
349
|
+
month: localDate(meta.first)?.slice(0, 7) ?? null,
|
|
305
350
|
startTs: meta.first,
|
|
306
351
|
lastTs: meta.last,
|
|
307
352
|
durationSec: meta.durationSec,
|
|
@@ -324,11 +369,22 @@ export function walk(dir, match, skipDir, out = []) {
|
|
|
324
369
|
}
|
|
325
370
|
for (const e of entries) {
|
|
326
371
|
const full = path.join(dir, e.name);
|
|
327
|
-
|
|
372
|
+
let stat;
|
|
373
|
+
try {
|
|
374
|
+
// Never follow entries that changed into symlinks/special files after readdir. A validated root
|
|
375
|
+
// may itself be a user-controlled symlink, but traversal stays inside regular child entries.
|
|
376
|
+
stat = fs.lstatSync(full);
|
|
377
|
+
}
|
|
378
|
+
catch {
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
if (stat.isSymbolicLink())
|
|
382
|
+
continue;
|
|
383
|
+
if (stat.isDirectory()) {
|
|
328
384
|
if (!skipDir(e.name))
|
|
329
385
|
walk(full, match, skipDir, out);
|
|
330
386
|
}
|
|
331
|
-
else if (match(full)) {
|
|
387
|
+
else if (stat.isFile() && match(full)) {
|
|
332
388
|
out.push(full);
|
|
333
389
|
}
|
|
334
390
|
}
|
|
@@ -336,18 +392,22 @@ export function walk(dir, match, skipDir, out = []) {
|
|
|
336
392
|
}
|
|
337
393
|
export function collect() {
|
|
338
394
|
const stats = [];
|
|
339
|
-
const
|
|
340
|
-
|
|
341
|
-
const
|
|
342
|
-
|
|
343
|
-
|
|
395
|
+
const codexDiscovery = discoverCodexSessionFiles({ includeArchived: true });
|
|
396
|
+
if (codexDiscovery.files.length) {
|
|
397
|
+
for (const { path: f } of codexDiscovery.files) {
|
|
398
|
+
const s = parseCodex(f);
|
|
399
|
+
if (s)
|
|
400
|
+
stats.push(s);
|
|
401
|
+
}
|
|
344
402
|
}
|
|
345
403
|
// Claude Code: top-level session files only (skip subagent/workflow dirs to avoid double-counting).
|
|
346
|
-
const claudeRoot =
|
|
347
|
-
|
|
348
|
-
const
|
|
349
|
-
|
|
350
|
-
|
|
404
|
+
const claudeRoot = resolveClaudeProjectsDir();
|
|
405
|
+
if (claudeRoot) {
|
|
406
|
+
for (const f of walk(claudeRoot, (p) => p.endsWith(".jsonl"), (name) => name === "subagents" || name === "workflows")) {
|
|
407
|
+
const s = parseClaude(f);
|
|
408
|
+
if (s)
|
|
409
|
+
stats.push(s);
|
|
410
|
+
}
|
|
351
411
|
}
|
|
352
412
|
return stats;
|
|
353
413
|
}
|
|
@@ -371,6 +431,7 @@ export function aggregate(stats) {
|
|
|
371
431
|
const fresh = total - cached;
|
|
372
432
|
const noncached = Math.max(0, total - cached - output);
|
|
373
433
|
const dates = stats.map((s) => s.date).filter(Boolean).sort();
|
|
434
|
+
const lastDates = stats.map((s) => localDate(s.lastTs)).filter(Boolean).sort();
|
|
374
435
|
const byMonth = new Map();
|
|
375
436
|
for (const s of stats) {
|
|
376
437
|
if (!s.month)
|
|
@@ -426,7 +487,7 @@ export function aggregate(stats) {
|
|
|
426
487
|
rereadPct: total ? Math.round((cached / total) * 100) : 0,
|
|
427
488
|
ratio: recalls > 0 ? Math.round(reads / recalls) : null,
|
|
428
489
|
first: dates[0] || null,
|
|
429
|
-
last: dates[dates.length - 1] || null,
|
|
490
|
+
last: lastDates[lastDates.length - 1] || dates[dates.length - 1] || null,
|
|
430
491
|
days: new Set(dates).size,
|
|
431
492
|
months,
|
|
432
493
|
busiestDay: { date: busiestDayEntry?.[0] || null, sessions: busiestDayEntry?.[1] || 0 },
|
|
@@ -623,7 +684,7 @@ function renderText(a, memCount, useColor) {
|
|
|
623
684
|
L(c.green(`✓ You're connected — every new session now builds on your ${memCount} memories.`));
|
|
624
685
|
}
|
|
625
686
|
else {
|
|
626
|
-
L(c.green("→ Start now (no signup wall): ") + c.bold("npm i -g @echomem/mcp@latest && echomem-mcp
|
|
687
|
+
L(c.green("→ Start now (no signup wall): ") + c.bold("npm i -g @echomem/mcp@latest && echomem-mcp init"));
|
|
627
688
|
L(c.dim(` ~1 minute. Your next coding session recalls instead of re-reading.`));
|
|
628
689
|
}
|
|
629
690
|
NL();
|