@echomem/mcp 1.4.3 → 1.4.5

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
@@ -49,18 +49,22 @@ your editor and you're done.
49
49
  |---|---|
50
50
  | `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and run setup in one explicit step |
51
51
  | `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
52
- | `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop]` | Write client config + log in |
53
- | `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|codex]` | Write client config without opening the browser or changing credentials |
54
- | `npx -y @echomem/mcp@latest update [--client cursor\|windsurf\|claude-desktop\|codex]` | One-shot update: repoint the client config to the latest bridge, with no browser login |
52
+ | `echomem-mcp setup [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config + log in |
53
+ | `echomem-mcp setup --skip-login [--client cursor\|windsurf\|claude-desktop\|claude-code\|codex]` | Write client config without opening the browser or changing credentials |
54
+ | `npx -y @echomem/mcp@latest update --all` | One-shot update: repoint detected client configs to the latest bridge, with no browser login |
55
+ | `npx -y @echomem/mcp@latest update --client codex` | Update one client only |
55
56
  | `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
56
57
  | `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
57
58
  | `echomem-mcp unlock` | Re-derive the encryption key after its TTL (or `--passphrase`) |
58
- | `echomem-mcp status` | Show token / key / detected clients |
59
+ | `echomem-mcp status` | Show token / key / detected clients, configured bridge versions, and update guidance |
60
+ | `echomem-mcp doctor [--no-network]` | Diagnose configured client bridge versions |
59
61
  | `echomem-mcp logout` | Remove stored credentials |
60
62
 
61
- The bridge reports its package version in MCP server instructions and in the `search_memories`
62
- tool description. Agents should use that version signal to update once when stale, not auto-update
63
- on every MCP startup.
63
+ The bridge reports its package version in MCP server instructions and in tool descriptions. It also
64
+ checks npm for a newer published bridge using a cached, non-blocking check. Agents can call
65
+ `echomem_update_status` to show the user whether an update exists and then run
66
+ `npx -y @echomem/mcp@latest update --all` if the user agrees. The bridge does not auto-update on
67
+ every MCP startup.
64
68
 
65
69
  ## EchoMem Context HUD
66
70
 
@@ -185,6 +189,7 @@ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run st
185
189
  * **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
186
190
  * **`search_others_memories`**: Search other users' public memories through MemoryFeed public search.
187
191
  * **`delete_memory`**: Delete a single personal memory through a two-step confirmation flow. First call with `memoryId` only to preview the target and receive `confirmationToken`; after the user explicitly confirms, call again with `confirmed: true` and that exact token. This deletes the memory row only and preserves raw `source_of_truth` conversation records.
192
+ * **`echomem_update_status`**: Check the installed bridge against the latest published npm version. Works without login, uses cached background checks in normal operation, and returns the update command to show the user when a newer bridge exists.
188
193
  * **`echo_context_health`**: Return the local Codex/Claude context-health score as markdown. Works without login and uploads no transcript content.
189
194
 
190
195
  Legacy aliases are preserved for compatibility:
@@ -9,10 +9,13 @@ import { formatTokens } from "./metric.js";
9
9
  const MAX_TURN_CHARS = 280;
10
10
  const MAX_FILES = 10;
11
11
  export function buildCapsuleText(score) {
12
- const { turns, edited } = scanSession(score.sourcePath);
12
+ const { turns, edited, cwd } = scanSession(score.sourcePath);
13
13
  const goal = turns[0];
14
14
  const lastAsk = turns.length > 1 ? turns[turns.length - 1] : undefined;
15
- const reads = readFiles(score, edited);
15
+ // Normalize every path to repo-relative so "editing" (recorded absolute) and "read"
16
+ // (recorded relative) dedupe correctly and the capsule isn't bloated by the cwd prefix.
17
+ const editedRel = edited.map((f) => relToCwd(f, cwd));
18
+ const reads = readFiles(score, editedRel, cwd);
16
19
  const sat = score.saturationPct !== null
17
20
  ? ` · ${score.saturationPct}% of ${formatTokens(score.modelContextWindow || 0)} window`
18
21
  : "";
@@ -25,21 +28,23 @@ export function buildCapsuleText(score) {
25
28
  ];
26
29
  if (lastAsk)
27
30
  lines.push("", "## Most recent instruction", lastAsk);
28
- if (edited.length)
29
- lines.push("", "## Files being edited", ...edited.slice(0, MAX_FILES).map((f) => `- ${f}`));
31
+ if (editedRel.length)
32
+ lines.push("", "## Files being edited", ...editedRel.slice(0, MAX_FILES).map((f) => `- ${f}`));
30
33
  if (reads.length)
31
34
  lines.push("", "## Other files read", ...reads.slice(0, MAX_FILES).map((f) => `- ${f}`));
32
35
  lines.push("", "## Where things stand", `- Turns ${score.turn} · reads ${score.reads} · edits ${score.stats?.patchEdits ?? 0} · compactions ${score.stats?.compactMarkers ?? 0}`, `- Tracked dead-weight ≥ ${formatTokens(score.pollutionTok)} (${score.pollutionPct}% pollution, lower bound)`, "", "---", "Start a fresh session and paste this capsule (or call search_memories) so the new window begins clean — you keep the goal, the working set, and your last instruction without re-reading everything. This is a clean recompose, not a provider compaction.");
33
36
  return lines.join("\n");
34
37
  }
35
38
  // Read files the scorer already tracked, minus anything we know was edited (shown separately).
36
- function readFiles(score, edited) {
37
- const editedSet = new Set(edited);
39
+ // Both sides are relativized to cwd first so the edited/read dedupe actually matches.
40
+ function readFiles(score, editedRel, cwd) {
41
+ const editedSet = new Set(editedRel);
38
42
  const seen = new Set();
39
43
  for (const files of Object.values(score.filesByTool || {})) {
40
44
  for (const file of files) {
41
- if (file && !editedSet.has(file))
42
- seen.add(file);
45
+ const rel = relToCwd(file, cwd);
46
+ if (rel && !editedSet.has(rel))
47
+ seen.add(rel);
43
48
  }
44
49
  }
45
50
  return [...seen];
@@ -47,45 +52,83 @@ function readFiles(score, edited) {
47
52
  function scanSession(file) {
48
53
  const turns = [];
49
54
  const edited = new Set();
55
+ let cwd = "";
50
56
  let records = [];
51
57
  try {
52
58
  records = readJsonl(file);
53
59
  }
54
60
  catch {
55
- return { turns, edited: [] };
61
+ return { turns, edited: [], cwd };
56
62
  }
57
63
  for (const record of records) {
58
64
  if (!isRecord(record))
59
65
  continue;
66
+ if (!cwd)
67
+ cwd = recordCwd(record);
60
68
  const text = userTextFromRecord(record);
61
69
  if (text)
62
70
  turns.push(text);
63
71
  collectEdited(record, edited);
64
72
  }
65
- return { turns, edited: [...edited] };
73
+ return { turns, edited: [...edited], cwd };
66
74
  }
67
- // Tolerant across Codex (payload.role/payload.content) and Claude (message.role/message.content)
68
- // shapes. Skips tool results, assistant turns, and injected <context> wrappers; degrades to nothing
69
- // rather than throwing, so the capsule still renders from the working set + health.
75
+ function recordCwd(record) {
76
+ if (typeof record.cwd === "string")
77
+ return record.cwd;
78
+ const payload = isRecord(record.payload) ? record.payload : null;
79
+ if (payload && typeof payload.cwd === "string")
80
+ return payload.cwd;
81
+ return "";
82
+ }
83
+ function relToCwd(file, cwd) {
84
+ if (!file)
85
+ return file;
86
+ if (cwd && (file === cwd || file.startsWith(`${cwd}/`)))
87
+ return file.slice(cwd.length + 1) || file;
88
+ return file;
89
+ }
90
+ // Reads the user's real turns, matching the same sources assembleCodex/assembleClaude use so the
91
+ // goal/last-instruction can't be the injected preamble:
92
+ // - Codex: the `user_message` EVENT (payload.message is a plain string). The AGENTS.md preamble is a
93
+ // separate `message`/role:user ITEM, so reading events excludes it by construction — no blocklist.
94
+ // - Claude: the top-level `type:"user"` turn with message.content text blocks.
95
+ // Degrades to nothing rather than throwing, so the capsule still renders from the working set + health.
70
96
  function userTextFromRecord(record) {
71
97
  const payload = isRecord(record.payload) ? record.payload : record;
72
- const message = isRecord(payload.message) ? payload.message : isRecord(record.message) ? record.message : null;
73
- let role = "";
74
- if (typeof payload.role === "string")
75
- role = payload.role;
76
- else if (message && typeof message.role === "string")
77
- role = message.role;
78
- else if (typeof record.type === "string")
79
- role = record.type;
80
- const ptype = typeof payload.type === "string" ? payload.type : "";
81
- if (role !== "user" && ptype !== "user_message")
98
+ // Codex user input.
99
+ if (payload.type === "user_message" && typeof payload.message === "string") {
100
+ return finishTurn(clean(stripCodexScaffolding(payload.message)));
101
+ }
102
+ // Claude user turn (top-level type:"user"). Codex `message`/role:user items are NOT type:"user",
103
+ // so they never reach here — that's what keeps the AGENTS.md item and duplicate turns out.
104
+ if (record.type !== "user")
82
105
  return null;
83
- const raw = (message ? message.content : undefined) ?? payload.content ?? payload.text;
106
+ const message = isRecord(record.message) ? record.message : null;
107
+ const raw = message ? message.content : undefined;
84
108
  const text = clean(flatten(raw));
85
- if (!text || text.startsWith("<") || text.length < 3)
109
+ if (!text || text.startsWith("<"))
110
+ return null;
111
+ if (isInstructionsPreamble(text))
112
+ return null; // Claude project-rules / caveat injections
113
+ return finishTurn(text);
114
+ }
115
+ function finishTurn(text) {
116
+ if (!text || text.length < 3)
86
117
  return null;
87
118
  return text.length > MAX_TURN_CHARS ? `${text.slice(0, MAX_TURN_CHARS)}…` : text;
88
119
  }
120
+ // Strip Codex's synthetic wrapper blocks from a user message (mirrors migrate.ts cleanCodexUser).
121
+ function stripCodexScaffolding(msg) {
122
+ return String(msg || "")
123
+ .replace(/<(environment_context|user_instructions|permissions|app-context)>[\s\S]*?<\/\1>/g, "")
124
+ .trim();
125
+ }
126
+ // Claude-side injected preambles (project rules / harness caveats). Anchored to the START so a real
127
+ // instruction that merely mentions these words isn't dropped. Codex's AGENTS.md is handled
128
+ // structurally above, so it isn't listed here.
129
+ function isInstructionsPreamble(text) {
130
+ return /^#\s*claudeMd\b/i.test(text) || text.startsWith("Caveat:");
131
+ }
89
132
  function collectEdited(record, set) {
90
133
  const payload = isRecord(record.payload) ? record.payload : record;
91
134
  if (payload.type === "patch_apply_end" && isRecord(payload.changes)) {
@@ -2,7 +2,7 @@ import fs from "node:fs";
2
2
  import os from "node:os";
3
3
  import path from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { app, BrowserWindow, ipcMain, Menu, screen } from "electron";
5
+ import { app, BrowserWindow, ipcMain, Menu, screen, shell } from "electron";
6
6
  import { createHudServer } from "./server.js";
7
7
  const flags = parseFlags(process.argv.slice(2));
8
8
  const mode = parseMode(flags.client);
@@ -32,8 +32,16 @@ app.whenReady().then(async () => {
32
32
  writeBounds(win);
33
33
  });
34
34
  const preferredUrl = `http://127.0.0.1:${port}`;
