@echomem/mcp 1.4.1 → 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/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.js +80 -1
- package/dist/v1-contract.js +61 -2
- package/package.json +12 -8
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { app, BrowserWindow, ipcMain, Menu, screen } from "electron";
|
|
6
|
+
import { createHudServer } from "./server.js";
|
|
7
|
+
const flags = parseFlags(process.argv.slice(2));
|
|
8
|
+
const mode = parseMode(flags.client);
|
|
9
|
+
const port = typeof flags.port === "string" ? Number(flags.port) || 17377 : 17377;
|
|
10
|
+
const COLLAPSED_WIDTH = 360;
|
|
11
|
+
const COLLAPSED_HEIGHT = 112;
|
|
12
|
+
const EXPANDED_HEIGHT = 460;
|
|
13
|
+
let hudServer = null;
|
|
14
|
+
let mainWindow = null;
|
|
15
|
+
// Single-instance: a second `echomem-hud app` brings the existing HUD back instead of opening another.
|
|
16
|
+
const gotSingleInstanceLock = app.requestSingleInstanceLock();
|
|
17
|
+
if (!gotSingleInstanceLock)
|
|
18
|
+
app.quit();
|
|
19
|
+
app.on("second-instance", showExistingWindow);
|
|
20
|
+
app.whenReady().then(async () => {
|
|
21
|
+
if (!gotSingleInstanceLock)
|
|
22
|
+
return;
|
|
23
|
+
ipcMain.on("hud:set-open", (event, payload) => {
|
|
24
|
+
const open = typeof payload === "object" ? Boolean(payload.open) : Boolean(payload);
|
|
25
|
+
const measured = typeof payload === "object" && typeof payload.height === "number" ? payload.height : undefined;
|
|
26
|
+
const win = BrowserWindow.fromWebContents(event.sender);
|
|
27
|
+
if (!win)
|
|
28
|
+
return;
|
|
29
|
+
const bounds = win.getBounds();
|
|
30
|
+
const height = open ? clampExpandedHeight(measured && measured > 200 ? measured : EXPANDED_HEIGHT) : COLLAPSED_HEIGHT;
|
|
31
|
+
win.setBounds({ ...bounds, height });
|
|
32
|
+
writeBounds(win);
|
|
33
|
+
});
|
|
34
|
+
const preferredUrl = `http://127.0.0.1:${port}`;
|
|
35
|
+
try {
|
|
36
|
+
hudServer = await createHudServer({ mode, port });
|
|
37
|
+
createWindow(hudServer.url);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (isAddrInUse(error) && await hasCompatibleHudState(preferredUrl, mode)) {
|
|
41
|
+
createWindow(preferredUrl);
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
hudServer = await createHudServer({ mode, port: 0 });
|
|
45
|
+
createWindow(hudServer.url);
|
|
46
|
+
}
|
|
47
|
+
}).catch((error) => {
|
|
48
|
+
console.error(error instanceof Error ? error.stack || error.message : String(error));
|
|
49
|
+
app.quit();
|
|
50
|
+
});
|
|
51
|
+
app.on("window-all-closed", async () => {
|
|
52
|
+
if (hudServer)
|
|
53
|
+
await hudServer.close();
|
|
54
|
+
app.quit();
|
|
55
|
+
});
|
|
56
|
+
function createWindow(url) {
|
|
57
|
+
const savedBounds = readBounds();
|
|
58
|
+
const bounds = savedBounds ? normalizedBounds(savedBounds) : defaultBounds();
|
|
59
|
+
const win = new BrowserWindow({
|
|
60
|
+
width: COLLAPSED_WIDTH,
|
|
61
|
+
height: COLLAPSED_HEIGHT,
|
|
62
|
+
x: bounds.x,
|
|
63
|
+
y: bounds.y,
|
|
64
|
+
frame: false,
|
|
65
|
+
resizable: false,
|
|
66
|
+
show: false,
|
|
67
|
+
transparent: true,
|
|
68
|
+
alwaysOnTop: true,
|
|
69
|
+
skipTaskbar: true,
|
|
70
|
+
acceptFirstMouse: true,
|
|
71
|
+
title: "EchoMem Context HUD",
|
|
72
|
+
webPreferences: {
|
|
73
|
+
contextIsolation: true,
|
|
74
|
+
nodeIntegration: false,
|
|
75
|
+
preload: fileURLPath("./preload.cjs"),
|
|
76
|
+
},
|
|
77
|
+
});
|
|
78
|
+
win.setAlwaysOnTop(true, "floating");
|
|
79
|
+
if (process.platform === "darwin") {
|
|
80
|
+
win.setVisibleOnAllWorkspaces(true, { visibleOnFullScreen: true });
|
|
81
|
+
win.setFullScreenable(false);
|
|
82
|
+
}
|
|
83
|
+
win.on("moved", () => writeBounds(win));
|
|
84
|
+
mainWindow = win;
|
|
85
|
+
win.on("closed", () => { if (mainWindow === win)
|
|
86
|
+
mainWindow = null; });
|
|
87
|
+
win.webContents.on("context-menu", () => {
|
|
88
|
+
const menu = Menu.buildFromTemplate([
|
|
89
|
+
{ label: "Hide for now", click: () => { if (!win.isDestroyed())
|
|
90
|
+
win.hide(); } },
|
|
91
|
+
{ type: "separator" },
|
|
92
|
+
{ label: "Quit EchoMem HUD", click: () => app.quit() },
|
|
93
|
+
]);
|
|
94
|
+
menu.popup({ window: win });
|
|
95
|
+
});
|
|
96
|
+
let shown = false;
|
|
97
|
+
const reveal = () => {
|
|
98
|
+
if (shown || win.isDestroyed())
|
|
99
|
+
return;
|
|
100
|
+
shown = true;
|
|
101
|
+
app.focus({ steal: true });
|
|
102
|
+
win.show();
|
|
103
|
+
win.moveTop();
|
|
104
|
+
win.focus();
|
|
105
|
+
writeBounds(win);
|
|
106
|
+
};
|
|
107
|
+
win.once("ready-to-show", reveal);
|
|
108
|
+
win.webContents.once("did-finish-load", reveal);
|
|
109
|
+
setTimeout(reveal, 1200);
|
|
110
|
+
if (process.env.ECHO_HUD_DEBUG)
|
|
111
|
+
win.webContents.openDevTools({ mode: "detach" });
|
|
112
|
+
win.loadURL(url);
|
|
113
|
+
}
|
|
114
|
+
function clampExpandedHeight(value) {
|
|
115
|
+
const workArea = screen.getPrimaryDisplay().workArea;
|
|
116
|
+
const max = Math.max(COLLAPSED_HEIGHT, workArea.height - 48);
|
|
117
|
+
return Math.min(max, Math.max(COLLAPSED_HEIGHT, Math.round(value)));
|
|
118
|
+
}
|
|
119
|
+
function showExistingWindow() {
|
|
120
|
+
if (!mainWindow || mainWindow.isDestroyed())
|
|
121
|
+
return;
|
|
122
|
+
if (!mainWindow.isVisible())
|
|
123
|
+
mainWindow.show();
|
|
124
|
+
mainWindow.moveTop();
|
|
125
|
+
app.focus({ steal: true });
|
|
126
|
+
mainWindow.focus();
|
|
127
|
+
}
|
|
128
|
+
async function hasCompatibleHudState(baseUrl, requestedMode) {
|
|
129
|
+
const signal = AbortSignal.timeout(750);
|
|
130
|
+
try {
|
|
131
|
+
const res = await fetch(`${baseUrl}/state`, { signal });
|
|
132
|
+
if (!res.ok)
|
|
133
|
+
return false;
|
|
134
|
+
const state = await res.json();
|
|
135
|
+
return compatibleMode(state.mode, requestedMode);
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
function compatibleMode(existingMode, requestedMode) {
|
|
142
|
+
if (existingMode === requestedMode)
|
|
143
|
+
return true;
|
|
144
|
+
const allSources = new Set(["auto", "both"]);
|
|
145
|
+
return typeof existingMode === "string" && allSources.has(existingMode) && allSources.has(requestedMode);
|
|
146
|
+
}
|
|
147
|
+
function isAddrInUse(error) {
|
|
148
|
+
return error instanceof Error && "code" in error && error.code === "EADDRINUSE";
|
|
149
|
+
}
|
|
150
|
+
function fileURLPath(relativePath) {
|
|
151
|
+
return fileURLToPath(new URL(relativePath, import.meta.url));
|
|
152
|
+
}
|
|
153
|
+
function boundsPath() {
|
|
154
|
+
return path.join(os.homedir(), ".echomem", "hud-window.json");
|
|
155
|
+
}
|
|
156
|
+
function readBounds() {
|
|
157
|
+
try {
|
|
158
|
+
return JSON.parse(fs.readFileSync(boundsPath(), "utf8"));
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
function defaultBounds() {
|
|
165
|
+
const width = COLLAPSED_WIDTH;
|
|
166
|
+
const workArea = screen.getPrimaryDisplay().workArea;
|
|
167
|
+
return {
|
|
168
|
+
width,
|
|
169
|
+
height: COLLAPSED_HEIGHT,
|
|
170
|
+
x: Math.max(workArea.x + 12, workArea.x + workArea.width - width - 24),
|
|
171
|
+
y: workArea.y + 24,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function normalizedBounds(saved) {
|
|
175
|
+
if (!saved)
|
|
176
|
+
return defaultBounds();
|
|
177
|
+
const width = COLLAPSED_WIDTH;
|
|
178
|
+
const height = COLLAPSED_HEIGHT;
|
|
179
|
+
const proposed = { x: saved.x, y: saved.y, width, height };
|
|
180
|
+
const displays = screen.getAllDisplays().map((display) => display.workArea);
|
|
181
|
+
const workArea = displays.find((area) => intersects(area, proposed)) ?? screen.getPrimaryDisplay().workArea;
|
|
182
|
+
return {
|
|
183
|
+
width,
|
|
184
|
+
height,
|
|
185
|
+
x: clamp(saved.x, workArea.x + 12, workArea.x + workArea.width - width - 12),
|
|
186
|
+
y: clamp(saved.y, workArea.y + 12, workArea.y + workArea.height - height - 12),
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
function intersects(a, b) {
|
|
190
|
+
return a.x < b.x + b.width && a.x + a.width > b.x && a.y < b.y + b.height && a.y + a.height > b.y;
|
|
191
|
+
}
|
|
192
|
+
function clamp(value, min, max) {
|
|
193
|
+
if (max < min)
|
|
194
|
+
return min;
|
|
195
|
+
return Math.min(Math.max(value, min), max);
|
|
196
|
+
}
|
|
197
|
+
function writeBounds(win) {
|
|
198
|
+
const b = win.getBounds();
|
|
199
|
+
fs.mkdirSync(path.dirname(boundsPath()), { recursive: true });
|
|
200
|
+
fs.writeFileSync(boundsPath(), JSON.stringify(b, null, 2));
|
|
201
|
+
}
|
|
202
|
+
function parseFlags(argv) {
|
|
203
|
+
const parsed = {};
|
|
204
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
205
|
+
const arg = argv[i];
|
|
206
|
+
if (!arg.startsWith("--"))
|
|
207
|
+
continue;
|
|
208
|
+
const key = arg.slice(2);
|
|
209
|
+
const next = argv[i + 1];
|
|
210
|
+
if (next && !next.startsWith("--")) {
|
|
211
|
+
parsed[key] = next;
|
|
212
|
+
i += 1;
|
|
213
|
+
}
|
|
214
|
+
else {
|
|
215
|
+
parsed[key] = true;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
return parsed;
|
|
219
|
+
}
|
|
220
|
+
function parseMode(value) {
|
|
221
|
+
return value === "codex" || value === "claude-code" || value === "claude-desktop" || value === "both" || value === "auto"
|
|
222
|
+
? value
|
|
223
|
+
: "auto";
|
|
224
|
+
}
|
package/dist/hud/fs.js
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function homePath(...segments) {
|
|
5
|
+
return path.join(os.homedir(), ...segments);
|
|
6
|
+
}
|
|
7
|
+
export function walkFiles(root, predicate, out = []) {
|
|
8
|
+
let entries = [];
|
|
9
|
+
try {
|
|
10
|
+
entries = fs.readdirSync(root, { withFileTypes: true });
|
|
11
|
+
}
|
|
12
|
+
catch {
|
|
13
|
+
return out;
|
|
14
|
+
}
|
|
15
|
+
for (const entry of entries) {
|
|
16
|
+
const full = path.join(root, entry.name);
|
|
17
|
+
if (entry.isDirectory())
|
|
18
|
+
walkFiles(full, predicate, out);
|
|
19
|
+
else if (predicate(full))
|
|
20
|
+
out.push(full);
|
|
21
|
+
}
|
|
22
|
+
return out;
|
|
23
|
+
}
|
|
24
|
+
export function newestFile(files) {
|
|
25
|
+
let newest = null;
|
|
26
|
+
for (const file of files) {
|
|
27
|
+
try {
|
|
28
|
+
const stat = fs.statSync(file);
|
|
29
|
+
if (!newest || stat.mtimeMs > newest.mtimeMs)
|
|
30
|
+
newest = { file, mtimeMs: stat.mtimeMs };
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
// ignore disappearing live files
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return newest?.file || null;
|
|
37
|
+
}
|
|
38
|
+
export function readJsonl(file) {
|
|
39
|
+
const text = fs.readFileSync(file, "utf8");
|
|
40
|
+
const records = [];
|
|
41
|
+
for (const line of text.split("\n")) {
|
|
42
|
+
if (!line.trim())
|
|
43
|
+
continue;
|
|
44
|
+
try {
|
|
45
|
+
records.push(JSON.parse(line));
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
// Malformed/incomplete live lines are ignored; the next poll can parse the completed record.
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return records;
|
|
52
|
+
}
|
|
53
|
+
export function statSignature(file) {
|
|
54
|
+
if (!file)
|
|
55
|
+
return "";
|
|
56
|
+
try {
|
|
57
|
+
const stat = fs.statSync(file);
|
|
58
|
+
return `${file}:${stat.size}:${stat.mtimeMs}`;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return "";
|
|
62
|
+
}
|
|
63
|
+
}
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
export function installHooks(mode) {
|
|
5
|
+
const written = [];
|
|
6
|
+
if (mode === "codex" || mode === "both" || mode === "auto") {
|
|
7
|
+
written.push(installCodexHooks());
|
|
8
|
+
}
|
|
9
|
+
if (mode === "claude-code" || mode === "both" || mode === "auto") {
|
|
10
|
+
written.push(installClaudeCodeSnippet());
|
|
11
|
+
}
|
|
12
|
+
return written;
|
|
13
|
+
}
|
|
14
|
+
function installCodexHooks() {
|
|
15
|
+
const dir = path.join(os.homedir(), ".codex");
|
|
16
|
+
const file = path.join(dir, "hooks.json");
|
|
17
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
18
|
+
const hookCommand = `${JSON.stringify(process.execPath)} ${JSON.stringify(process.argv[1])} summary --client codex --json`;
|
|
19
|
+
const content = readHooksFile(file);
|
|
20
|
+
content.hooks = content.hooks || {};
|
|
21
|
+
content.hooks.PostToolUse = mergeHookGroup(content.hooks.PostToolUse, { matcher: "*", hooks: [{ type: "command", command: hookCommand, timeout: 5 }] });
|
|
22
|
+
content.hooks.PostCompact = mergeHookGroup(content.hooks.PostCompact, { hooks: [{ type: "command", command: hookCommand, timeout: 5 }] });
|
|
23
|
+
content.hooks.Stop = mergeHookGroup(content.hooks.Stop, { hooks: [{ type: "command", command: hookCommand, timeout: 5 }] });
|
|
24
|
+
fs.writeFileSync(file, JSON.stringify(content, null, 2));
|
|
25
|
+
return file;
|
|
26
|
+
}
|
|
27
|
+
function installClaudeCodeSnippet() {
|
|
28
|
+
const dir = path.join(os.homedir(), ".claude", "echo-ctx");
|
|
29
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
30
|
+
return dir;
|
|
31
|
+
}
|
|
32
|
+
function readHooksFile(file) {
|
|
33
|
+
try {
|
|
34
|
+
const parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
35
|
+
return typeof parsed === "object" && parsed !== null ? parsed : {};
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return {};
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
function mergeHookGroup(existing, group) {
|
|
42
|
+
const groups = Array.isArray(existing) ? existing.filter((item) => !isEchoHudGroup(item)) : [];
|
|
43
|
+
groups.push(group);
|
|
44
|
+
return groups;
|
|
45
|
+
}
|
|
46
|
+
function isEchoHudGroup(value) {
|
|
47
|
+
if (typeof value !== "object" || value === null)
|
|
48
|
+
return false;
|
|
49
|
+
return JSON.stringify(value).includes("summary --client codex --json");
|
|
50
|
+
}
|
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
export const BUCKETS = {
|
|
2
|
+
rangeRedundant: "range_redundant",
|
|
3
|
+
staleRead: "stale_read",
|
|
4
|
+
supersededImage: "superseded_image",
|
|
5
|
+
staleToolOutput: "stale_tool_output",
|
|
6
|
+
compactionRecoverable: "compaction_recoverable",
|
|
7
|
+
};
|
|
8
|
+
const SHELL_READ_BINS = new Set(["cat", "head", "tail", "sed", "nl", "less", "more", "bat"]);
|
|
9
|
+
const EXT = /\.[A-Za-z0-9]{1,8}$/;
|
|
10
|
+
export function newMetricState() {
|
|
11
|
+
return {
|
|
12
|
+
turn: 0,
|
|
13
|
+
reads: 0,
|
|
14
|
+
redundantCount: 0,
|
|
15
|
+
readHist: new Map(),
|
|
16
|
+
editTurn: new Map(),
|
|
17
|
+
buckets: {
|
|
18
|
+
[BUCKETS.rangeRedundant]: { tokens: 0, count: 0 },
|
|
19
|
+
[BUCKETS.staleRead]: { tokens: 0, count: 0 },
|
|
20
|
+
[BUCKETS.supersededImage]: { tokens: 0, count: 0 },
|
|
21
|
+
[BUCKETS.staleToolOutput]: { tokens: 0, count: 0 },
|
|
22
|
+
[BUCKETS.compactionRecoverable]: { tokens: 0, count: 0 },
|
|
23
|
+
},
|
|
24
|
+
tools: {},
|
|
25
|
+
};
|
|
26
|
+
}
|
|
27
|
+
export function bumpTurn(state) {
|
|
28
|
+
state.turn += 1;
|
|
29
|
+
}
|
|
30
|
+
export function recordTool(state, name) {
|
|
31
|
+
if (!name)
|
|
32
|
+
return;
|
|
33
|
+
state.tools[name] = (state.tools[name] || 0) + 1;
|
|
34
|
+
}
|
|
35
|
+
export function recordEdit(state, file) {
|
|
36
|
+
if (!file)
|
|
37
|
+
return;
|
|
38
|
+
state.editTurn.set(file, state.turn);
|
|
39
|
+
}
|
|
40
|
+
export function recordRead(state, file, start = 1, end = 1e9) {
|
|
41
|
+
if (!file)
|
|
42
|
+
return;
|
|
43
|
+
const safeStart = Number(start) || 1;
|
|
44
|
+
const safeEnd = Number(end) || 1e9;
|
|
45
|
+
const tokens = estimateReadTokens(safeStart, safeEnd);
|
|
46
|
+
const previous = state.readHist.get(file) || [];
|
|
47
|
+
const lastEdit = state.editTurn.get(file) ?? -1;
|
|
48
|
+
const redundant = previous.some((read) => read.turn >= lastEdit && safeStart <= read.end && safeEnd >= read.start);
|
|
49
|
+
state.reads += 1;
|
|
50
|
+
if (redundant) {
|
|
51
|
+
addBucket(state, BUCKETS.rangeRedundant, tokens, 1);
|
|
52
|
+
state.redundantCount += 1;
|
|
53
|
+
}
|
|
54
|
+
previous.push({ start: safeStart, end: safeEnd, turn: state.turn, tokens });
|
|
55
|
+
state.readHist.set(file, previous);
|
|
56
|
+
}
|
|
57
|
+
export function addBucket(state, bucket, tokens, count = 1) {
|
|
58
|
+
state.buckets[bucket].tokens += Math.max(0, Math.round(tokens));
|
|
59
|
+
state.buckets[bucket].count += Math.max(0, count);
|
|
60
|
+
}
|
|
61
|
+
export function scoreMetric(params) {
|
|
62
|
+
const ct = Number(params.ctTokens) || 0;
|
|
63
|
+
const modelWindow = Number(params.modelContextWindow) || 0;
|
|
64
|
+
const pollutionTok = Object.values(params.state.buckets).reduce((sum, bucket) => sum + bucket.tokens, 0);
|
|
65
|
+
const pollution = ct > 0 ? Math.min(0.95, pollutionTok / ct) : 0;
|
|
66
|
+
const pollutionPct = Math.round(pollution * 100);
|
|
67
|
+
const saturationPct = modelWindow > 0 ? Math.round((ct / modelWindow) * 100) : null;
|
|
68
|
+
return {
|
|
69
|
+
client: params.client,
|
|
70
|
+
sourcePath: params.sourcePath,
|
|
71
|
+
turn: params.state.turn,
|
|
72
|
+
reads: params.state.reads,
|
|
73
|
+
redundantCount: params.state.redundantCount,
|
|
74
|
+
usefulPct: Math.round((1 - pollution) * 100),
|
|
75
|
+
pollutionPct,
|
|
76
|
+
pollutionTok,
|
|
77
|
+
ctTokens: ct,
|
|
78
|
+
ctSource: params.ctSource,
|
|
79
|
+
modelContextWindow: modelWindow || null,
|
|
80
|
+
saturationPct,
|
|
81
|
+
color: qualityColor(pollutionPct, saturationPct),
|
|
82
|
+
buckets: cloneBuckets(params.state.buckets),
|
|
83
|
+
honesty: "tracked lower bound",
|
|
84
|
+
updatedAt: params.updatedAt || new Date().toISOString(),
|
|
85
|
+
stats: params.stats,
|
|
86
|
+
tools: { ...params.state.tools },
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
export function shellRead(cmd) {
|
|
90
|
+
const text = String(cmd || "").trim();
|
|
91
|
+
if (!text)
|
|
92
|
+
return null;
|
|
93
|
+
const firstPipeline = text.split(/[|;&]/)[0].trim();
|
|
94
|
+
const tokens = firstPipeline.match(/(?:[^\s"']+|"[^"]*"|'[^']*')+/g) || [];
|
|
95
|
+
if (!tokens.length)
|
|
96
|
+
return null;
|
|
97
|
+
let bin = stripQuotes(tokens[0] || "").split("/").pop() || "";
|
|
98
|
+
if (bin === "sudo")
|
|
99
|
+
bin = stripQuotes(tokens[1] || "").split("/").pop() || "";
|
|
100
|
+
if (!SHELL_READ_BINS.has(bin))
|
|
101
|
+
return null;
|
|
102
|
+
let file = "";
|
|
103
|
+
for (let i = tokens.length - 1; i >= 1; i -= 1) {
|
|
104
|
+
const token = stripQuotes(tokens[i] || "");
|
|
105
|
+
if (!token || token.startsWith("-") || /^\d+(,\d+)?p?$/.test(token))
|
|
106
|
+
continue;
|
|
107
|
+
if (token.includes("/") || EXT.test(token)) {
|
|
108
|
+
file = token;
|
|
109
|
+
break;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
if (!file)
|
|
113
|
+
return null;
|
|
114
|
+
const [start, end] = rangeFromCmd(text);
|
|
115
|
+
return { file, start, end };
|
|
116
|
+
}
|
|
117
|
+
export function formatTokens(tokens) {
|
|
118
|
+
const n = Number(tokens) || 0;
|
|
119
|
+
if (n >= 1_000_000)
|
|
120
|
+
return `${(n / 1_000_000).toFixed(n >= 10_000_000 ? 0 : 1)}m`;
|
|
121
|
+
if (n >= 1_000)
|
|
122
|
+
return `${Math.round(n / 1_000)}k`;
|
|
123
|
+
return String(Math.round(n));
|
|
124
|
+
}
|
|
125
|
+
export function formatGlance(score) {
|
|
126
|
+
const dot = score.color === "amber" ? "◑" : "●";
|
|
127
|
+
return `${dot} ${score.usefulPct}% clean · ${formatTokens(score.ctTokens)}`;
|
|
128
|
+
}
|
|
129
|
+
function estimateReadTokens(start, end) {
|
|
130
|
+
const boundedEnd = Math.min(end, start + 4000);
|
|
131
|
+
const lines = Math.max(1, boundedEnd - start + 1);
|
|
132
|
+
return Math.min(8000, Math.max(40, lines * 12));
|
|
133
|
+
}
|
|
134
|
+
function qualityColor(pollutionPct, saturationPct) {
|
|
135
|
+
if (pollutionPct > 40 || (saturationPct !== null && saturationPct >= 95))
|
|
136
|
+
return "red";
|
|
137
|
+
if (pollutionPct >= 25 || (saturationPct !== null && saturationPct >= 90))
|
|
138
|
+
return "amber";
|
|
139
|
+
return "green";
|
|
140
|
+
}
|
|
141
|
+
function rangeFromCmd(cmd) {
|
|
142
|
+
const sed = cmd.match(/\bsed\s+(?:[^\n;|&]*?\s)?-n\s*['"]?\s*(\d+)\s*,\s*(\d+)\s*p/);
|
|
143
|
+
if (sed)
|
|
144
|
+
return [Number(sed[1]), Number(sed[2])];
|
|
145
|
+
const nlSed = cmd.match(/\bnl\b[\s\S]*?\|\s*sed\s+-n\s*['"]?\s*(\d+)\s*,\s*(\d+)\s*p/);
|
|
146
|
+
if (nlSed)
|
|
147
|
+
return [Number(nlSed[1]), Number(nlSed[2])];
|
|
148
|
+
const head = cmd.match(/\bhead\s+(?:-n\s*)?(\d+)\b/);
|
|
149
|
+
if (head)
|
|
150
|
+
return [1, Number(head[1])];
|
|
151
|
+
return [1, 1e9];
|
|
152
|
+
}
|
|
153
|
+
function stripQuotes(value) {
|
|
154
|
+
return value.replace(/^['"]|['"]$/g, "");
|
|
155
|
+
}
|
|
156
|
+
function cloneBuckets(buckets) {
|
|
157
|
+
return Object.fromEntries(Object.entries(buckets).map(([name, value]) => [name, { ...value }]));
|
|
158
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { EventEmitter } from "node:events";
|
|
3
|
+
import { adapterList } from "./adapters.js";
|
|
4
|
+
import { statSignature } from "./fs.js";
|
|
5
|
+
export class HudMonitor extends EventEmitter {
|
|
6
|
+
mode;
|
|
7
|
+
pollMs;
|
|
8
|
+
timer = null;
|
|
9
|
+
signatures = new Map();
|
|
10
|
+
scores = new Map();
|
|
11
|
+
missing = [];
|
|
12
|
+
frontmostCheckedAt = 0;
|
|
13
|
+
frontmostClient = null;
|
|
14
|
+
lastActiveClient = null;
|
|
15
|
+
constructor(mode = "auto", pollMs = 750) {
|
|
16
|
+
super();
|
|
17
|
+
this.mode = mode;
|
|
18
|
+
this.pollMs = pollMs;
|
|
19
|
+
}
|
|
20
|
+
start() {
|
|
21
|
+
if (this.timer)
|
|
22
|
+
return;
|
|
23
|
+
this.tick();
|
|
24
|
+
this.timer = setInterval(() => this.tick(), this.pollMs);
|
|
25
|
+
}
|
|
26
|
+
stop() {
|
|
27
|
+
if (!this.timer)
|
|
28
|
+
return;
|
|
29
|
+
clearInterval(this.timer);
|
|
30
|
+
this.timer = null;
|
|
31
|
+
}
|
|
32
|
+
snapshot() {
|
|
33
|
+
const preferred = this.frontmostPreferredClient();
|
|
34
|
+
const scores = [...this.scores.values()].sort((a, b) => {
|
|
35
|
+
if (preferred && a.client === preferred && b.client !== preferred)
|
|
36
|
+
return -1;
|
|
37
|
+
if (preferred && b.client === preferred && a.client !== preferred)
|
|
38
|
+
return 1;
|
|
39
|
+
return Date.parse(b.updatedAt) - Date.parse(a.updatedAt);
|
|
40
|
+
});
|
|
41
|
+
return {
|
|
42
|
+
mode: this.mode,
|
|
43
|
+
active: scores[0] || null,
|
|
44
|
+
scores,
|
|
45
|
+
missing: this.missing,
|
|
46
|
+
updatedAt: new Date().toISOString(),
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
frontmostPreferredClient() {
|
|
50
|
+
if (this.mode !== "auto" && this.mode !== "both")
|
|
51
|
+
return null;
|
|
52
|
+
const now = Date.now();
|
|
53
|
+
if (now - this.frontmostCheckedAt > 1500) {
|
|
54
|
+
this.frontmostCheckedAt = now;
|
|
55
|
+
this.frontmostClient = detectFrontmostClient();
|
|
56
|
+
}
|
|
57
|
+
return this.frontmostClient && this.scores.has(this.frontmostClient) ? this.frontmostClient : null;
|
|
58
|
+
}
|
|
59
|
+
tick() {
|
|
60
|
+
const missing = [];
|
|
61
|
+
let changed = false;
|
|
62
|
+
for (const adapter of adapterList(this.mode)) {
|
|
63
|
+
const file = adapter.findActive();
|
|
64
|
+
if (!file) {
|
|
65
|
+
missing.push(adapter.client);
|
|
66
|
+
if (this.scores.delete(adapter.client))
|
|
67
|
+
changed = true;
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
const signature = statSignature(file);
|
|
71
|
+
if (!signature || signature === this.signatures.get(adapter.client))
|
|
72
|
+
continue;
|
|
73
|
+
this.signatures.set(adapter.client, signature);
|
|
74
|
+
try {
|
|
75
|
+
this.scores.set(adapter.client, adapter.score(file));
|
|
76
|
+
changed = true;
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
missing.push(`${adapter.client}: ${error instanceof Error ? error.message : String(error)}`);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
this.missing = missing;
|
|
83
|
+
const activeClient = this.snapshot().active?.client ?? null;
|
|
84
|
+
if (activeClient !== this.lastActiveClient) {
|
|
85
|
+
this.lastActiveClient = activeClient;
|
|
86
|
+
changed = true;
|
|
87
|
+
}
|
|
88
|
+
if (changed)
|
|
89
|
+
this.emit("state", this.snapshot());
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
function detectFrontmostClient() {
|
|
93
|
+
if (process.platform !== "darwin")
|
|
94
|
+
return null;
|
|
95
|
+
try {
|
|
96
|
+
const name = execFileSync("osascript", ["-e", 'tell application "System Events" to get name of first application process whose frontmost is true'], { encoding: "utf8", timeout: 500, stdio: ["ignore", "pipe", "ignore"] }).trim().toLowerCase();
|
|
97
|
+
if (name.includes("codex"))
|
|
98
|
+
return "codex";
|
|
99
|
+
if (name.includes("claude"))
|
|
100
|
+
return "claude-desktop";
|
|
101
|
+
}
|
|
102
|
+
catch {
|
|
103
|
+
/* Accessibility may be unavailable; fall back to newest log. */
|
|
104
|
+
}
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
const electron_1 = require("electron");
|
|
4
|
+
// CommonJS preload (.cts -> .cjs). Sandboxed Electron preloads must be CommonJS;
|
|
5
|
+
// an ESM preload throws "Cannot use import statement outside a module" on load.
|
|
6
|
+
electron_1.contextBridge.exposeInMainWorld("echomemHud", {
|
|
7
|
+
setOpen(open, height) {
|
|
8
|
+
electron_1.ipcRenderer.send("hud:set-open", { open, height });
|
|
9
|
+
},
|
|
10
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { formatGlance, formatTokens } from "./metric.js";
|
|
2
|
+
export function renderScoreText(score) {
|
|
3
|
+
const range = score.buckets.range_redundant;
|
|
4
|
+
const lines = [
|
|
5
|
+
`${clientLabel(score.client)} context health: ${formatGlance(score)}`,
|
|
6
|
+
"",
|
|
7
|
+
`Tracked dead-weight ≥ ${formatTokens(score.pollutionTok)} (${score.pollutionPct}% pollution lower bound).`,
|
|
8
|
+
`Range redundant: ${formatTokens(range.tokens)} · ${range.count} reads.`,
|
|
9
|
+
`Reads: ${score.reads} · Edits: ${score.stats?.patchEdits ?? 0} · Large outputs: ${score.stats?.largeFunctionOutputs ?? 0} · Compactions: ${score.stats?.compactMarkers ?? 0}.`,
|
|
10
|
+
score.saturationPct !== null ? `Saturation: ${score.saturationPct}% of ${formatTokens(score.modelContextWindow || 0)}.` : "",
|
|
11
|
+
`Context token source: ${score.ctSource}.`,
|
|
12
|
+
"",
|
|
13
|
+
"Honesty: tracked lower bound; provider eviction and exact context composition are not observable.",
|
|
14
|
+
].filter(Boolean);
|
|
15
|
+
return lines.join("\n");
|
|
16
|
+
}
|
|
17
|
+
export function renderStateText(state) {
|
|
18
|
+
if (!state.active) {
|
|
19
|
+
return [
|
|
20
|
+
"EchoMem context HUD: no local active Codex/Claude source found.",
|
|
21
|
+
state.missing.length ? `Missing: ${state.missing.join(", ")}` : "",
|
|
22
|
+
].filter(Boolean).join("\n");
|
|
23
|
+
}
|
|
24
|
+
if (state.scores.length <= 1)
|
|
25
|
+
return renderScoreText(state.active);
|
|
26
|
+
return [
|
|
27
|
+
renderScoreText(state.active),
|
|
28
|
+
"",
|
|
29
|
+
"Other visible sources:",
|
|
30
|
+
...state.scores.slice(1).map((score) => `- ${clientLabel(score.client)}: ${formatGlance(score)}`),
|
|
31
|
+
].join("\n");
|
|
32
|
+
}
|
|
33
|
+
export function clientLabel(client) {
|
|
34
|
+
if (client === "codex")
|
|
35
|
+
return "Codex";
|
|
36
|
+
if (client === "claude-code")
|
|
37
|
+
return "Claude Code";
|
|
38
|
+
return "Claude Desktop";
|
|
39
|
+
}
|