@echomem/mcp 1.4.0 → 1.4.2
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/city/echo-ai-city-only.html +643 -35
- package/dist/city/personality_stickers/bossy.png +0 -0
- package/dist/city/personality_stickers/ghosty.png +0 -0
- package/dist/city/personality_stickers/loopy.png +0 -0
- package/dist/city/personality_stickers/lusty.png +0 -0
- package/dist/city/personality_stickers/maxxy.png +0 -0
- package/dist/city/personality_stickers/tabby.png +0 -0
- package/dist/forensics.js +9 -4
- 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 +507 -0
- package/dist/index.js +119 -6
- package/dist/package-metadata.js +32 -0
- package/dist/report.js +1 -1
- package/dist/setup-page.js +1 -2
- package/dist/setup.js +80 -1
- package/dist/v1-contract.js +61 -2
- package/package.json +12 -8
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/dist/forensics.js
CHANGED
|
@@ -191,7 +191,7 @@ class Forensics {
|
|
|
191
191
|
const name = repoLabel(cwd);
|
|
192
192
|
let r = this.repos.get(name);
|
|
193
193
|
if (!r) {
|
|
194
|
-
r = { name, cwd: null, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, codexTokens: 0, claudeTokens: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [] };
|
|
194
|
+
r = { name, cwd: null, sessions: new Set(), assistantMessages: 0, cold: 0, cacheWrite: 0, cacheRead: 0, output: 0, codexTokens: 0, claudeTokens: 0, reads: 0, rereads: 0, staleRereads: 0, userTimestamps: [], byDay: new Map() };
|
|
195
195
|
this.repos.set(name, r);
|
|
196
196
|
}
|
|
197
197
|
if (cwd && !r.cwd)
|
|
@@ -222,7 +222,7 @@ class Forensics {
|
|
|
222
222
|
this.allSessionIds.add(id);
|
|
223
223
|
}
|
|
224
224
|
/** assistant-turn token usage (already split into cold/cacheWrite/cacheRead/output). */
|
|
225
|
-
recordUsage(model, cwd, session, u, provider) {
|
|
225
|
+
recordUsage(model, cwd, session, u, provider, ms) {
|
|
226
226
|
const m = this.modelBucket(model);
|
|
227
227
|
m.messages += 1;
|
|
228
228
|
m.cold += u.cold;
|
|
@@ -244,6 +244,10 @@ class Forensics {
|
|
|
244
244
|
r.codexTokens += turnTokens;
|
|
245
245
|
else
|
|
246
246
|
r.claudeTokens += turnTokens;
|
|
247
|
+
if (ms != null && turnTokens > 0) {
|
|
248
|
+
const d = localDay(ms);
|
|
249
|
+
r.byDay.set(d, (r.byDay.get(d) || 0) + turnTokens);
|
|
250
|
+
}
|
|
247
251
|
}
|
|
248
252
|
}
|
|
249
253
|
recordEdit(target, cwd) {
|
|
@@ -414,6 +418,7 @@ class Forensics {
|
|
|
414
418
|
dominantProvider: repo.codexTokens >= repo.claudeTokens ? "codex" : "claude",
|
|
415
419
|
codexTokens: repo.codexTokens, claudeTokens: repo.claudeTokens,
|
|
416
420
|
commits,
|
|
421
|
+
daily: [...repo.byDay.entries()].sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)),
|
|
417
422
|
});
|
|
418
423
|
for (const b of wb.blocks) {
|
|
419
424
|
const d = localDay(b.start);
|
|
@@ -528,7 +533,7 @@ function feedClaude(file, eng) {
|
|
|
528
533
|
eng.recordUsage(o.message.model || "unknown", cwd, session, {
|
|
529
534
|
cold: u.input_tokens || 0, cacheWrite: u.cache_creation_input_tokens || 0,
|
|
530
535
|
cacheRead: u.cache_read_input_tokens || 0, output: u.output_tokens || 0,
|
|
531
|
-
}, "claude");
|
|
536
|
+
}, "claude", Number.isFinite(ts) ? ts : undefined);
|
|
532
537
|
const blocks = Array.isArray(o.message.content) ? o.message.content : [];
|
|
533
538
|
for (const b of blocks) {
|
|
534
539
|
if (b?.type !== "tool_use")
|
|
@@ -663,7 +668,7 @@ function replayFile(eng, fe) {
|
|
|
663
668
|
eng.noteSession(fe.session);
|
|
664
669
|
const provider = fe.source === "codex" ? "codex" : "claude";
|
|
665
670
|
for (const u of fe.usage)
|
|
666
|
-
eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output }, provider);
|
|
671
|
+
eng.recordUsage(u.model, fe.cwd, fe.session, { cold: u.cold, cacheWrite: 0, cacheRead: u.cacheRead, output: u.output }, provider, fe.firstTs ?? undefined);
|
|
667
672
|
for (const e of fe.ev) {
|
|
668
673
|
if (e.t === "m")
|
|
669
674
|
eng.recordUserMsg(fe.cwd, fe.session, e.ms);
|
|
@@ -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
|
+
}
|