35
+ const onReveal = (filePath) => {
36
+ try {
37
+ shell.showItemInFolder(filePath);
38
+ }
39
+ catch {
40
+ /* best-effort */
41
+ }
42
+ };
35
43
  try {
36
- hudServer = await createHudServer({ mode, port });
44
+ hudServer = await createHudServer({ mode, port, onReveal });
37
45
  createWindow(hudServer.url);
38
46
  }
39
47
  catch (error) {
@@ -41,7 +49,7 @@ app.whenReady().then(async () => {
41
49
  createWindow(preferredUrl);
42
50
  return;
43
51
  }
44
- hudServer = await createHudServer({ mode, port: 0 });
52
+ hudServer = await createHudServer({ mode, port: 0, onReveal });
45
53
  createWindow(hudServer.url);
46
54
  }
47
55
  }).catch((error) => {
@@ -7,6 +7,10 @@ export const BUCKETS = {
7
7
  };
8
8
  const SHELL_READ_BINS = new Set(["cat", "head", "tail", "sed", "nl", "less", "more", "bat"]);
9
9
  const EXT = /\.[A-Za-z0-9]{1,8}$/;
10
+ // A re-read counts as redundant only if a prior read substantially re-covers THIS read. A shared
11
+ // boundary line (sequential paging, e.g. sed 1,260p then 260,620p) is not a re-read; requiring ≥50%
12
+ // of the new range to be already-seen keeps paging out while still catching genuine sub-range re-reads.
13
+ const REDUNDANT_OVERLAP_FRACTION = 0.5;
10
14
  export function newMetricState() {
11
15
  return {
12
16
  turn: 0,
@@ -45,7 +49,13 @@ export function recordRead(state, file, start = 1, end = 1e9) {
45
49
  const tokens = estimateReadTokens(safeStart, safeEnd);
46
50
  const previous = state.readHist.get(file) || [];
47
51
  const lastEdit = state.editTurn.get(file) ?? -1;
48
- const redundant = previous.some((read) => read.turn >= lastEdit && safeStart <= read.end && safeEnd >= read.start);
52
+ const newLines = Math.max(1, safeEnd - safeStart + 1);
53
+ const redundant = previous.some((read) => {
54
+ if (read.turn < lastEdit)
55
+ return false; // an edit since this read invalidated it — re-read is fresh
56
+ const overlap = Math.min(safeEnd, read.end) - Math.max(safeStart, read.start) + 1;
57
+ return overlap > 0 && overlap / newLines >= REDUNDANT_OVERLAP_FRACTION;
58
+ });
49
59
  state.reads += 1;
50
60
  if (redundant) {
51
61
  addBucket(state, BUCKETS.rangeRedundant, tokens, 1);
@@ -132,9 +142,12 @@ function estimateReadTokens(start, end) {
132
142
  return Math.min(8000, Math.max(40, lines * 12));
133
143
  }
134
144
  function qualityColor(pollutionPct, saturationPct) {
135
- if (pollutionPct > 40 || (saturationPct !== null && saturationPct >= 95))
145
+ if (pollutionPct >= 35 ||
146
+ (saturationPct !== null && saturationPct >= 95) ||
147
+ (saturationPct !== null && saturationPct >= 85 && pollutionPct >= 20)) {
136
148
  return "red";
137
- if (pollutionPct >= 25 || (saturationPct !== null && saturationPct >= 90))
149
+ }
150
+ if (pollutionPct >= 18 || (saturationPct !== null && saturationPct >= 75))
138
151
  return "amber";
139
152
  return "green";
140
153
  }