@echomem/mcp 1.4.33 → 1.4.35

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 CHANGED
@@ -37,38 +37,26 @@ legacy EchoMem-synthesized recall answer.
37
37
 
38
38
  ## Quick start (recommended)
39
39
 
40
- ```bash
41
- # One command, everything: download the bridge + HUD, configure every coding agent you have
42
- # installed (Codex, Claude Code, Claude Desktop, Cursor, Windsurf…), log in, and launch the HUD.
43
- npm i -g @echomem/mcp@latest && echomem-mcp init
44
- ```
40
+ Install Echo Desktop, sign in once, then open **Connect MCP** and choose **Connect Echo**. The app
41
+ ships and installs the MCP runtime, configures the detected Codex and Claude hosts, mirrors the same
42
+ device credential used by the desktop app, and verifies the MCP handshake. No terminal login is
43
+ required.
44
+
45
+ The standalone HUD has been retired; Echo Desktop is the persistent setup and status surface. The
46
+ bridge still reads local agent logs for on-demand context-health tools, but it no longer publishes or
47
+ auto-launches an Electron overlay.
45
48
 
46
- A global install (not `npx`) is recommended because it wires each editor to a **stable** path: a bare
47
- `npx` run resolves the bridge into a throwaway `_npx/<hash>` cache dir that npm later garbage-collects,
48
- which would break the MCP server after the fact (`setup` now refuses to pin such a path and falls back
49
- to the global install, but installing globally avoids the issue entirely). The context HUD's
50
- launch-at-login also runs from the installed path. `init` is the flagship one-liner — it wraps `setup --all --with-hud`: it writes each
51
- installed agent's MCP config (with **no secret** in it — credentials live in
52
- `~/.echomem/credentials.json`, mode 0600), adds the EchoMem memory guidance to their global
53
- `AGENTS.md` / `CLAUDE.md`, installs first-party `echomem-search`, `echomem-save`,
54
- `echomem-forget`, and `echomem-login` skills for Codex, opens the browser to approve the device
55
- (and unlock the vault for encrypted accounts), then launches the context HUD. EchoMem updates only
56
- its own skill folders; other memory-provider skills are detected and reported but never modified.
57
- Reload your editors and you're done.
58
-
59
- Prefer to keep it minimal? `echomem-mcp setup` configures only the auto-detected editor and skips the
60
- HUD; the granular commands below still work.
49
+ For headless systems and development, the granular CLI commands remain available:
61
50
 
62
51
  | Command | What it does |
63
52
  |---|---|
64
- | `npm i -g @echomem/mcp@latest && echomem-mcp init` | **Everything in one command** — all installed agents + HUD + login |
53
+ | `npm i -g @echomem/mcp@latest && echomem-mcp init` | Legacy/headless setup for installed agents + login |
65
54
  | `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and configure just the detected editor |
66
55
  | `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
67
56
  | `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
68
57
  | `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config without opening the browser or changing credentials |
69
58
  | `npx -y @echomem/mcp@latest update --all` | One-shot update: install the latest bridge durably and repoint detected client configs, with no browser login |
