@echomem/mcp 1.4.7 → 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 (46) hide show
  1. package/README.md +35 -9
  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/assets/hud/claude.svg +1 -0
  8. package/assets/hud/codex.svg +1 -0
  9. package/assets/hud/session-viewer.html +35 -0
  10. package/dist/city/chaos-to-clarity-pencil.html +582 -0
  11. package/dist/city/echo-ai-city-only.html +1126 -109
  12. package/dist/city/echo-ai-city-only.template.html +1126 -109
  13. package/dist/city/echo-face-cutout.png +0 -0
  14. package/dist/city/pencil-pie-generator.html +883 -0
  15. package/dist/city/pencil-webgl-landscape.html +1239 -0
  16. package/dist/city/spatial-fan-story.html +479 -0
  17. package/dist/codex-session-files.js +283 -0
  18. package/dist/codex-sync.js +7 -2
  19. package/dist/context-analysis/canonical-golden.js +47 -0
  20. package/dist/context-analysis/claude-native-canonical.js +1193 -0
  21. package/dist/context-analysis/vendored-canonical.js +793 -0
  22. package/dist/context-analysis/workspace-report.js +1838 -0
  23. package/dist/context-metrics/calculate.js +56 -0
  24. package/dist/context-metrics/model-limits.js +26 -0
  25. package/dist/context-metrics/types.js +1 -0
  26. package/dist/forensics-10-problems.js +7 -6
  27. package/dist/forensics.js +863 -132
  28. package/dist/hud/adapters.js +8 -4
  29. package/dist/hud/autostart.js +66 -0
  30. package/dist/hud/cli.js +31 -0
  31. package/dist/hud/electron-main.js +182 -19
  32. package/dist/hud/metric.js +13 -4
  33. package/dist/hud/monitor.js +171 -84
  34. package/dist/hud/preload.cjs +3 -0
  35. package/dist/hud/server.js +321 -4
  36. package/dist/hud/web.js +880 -270
  37. package/dist/index.js +122 -24
  38. package/dist/local-data-paths.js +87 -0
  39. package/dist/migrate.js +55 -29
  40. package/dist/report.js +101 -40
  41. package/dist/setup-page.js +4257 -245
  42. package/dist/setup-preview.js +245 -0
  43. package/dist/setup.js +786 -75
  44. package/dist/v1-contract.js +20 -2
  45. package/package.json +6 -4
  46. package/templates/echomem-recall.md +2 -2
@@ -6,7 +6,6 @@ import { adapterList, adapters } from "./adapters.js";
6
6
  import { homePath, newestFile, walkFiles } from "./fs.js";
7
7
  const LIVE_WINDOW_MS = 45_000;
8
8
  const ONGOING_WINDOW_MS = 300_000; // a thread counts as "ongoing" if its log was written in the last 5 min
9
- const FOCUS_GRACE_MS = 8_000; // keep the last-known frontmost client this long when detection momentarily misses
10
9
  const RECENT_WINDOW_MS = 12 * 60 * 60 * 1000; // a session is a "recent tab" if its log was touched in the last 12h
11
10
  const RECENT_CAP = 16; // most-recent N sessions kept as switchable tabs
