@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/hud/adapters.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { resolveClaudeProjectsDir, resolveCodexSessionsDir } from "../local-data-paths.js";
|
|
3
4
|
import { homePath, newestFile, readJsonl, walkFiles } from "./fs.js";
|
|
4
5
|
import { bumpTurn, newMetricState, recordEdit, recordRead, recordTool, scoreMetric, shellRead, } from "./metric.js";
|
|
5
6
|
export const adapters = {
|
|
@@ -31,8 +32,8 @@ export function adapterList(mode) {
|
|
|
31
32
|
return [adapters[mode]];
|
|
32
33
|
}
|
|
33
34
|
function listCodex() {
|
|
34
|
-
const root =
|
|
35
|
-
return walkFiles(root, (file) => /^rollout-.*\.jsonl$/.test(path.basename(file)));
|
|
35
|
+
const root = resolveCodexSessionsDir();
|
|
36
|
+
return root ? walkFiles(root, (file) => /^rollout-.*\.jsonl$/.test(path.basename(file))) : [];
|
|
36
37
|
}
|
|
37
38
|
function findActiveCodex() {
|
|
38
39
|
return newestFile(listCodex());
|
|
@@ -121,10 +122,13 @@ function scoreCodex(file) {
|
|
|
121
122
|
return score;
|
|
122
123
|
}
|
|
123
124
|
function listClaudeCode() {
|
|
124
|
-
|
|
125
|
+
const root = resolveClaudeProjectsDir();
|
|
126
|
+
return root ? walkFiles(root, (file) => file.endsWith(".jsonl")) : [];
|
|
125
127
|
}
|
|
126
128
|
function findActiveClaudeCode() {
|
|
127
|
-
const
|
|
129
|
+
const projects = resolveClaudeProjectsDir();
|
|
130
|
+
const cacheRoot = projects ? path.join(path.dirname(projects), "echo-ctx") : null;
|
|
131
|
+
const cache = cacheRoot ? newestFile(walkFiles(cacheRoot, (file) => file.endsWith(".json"))) : null;
|
|
128
132
|
if (cache)
|
|
129
133
|
return cache;
|
|
130
134
|
return newestFile(listClaudeCode());
|
package/dist/hud/metric.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { calculateContextMetrics } from "../context-metrics/calculate.js";
|
|
1
2
|
export const BUCKETS = {
|
|
2
3
|
rangeRedundant: "range_redundant",
|
|
3
4
|
staleRead: "stale_read",
|
|
@@ -72,16 +73,24 @@ export function scoreMetric(params) {
|
|
|
72
73
|
const ct = Number(params.ctTokens) || 0;
|
|
73
74
|
const modelWindow = Number(params.modelContextWindow) || 0;
|
|
74
75
|
const pollutionTok = Object.values(params.state.buckets).reduce((sum, bucket) => sum + bucket.tokens, 0);
|
|
75
|
-
const
|
|
76
|
+
const contextMetrics = calculateContextMetrics({
|
|
77
|
+
latestInputTokens: ct,
|
|
78
|
+
modelContextLimitTokens: modelWindow || null,
|
|
79
|
+
currentResidentWasteTokens: pollutionTok,
|
|
80
|
+
});
|
|
81
|
+
const pollution = Math.min(0.95, contextMetrics.noisePct);
|
|
76
82
|
const pollutionPct = Math.round(pollution * 100);
|
|
77
|
-
const saturationPct =
|
|
83
|
+
const saturationPct = contextMetrics.contextFullnessPct !== undefined
|
|
84
|
+
? Math.round(contextMetrics.contextFullnessPct * 100)
|
|
85
|
+
: null;
|
|
78
86
|
return {
|
|
79
87
|
client: params.client,
|
|
80
88
|
sourcePath: params.sourcePath,
|
|
81
89
|
turn: params.state.turn,
|
|
82
90
|
reads: params.state.reads,
|
|
83
91
|
redundantCount: params.state.redundantCount,
|
|
84
|
-
usefulPct: Math.round((1
|
|
92
|
+
usefulPct: Math.round(Math.max(0, Math.min(1, contextMetrics.usefulPct)) * 100),
|
|
93
|
+
healthScorePct: contextMetrics.healthScorePct,
|
|
85
94
|
pollutionPct,
|
|
86
95
|
pollutionTok,
|
|
87
96
|
ctTokens: ct,
|
|
@@ -134,7 +143,7 @@ export function formatTokens(tokens) {
|
|
|
134
143
|
}
|
|
135
144
|
export function formatGlance(score) {
|
|
136
145
|
const dot = score.color === "amber" ? "◑" : "●";
|
|
137
|
-
return `${dot} ${score.
|
|
146
|
+
return `${dot} ${score.healthScorePct}% score · ${formatTokens(score.ctTokens)}`;
|
|
138
147
|
}
|
|
139
148
|
function estimateReadTokens(start, end) {
|
|
140
149
|
const boundedEnd = Math.min(end, start + 4000);
|
package/dist/hud/monitor.js
CHANGED
|
@@ -18,17 +18,21 @@ export class HudMonitor extends EventEmitter {
|
|
|
18
18
|
labelCache = new Map();
|
|
19
19
|
missing = [];
|
|
20
20
|
frontmostCheckedAt = 0;
|
|
21
|
-
|
|
21
|
+
frontmostFamily = null;
|
|
22
22
|
lastActiveClient = null;
|
|
23
23
|
threadCounts = {};
|
|
24
24
|
threadCountsAt = 0;
|
|
25
25
|
recentSessions = [];
|
|
26
26
|
codexTitles = new Map();
|
|
27
27
|
codexTitlesSig = "";
|
|
28
|
+
claudeTitles = new Map();
|
|
29
|
+
claudeTitlesSig = "";
|
|
30
|
+
claudeTitlesCheckedAt = 0;
|
|
28
31
|
claudeTitleCache = new Map();
|
|
29
32
|
lastPersistedRecent = "";
|
|
30
33
|
pinnedId = null;
|
|
31
34
|
pinnedScore = null;
|
|
35
|
+
pinnedFromFamily = null;
|
|
32
36
|
constructor(mode = "auto", pollMs = 750) {
|
|
33
37
|
super();
|
|
34
38
|
this.mode = mode;
|
|
@@ -47,8 +51,9 @@ export class HudMonitor extends EventEmitter {
|
|
|
47
51
|
this.timer = null;
|
|
48
52
|
}
|
|
49
53
|
snapshot() {
|
|
50
|
-
const
|
|
54
|
+
const focusedFamily = this.frontmostPreferredFamily();
|
|
51
55
|
this.refreshRecent();
|
|
56
|
+
this.releasePinAfterForegroundSwitch(focusedFamily);
|
|
52
57
|
const now = Date.now();
|
|
53
58
|
const sessions = [...this.scores.values()].map((score) => {
|
|
54
59
|
// Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
|
|
@@ -58,7 +63,7 @@ export class HudMonitor extends EventEmitter {
|
|
|
58
63
|
return {
|
|
59
64
|
...score,
|
|
60
65
|
live: lastActiveMs < LIVE_WINDOW_MS,
|
|
61
|
-
focused:
|
|
66
|
+
focused: focusedFamily === agentFamily(score.client),
|
|
62
67
|
label: this.labelFor(score),
|
|
63
68
|
lastActiveMs,
|
|
64
69
|
};
|
|
@@ -66,7 +71,8 @@ export class HudMonitor extends EventEmitter {
|
|
|
66
71
|
// The user's foreground agent is the primary truth. Background agents can keep writing logs, but
|
|
67
72
|
// they should never steal the main HUD away from Codex/Claude while that app is what the user opened.
|
|
68
73
|
sessions.sort((a, b) => hudSelectionRank(a) - hudSelectionRank(b) || a.lastActiveMs - b.lastActiveMs);
|
|
69
|
-
// A
|
|
74
|
+
// A pin selects one session while the user remains in the same agent. Crossing to the other
|
|
75
|
+
// foreground agent releases it so Auto can follow the workflow again.
|
|
70
76
|
const pinned = this.pinnedView(now);
|
|
71
77
|
const ordered = pinned ? [pinned, ...sessions.filter((s) => s.sourcePath !== pinned.sourcePath)] : sessions;
|
|
72
78
|
const pinnedId = pinned ? this.pinnedId : null;
|
|
@@ -96,6 +102,16 @@ export class HudMonitor extends EventEmitter {
|
|
|
96
102
|
return;
|
|
97
103
|
this.pinnedId = id;
|
|
98
104
|
this.pinnedScore = null;
|
|
105
|
+
this.pinnedFromFamily = id ? this.frontmostPreferredFamily() : null;
|
|
106
|
+
}
|
|
107
|
+
releasePinAfterForegroundSwitch(focusedFamily) {
|
|
108
|
+
if (!this.pinnedId || !this.pinnedFromFamily || !focusedFamily)
|
|
109
|
+
return;
|
|
110
|
+
if (focusedFamily === this.pinnedFromFamily)
|
|
111
|
+
return;
|
|
112
|
+
this.pinnedId = null;
|
|
113
|
+
this.pinnedScore = null;
|
|
114
|
+
this.pinnedFromFamily = null;
|
|
99
115
|
}
|
|
100
116
|
// The user-selected tab, scored on demand (cached by file signature). null if nothing is pinned or
|
|
101
117
|
// the pinned session has aged out of the recent list.
|
|
@@ -122,21 +138,27 @@ export class HudMonitor extends EventEmitter {
|
|
|
122
138
|
}
|
|
123
139
|
}
|
|
124
140
|
const lastActiveMs = Math.max(0, now - stat.mtimeMs);
|
|
125
|
-
return { ...this.pinnedScore.score, live: lastActiveMs < LIVE_WINDOW_MS, focused: true, label: rec.title, lastActiveMs };
|
|
141
|
+
return { ...this.pinnedScore.score, live: lastActiveMs < LIVE_WINDOW_MS, focused: true, label: rec.label || rec.title, lastActiveMs };
|
|
126
142
|
}
|
|
127
|
-
|
|
143
|
+
frontmostPreferredFamily() {
|
|
128
144
|
if (this.mode !== "auto" && this.mode !== "both")
|
|
129
145
|
return null;
|
|
130
146
|
const now = Date.now();
|
|
131
147
|
if (now - this.frontmostCheckedAt > 1500) {
|
|
132
148
|
this.frontmostCheckedAt = now;
|
|
133
|
-
const detected =
|
|
149
|
+
const detected = detectFrontmostFamily();
|
|
134
150
|
if (detected) {
|
|
135
|
-
// Positive detection wins immediately
|
|
136
|
-
|
|
151
|
+
// Positive detection wins immediately. Claude Code and Claude Desktop share one family;
|
|
152
|
+
// recency below decides which Claude source is the conversation the user is actually using.
|
|
153
|
+
this.frontmostFamily = detected;
|
|
137
154
|
}
|
|
138
155
|
}
|
|
139
|
-
|
|
156
|
+
if (this.frontmostFamily === "codex")
|
|
157
|
+
return this.scores.has("codex") ? "codex" : null;
|
|
158
|
+
if (this.frontmostFamily === "claude") {
|
|
159
|
+
return this.scores.has("claude-code") || this.scores.has("claude-desktop") ? "claude" : null;
|
|
160
|
+
}
|
|
161
|
+
return null;
|
|
140
162
|
}
|
|
141
163
|
// One throttled walk over every session file per client → both the ongoing-thread counts and the
|
|
142
164
|
// "recent tabs" list (touched in the last RECENT_WINDOW_MS), which is persisted to disk so the tab
|
|
@@ -147,6 +169,7 @@ export class HudMonitor extends EventEmitter {
|
|
|
147
169
|
return;
|
|
148
170
|
this.threadCountsAt = now;
|
|
149
171
|
this.loadCodexTitles();
|
|
172
|
+
this.loadClaudeTitles();
|
|
150
173
|
const counts = {};
|
|
151
174
|
const recent = [];
|
|
152
175
|
const newestByClient = new Map();
|
|
@@ -188,6 +211,7 @@ export class HudMonitor extends EventEmitter {
|
|
|
188
211
|
id: sessionIdFromPath(file),
|
|
189
212
|
client,
|
|
190
213
|
title: this.titleFor(client, file),
|
|
214
|
+
label: sessionLabel(file, client),
|
|
191
215
|
sourcePath: file,
|
|
192
216
|
lastActiveMs: age,
|
|
193
217
|
live: age < LIVE_WINDOW_MS,
|
|
@@ -201,6 +225,9 @@ export class HudMonitor extends EventEmitter {
|
|
|
201
225
|
return title;
|
|
202
226
|
}
|
|
203
227
|
else {
|
|
228
|
+
const metadataTitle = this.claudeTitles.get(sessionIdFromPath(file));
|
|
229
|
+
if (metadataTitle)
|
|
230
|
+
return metadataTitle;
|
|
204
231
|
const cached = this.claudeTitleCache.get(file);
|
|
205
232
|
if (cached !== undefined)
|
|
206
233
|
return cached;
|
|
@@ -245,6 +272,28 @@ export class HudMonitor extends EventEmitter {
|
|
|
245
272
|
}
|
|
246
273
|
this.codexTitles = map;
|
|
247
274
|
}
|
|
275
|
+
// Claude Desktop keeps its sidebar titles outside the transcripts. Join those records by
|
|
276
|
+
// cliSessionId so the HUD can show the same title the user sees in Claude.
|
|
277
|
+
loadClaudeTitles() {
|
|
278
|
+
const now = Date.now();
|
|
279
|
+
if (this.claudeTitlesCheckedAt && now - this.claudeTitlesCheckedAt < 5000)
|
|
280
|
+
return;
|
|
281
|
+
this.claudeTitlesCheckedAt = now;
|
|
282
|
+
const files = listClaudeSessionMetadataFiles();
|
|
283
|
+
const signature = files.map((file) => {
|
|
284
|
+
try {
|
|
285
|
+
const stat = fs.statSync(file);
|
|
286
|
+
return `${file}:${stat.size}:${stat.mtimeMs}`;
|
|
287
|
+
}
|
|
288
|
+
catch {
|
|
289
|
+
return "";
|
|
290
|
+
}
|
|
291
|
+
}).join("|");
|
|
292
|
+
if (signature === this.claudeTitlesSig)
|
|
293
|
+
return;
|
|
294
|
+
this.claudeTitlesSig = signature;
|
|
295
|
+
this.claudeTitles = claudeSessionTitlesFromFiles(files);
|
|
296
|
+
}
|
|
248
297
|
persistRecent() {
|
|
249
298
|
const signature = JSON.stringify(this.recentSessions.map((s) => `${s.id}:${s.live}`));
|
|
250
299
|
if (signature === this.lastPersistedRecent)
|
|
@@ -315,21 +364,56 @@ export function claudeDisplayTitle(file, client) {
|
|
|
315
364
|
const stamp = sessionTimeLabel(resolveClaudeTranscript(file) ?? file);
|
|
316
365
|
return label === defaultLabel(client) ? stamp : `${label} · ${stamp}`;
|
|
317
366
|
}
|
|
318
|
-
function
|
|
367
|
+
export function claudeSessionTitlesFromFiles(files) {
|
|
368
|
+
const entries = new Map();
|
|
369
|
+
for (const file of files) {
|
|
370
|
+
try {
|
|
371
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
372
|
+
if (!isRecord(raw))
|
|
373
|
+
continue;
|
|
374
|
+
const id = typeof raw.cliSessionId === "string" ? raw.cliSessionId.trim() : "";
|
|
375
|
+
const title = typeof raw.title === "string" ? raw.title.trim() : "";
|
|
376
|
+
if (!id || !title)
|
|
377
|
+
continue;
|
|
378
|
+
const freshness = Number(raw.lastFocusedAt) || Number(raw.lastActivityAt) || fs.statSync(file).mtimeMs;
|
|
379
|
+
const previous = entries.get(id);
|
|
380
|
+
if (!previous || freshness >= previous.freshness)
|
|
381
|
+
entries.set(id, { title, freshness });
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
/* Ignore incomplete or disappearing live metadata files. */
|
|
385
|
+
}
|
|
386
|
+
}
|
|
387
|
+
return new Map([...entries].map(([id, entry]) => [id, entry.title]));
|
|
388
|
+
}
|
|
389
|
+
function detectFrontmostFamily() {
|
|
319
390
|
if (process.platform !== "darwin")
|
|
320
391
|
return null;
|
|
321
392
|
try {
|
|
322
|
-
const
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
393
|
+
const output = execFileSync("osascript", [
|
|
394
|
+
"-e",
|
|
395
|
+
'tell application "System Events" to tell first application process whose frontmost is true to return (name as text) & linefeed & (bundle identifier as text)',
|
|
396
|
+
], { encoding: "utf8", timeout: 500, stdio: ["ignore", "pipe", "ignore"] }).trim();
|
|
397
|
+
const [name = "", bundleId = ""] = output.split("\n");
|
|
398
|
+
return agentFamilyForApplication(name, bundleId);
|
|
327
399
|
}
|
|
328
400
|
catch {
|
|
329
401
|
/* Accessibility may be unavailable; fall back to newest log. */
|
|
330
402
|
}
|
|
331
403
|
return null;
|
|
332
404
|
}
|
|
405
|
+
export function agentFamilyForApplication(name, bundleId = "") {
|
|
406
|
+
const appName = name.trim().toLowerCase();
|
|
407
|
+
const appId = bundleId.trim().toLowerCase();
|
|
408
|
+
if (appId === "com.openai.codex" || appName.includes("codex"))
|
|
409
|
+
return "codex";
|
|
410
|
+
if (appId === "com.anthropic.claudefordesktop" || appName.includes("claude"))
|
|
411
|
+
return "claude";
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
export function agentFamily(client) {
|
|
415
|
+
return client === "codex" ? "codex" : "claude";
|
|
416
|
+
}
|
|
333
417
|
function isRecord(value) {
|
|
334
418
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
335
419
|
}
|
|
@@ -347,6 +431,41 @@ function defaultLabel(client) {
|
|
|
347
431
|
return "Claude Code";
|
|
348
432
|
return "Claude Desktop";
|
|
349
433
|
}
|
|
434
|
+
function listClaudeSessionMetadataFiles() {
|
|
435
|
+
const roots = [
|
|
436
|
+
homePath("Library", "Application Support", "Claude", "claude-code-sessions"),
|
|
437
|
+
homePath("Library", "Application Support", "Claude", "local-agent-mode-sessions"),
|
|
438
|
+
];
|
|
439
|
+
const files = [];
|
|
440
|
+
for (const root of roots) {
|
|
441
|
+
for (const account of childDirectories(root)) {
|
|
442
|
+
for (const organization of childDirectories(account)) {
|
|
443
|
+
let entries = [];
|
|
444
|
+
try {
|
|
445
|
+
entries = fs.readdirSync(organization, { withFileTypes: true });
|
|
446
|
+
}
|
|
447
|
+
catch {
|
|
448
|
+
continue;
|
|
449
|
+
}
|
|
450
|
+
for (const entry of entries) {
|
|
451
|
+
if (entry.isFile() && /^local_[0-9a-f-]+\.json$/i.test(entry.name))
|
|
452
|
+
files.push(path.join(organization, entry.name));
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
return files.sort();
|
|
458
|
+
}
|
|
459
|
+
function childDirectories(root) {
|
|
460
|
+
try {
|
|
461
|
+
return fs.readdirSync(root, { withFileTypes: true })
|
|
462
|
+
.filter((entry) => entry.isDirectory())
|
|
463
|
+
.map((entry) => path.join(root, entry.name));
|
|
464
|
+
}
|
|
465
|
+
catch {
|
|
466
|
+
return [];
|
|
467
|
+
}
|
|
468
|
+
}
|
|
350
469
|
// Recognizable label = basename of the session's cwd. Both Codex rollouts and Claude transcripts
|
|
351
470
|
// carry a "cwd" field; the Claude Code active source is the echo-ctx cache (<sessionId>.json, no cwd),
|
|
352
471
|
// so resolve its transcript by sessionId first. Falls back to the client name when cwd is absent.
|