70
59
  | `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
71
- | `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
72
60
  | `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
73
61
  | `echomem-mcp unlock` | Privately unlock the vault on this trusted device |
74
62
  | `echomem-mcp lock` | Remove the local vault key while keeping the device login |
@@ -82,29 +70,8 @@ checks npm for a newer published bridge using a cached, non-blocking check. Agen
82
70
  `npx -y @echomem/mcp@latest update --all` if the user agrees. The bridge does not auto-update on
83
71
  every MCP startup.
84
72
 
85
- ## EchoMem Context HUD
86
-
87
- The package also ships `echomem-hud`, a local context-health sidecar for Codex and Claude. It reads
88
- local agent logs, computes a tracked lower-bound "clean vs dirty" context score, and renders a small
89
- HUD without adding tokens to the agent conversation.
90
-
91
- ```bash
92
- echomem-hud app # Electron floating HUD
93
- echomem-hud serve # Browser fallback at http://127.0.0.1:17377
94
- echomem-hud summary --json # Machine-readable current score
95
- echomem-hud status # Show detected Codex/Claude sources
96
- ```
97
-
98
- Supported local sources:
99
- - Codex: `~/.codex/sessions/**/rollout-*.jsonl`
100
- - Claude Code: `~/.claude/echo-ctx/*.json` first, then local transcripts when present
101
- - Claude desktop agent/Cowork: local agent-mode transcripts under Application Support
102
-
103
- The v1 metric counts `range_redundant` reads only and reports pollution as `tracked dead-weight ≥`
104
- because exact context composition and provider eviction are not observable.
105
-
106
- MCP fallback: agents can call `echo_context_health` to get the same score as markdown in-chat. This
107
- is on-demand; the passive HUD remains a separate local process.
73
+ Agents can still call `echo_context_health` for an on-demand local context-health report. It reads
74
+ the local Codex/Claude logs and does not require a separate process or desktop overlay.
108
75
 
109
76
  ## Agent Doctor — local workspace forensics (new in 1.4.9)
110
77
 
@@ -7,6 +7,14 @@ import { walk } from "../report.js";
7
7
  import { isStrongPositiveFeedback, } from "./workspace-report.js";
8
8
  const OUTPUT_TOKEN_CAP = 12_000;
9
9
  const IMAGE_TOKENS = 4_000;
10
+ // Request-by-request retained-context scoring grows faster than the raw JSONL. Keep onboarding
11
+ // predictable by analyzing a deterministic head/middle/tail sample from oversized sessions. The
12
+ // source transcript is untouched and remains available to the separate import pipeline.
13
+ const CLAUDE_MAX_TURNS_PER_SESSION = 1_000;
14
+ const CLAUDE_HEAD_TURNS = 200;
15
+ const CLAUDE_TAIL_TURNS = 500;
16
+ const CLAUDE_MIDDLE_WINDOWS = 3;
17
+ const CLAUDE_MIDDLE_WINDOW_TURNS = 100;
10
18
  const PROBLEM_META = {
11
19
  P01: { label: "Outdated Images & Screenshots", bucket: "dead", category: "Runtime Bug", confidence: "high" },
12
20
  P02: { label: "Ignored User Instructions", bucket: "refind", category: "Model Behavior", confidence: "medium" },
@@ -350,26 +358,31 @@ function resultItem(args) {
350
358
  }
351
359
  function scoreClaudeNativeSession(parsed) {
352
360
  classifyItems(parsed.items);
353
- const requestRows = [];
361
+ const terminalRequestByTurn = new Map();
354
362
  for (const request of parsed.requests) {
355
363
  const rawBuckets = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
356
364
  const buckets = { duplicate: 0, refind: 0, dead: 0, unattributed: 0 };
357
365
  const problemStats = new Map();
358
- const occurrences = [];
359
- const priorItems = parsed.items
360
- .filter((item) => item.requestSeq < request.seq)
361
- .flatMap((item) => {
366
+ // Episode rendering only needs totals by episode/problem/bucket. Aggregating here avoids
367
+ // retaining one occurrence for every prior item on every request (quadratic heap growth).
368
+ const occurrenceMap = new Map();
369
+ const priorItems = [];
370
+ let usefulItemTokens = 0;
371
+ let wasteItemTokens = 0;
372
+ for (const item of parsed.items) {
373
+ if (item.requestSeq >= request.seq)
374
+ continue;
362
375
  const classification = classForRequest(item, request);
363
- return classification ? [{ item, classification }] : [];
364
- });
376
+ if (!classification)
377
+ continue;
378
+ priorItems.push({ item, classification });
379
+ if (classification.kind === "useful")
380
+ usefulItemTokens += item.tokens;
381
+ else
382
+ wasteItemTokens += item.tokens;
383
+ }
365
384
  const overhead = Math.min(request.inputTokens, parsed.overheadTokens);
366
385
  const productBudget = Math.max(0, request.inputTokens - overhead);
367
- const usefulItemTokens = priorItems
368
- .filter(({ classification }) => classification.kind === "useful")
369
- .reduce((sum, { item }) => sum + item.tokens, 0);
370
- const wasteItemTokens = priorItems
371
- .filter(({ classification }) => classification.kind === "waste")
372
- .reduce((sum, { item }) => sum + item.tokens, 0);
373
386
  const totalObserved = usefulItemTokens + wasteItemTokens;
374
387
  const scale = totalObserved > productBudget && totalObserved > 0 ? productBudget / totalObserved : 1;
375
388
  const keepProd = usefulItemTokens * scale;
@@ -413,7 +426,7 @@ function scoreClaudeNativeSession(parsed) {
413
426
  });
414
427
  }
415
428
  problemStats.set(classification.problemId, stat);
416
- occurrences.push({
429
+ recordNativeOccurrence(occurrenceMap, {
417
430
  episode: request.episode,
418
431
  problemId: classification.problemId,
419
432
  bucket: classification.bucket,
@@ -437,7 +450,7 @@ function scoreClaudeNativeSession(parsed) {
437
450
  problemStats,
438
451
  buckets,
439
452
  rawBuckets,
440
- occurrences,
453
+ occurrences: occurrenceMap,
441
454
  requestUsefulTokens: rawUseful,
442
455
  requestWasteTokens: rawResidue,
443
456
  });
@@ -466,7 +479,7 @@ function scoreClaudeNativeSession(parsed) {
466
479
  turnWasteTokens: Math.round(rawResidue),
467
480
  }];
468
481
  }
469
- requestRows.push({
482
+ terminalRequestByTurn.set(request.turn, {
470
483
  request,
471
484
  officialInputTokens: request.inputTokens,
472
485
  usefulTokens: rawUseful,
@@ -477,10 +490,11 @@ function scoreClaudeNativeSession(parsed) {
477
490
  buckets,
478
491
  rawBuckets,
479
492
  problems: problemStats,
480
- occurrences,
493
+ occurrences: [...occurrenceMap.values()],
481
494
  });
482
495
  }
483
- const selectedRows = selectTerminalRequestPerTurn(requestRows);
496
+ const selectedRows = [...terminalRequestByTurn.values()]
497
+ .sort((left, right) => left.request.turn - right.request.turn || left.request.seq - right.request.seq);
484
498
  const selected = mergeRequestAccountingRows(selectedRows);
485
499
  const zeroBuckets = { duplicate: 0, refind: 0, dead: 0, unattributed: 0 };
486
500
  const zeroRawBuckets = { keep_oh: 0, keep_prod: 0, opt_dup: 0, opt_refind: 0, opt_dead: 0 };
@@ -537,7 +551,7 @@ function allocateResidualToProblemSignals(args) {
537
551
  });
538
552
  }
539
553
  args.problemStats.set(problemId, stat);
540
- args.occurrences.push({
554
+ recordNativeOccurrence(args.occurrences, {
541
555
  episode: args.request.episode,
542
556
  problemId,
543
557
  bucket,
@@ -548,15 +562,15 @@ function allocateResidualToProblemSignals(args) {
548
562
  }
549
563
  return allocated;
550
564
  }
551
- function selectTerminalRequestPerTurn(rows) {
552
- const byTurn = new Map();
553
- for (const row of rows) {
554
- const current = byTurn.get(row.request.turn);
555
- if (!current || row.request.seq > current.request.seq) {
556
- byTurn.set(row.request.turn, row);
557
- }
565
+ function recordNativeOccurrence(occurrences, occurrence) {
566
+ const key = `${occurrence.episode}:${occurrence.problemId}:${occurrence.bucket}`;
567
+ const existing = occurrences.get(key);
568
+ if (existing) {
569
+ existing.tokens += occurrence.tokens;
570
+ existing.turn = Math.max(existing.turn, occurrence.turn);
571
+ return;
558
572
  }
559
- return [...byTurn.values()].sort((left, right) => left.request.turn - right.request.turn || left.request.seq - right.request.seq);
573
+ occurrences.set(key, { ...occurrence });
560
574
  }
561
575
  function mergeRequestAccountingRows(rows) {
562
576
  const buckets = { duplicate: 0, refind: 0, dead: 0, unattributed: 0 };
@@ -1023,7 +1037,52 @@ function internalBucket(bucket) {
1023
1037
  return "opt_dead";
1024
1038
  }
1025
1039
  function readRows(file) {
1040
+ const totalTurns = countClaudeNativeTurns(file);
1041
+ const ranges = claudeTurnSampleRanges(totalTurns);
1026
1042
  const rows = [];
1043
+ let turn = 0;
1044
+ forEachJsonRow(file, (row) => {
1045
+ if (stringValue(row.type) === "user" && !isClaudeToolResultUser(row))
1046
+ turn += 1;
1047
+ if (turn === 0 || turnInClaudeRanges(turn, ranges))
1048
+ rows.push(row);
1049
+ });
1050
+ return rows;
1051
+ }
1052
+ function countClaudeNativeTurns(file) {
1053
+ let turns = 0;
1054
+ forEachJsonRow(file, (row) => {
1055
+ if (stringValue(row.type) === "user" && !isClaudeToolResultUser(row))
1056
+ turns += 1;
1057
+ });
1058
+ return turns;
1059
+ }
1060
+ export function claudeTurnSampleRanges(totalTurns) {
1061
+ if (totalTurns <= CLAUDE_MAX_TURNS_PER_SESSION)
1062
+ return totalTurns > 0 ? [[1, totalTurns]] : [];
1063
+ const tailStart = totalTurns - CLAUDE_TAIL_TURNS + 1;
1064
+ const ranges = [
1065
+ [1, CLAUDE_HEAD_TURNS],
1066
+ [tailStart, totalTurns],
1067
+ ];
1068
+ const middleStart = CLAUDE_HEAD_TURNS + 1;
1069
+ const middleEnd = tailStart - 1;
1070
+ const middleSpan = Math.max(0, middleEnd - middleStart + 1);
1071
+ for (let index = 0; index < CLAUDE_MIDDLE_WINDOWS; index += 1) {
1072
+ const segmentStart = middleStart + Math.floor((middleSpan * index) / CLAUDE_MIDDLE_WINDOWS);
1073
+ const segmentEnd = middleStart + Math.floor((middleSpan * (index + 1)) / CLAUDE_MIDDLE_WINDOWS) - 1;
1074
+ const segmentTurns = Math.max(0, segmentEnd - segmentStart + 1);
1075
+ const windowTurns = Math.min(CLAUDE_MIDDLE_WINDOW_TURNS, segmentTurns);
1076
+ const start = segmentStart + Math.floor((segmentTurns - windowTurns) / 2);
1077
+ if (windowTurns > 0)
1078
+ ranges.push([start, start + windowTurns - 1]);
1079
+ }
1080
+ return ranges.sort((left, right) => left[0] - right[0]);
1081
+ }
1082
+ function turnInClaudeRanges(turn, ranges) {
1083
+ return ranges.some(([start, end]) => turn >= start && turn <= end);
1084
+ }
1085
+ function forEachJsonRow(file, visit) {
1027
1086
  const fd = fs.openSync(file, "r");
1028
1087
  const decoder = new StringDecoder("utf8");
1029
1088
  const buffer = Buffer.allocUnsafe(1 << 20);
@@ -1034,7 +1093,7 @@ function readRows(file) {
1034
1093
  try {
1035
1094
  const parsed = JSON.parse(line);
1036
1095
  if (isRecord(parsed))
1037
- rows.push(parsed);
1096
+ visit(parsed);
1038
1097
  }
1039
1098
  catch {
1040
1099
  // Claude Code may leave an active final JSONL line partial.
@@ -1064,7 +1123,6 @@ function readRows(file) {
1064
1123
  finally {
1065
1124
  fs.closeSync(fd);
1066
1125
  }
1067
- return rows;
1068
1126
  }
1069
1127
  function isClaudeToolResultUser(record) {
1070
1128
  const content = recordValue(record.message).content;
package/dist/forensics.js CHANGED
@@ -1158,9 +1158,9 @@ function invalidSetupReport(code, message) {
1158
1158
  /**
1159
1159
  * Fail-closed boundary between the local scanner and setup UI.
1160
1160
  *
1161
- * The setup page must never infer that a fixture, partial scan, or internally inconsistent token
1162
- * ledger is the user's report. This validator checks the provenance and the cross-ledger identities
1163
- * the UI relies on before any absolute token number is rendered.
1161
+ * The setup page must never infer that a fixture, unreconciled scan, or internally inconsistent
1162
+ * token ledger is the user's report. This validator checks provenance and the cross-ledger
1163
+ * identities the UI relies on before any absolute token number is rendered.
1164
1164
  */
