@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/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
+ }
@@ -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
+ }
@@ -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
+ }