@echomem/mcp 1.4.7 → 1.4.8
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 +14 -8
- package/assets/hud/claude.svg +1 -0
- package/assets/hud/codex.svg +1 -0
- package/assets/hud/session-viewer.html +35 -0
- package/dist/city/echo-ai-city-only.html +31 -13
- package/dist/city/echo-ai-city-only.template.html +31 -13
- package/dist/city/echo-face-cutout.png +0 -0
- package/dist/hud/autostart.js +66 -0
- package/dist/hud/cli.js +31 -0
- package/dist/hud/electron-main.js +182 -19
- package/dist/hud/monitor.js +36 -68
- package/dist/hud/preload.cjs +3 -0
- package/dist/hud/server.js +321 -4
- package/dist/hud/web.js +633 -69
- package/dist/index.js +116 -22
- package/dist/migrate.js +18 -0
- package/dist/setup-page.js +978 -60
- package/dist/setup.js +355 -42
- package/dist/v1-contract.js +20 -2
- package/package.json +3 -2
package/dist/hud/server.js
CHANGED
|
@@ -6,7 +6,10 @@ import { fileURLToPath } from "node:url";
|
|
|
6
6
|
import { HudMonitor } from "./monitor.js";
|
|
7
7
|
import { HUD_HTML } from "./web.js";
|
|
8
8
|
import { buildCapsuleText } from "./capsule.js";
|
|
9
|
-
import { homePath } from "./fs.js";
|
|
9
|
+
import { homePath, newestFile, walkFiles } from "./fs.js";
|
|
10
|
+
import { KeyStore } from "../keystore.js";
|
|
11
|
+
import { assembleCodex, assembleClaude } from "../migrate.js";
|
|
12
|
+
const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
|
|
10
13
|
export async function createHudServer(opts = {}) {
|
|
11
14
|
const mode = opts.mode || "auto";
|
|
12
15
|
const port = opts.port ?? 17377;
|
|
@@ -154,6 +157,28 @@ export async function createHudServer(opts = {}) {
|
|
|
154
157
|
res.end(JSON.stringify({ ok: canReveal, reason: canReveal ? undefined : opts.onReveal ? "unknown_path" : "no_desktop" }));
|
|
155
158
|
return;
|
|
156
159
|
}
|
|
160
|
+
if (url.pathname === "/renew") {
|
|
161
|
+
handleRenew(latest, res).catch((error) => {
|
|
162
|
+
if (res.writableEnded)
|
|
163
|
+
return;
|
|
164
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
165
|
+
res.end(JSON.stringify({ ok: false, reason: error instanceof Error ? error.message : "renew failed" }));
|
|
166
|
+
});
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (url.pathname === "/checkpoint-status") {
|
|
170
|
+
handleCheckpointStatus(latest, res).catch((error) => {
|
|
171
|
+
if (res.writableEnded)
|
|
172
|
+
return;
|
|
173
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
174
|
+
res.end(JSON.stringify({ ok: false, reason: error instanceof Error ? error.message : "checkpoint status failed" }));
|
|
175
|
+
});
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
if (url.pathname === "/renew-estimate") {
|
|
179
|
+
handleRenewEstimate(latest, res);
|
|
180
|
+
return;
|
|
181
|
+
}
|
|
157
182
|
if (url.pathname === "/capsule") {
|
|
158
183
|
const active = latest.active;
|
|
159
184
|
if (!active) {
|
|
@@ -191,21 +216,313 @@ export async function createHudServer(opts = {}) {
|
|
|
191
216
|
}),
|
|
192
217
|
};
|
|
193
218
|
}
|
|
219
|
+
async function handleCheckpointStatus(latest, res) {
|
|
220
|
+
const respond = (obj) => {
|
|
221
|
+
if (res.writableEnded)
|
|
222
|
+
return;
|
|
223
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
224
|
+
res.end(JSON.stringify(obj));
|
|
225
|
+
};
|
|
226
|
+
const active = latest.active;
|
|
227
|
+
if (!active || !active.sourcePath)
|
|
228
|
+
return respond({ ok: false, reason: "no_active_session" });
|
|
229
|
+
const token = new KeyStore().getToken();
|
|
230
|
+
if (!token)
|
|
231
|
+
return respond({ ok: false, reason: "not_logged_in" });
|
|
232
|
+
const sessionFile = resolveSessionFile(active.client, active.sourcePath);
|
|
233
|
+
const assembled = active.client === "codex" ? assembleCodex(sessionFile) : assembleClaude(sessionFile);
|
|
234
|
+
if (!assembled || !assembled.conversationKey) {
|
|
235
|
+
return respond({ ok: false, reason: "empty_session" });
|
|
236
|
+
}
|
|
237
|
+
const response = await fetch(`${API_BASE}/api/extension/memories/session-status`, {
|
|
238
|
+
method: "POST",
|
|
239
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
240
|
+
body: JSON.stringify({ conversationKey: assembled.conversationKey }),
|
|
241
|
+
});
|
|
242
|
+
const data = (await response.json().catch(() => ({})));
|
|
243
|
+
if (!response.ok || data.success === false) {
|
|
244
|
+
return respond({ ok: false, reason: data.error || `HTTP ${response.status}` });
|
|
245
|
+
}
|
|
246
|
+
respond({
|
|
247
|
+
ok: true,
|
|
248
|
+
hasCheckpoint: Boolean(data.hasCheckpoint ?? data.exists),
|
|
249
|
+
contextId: typeof data.contextId === "string" ? data.contextId : undefined,
|
|
250
|
+
checkpointMemoryId: typeof data.checkpointMemoryId === "string" ? data.checkpointMemoryId : undefined,
|
|
251
|
+
renewedAt: typeof data.renewedAt === "string" ? data.renewedAt : undefined,
|
|
252
|
+
memoryCount: typeof data.memoryCount === "number" ? data.memoryCount : 0,
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
// Renew = the core loop: extract the active coding session into checkpoint memories (cloud, coding
|
|
256
|
+
// prompt) and hand back a "carryover" the user pastes into a fresh session.
|
|
257
|
+
async function handleRenew(latest, res) {
|
|
258
|
+
const respond = (obj) => {
|
|
259
|
+
if (res.writableEnded)
|
|
260
|
+
return;
|
|
261
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
262
|
+
res.end(JSON.stringify(obj));
|
|
263
|
+
};
|
|
264
|
+
const active = latest.active;
|
|
265
|
+
if (!active || !active.sourcePath)
|
|
266
|
+
return respond({ ok: false, reason: "no_active_session" });
|
|
267
|
+
const token = new KeyStore().getToken();
|
|
268
|
+
if (!token)
|
|
269
|
+
return respond({ ok: false, reason: "not_logged_in" });
|
|
270
|
+
const sessionFile = resolveSessionFile(active.client, active.sourcePath);
|
|
271
|
+
const assembled = active.client === "codex" ? assembleCodex(sessionFile) : assembleClaude(sessionFile);
|
|
272
|
+
if (!assembled || !assembled.rawData || assembled.rawData.length < 20) {
|
|
273
|
+
return respond({ ok: false, reason: "empty_session" });
|
|
274
|
+
}
|
|
275
|
+
const body = JSON.stringify({
|
|
276
|
+
rawData: assembled.rawData,
|
|
277
|
+
source: active.client,
|
|
278
|
+
sessionType: "coding",
|
|
279
|
+
cleanCarry: true, // → the GetCleanCarryPrompt path: EXACTLY ONE resumption capsule
|
|
280
|
+
conversationKey: assembled.conversationKey,
|
|
281
|
+
title: assembled.title,
|
|
282
|
+
});
|
|
283
|
+
const post = (extra) => fetch(`${API_BASE}/api/extension/memories/ingest`, {
|
|
284
|
+
method: "POST",
|
|
285
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}`, ...extra },
|
|
286
|
+
body,
|
|
287
|
+
});
|
|
288
|
+
let response = await post({});
|
|
289
|
+
if (response.status === 422) {
|
|
290
|
+
const key = new KeyStore().getKey();
|
|
291
|
+
if (key)
|
|
292
|
+
response = await post({ "X-Encryption-Key": key });
|
|
293
|
+
}
|
|
294
|
+
const data = (await response.json().catch(() => ({})));
|
|
295
|
+
if (!response.ok || data.success === false) {
|
|
296
|
+
const err = data.error || `HTTP ${response.status}`;
|
|
297
|
+
const vaultLocked = /ENCRYPTION_KEY_REQUIRED|encrypted/i.test(err);
|
|
298
|
+
return respond({ ok: false, reason: vaultLocked ? "vault_locked" : err, hint: vaultLocked ? "Run `echomem-mcp unlock` to enable cloud checkpoint extraction." : undefined });
|
|
299
|
+
}
|
|
300
|
+
const contextId = typeof data.contextId === "string" ? data.contextId : undefined;
|
|
301
|
+
// How many memories this session has in EchoMem, so the fresh agent can judge whether to fetch.
|
|
302
|
+
let sessionMemoryCount = data.memoriesExtracted || 0;
|
|
303
|
+
if (contextId) {
|
|
304
|
+
try {
|
|
305
|
+
const countRes = await fetch(`${API_BASE}/api/extension/memories/by-context`, {
|
|
306
|
+
method: "POST",
|
|
307
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${token}` },
|
|
308
|
+
body: JSON.stringify({ contextId, limit: 200 }),
|
|
309
|
+
});
|
|
310
|
+
const countData = (await countRes.json().catch(() => ({})));
|
|
311
|
+
if (typeof countData.count === "number")
|
|
312
|
+
sessionMemoryCount = countData.count;
|
|
313
|
+
}
|
|
314
|
+
catch {
|
|
315
|
+
/* count is best-effort */
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
respond({
|
|
319
|
+
ok: true,
|
|
320
|
+
carryover: formatCarryover(data.extractedMemories || [], assembled.title, contextId, sessionMemoryCount),
|
|
321
|
+
saved: data.memoriesExtracted || 0,
|
|
322
|
+
sessionMemoryCount,
|
|
323
|
+
contextId,
|
|
324
|
+
});
|
|
325
|
+
}
|
|
326
|
+
function handleRenewEstimate(latest, res) {
|
|
327
|
+
const respond = (obj) => {
|
|
328
|
+
if (res.writableEnded)
|
|
329
|
+
return;
|
|
330
|
+
res.writeHead(200, { "content-type": "application/json" });
|
|
331
|
+
res.end(JSON.stringify(obj));
|
|
332
|
+
};
|
|
333
|
+
const active = latest.active;
|
|
334
|
+
if (!active || !active.sourcePath)
|
|
335
|
+
return respond({ ok: false, reason: "no_active_session" });
|
|
336
|
+
const sessionFile = resolveSessionFile(active.client, active.sourcePath);
|
|
337
|
+
const assembled = active.client === "codex" ? assembleCodex(sessionFile) : assembleClaude(sessionFile);
|
|
338
|
+
if (!assembled || !assembled.rawData || assembled.rawData.length < 20) {
|
|
339
|
+
return respond({ ok: false, reason: "empty_session" });
|
|
340
|
+
}
|
|
341
|
+
respond({ ok: true, ...estimateRenewDuration(assembled.rawData, active.client) });
|
|
342
|
+
}
|
|
343
|
+
function estimateRenewDuration(rawData, client) {
|
|
344
|
+
const approxInputTokens = Math.max(1, Math.ceil(rawData.length / 4));
|
|
345
|
+
const history = readRenewMetrics().filter((m) => sourceMatchesClient(m.source, client));
|
|
346
|
+
const candidates = history.length >= 5 ? history : readRenewMetrics();
|
|
347
|
+
const bucket = tokenBucket(approxInputTokens);
|
|
348
|
+
let basis = "bucket";
|
|
349
|
+
let samples = candidates.filter((m) => {
|
|
350
|
+
const n = Number(m.approxInputTokens) || 0;
|
|
351
|
+
return n > bucket.min && n <= bucket.max;
|
|
352
|
+
});
|
|
353
|
+
if (samples.length < 5) {
|
|
354
|
+
basis = "nearest";
|
|
355
|
+
samples = candidates
|
|
356
|
+
.slice()
|
|
357
|
+
.sort((a, b) => tokenDistance(Number(a.approxInputTokens) || 0, approxInputTokens) - tokenDistance(Number(b.approxInputTokens) || 0, approxInputTokens))
|
|
358
|
+
.slice(0, Math.min(20, candidates.length));
|
|
359
|
+
}
|
|
360
|
+
if (!samples.length) {
|
|
361
|
+
basis = "fallback";
|
|
362
|
+
const etaMs = fallbackRenewEtaMs(approxInputTokens);
|
|
363
|
+
return { etaMs, approxInputTokens, sampleCount: 0, historyCount: candidates.length, basis };
|
|
364
|
+
}
|
|
365
|
+
const durations = samples.map((m) => Number(m.durationMs)).filter((n) => Number.isFinite(n) && n > 0);
|
|
366
|
+
if (!durations.length) {
|
|
367
|
+
basis = "fallback";
|
|
368
|
+
const etaMs = fallbackRenewEtaMs(approxInputTokens);
|
|
369
|
+
return { etaMs, approxInputTokens, sampleCount: 0, historyCount: candidates.length, basis };
|
|
370
|
+
}
|
|
371
|
+
const p50Ms = percentile(durations, 0.5);
|
|
372
|
+
const p75Ms = percentile(durations, 0.75);
|
|
373
|
+
return {
|
|
374
|
+
etaMs: clampMs(p50Ms),
|
|
375
|
+
approxInputTokens,
|
|
376
|
+
sampleCount: samples.length,
|
|
377
|
+
historyCount: candidates.length,
|
|
378
|
+
basis,
|
|
379
|
+
p50Ms,
|
|
380
|
+
p75Ms,
|
|
381
|
+
};
|
|
382
|
+
}
|
|
383
|
+
function readRenewMetrics() {
|
|
384
|
+
const file = homePath(".echomem", "migrate-metrics.jsonl");
|
|
385
|
+
let lines;
|
|
386
|
+
try {
|
|
387
|
+
lines = fs.readFileSync(file, "utf8").split("\n");
|
|
388
|
+
}
|
|
389
|
+
catch {
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
const out = [];
|
|
393
|
+
for (const line of lines.slice(-600)) {
|
|
394
|
+
const s = line.trim();
|
|
395
|
+
if (!s)
|
|
396
|
+
continue;
|
|
397
|
+
try {
|
|
398
|
+
const m = JSON.parse(s);
|
|
399
|
+
if (m.status === "completed" &&
|
|
400
|
+
!m.duplicate &&
|
|
401
|
+
!m.alreadyDone &&
|
|
402
|
+
typeof m.durationMs === "number" &&
|
|
403
|
+
m.durationMs > 0 &&
|
|
404
|
+
typeof m.approxInputTokens === "number" &&
|
|
405
|
+
m.approxInputTokens > 0) {
|
|
406
|
+
out.push(m);
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
catch {
|
|
410
|
+
/* ignore malformed metrics */
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
return out;
|
|
414
|
+
}
|
|
415
|
+
function sourceMatchesClient(source, client) {
|
|
416
|
+
if (!source)
|
|
417
|
+
return false;
|
|
418
|
+
if (client === "codex")
|
|
419
|
+
return source === "codex";
|
|
420
|
+
if (client.startsWith("claude"))
|
|
421
|
+
return source.startsWith("claude");
|
|
422
|
+
return source === client;
|
|
423
|
+
}
|
|
424
|
+
function tokenBucket(tokens) {
|
|
425
|
+
if (tokens <= 5_000)
|
|
426
|
+
return { min: 0, max: 5_000 };
|
|
427
|
+
if (tokens <= 10_000)
|
|
428
|
+
return { min: 5_000, max: 10_000 };
|
|
429
|
+
if (tokens <= 25_000)
|
|
430
|
+
return { min: 10_000, max: 25_000 };
|
|
431
|
+
if (tokens <= 50_000)
|
|
432
|
+
return { min: 25_000, max: 50_000 };
|
|
433
|
+
if (tokens <= 100_000)
|
|
434
|
+
return { min: 50_000, max: 100_000 };
|
|
435
|
+
return { min: 100_000, max: Number.POSITIVE_INFINITY };
|
|
436
|
+
}
|
|
437
|
+
function tokenDistance(a, b) {
|
|
438
|
+
return Math.abs(Math.log((Math.max(1, a) + 1000) / (Math.max(1, b) + 1000)));
|
|
439
|
+
}
|
|
440
|
+
function percentile(values, p) {
|
|
441
|
+
const xs = values.filter((n) => Number.isFinite(n)).sort((a, b) => a - b);
|
|
442
|
+
if (!xs.length)
|
|
443
|
+
return 0;
|
|
444
|
+
return xs[Math.min(xs.length - 1, Math.floor((xs.length - 1) * p))];
|
|
445
|
+
}
|
|
446
|
+
function clampMs(ms) {
|
|
447
|
+
return Math.max(6_000, Math.min(180_000, Math.round(ms)));
|
|
448
|
+
}
|
|
449
|
+
function fallbackRenewEtaMs(tokens) {
|
|
450
|
+
if (tokens <= 5_000)
|
|
451
|
+
return 10_000;
|
|
452
|
+
if (tokens <= 10_000)
|
|
453
|
+
return 12_000;
|
|
454
|
+
if (tokens <= 25_000)
|
|
455
|
+
return 15_000;
|
|
456
|
+
if (tokens <= 50_000)
|
|
457
|
+
return 18_000;
|
|
458
|
+
if (tokens <= 100_000)
|
|
459
|
+
return 20_000;
|
|
460
|
+
return 45_000;
|
|
461
|
+
}
|
|
462
|
+
// Claude Code's active source is the echo-ctx cache (no turns) — resolve its transcript for assembly.
|
|
463
|
+
function resolveSessionFile(client, sourcePath) {
|
|
464
|
+
if (client === "codex" || !sourcePath.endsWith(".json") || !sourcePath.includes(`${"/"}echo-ctx${"/"}`))
|
|
465
|
+
return sourcePath;
|
|
466
|
+
const sessionId = sourcePath.split("/").pop()?.replace(/\.json$/, "") || "";
|
|
467
|
+
const transcript = newestFile(walkFiles(homePath(".claude", "projects"), (f) => f.split("/").pop() === `${sessionId}.jsonl`));
|
|
468
|
+
return transcript || sourcePath;
|
|
469
|
+
}
|
|
470
|
+
function formatCarryover(memories, title, contextId, sessionMemoryCount) {
|
|
471
|
+
const str = (m, k) => (typeof m[k] === "string" ? m[k] : "");
|
|
472
|
+
const clean = memories.find((m) => /^clean[ -]?carry/i.test(str(m, "description")));
|
|
473
|
+
const others = memories.filter((m) => m !== clean);
|
|
474
|
+
// STATE, not INSTRUCTIONS: the receiving agent should orient, then ASK — never start verifying
|
|
475
|
+
// files or "continuing" a task the user may not want. (A real test showed "Verify anything that
|
|
476
|
+
// references file state" became the agent's task: it ran 15 commands before the user said a word.)
|
|
477
|
+
const lines = [
|
|
478
|
+
"Here is a snapshot of my previous coding session, for context. It is point-in-time: work may have continued after it was taken, so treat file/state references as possibly stale.",
|
|
479
|
+
"Use it to orient yourself in a clean context window. It is context, not a command.",
|
|
480
|
+
"If my current message gives a clear request, respond to that request using this snapshot as background. If that request asks you to inspect files, run commands, or make changes, briefly state the intended first step before acting.",
|
|
481
|
+
"If my current message gives no clear next step, summarize what you know in 3-5 bullets and ask what I want to do next.",
|
|
482
|
+
"",
|
|
483
|
+
];
|
|
484
|
+
if (clean) {
|
|
485
|
+
lines.push("## Where I left off", str(clean, "description"));
|
|
486
|
+
if (str(clean, "details"))
|
|
487
|
+
lines.push(str(clean, "details"));
|
|
488
|
+
lines.push("");
|
|
489
|
+
}
|
|
490
|
+
if (others.length) {
|
|
491
|
+
lines.push("## Checkpoints from this session");
|
|
492
|
+
for (const m of others)
|
|
493
|
+
lines.push(`- ${str(m, "keys") || "checkpoint"}: ${str(m, "description")}`);
|
|
494
|
+
lines.push("");
|
|
495
|
+
}
|
|
496
|
+
if (!clean && !others.length) {
|
|
497
|
+
lines.push(`(${title || "This session"} had no clear decision or state to carry — nothing was extracted.)`);
|
|
498
|
+
}
|
|
499
|
+
// Tell the fresh agent exactly how much is in EchoMem for this session, so it can decide whether to fetch.
|
|
500
|
+
if (contextId && typeof sessionMemoryCount === "number") {
|
|
501
|
+
const n = sessionMemoryCount;
|
|
502
|
+
lines.push(`(EchoMem has ${n} ${n === 1 ? "memory" : "memories"} saved for this checkpoint. If you need more than this snapshot, pull the decision trail with get_checkpoint_by_context({ contextId: "${contextId}" }) — otherwise just work from the above.)`);
|
|
503
|
+
}
|
|
504
|
+
else {
|
|
505
|
+
lines.push("(Recalled from EchoMem — call search_memories or get_checkpoint_by_context for more checkpoint context.)");
|
|
506
|
+
}
|
|
507
|
+
return lines.join("\n");
|
|
508
|
+
}
|
|
194
509
|
function serveHudAsset(pathname, res) {
|
|
195
510
|
const assets = {
|
|
196
|
-
"/assets/hud/echo-face-cutout.png": "../../assets/hud/echo-face-cutout.png",
|
|
511
|
+
"/assets/hud/echo-face-cutout.png": { path: "../../assets/hud/echo-face-cutout.png", type: "image/png" },
|
|
512
|
+
"/assets/hud/claude.svg": { path: "../../assets/hud/claude.svg", type: "image/svg+xml; charset=utf-8" },
|
|
513
|
+
"/assets/hud/codex.svg": { path: "../../assets/hud/codex.svg", type: "image/svg+xml; charset=utf-8" },
|
|
197
514
|
};
|
|
198
515
|
const asset = assets[pathname];
|
|
199
516
|
if (!asset)
|
|
200
517
|
return false;
|
|
201
|
-
const assetPath = fileURLToPath(new URL(asset, import.meta.url));
|
|
518
|
+
const assetPath = fileURLToPath(new URL(asset.path, import.meta.url));
|
|
202
519
|
if (!fs.existsSync(assetPath)) {
|
|
203
520
|
res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
|
|
204
521
|
res.end("HUD asset not found");
|
|
205
522
|
return true;
|
|
206
523
|
}
|
|
207
524
|
res.writeHead(200, {
|
|
208
|
-
"content-type":
|
|
525
|
+
"content-type": asset.type,
|
|
209
526
|
"cache-control": "public, max-age=31536000, immutable",
|
|
210
527
|
});
|
|
211
528
|
fs.createReadStream(assetPath).pipe(res);
|