1165
1165
  export function validateForensicReportForSetup(value) {
1166
1166
  const report = recordValue(value);
@@ -1268,6 +1268,7 @@ export function validateForensicReportForSetup(value) {
1268
1268
  return invalidSetupReport("REPORT_COST_INVALID", "Per-workspace costs do not reconcile with total spend.");
1269
1269
  }
1270
1270
  const diagnostics = canonical.diagnostics === undefined ? null : recordValue(canonical.diagnostics);
1271
+ let skippedCanonicalSessions = 0;
1271
1272
  if (canonical.diagnostics !== undefined && !diagnostics) {
1272
1273
  return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics are malformed.");
1273
1274
  }
@@ -1277,12 +1278,15 @@ export function validateForensicReportForSetup(value) {
1277
1278
  if (skippedSessions === null || !Array.isArray(errors)) {
1278
1279
  return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics are malformed.");
1279
1280
  }
1280
- if (skippedSessions > 0 || errors.length > 0) {
1281
- return invalidSetupReport("REPORT_PARTIAL", "Some local sessions could not be analyzed; no partial report was shown.");
1281
+ if (skippedSessions !== errors.length) {
1282
+ return invalidSetupReport("REPORT_MALFORMED", "Canonical scan diagnostics do not reconcile.");
1282
1283
  }
1284
+ skippedCanonicalSessions = skippedSessions;
1283
1285
  }
1284
- if (sessionCount !== canonicalSessionCount) {
1285
- return invalidSetupReport("REPORT_COHORT_MISMATCH", "Provider and canonical ledgers cover different session cohorts.");
1286
+ if (canonicalSessionCount > sessionCount ||
1287
+ canonicalSessionCount + skippedCanonicalSessions !== sessionCount ||
1288
+ (sessionCount > 0 && canonicalSessionCount === 0)) {
1289
+ return invalidSetupReport("REPORT_COHORT_MISMATCH", "Canonical analysis did not retain a usable local-session cohort.");
1286
1290
  }
1287
1291
  if (canonicalUseful + canonicalWaste !== canonicalInput) {
1288
1292
  return invalidSetupReport("REPORT_CANONICAL_INVALID", "Canonical useful and waste totals do not reconcile.");
@@ -1517,8 +1521,7 @@ export async function buildForensicReport(opts) {
1517
1521
  }
1518
1522
  if (report.canonicalGoldenStandard &&
1519
1523
  !("error" in report.canonicalGoldenStandard) &&
1520
- (report.canonicalGoldenStandard.diagnostics?.skippedSessions ?? 0) === 0 &&
1521
- report.scale.sessionCount === report.canonicalGoldenStandard.summary.sessionsAnalyzed) {
1524
+ report.canonicalGoldenStandard.summary.sessionsAnalyzed > 0) {
1522
1525
  const projection = projectCanonicalWasteToBilledInput(Number(report.scale.totalInputTokens || 0), report.canonicalGoldenStandard.summary);
1523
1526
  if (projection)
1524
1527
  report.billedWasteProjection = projection;