@echomem/mcp 1.4.1 → 1.4.3
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 +42 -7
- package/assets/hud/echo-face-cutout.png +0 -0
- package/dist/hud/adapters.js +288 -0
- package/dist/hud/api.js +29 -0
- package/dist/hud/capsule.js +125 -0
- package/dist/hud/cli.js +142 -0
- package/dist/hud/electron-main.js +224 -0
- package/dist/hud/fs.js +63 -0
- package/dist/hud/hooks.js +50 -0
- package/dist/hud/metric.js +158 -0
- package/dist/hud/monitor.js +106 -0
- package/dist/hud/preload.cjs +10 -0
- package/dist/hud/render.js +39 -0
- package/dist/hud/report.js +125 -0
- package/dist/hud/server.js +95 -0
- package/dist/hud/web.js +509 -0
- package/dist/index.js +119 -6
- package/dist/package-metadata.js +32 -0
- package/dist/report.js +1 -1
- package/dist/setup.js +386 -5
- package/dist/v1-contract.js +61 -2
- package/package.json +12 -8
package/README.md
CHANGED
|
@@ -35,23 +35,57 @@ legacy EchoMem-synthesized recall answer.
|
|
|
35
35
|
## Quick start (recommended)
|
|
36
36
|
|
|
37
37
|
```bash
|
|
38
|
-
# One command: detect your editor, write
|
|
39
|
-
|
|
38
|
+
# One command: install the CLI, detect your editor, write MCP config, log in via the browser.
|
|
39
|
+
npm i -g @echomem/mcp@latest && echomem-mcp setup
|
|
40
40
|
```
|
|
41
41
|
|
|
42
|
-
`
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
42
|
+
This keeps `echomem-mcp` available for later commands like `status`, `update`, `unlock`, and
|
|
43
|
+
playground setup. `setup` detects the client (Cursor / Windsurf / Claude Desktop), writes its MCP
|
|
44
|
+
config (with **no secret** in it — credentials live in `~/.echomem/credentials.json`, mode 0600),
|
|
45
|
+
then opens the browser to approve the device and, for encrypted accounts, unlock the vault. Reload
|
|
46
|
+
your editor and you're done.
|
|
46
47
|
|
|
47
48
|
| Command | What it does |
|
|
48
49
|
|---|---|
|
|
50
|
+
| `npm i -g @echomem/mcp@latest && echomem-mcp setup` | Install the CLI globally and run setup in one explicit step |
|
|
51
|
+
| `npx -y @echomem/mcp@latest setup` | One-off setup without keeping a global CLI command |
|
|
49
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 |
|
|
55
|
+
| `echomem-mcp setup --with-hud [--client codex]` | Write client config + log in + launch the EchoMem context HUD |
|
|
50
56
|
| `echomem-mcp login` | Approve device in browser (or use `--token` / `--passphrase`) |
|
|
51
57
|
| `echomem-mcp unlock` | Re-derive the encryption key after its TTL (or `--passphrase`) |
|
|
52
58
|
| `echomem-mcp status` | Show token / key / detected clients |
|
|
53
59
|
| `echomem-mcp logout` | Remove stored credentials |
|
|
54
60
|
|
|
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.
|
|
64
|
+
|
|
65
|
+
## EchoMem Context HUD
|
|
66
|
+
|
|
67
|
+
The package also ships `echomem-hud`, a local context-health sidecar for Codex and Claude. It reads
|
|
68
|
+
local agent logs, computes a tracked lower-bound "clean vs dirty" context score, and renders a small
|
|
69
|
+
HUD without adding tokens to the agent conversation.
|
|
70
|
+
|
|
71
|
+
```bash
|
|
72
|
+
echomem-hud app # Electron floating HUD
|
|
73
|
+
echomem-hud serve # Browser fallback at http://127.0.0.1:17377
|
|
74
|
+
echomem-hud summary --json # Machine-readable current score
|
|
75
|
+
echomem-hud status # Show detected Codex/Claude sources
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
Supported local sources:
|
|
79
|
+
- Codex: `~/.codex/sessions/**/rollout-*.jsonl`
|
|
80
|
+
- Claude Code: `~/.claude/echo-ctx/*.json` first, then local transcripts when present
|
|
81
|
+
- Claude desktop agent/Cowork: local agent-mode transcripts under Application Support
|
|
82
|
+
|
|
83
|
+
The v1 metric counts `range_redundant` reads only and reports pollution as `tracked dead-weight ≥`
|
|
84
|
+
because exact context composition and provider eviction are not observable.
|
|
85
|
+
|
|
86
|
+
MCP fallback: agents can call `echo_context_health` to get the same score as markdown in-chat. This
|
|
87
|
+
is on-demand; the passive HUD remains a separate local process.
|
|
88
|
+
|
|
55
89
|
### Manual / headless (SSH, containers, CI)
|
|
56
90
|
|
|
57
91
|
No browser? Provide secrets directly — this is the documented headless path:
|
|
@@ -80,7 +114,7 @@ npm test # crypto compat + encrypted-local integration tests
|
|
|
80
114
|
|
|
81
115
|
### Add to your IDE (Cursor or Windsurf)
|
|
82
116
|
|
|
83
|
-
> Prefer `
|
|
117
|
+
> Prefer `npm i -g @echomem/mcp@latest && echomem-mcp setup` above — it writes these files for you and keeps secrets out of
|
|
84
118
|
> the client config. The manual steps below are the fallback.
|
|
85
119
|
|
|
86
120
|
#### For Cursor
|
|
@@ -151,6 +185,7 @@ ECHO_API_TOKEN="your_token" ECHO_API_BASE_URL="http://localhost:3000" npm run st
|
|
|
151
185
|
* **`search_memories_by_keywords`**: Retrieve memories by matching the `keys` field.
|
|
152
186
|
* **`search_others_memories`**: Search other users' public memories through MemoryFeed public search.
|
|
153
187
|
* **`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.
|
|
188
|
+
* **`echo_context_health`**: Return the local Codex/Claude context-health score as markdown. Works without login and uploads no transcript content.
|
|
154
189
|
|
|
155
190
|
Legacy aliases are preserved for compatibility:
|
|
156
191
|
|
|
Binary file
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { homePath, newestFile, readJsonl, walkFiles } from "./fs.js";
|
|
4
|
+
import { bumpTurn, newMetricState, recordEdit, recordRead, recordTool, scoreMetric, shellRead, } from "./metric.js";
|
|
5
|
+
export const adapters = {
|
|
6
|
+
codex: {
|
|
7
|
+
client: "codex",
|
|
8
|
+
label: "Codex",
|
|
9
|
+
findActive: findActiveCodex,
|
|
10
|
+
findAll: listCodex,
|
|
11
|
+
score: scoreCodex,
|
|
12
|
+
},
|
|
13
|
+
"claude-code": {
|
|
14
|
+
client: "claude-code",
|
|
15
|
+
label: "Claude Code",
|
|
16
|
+
findActive: findActiveClaudeCode,
|
|
17
|
+
findAll: listClaudeCode,
|
|
18
|
+
score: scoreClaudeCode,
|
|
19
|
+
},
|
|
20
|
+
"claude-desktop": {
|
|
21
|
+
client: "claude-desktop",
|
|
22
|
+
label: "Claude Desktop",
|
|
23
|
+
findActive: findActiveClaudeDesktop,
|
|
24
|
+
findAll: listClaudeDesktop,
|
|
25
|
+
score: scoreClaudeDesktop,
|
|
26
|
+
},
|
|
27
|
+
};
|
|
28
|
+
export function adapterList(mode) {
|
|
29
|
+
if (mode === "both" || mode === "auto")
|
|
30
|
+
return Object.values(adapters);
|
|
31
|
+
return [adapters[mode]];
|
|
32
|
+
}
|
|
33
|
+
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)));
|
|
36
|
+
}
|
|
37
|
+
function findActiveCodex() {
|
|
38
|
+
return newestFile(listCodex());
|
|
39
|
+
}
|
|
40
|
+
function scoreCodex(file) {
|
|
41
|
+
const state = newMetricState();
|
|
42
|
+
let ctTokens = 0;
|
|
43
|
+
let modelContextWindow = 0;
|
|
44
|
+
let compactMarkers = 0;
|
|
45
|
+
let patchEdits = 0;
|
|
46
|
+
let functionOutputs = 0;
|
|
47
|
+
let largeFunctionOutputs = 0;
|
|
48
|
+
let lastTool = "";
|
|
49
|
+
const largeByTool = {};
|
|
50
|
+
const outputTokensByTool = {};
|
|
51
|
+
const filesByTool = {};
|
|
52
|
+
let updatedAt = new Date().toISOString();
|
|
53
|
+
for (const record of readJsonl(file)) {
|
|
54
|
+
const top = isRecord(record) ? record : {};
|
|
55
|
+
const payload = isRecord(top.payload) ? top.payload : {};
|
|
56
|
+
const payloadType = typeof payload.type === "string" ? payload.type : "";
|
|
57
|
+
if (typeof top.timestamp === "string")
|
|
58
|
+
updatedAt = top.timestamp;
|
|
59
|
+
if (payloadType === "task_started")
|
|
60
|
+
bumpTurn(state);
|
|
61
|
+
else if (payloadType === "token_count") {
|
|
62
|
+
const info = isRecord(payload.info) ? payload.info : {};
|
|
63
|
+
const last = isRecord(info.last_token_usage) ? info.last_token_usage : {};
|
|
64
|
+
ctTokens = readNumber(last.input_tokens) || ctTokens;
|
|
65
|
+
modelContextWindow = readNumber(info.model_context_window) || modelContextWindow;
|
|
66
|
+
}
|
|
67
|
+
else if (payloadType === "function_call") {
|
|
68
|
+
const name = typeof payload.name === "string" ? payload.name : "";
|
|
69
|
+
recordTool(state, name);
|
|
70
|
+
if (name)
|
|
71
|
+
lastTool = name;
|
|
72
|
+
const args = parseArguments(payload.arguments);
|
|
73
|
+
const cmd = isRecord(args) && typeof args.cmd === "string" ? args.cmd : "";
|
|
74
|
+
if ((name === "exec_command" || name === "shell") && cmd) {
|
|
75
|
+
const read = shellRead(cmd);
|
|
76
|
+
if (read) {
|
|
77
|
+
recordRead(state, read.file, read.start, read.end);
|
|
78
|
+
addToolFile(filesByTool, name, read.file);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
else if (payloadType === "function_call_output") {
|
|
83
|
+
functionOutputs += 1;
|
|
84
|
+
const outTool = lastTool || "output";
|
|
85
|
+
const osize = outputSize(payload.output);
|
|
86
|
+
// Image outputs (base64 PNGs) cost ~IMG_TOK, not their byte length — Kobe's calibration.
|
|
87
|
+
const outTok = /image/i.test(outTool) ? 4000 : Math.round(osize / 4);
|
|
88
|
+
outputTokensByTool[outTool] = (outputTokensByTool[outTool] || 0) + outTok;
|
|
89
|
+
if (osize > 12_000) {
|
|
90
|
+
largeFunctionOutputs += 1;
|
|
91
|
+
largeByTool[outTool] = (largeByTool[outTool] || 0) + 1;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
else if (payloadType === "patch_apply_end") {
|
|
95
|
+
const changes = isRecord(payload.changes) ? payload.changes : {};
|
|
96
|
+
for (const changed of Object.keys(changes)) {
|
|
97
|
+
recordEdit(state, changed);
|
|
98
|
+
patchEdits += 1;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
else if (payloadType === "context_compacted" || top.type === "compacted") {
|
|
102
|
+
compactMarkers += 1;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const score = scoreMetric({
|
|
106
|
+
client: "codex",
|
|
107
|
+
sourcePath: file,
|
|
108
|
+
state,
|
|
109
|
+
ctTokens,
|
|
110
|
+
ctSource: "token_count",
|
|
111
|
+
modelContextWindow,
|
|
112
|
+
updatedAt,
|
|
113
|
+
stats: { compactMarkers, patchEdits, functionOutputs, largeFunctionOutputs },
|
|
114
|
+
});
|
|
115
|
+
if (Object.keys(largeByTool).length)
|
|
116
|
+
score.largeOutputsByTool = largeByTool;
|
|
117
|
+
if (Object.keys(outputTokensByTool).length)
|
|
118
|
+
score.outputTokensByTool = outputTokensByTool;
|
|
119
|
+
if (Object.keys(filesByTool).length)
|
|
120
|
+
score.filesByTool = topFilesByTool(filesByTool);
|
|
121
|
+
return score;
|
|
122
|
+
}
|
|
123
|
+
function listClaudeCode() {
|
|
124
|
+
return walkFiles(homePath(".claude", "projects"), (file) => file.endsWith(".jsonl"));
|
|
125
|
+
}
|
|
126
|
+
function findActiveClaudeCode() {
|
|
127
|
+
const cache = newestFile(walkFiles(homePath(".claude", "echo-ctx"), (file) => file.endsWith(".json")));
|
|
128
|
+
if (cache)
|
|
129
|
+
return cache;
|
|
130
|
+
return newestFile(listClaudeCode());
|
|
131
|
+
}
|
|
132
|
+
function scoreClaudeCode(file) {
|
|
133
|
+
if (file.includes(`${path.sep}.claude${path.sep}echo-ctx${path.sep}`) || file.endsWith(".json")) {
|
|
134
|
+
const raw = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
135
|
+
return normalizeCachedClaudeScore(raw, file, "claude-code", "statusline");
|
|
136
|
+
}
|
|
137
|
+
return scoreClaudeTranscript(file, "claude-code", "usage_estimate");
|
|
138
|
+
}
|
|
139
|
+
function listClaudeDesktop() {
|
|
140
|
+
const root = homePath("Library", "Application Support", "Claude", "local-agent-mode-sessions");
|
|
141
|
+
return walkFiles(root, (file) => file.endsWith(".jsonl") && file.includes(`${path.sep}.claude${path.sep}projects${path.sep}`));
|
|
142
|
+
}
|
|
143
|
+
function findActiveClaudeDesktop() {
|
|
144
|
+
return newestFile(listClaudeDesktop());
|
|
145
|
+
}
|
|
146
|
+
function scoreClaudeDesktop(file) {
|
|
147
|
+
return scoreClaudeTranscript(file, "claude-desktop", "usage_estimate");
|
|
148
|
+
}
|
|
149
|
+
function scoreClaudeTranscript(file, client, ctSource) {
|
|
150
|
+
const state = newMetricState();
|
|
151
|
+
let ctTokens = 0;
|
|
152
|
+
let updatedAt = new Date().toISOString();
|
|
153
|
+
const toolById = new Map();
|
|
154
|
+
const outputTokensByTool = {};
|
|
155
|
+
const filesByTool = {};
|
|
156
|
+
for (const record of readJsonl(file)) {
|
|
157
|
+
const obj = isRecord(record) ? record : {};
|
|
158
|
+
if (typeof obj.timestamp === "string")
|
|
159
|
+
updatedAt = obj.timestamp;
|
|
160
|
+
const message = isRecord(obj.message) ? obj.message : obj;
|
|
161
|
+
const role = typeof obj.type === "string" ? obj.type : typeof message.role === "string" ? message.role : "";
|
|
162
|
+
if (role === "assistant")
|
|
163
|
+
bumpTurn(state);
|
|
164
|
+
const usage = isRecord(message.usage) ? message.usage : isRecord(obj.usage) ? obj.usage : null;
|
|
165
|
+
if (usage) {
|
|
166
|
+
const input = readNumber(usage.input_tokens) || 0;
|
|
167
|
+
const cacheRead = readNumber(usage.cache_read_input_tokens) || 0;
|
|
168
|
+
const cacheCreate = readNumber(usage.cache_creation_input_tokens) || 0;
|
|
169
|
+
ctTokens = input + cacheRead + cacheCreate || ctTokens;
|
|
170
|
+
}
|
|
171
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
172
|
+
for (const block of content) {
|
|
173
|
+
if (!isRecord(block))
|
|
174
|
+
continue;
|
|
175
|
+
if (block.type === "tool_use") {
|
|
176
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
177
|
+
if (typeof block.id === "string" && name)
|
|
178
|
+
toolById.set(block.id, name);
|
|
179
|
+
const input = isRecord(block.input) ? block.input : {};
|
|
180
|
+
recordTool(state, name);
|
|
181
|
+
if (name === "Read" && typeof input.file_path === "string") {
|
|
182
|
+
const start = readNumber(input.offset) || 1;
|
|
183
|
+
const end = input.limit ? start + (readNumber(input.limit) || 1) - 1 : 1e9;
|
|
184
|
+
recordRead(state, input.file_path, start, end);
|
|
185
|
+
addToolFile(filesByTool, name, input.file_path);
|
|
186
|
+
}
|
|
187
|
+
else if ((name === "Edit" || name === "Write" || name === "MultiEdit") && typeof input.file_path === "string") {
|
|
188
|
+
recordEdit(state, input.file_path);
|
|
189
|
+
addToolFile(filesByTool, name, input.file_path);
|
|
190
|
+
}
|
|
191
|
+
else if (name === "Bash" && typeof input.command === "string") {
|
|
192
|
+
const read = shellRead(input.command);
|
|
193
|
+
if (read) {
|
|
194
|
+
recordRead(state, read.file, read.start, read.end);
|
|
195
|
+
addToolFile(filesByTool, "Bash", read.file);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
else if (block.type === "tool_result") {
|
|
200
|
+
const id = typeof block.tool_use_id === "string" ? block.tool_use_id : "";
|
|
201
|
+
const tool = toolById.get(id) || "tool";
|
|
202
|
+
outputTokensByTool[tool] = (outputTokensByTool[tool] || 0) + claudeResultTokens(block.content);
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
const score = scoreMetric({ client, sourcePath: file, state, ctTokens, ctSource, updatedAt });
|
|
207
|
+
if (Object.keys(outputTokensByTool).length)
|
|
208
|
+
score.outputTokensByTool = outputTokensByTool;
|
|
209
|
+
if (Object.keys(filesByTool).length)
|
|
210
|
+
score.filesByTool = topFilesByTool(filesByTool);
|
|
211
|
+
return score;
|
|
212
|
+
}
|
|
213
|
+
function addToolFile(map, tool, file) {
|
|
214
|
+
if (!tool || !file)
|
|
215
|
+
return;
|
|
216
|
+
const inner = map[tool] || (map[tool] = {});
|
|
217
|
+
inner[file] = (inner[file] || 0) + 1;
|
|
218
|
+
}
|
|
219
|
+
function topFilesByTool(map) {
|
|
220
|
+
const out = {};
|
|
221
|
+
for (const tool of Object.keys(map)) {
|
|
222
|
+
out[tool] = Object.entries(map[tool]).sort((a, b) => b[1] - a[1]).slice(0, 12).map((entry) => entry[0]);
|
|
223
|
+
}
|
|
224
|
+
return out;
|
|
225
|
+
}
|
|
226
|
+
function claudeResultTokens(content) {
|
|
227
|
+
if (typeof content === "string")
|
|
228
|
+
return Math.round(outputSize(content) / 4);
|
|
229
|
+
if (Array.isArray(content)) {
|
|
230
|
+
let total = 0;
|
|
231
|
+
for (const block of content) {
|
|
232
|
+
if (!isRecord(block))
|
|
233
|
+
continue;
|
|
234
|
+
if (block.type === "image")
|
|
235
|
+
total += 4000; // IMG_TOK, not base64 byte-length
|
|
236
|
+
else if (typeof block.text === "string")
|
|
237
|
+
total += Math.round(outputSize(block.text) / 4);
|
|
238
|
+
else
|
|
239
|
+
total += Math.round(outputSize(block) / 4);
|
|
240
|
+
}
|
|
241
|
+
return total;
|
|
242
|
+
}
|
|
243
|
+
return Math.round(outputSize(content) / 4);
|
|
244
|
+
}
|
|
245
|
+
function normalizeCachedClaudeScore(raw, file, client, ctSource) {
|
|
246
|
+
const ctTokens = readNumber(raw.ctTokens) || readNumber(raw.size) || readNumber(raw.contextTokens) || 0;
|
|
247
|
+
const pollutionTok = readNumber(raw.pollutionTok) || readNumber(raw.redundantTok) || readNumber(raw.pollution_tokens) || 0;
|
|
248
|
+
const state = newMetricState();
|
|
249
|
+
state.turn = readNumber(raw.turn) || 0;
|
|
250
|
+
state.reads = readNumber(raw.reads) || 0;
|
|
251
|
+
state.redundantCount = readNumber(raw.redundantCount) || 0;
|
|
252
|
+
state.buckets.range_redundant.tokens = pollutionTok;
|
|
253
|
+
state.buckets.range_redundant.count = state.redundantCount;
|
|
254
|
+
return scoreMetric({
|
|
255
|
+
client,
|
|
256
|
+
sourcePath: file,
|
|
257
|
+
state,
|
|
258
|
+
ctTokens,
|
|
259
|
+
ctSource,
|
|
260
|
+
modelContextWindow: readNumber(raw.modelContextWindow) || null,
|
|
261
|
+
updatedAt: new Date().toISOString(),
|
|
262
|
+
});
|
|
263
|
+
}
|
|
264
|
+
function parseArguments(value) {
|
|
265
|
+
if (!value)
|
|
266
|
+
return {};
|
|
267
|
+
if (isRecord(value))
|
|
268
|
+
return value;
|
|
269
|
+
if (typeof value !== "string")
|
|
270
|
+
return {};
|
|
271
|
+
try {
|
|
272
|
+
return JSON.parse(value);
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return {};
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
function outputSize(value) {
|
|
279
|
+
if (typeof value === "string")
|
|
280
|
+
return Buffer.byteLength(value);
|
|
281
|
+
return Buffer.byteLength(JSON.stringify(value || ""));
|
|
282
|
+
}
|
|
283
|
+
function readNumber(value) {
|
|
284
|
+
return typeof value === "number" && Number.isFinite(value) ? value : undefined;
|
|
285
|
+
}
|
|
286
|
+
function isRecord(value) {
|
|
287
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
288
|
+
}
|
package/dist/hud/api.js
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { HudMonitor } from "./monitor.js";
|
|
2
|
+
import { renderStateText } from "./render.js";
|
|
3
|
+
import { buildCapsuleText } from "./capsule.js";
|
|
4
|
+
export async function contextHealthMarkdown(mode = "auto") {
|
|
5
|
+
const state = await snapshot(mode);
|
|
6
|
+
const base = renderStateText(state);
|
|
7
|
+
const active = state.active;
|
|
8
|
+
// When the window is amber/red, point the agent at the action half of the loop.
|
|
9
|
+
if (active && active.color !== "green") {
|
|
10
|
+
const sat = active.saturationPct !== null ? `${active.saturationPct}% full · ` : "";
|
|
11
|
+
return `${base}\n\n→ Context is heavy (${sat}${active.pollutionPct}% tracked pollution). Call echo_recompose to capture a clean-start capsule and begin a fresh session before the window auto-compacts.`;
|
|
12
|
+
}
|
|
13
|
+
return base;
|
|
14
|
+
}
|
|
15
|
+
export async function recomposeCapsuleMarkdown(mode = "auto") {
|
|
16
|
+
const state = await snapshot(mode);
|
|
17
|
+
if (!state.active) {
|
|
18
|
+
return "EchoMem: no active local Codex/Claude session found to recompose. Open a coding session and try again.";
|
|
19
|
+
}
|
|
20
|
+
return buildCapsuleText(state.active);
|
|
21
|
+
}
|
|
22
|
+
async function snapshot(mode) {
|
|
23
|
+
const monitor = new HudMonitor(mode, 250);
|
|
24
|
+
monitor.start();
|
|
25
|
+
await new Promise((resolve) => setTimeout(resolve, 300));
|
|
26
|
+
const state = monitor.snapshot();
|
|
27
|
+
monitor.stop();
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
// Builds an inspectable "clean-start capsule" from the active local session: the goal, the files in
|
|
2
|
+
// play, the most recent instruction, and where things stand. Reuses the scorer's Score for the
|
|
3
|
+
// working set + health numbers, and does one tolerant pass over the raw log for user turns + edits.
|
|
4
|
+
// Pull model only — returns text the user pastes into a fresh session; it never mutates the live
|
|
5
|
+
// window (no provider API for that). Tracked-lower-bound honesty matches the rest of the HUD.
|
|
6
|
+
import { readJsonl } from "./fs.js";
|
|
7
|
+
import { clientLabel } from "./render.js";
|
|
8
|
+
import { formatTokens } from "./metric.js";
|
|
9
|
+
const MAX_TURN_CHARS = 280;
|
|
10
|
+
const MAX_FILES = 10;
|
|
11
|
+
export function buildCapsuleText(score) {
|
|
12
|
+
const { turns, edited } = scanSession(score.sourcePath);
|
|
13
|
+
const goal = turns[0];
|
|
14
|
+
const lastAsk = turns.length > 1 ? turns[turns.length - 1] : undefined;
|
|
15
|
+
const reads = readFiles(score, edited);
|
|
16
|
+
const sat = score.saturationPct !== null
|
|
17
|
+
? ` · ${score.saturationPct}% of ${formatTokens(score.modelContextWindow || 0)} window`
|
|
18
|
+
: "";
|
|
19
|
+
const lines = [
|
|
20
|
+
`# EchoMem clean-start capsule — ${clientLabel(score.client)}`,
|
|
21
|
+
`Captured ${score.updatedAt} · ${score.usefulPct}% clean${sat}`,
|
|
22
|
+
"",
|
|
23
|
+
"## Goal",
|
|
24
|
+
goal || "(state the goal of this session)",
|
|
25
|
+
];
|
|
26
|
+
if (lastAsk)
|
|
27
|
+
lines.push("", "## Most recent instruction", lastAsk);
|
|
28
|
+
if (edited.length)
|
|
29
|
+
lines.push("", "## Files being edited", ...edited.slice(0, MAX_FILES).map((f) => `- ${f}`));
|
|
30
|
+
if (reads.length)
|
|
31
|
+
lines.push("", "## Other files read", ...reads.slice(0, MAX_FILES).map((f) => `- ${f}`));
|
|
32
|
+
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
|
+
return lines.join("\n");
|
|
34
|
+
}
|
|
35
|
+
// 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);
|
|
38
|
+
const seen = new Set();
|
|
39
|
+
for (const files of Object.values(score.filesByTool || {})) {
|
|
40
|
+
for (const file of files) {
|
|
41
|
+
if (file && !editedSet.has(file))
|
|
42
|
+
seen.add(file);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
return [...seen];
|
|
46
|
+
}
|
|
47
|
+
function scanSession(file) {
|
|
48
|
+
const turns = [];
|
|
49
|
+
const edited = new Set();
|
|
50
|
+
let records = [];
|
|
51
|
+
try {
|
|
52
|
+
records = readJsonl(file);
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
return { turns, edited: [] };
|
|
56
|
+
}
|
|
57
|
+
for (const record of records) {
|
|
58
|
+
if (!isRecord(record))
|
|
59
|
+
continue;
|
|
60
|
+
const text = userTextFromRecord(record);
|
|
61
|
+
if (text)
|
|
62
|
+
turns.push(text);
|
|
63
|
+
collectEdited(record, edited);
|
|
64
|
+
}
|
|
65
|
+
return { turns, edited: [...edited] };
|
|
66
|
+
}
|
|
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.
|
|
70
|
+
function userTextFromRecord(record) {
|
|
71
|
+
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")
|
|
82
|
+
return null;
|
|
83
|
+
const raw = (message ? message.content : undefined) ?? payload.content ?? payload.text;
|
|
84
|
+
const text = clean(flatten(raw));
|
|
85
|
+
if (!text || text.startsWith("<") || text.length < 3)
|
|
86
|
+
return null;
|
|
87
|
+
return text.length > MAX_TURN_CHARS ? `${text.slice(0, MAX_TURN_CHARS)}…` : text;
|
|
88
|
+
}
|
|
89
|
+
function collectEdited(record, set) {
|
|
90
|
+
const payload = isRecord(record.payload) ? record.payload : record;
|
|
91
|
+
if (payload.type === "patch_apply_end" && isRecord(payload.changes)) {
|
|
92
|
+
for (const file of Object.keys(payload.changes))
|
|
93
|
+
if (file)
|
|
94
|
+
set.add(file);
|
|
95
|
+
}
|
|
96
|
+
const message = isRecord(record.message) ? record.message : payload;
|
|
97
|
+
const content = Array.isArray(message.content) ? message.content : [];
|
|
98
|
+
for (const block of content) {
|
|
99
|
+
if (!isRecord(block) || block.type !== "tool_use")
|
|
100
|
+
continue;
|
|
101
|
+
const name = typeof block.name === "string" ? block.name : "";
|
|
102
|
+
const input = isRecord(block.input) ? block.input : {};
|
|
103
|
+
if ((name === "Edit" || name === "Write" || name === "MultiEdit") && typeof input.file_path === "string") {
|
|
104
|
+
set.add(input.file_path);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
function flatten(content) {
|
|
109
|
+
if (typeof content === "string")
|
|
110
|
+
return content;
|
|
111
|
+
if (Array.isArray(content)) {
|
|
112
|
+
return content
|
|
113
|
+
.map((block) => (typeof block === "string" ? block : isRecord(block) && typeof block.text === "string" ? block.text : ""))
|
|
114
|
+
.join(" ");
|
|
115
|
+
}
|
|
116
|
+
if (isRecord(content) && typeof content.text === "string")
|
|
117
|
+
return content.text;
|
|
118
|
+
return "";
|
|
119
|
+
}
|
|
120
|
+
function clean(value) {
|
|
121
|
+
return value.replace(/\s+/g, " ").trim();
|
|
122
|
+
}
|
|
123
|
+
function isRecord(value) {
|
|
124
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
125
|
+
}
|
package/dist/hud/cli.js
ADDED
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { createRequire } from "node:module";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { adapterList } from "./adapters.js";
|
|
6
|
+
import { statSignature } from "./fs.js";
|
|
7
|
+
import { installHooks } from "./hooks.js";
|
|
8
|
+
import { HudMonitor } from "./monitor.js";
|
|
9
|
+
import { renderStateText } from "./render.js";
|
|
10
|
+
import { renderReportText, runReport } from "./report.js";
|
|
11
|
+
import { createHudServer } from "./server.js";
|
|
12
|
+
const argv = process.argv.slice(2);
|
|
13
|
+
const command = argv[0] || "summary";
|
|
14
|
+
const flags = parseFlags(argv.slice(1));
|
|
15
|
+
try {
|
|
16
|
+
if (command === "summary")
|
|
17
|
+
await cmdSummary(flags);
|
|
18
|
+
else if (command === "watch")
|
|
19
|
+
await cmdWatch(flags);
|
|
20
|
+
else if (command === "serve")
|
|
21
|
+
await cmdServe(flags);
|
|
22
|
+
else if (command === "app")
|
|
23
|
+
await cmdApp(flags);
|
|
24
|
+
else if (command === "install-hooks")
|
|
25
|
+
await cmdInstallHooks(flags);
|
|
26
|
+
else if (command === "status")
|
|
27
|
+
await cmdStatus(flags);
|
|
28
|
+
else if (command === "report")
|
|
29
|
+
await cmdReport(flags);
|
|
30
|
+
else if (command === "help" || command === "--help" || command === "-h")
|
|
31
|
+
printHelp();
|
|
32
|
+
else {
|
|
33
|
+
console.error(`Unknown command: ${command}`);
|
|
34
|
+
printHelp();
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
catch (error) {
|
|
39
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
40
|
+
process.exitCode = 1;
|
|
41
|
+
}
|
|
42
|
+
async function cmdSummary(flags) {
|
|
43
|
+
const mode = parseMode(flags.client);
|
|
44
|
+
const monitor = new HudMonitor(mode, 250);
|
|
45
|
+
monitor.start();
|
|
46
|
+
await sleep(300);
|
|
47
|
+
const state = monitor.snapshot();
|
|
48
|
+
monitor.stop();
|
|
49
|
+
if (flags.json)
|
|
50
|
+
console.log(JSON.stringify(state.active || state, null, 2));
|
|
51
|
+
else
|
|
52
|
+
console.log(renderStateText(state));
|
|
53
|
+
}
|
|
54
|
+
async function cmdWatch(flags) {
|
|
55
|
+
const mode = parseMode(flags.client);
|
|
56
|
+
const monitor = new HudMonitor(mode, readNumber(flags["poll-ms"], 750));
|
|
57
|
+
monitor.on("state", (state) => {
|
|
58
|
+
if (flags.json)
|
|
59
|
+
console.log(JSON.stringify(state.active || state));
|
|
60
|
+
else
|
|
61
|
+
console.log(`${new Date().toLocaleTimeString()} ${state.active ? renderStateText(state).split("\n")[0] : "no source"}`);
|
|
62
|
+
});
|
|
63
|
+
monitor.start();
|
|
64
|
+
}
|
|
65
|
+
async function cmdServe(flags) {
|
|
66
|
+
const server = await createHudServer({
|
|
67
|
+
mode: parseMode(flags.client),
|
|
68
|
+
port: readNumber(flags.port, 17377),
|
|
69
|
+
pollMs: readNumber(flags["poll-ms"], 750),
|
|
70
|
+
});
|
|
71
|
+
console.log(`EchoMem HUD listening at ${server.url}`);
|
|
72
|
+
}
|
|
73
|
+
async function cmdApp(flags) {
|
|
74
|
+
const require = createRequire(import.meta.url);
|
|
75
|
+
const electronPath = require("electron");
|
|
76
|
+
const mainPath = fileURLToPath(new URL("./electron-main.js", import.meta.url));
|
|
77
|
+
const args = [mainPath, "--client", String(flags.client || "auto"), "--port", String(flags.port || 17377)];
|
|
78
|
+
const child = spawn(electronPath, args, { detached: true, stdio: "ignore" });
|
|
79
|
+
child.unref();
|
|
80
|
+
console.log("EchoMem HUD app launched.");
|
|
81
|
+
}
|
|
82
|
+
async function cmdInstallHooks(flags) {
|
|
83
|
+
const paths = installHooks(parseMode(flags.client));
|
|
84
|
+
console.log(`Installed EchoMem HUD hook support:\n${paths.map((p) => `- ${p}`).join("\n")}`);
|
|
85
|
+
console.log("Codex users: run /hooks in a new Codex session to review and trust changed hooks.");
|
|
86
|
+
}
|
|
87
|
+
async function cmdReport(flags) {
|
|
88
|
+
const result = runReport(parseMode(flags.client), { limit: readNumber(flags.limit, 40) });
|
|
89
|
+
if (flags.json)
|
|
90
|
+
console.log(JSON.stringify(result, null, 2));
|
|
91
|
+
else
|
|
92
|
+
console.log(renderReportText(result));
|
|
93
|
+
}
|
|
94
|
+
async function cmdStatus(flags) {
|
|
95
|
+
const mode = parseMode(flags.client);
|
|
96
|
+
for (const adapter of adapterList(mode)) {
|
|
97
|
+
const file = adapter.findActive();
|
|
98
|
+
console.log(`${adapter.label}: ${file ? `found (${statSignature(file).split(":").slice(1).join(":")}) ${file}` : "missing"}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
function parseFlags(args) {
|
|
102
|
+
const parsed = {};
|
|
103
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
104
|
+
const arg = args[i];
|
|
105
|
+
if (!arg.startsWith("--"))
|
|
106
|
+
continue;
|
|
107
|
+
const key = arg.slice(2);
|
|
108
|
+
const next = args[i + 1];
|
|
109
|
+
if (next && !next.startsWith("--")) {
|
|
110
|
+
parsed[key] = next;
|
|
111
|
+
i += 1;
|
|
112
|
+
}
|
|
113
|
+
else {
|
|
114
|
+
parsed[key] = true;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
return parsed;
|
|
118
|
+
}
|
|
119
|
+
function parseMode(value) {
|
|
120
|
+
return value === "codex" || value === "claude-code" || value === "claude-desktop" || value === "both" || value === "auto"
|
|
121
|
+
? value
|
|
122
|
+
: "auto";
|
|
123
|
+
}
|
|
124
|
+
function readNumber(value, fallback) {
|
|
125
|
+
return typeof value === "string" ? Number(value) || fallback : fallback;
|
|
126
|
+
}
|
|
127
|
+
function sleep(ms) {
|
|
128
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
129
|
+
}
|
|
130
|
+
function printHelp() {
|
|
131
|
+
console.log(`EchoMem HUD
|
|
132
|
+
|
|
133
|
+
Usage:
|
|
134
|
+
echomem-hud summary [--client codex|claude-code|claude-desktop|auto] [--json]
|
|
135
|
+
echomem-hud watch [--client codex|claude-code|claude-desktop|auto] [--json]
|
|
136
|
+
echomem-hud serve [--client codex|claude-code|claude-desktop|both|auto] [--port 17377]
|
|
137
|
+
echomem-hud app [--client codex|claude-code|claude-desktop|both|auto]
|
|
138
|
+
echomem-hud install-hooks [--client codex|claude-code|both]
|
|
139
|
+
echomem-hud status
|
|
140
|
+
echomem-hud report [--client codex|claude-code|claude-desktop|auto] [--limit 40] [--json]
|
|
141
|
+
`);
|
|
142
|
+
}
|