12
11
  export class HudMonitor extends EventEmitter {
@@ -19,18 +18,21 @@ export class HudMonitor extends EventEmitter {
19
18
  labelCache = new Map();
20
19
  missing = [];
21
20
  frontmostCheckedAt = 0;
22
- frontmostSeenAt = 0;
23
- frontmostClient = null;
21
+ frontmostFamily = null;
24
22
  lastActiveClient = null;
25
23
  threadCounts = {};
26
24
  threadCountsAt = 0;
27
25
  recentSessions = [];
28
26
  codexTitles = new Map();
29
27
  codexTitlesSig = "";
28
+ claudeTitles = new Map();
29
+ claudeTitlesSig = "";
30
+ claudeTitlesCheckedAt = 0;
30
31
  claudeTitleCache = new Map();
31
32
  lastPersistedRecent = "";
32
33
  pinnedId = null;
33
34
  pinnedScore = null;
35
+ pinnedFromFamily = null;
34
36
  constructor(mode = "auto", pollMs = 750) {
35
37
  super();
36
38
  this.mode = mode;
@@ -49,8 +51,9 @@ export class HudMonitor extends EventEmitter {
49
51
  this.timer = null;
50
52
  }
51
53
  snapshot() {
52
- const focused = this.frontmostPreferredClient();
54
+ const focusedFamily = this.frontmostPreferredFamily();
53
55
  this.refreshRecent();
56
+ this.releasePinAfterForegroundSwitch(focusedFamily);
54
57
  const now = Date.now();
55
58
  const sessions = [...this.scores.values()].map((score) => {
56
59
  // Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
@@ -60,22 +63,19 @@ export class HudMonitor extends EventEmitter {
60
63
  return {
61
64
  ...score,
62
65
  live: lastActiveMs < LIVE_WINDOW_MS,
63
- focused: focused === score.client,
66
+ focused: focusedFamily === agentFamily(score.client),
64
67
  label: this.labelFor(score),
65
68
  lastActiveMs,
66
69
  };
67
70
  });
68
- // focused first, then live (most-recent first), then idle (most-recent first).
69
- sessions.sort((a, b) => {
70
- if (a.focused !== b.focused)
71
- return a.focused ? -1 : 1;
72
- if (a.live !== b.live)
73
- return a.live ? -1 : 1;
74
- return a.lastActiveMs - b.lastActiveMs;
75
- });
76
- // A user-pinned tab overrides auto-selection: it becomes the primary and never flips away.
71
+ // The user's foreground agent is the primary truth. Background agents can keep writing logs, but
72
+ // they should never steal the main HUD away from Codex/Claude while that app is what the user opened.
73
+ sessions.sort((a, b) => hudSelectionRank(a) - hudSelectionRank(b) || a.lastActiveMs - b.lastActiveMs);
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.
77
76
  const pinned = this.pinnedView(now);
78
77
  const ordered = pinned ? [pinned, ...sessions.filter((s) => s.sourcePath !== pinned.sourcePath)] : sessions;
78
+ const pinnedId = pinned ? this.pinnedId : null;
79
79
  return {
80
80
  mode: this.mode,
81
81
  active: ordered[0] || null,
@@ -83,6 +83,7 @@ export class HudMonitor extends EventEmitter {
83
83
  sessions: ordered,
84
84
  threadCounts: this.threadCounts,
85
85
  recentSessions: this.recentSessions,
86
+ pinnedId,
86
87
  missing: this.missing,
87
88
  updatedAt: new Date().toISOString(),
88
89
  };
@@ -101,6 +102,16 @@ export class HudMonitor extends EventEmitter {
101
102
  return;
102
103
  this.pinnedId = id;
103
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;
104
115
  }
105
116
  // The user-selected tab, scored on demand (cached by file signature). null if nothing is pinned or
106
117
  // the pinned session has aged out of the recent list.
@@ -127,28 +138,27 @@ export class HudMonitor extends EventEmitter {
127
138
  }
128
139
  }
129
140
  const lastActiveMs = Math.max(0, now - stat.mtimeMs);
130
- 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 };
131
142
  }
132
- frontmostPreferredClient() {
143
+ frontmostPreferredFamily() {
133
144
  if (this.mode !== "auto" && this.mode !== "both")
134
145
  return null;
135
146
  const now = Date.now();
136
147
  if (now - this.frontmostCheckedAt > 1500) {
137
148
  this.frontmostCheckedAt = now;
138
- const detected = detectFrontmostClient();
149
+ const detected = detectFrontmostFamily();
139
150
  if (detected) {
140
- // Positive detection wins immediately (real focus switch, e.g. Codex Claude Desktop).
141
- this.frontmostClient = detected;
142
- this.frontmostSeenAt = now;
143
- }
144
- else if (now - this.frontmostSeenAt > FOCUS_GRACE_MS) {
145
- // Detection missed (osascript timeout, or a non-agent app like a browser is front). Hold the
146
- // last-known focus for a grace window so the active slot doesn't flip to a background client
147
- // just because it's writing — then release once the grace expires.
148
- this.frontmostClient = null;
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;
149
154
  }
150
155
  }
151
- 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;
152
162
  }
153
163
  // One throttled walk over every session file per client → both the ongoing-thread counts and the
154
164
  // "recent tabs" list (touched in the last RECENT_WINDOW_MS), which is persisted to disk so the tab
@@ -159,6 +169,7 @@ export class HudMonitor extends EventEmitter {
159
169
  return;
160
170
  this.threadCountsAt = now;
161
171
  this.loadCodexTitles();
172
+ this.loadClaudeTitles();
162
173
  const counts = {};
163
174
  const recent = [];
164
175
  const newestByClient = new Map();
@@ -200,6 +211,7 @@ export class HudMonitor extends EventEmitter {
200
211
  id: sessionIdFromPath(file),
201
212
  client,
202
213
  title: this.titleFor(client, file),
214
+ label: sessionLabel(file, client),
203
215
  sourcePath: file,
204
216
  lastActiveMs: age,
205
217
  live: age < LIVE_WINDOW_MS,
@@ -213,60 +225,18 @@ export class HudMonitor extends EventEmitter {
213
225
  return title;
214
226
  }
215
227
  else {
216
- const title = this.claudeTitle(file);
217
- if (title)
218
- return title;
228
+ const metadataTitle = this.claudeTitles.get(sessionIdFromPath(file));
229
+ if (metadataTitle)
230
+ return metadataTitle;
231
+ const cached = this.claudeTitleCache.get(file);
232
+ if (cached !== undefined)
233
+ return cached;
234
+ const title = claudeDisplayTitle(file, client);
235
+ this.claudeTitleCache.set(file, title);
236
+ return title;
219
237
  }
220
238
  return sessionLabel(file, client); // repo/cwd basename fallback
221
239
  }
222
- // Claude has no title index, so derive one from the thread's first real user message (cached per file
223
- // — the first message never changes). Distinguishes two threads in the same repo.
224
- claudeTitle(file) {
225
- const cached = this.claudeTitleCache.get(file);
226
- if (cached !== undefined)
227
- return cached;
228
- let title = "";
229
- try {
230
- const fd = fs.openSync(file, "r");
231
- try {
232
- const buf = Buffer.alloc(64 * 1024);
233
- const bytes = fs.readSync(fd, buf, 0, buf.length, 0);
234
- for (const line of buf.toString("utf8", 0, bytes).split("\n")) {
235
- if (!line.includes('"user"'))
236
- continue;
237
- let obj;
238
- try {
239
- obj = JSON.parse(line);
240
- }
241
- catch {
242
- continue; // partial/last line
243
- }
244
- if (!isRecord(obj) || obj.type !== "user")
245
- continue;
246
- const message = isRecord(obj.message) ? obj.message : null;
247
- const content = message ? message.content : undefined;
248
- let body = "";
249
- if (typeof content === "string")
250
- body = content;
251
- else if (Array.isArray(content))
252
- body = content.map((b) => (isRecord(b) && typeof b.text === "string" ? b.text : "")).join(" ");
253
- body = body.replace(/\s+/g, " ").trim();
254
- if (!body || body.startsWith("<") || /^#\s*claudeMd\b/i.test(body) || body.startsWith("Caveat:"))
255
- continue;
256
- title = body.length > 48 ? `${body.slice(0, 47)}…` : body;
257
- break;
258
- }
259
- }
260
- finally {
261
- fs.closeSync(fd);
262
- }
263
- }
264
- catch {
265
- /* unreadable → falls back to repo label */
266
- }
267
- this.claudeTitleCache.set(file, title);
268
- return title;
269
- }
270
240
  // Codex writes a lightweight index of every thread (id + human title). Load it (mtime-cached) so tabs
271
241
  // show real titles like "Verify file state" instead of a UUID.
272
242
  loadCodexTitles() {
@@ -302,6 +272,28 @@ export class HudMonitor extends EventEmitter {
302
272
  }
303
273
  this.codexTitles = map;
304
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
+ }
305
297
  persistRecent() {
306
298
  const signature = JSON.stringify(this.recentSessions.map((s) => `${s.id}:${s.live}`));
307
299
  if (signature === this.lastPersistedRecent)
@@ -360,21 +352,68 @@ export class HudMonitor extends EventEmitter {
360
352
  this.emit("state", this.snapshot());
361
353
  }
362
354
  }
363
- function detectFrontmostClient() {
355
+ export function hudSelectionRank(session) {
356
+ if (session.focused)
357
+ return 0;
358
+ if (session.live)
359
+ return 1;
360
+ return 2;
361
+ }
362
+ export function claudeDisplayTitle(file, client) {
363
+ const label = sessionLabel(file, client);
364
+ const stamp = sessionTimeLabel(resolveClaudeTranscript(file) ?? file);
365
+ return label === defaultLabel(client) ? stamp : `${label} · ${stamp}`;
366
+ }
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() {
364
390
  if (process.platform !== "darwin")
365
391
  return null;
366
392
  try {
367
- 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();
368
- if (name.includes("codex"))
369
- return "codex";
370
- if (name.includes("claude"))
371
- 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);
372
399
  }
373
400
  catch {
374
401
  /* Accessibility may be unavailable; fall back to newest log. */
375
402
  }
376
403
  return null;
377
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
+ }
378
417
  function isRecord(value) {
379
418
  return typeof value === "object" && value !== null && !Array.isArray(value);
380
419
  }
@@ -392,6 +431,41 @@ function defaultLabel(client) {
392
431
  return "Claude Code";
393
432
  return "Claude Desktop";
394
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
+ }
395
469
  // Recognizable label = basename of the session's cwd. Both Codex rollouts and Claude transcripts
396
470
  // carry a "cwd" field; the Claude Code active source is the echo-ctx cache (<sessionId>.json, no cwd),
397
471
  // so resolve its transcript by sessionId first. Falls back to the client name when cwd is absent.
@@ -400,6 +474,19 @@ function sessionLabel(file, client) {
400
474
  const cwd = peekCwd(target);
401
475
  return cwd ? path.basename(cwd) : defaultLabel(client);
402
476
  }
477
+ function sessionTimeLabel(file) {
478
+ try {
479
+ const date = new Date(fs.statSync(file).mtimeMs);
480
+ const month = String(date.getMonth() + 1).padStart(2, "0");
481
+ const day = String(date.getDate()).padStart(2, "0");
482
+ const hour = String(date.getHours()).padStart(2, "0");
483
+ const minute = String(date.getMinutes()).padStart(2, "0");
484
+ return `${month}/${day} ${hour}:${minute}`;
485
+ }
486
+ catch {
487
+ return sessionIdFromPath(file).slice(0, 8) || "session";
488
+ }
489
+ }
403
490
  // The Claude Code active source is the echo-ctx cache (<sessionId>.json), whose mtime only bumps when
404
491
  // the statusline re-renders — stale during a long turn. The transcript grows every tool call, so it's
405
492
  // the true liveness signal. Take the fresher of the two. Cached per cache-file (paths are stable).
@@ -7,6 +7,9 @@ electron_1.contextBridge.exposeInMainWorld("echomemHud", {
7
7
  setOpen(open, height) {
8
8
  electron_1.ipcRenderer.send("hud:set-open", { open, height });
9
9
  },
10
+ setMini(mini) {
11
+ electron_1.ipcRenderer.send("hud:set-mini", { mini });
12
+ },
10
13
  startDrag(screenX, screenY) {
11
14
  electron_1.ipcRenderer.send("hud:drag-start", { screenX, screenY });
12
15
  },