@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.
Files changed (36) hide show
  1. package/README.md +23 -3
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  8. package/dist/city/echo-ai-city-only.html +1104 -105
  9. package/dist/city/echo-ai-city-only.template.html +1104 -105
  10. package/dist/city/pencil-pie-generator.html +883 -0
  11. package/dist/city/pencil-webgl-landscape.html +1239 -0
  12. package/dist/city/spatial-fan-story.html +479 -0
  13. package/dist/codex-session-files.js +283 -0
  14. package/dist/codex-sync.js +7 -2
  15. package/dist/context-analysis/canonical-golden.js +47 -0
  16. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  17. package/dist/context-analysis/vendored-canonical.js +793 -0
  18. package/dist/context-analysis/workspace-report.js +1838 -0
  19. package/dist/context-metrics/calculate.js +56 -0
  20. package/dist/context-metrics/model-limits.js +26 -0
  21. package/dist/context-metrics/types.js +1 -0
  22. package/dist/forensics-10-problems.js +7 -6
  23. package/dist/forensics.js +863 -132
  24. package/dist/hud/adapters.js +8 -4
  25. package/dist/hud/metric.js +13 -4
  26. package/dist/hud/monitor.js +135 -16
  27. package/dist/hud/web.js +344 -298
  28. package/dist/index.js +7 -3
  29. package/dist/local-data-paths.js +87 -0
  30. package/dist/migrate.js +37 -29
  31. package/dist/report.js +101 -40
  32. package/dist/setup-page.js +3290 -196
  33. package/dist/setup-preview.js +245 -0
  34. package/dist/setup.js +432 -34
  35. package/package.json +5 -4
  36. package/templates/echomem-recall.md +2 -2
@@ -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 = process.env.CODEX_HOME ? path.join(process.env.CODEX_HOME, "sessions") : homePath(".codex", "sessions");
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
- return walkFiles(homePath(".claude", "projects"), (file) => file.endsWith(".jsonl"));
125
+ const root = resolveClaudeProjectsDir();
126
+ return root ? walkFiles(root, (file) => file.endsWith(".jsonl")) : [];
125
127
  }
126
128
  function findActiveClaudeCode() {
127
- const cache = newestFile(walkFiles(homePath(".claude", "echo-ctx"), (file) => file.endsWith(".json")));
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());
@@ -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 pollution = ct > 0 ? Math.min(0.95, pollutionTok / ct) : 0;
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 = modelWindow > 0 ? Math.round((ct / modelWindow) * 100) : null;
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 - pollution) * 100),
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.usefulPct}% clean · ${formatTokens(score.ctTokens)}`;
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);
@@ -18,17 +18,21 @@ export class HudMonitor extends EventEmitter {
18
18
  labelCache = new Map();
19
19
  missing = [];
20
20
  frontmostCheckedAt = 0;
21
- frontmostClient = null;
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 focused = this.frontmostPreferredClient();
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: focused === score.client,
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 user-pinned tab overrides auto-selection: it becomes the primary and never flips away.
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
- frontmostPreferredClient() {
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 = detectFrontmostClient();
149
+ const detected = detectFrontmostFamily();
134
150
  if (detected) {
135
- // Positive detection wins immediately (real focus switch, e.g. Codex Claude Desktop).
136
- this.frontmostClient = detected;
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
- return this.frontmostClient && this.scores.has(this.frontmostClient) ? this.frontmostClient : null;
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 detectFrontmostClient() {
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 name = execFileSync("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], { encoding: "utf8", timeout: 500, stdio: ["ignore", "pipe", "ignore"] }).trim().toLowerCase();
323
- if (name.includes("codex"))
324
- return "codex";
325
- if (name.includes("claude"))
326
- return "claude-desktop";
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.