@echomem/mcp 1.4.6 → 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.
@@ -1,9 +1,15 @@
1
1
  import http from "node:http";
2
2
  import fs from "node:fs";
3
+ import path from "node:path";
4
+ import { execFileSync } from "node:child_process";
3
5
  import { fileURLToPath } from "node:url";
4
6
  import { HudMonitor } from "./monitor.js";
5
7
  import { HUD_HTML } from "./web.js";
6
8
  import { buildCapsuleText } from "./capsule.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(/\/$/, "");
7
13
  export async function createHudServer(opts = {}) {
8
14
  const mode = opts.mode || "auto";
9
15
  const port = opts.port ?? 17377;
@@ -47,6 +53,96 @@ export async function createHudServer(opts = {}) {
47
53
  res.end(JSON.stringify({ ok: true, pinned: id && id !== "auto" ? id : null }));
48
54
  return;
49
55
  }
56
+ if (url.pathname === "/viewer") {
57
+ const viewerPath = fileURLToPath(new URL("../../assets/hud/session-viewer.html", import.meta.url));
58
+ if (!fs.existsSync(viewerPath)) {
59
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
60
+ res.end("session viewer not found");
61
+ return;
62
+ }
63
+ res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
64
+ fs.createReadStream(viewerPath).pipe(res);
65
+ return;
66
+ }
67
+ if (url.pathname === "/session-raw") {
68
+ // Serve a tracked session's raw log so the viewer can fetch it same-origin. Path-validated.
69
+ const target = url.searchParams.get("path") || "";
70
+ const known = new Set([
71
+ ...(latest.recentSessions || []).map((s) => s.sourcePath),
72
+ ...(latest.sessions || []).map((s) => s.sourcePath),
73
+ ]);
74
+ if (!target || !known.has(target) || !fs.existsSync(target)) {
75
+ res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
76
+ res.end("not a tracked session");
77
+ return;
78
+ }
79
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
80
+ fs.createReadStream(target).pipe(res);
81
+ return;
82
+ }
83
+ if (url.pathname === "/session-payload") {
84
+ // The REAL context window: Codex logs each API request (websocket request:) in logs_2.sqlite.
85
+ // Return one payload per line (NDJSON) so the same viewer can render them like records.
86
+ const target = url.searchParams.get("path") || "";
87
+ const known = new Set([
88
+ ...(latest.recentSessions || []).map((s) => s.sourcePath),
89
+ ...(latest.sessions || []).map((s) => s.sourcePath),
90
+ ]);
91
+ res.writeHead(200, { "content-type": "text/plain; charset=utf-8", "cache-control": "no-store" });
92
+ if (!target || !known.has(target)) {
93
+ res.end(JSON.stringify({ note: "not a tracked session" }));
94
+ return;
95
+ }
96
+ // Real request payloads are only logged by Codex (logs_2.sqlite). Claude sessions have none.
97
+ if (!path.basename(target).startsWith("rollout-")) {
98
+ res.end(JSON.stringify({ note: "This is a Claude session — the real request payload (context window) is only recorded for Codex. Switch to a Codex tab in the HUD, then open the viewer." }));
99
+ return;
100
+ }
101
+ const uuid = (path.basename(target).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/) || [])[0] || "";
102
+ const db = homePath(".codex", "logs_2.sqlite");
103
+ if (!uuid || !fs.existsSync(db)) {
104
+ res.end(JSON.stringify({ note: "no Codex request-payload log found (logs_2.sqlite missing or no thread id)" }));
105
+ return;
106
+ }
107
+ try {
108
+ const query = `SELECT feedback_log_body FROM logs WHERE thread_id='${uuid}' AND feedback_log_body LIKE '%websocket request: %' ORDER BY ts LIMIT 500;`;
109
+ const out = execFileSync("sqlite3", ["-json", db, query], { maxBuffer: 256 * 1024 * 1024 }).toString();
110
+ const rows = JSON.parse(out || "[]");
111
+ const lines = [];
112
+ for (const row of rows) {
113
+ const body = row.feedback_log_body || "";
114
+ const at = body.indexOf("websocket request: ");
115
+ if (at < 0)
116
+ continue;
117
+ try {
118
+ lines.push(JSON.stringify(JSON.parse(body.slice(at + "websocket request: ".length))));
119
+ }
120
+ catch {
121
+ /* skip unparseable payload */
122
+ }
123
+ }
124
+ res.end(lines.length ? lines.join("\n") : JSON.stringify({ note: "no request payloads found for this thread" }));
125
+ }
126
+ catch (error) {
127
+ res.end(JSON.stringify({ note: "payload query failed", error: error instanceof Error ? error.message : String(error) }));
128
+ }
129
+ return;
130
+ }
131
+ if (url.pathname === "/open-viewer") {
132
+ const target = url.searchParams.get("path") || "";
133
+ const known = new Set([
134
+ ...(latest.recentSessions || []).map((s) => s.sourcePath),
135
+ ...(latest.sessions || []).map((s) => s.sourcePath),
136
+ ]);
137
+ const canOpen = Boolean(target) && known.has(target) && typeof opts.onOpenExternal === "function";
138
+ if (canOpen) {
139
+ const host = req.headers.host || `127.0.0.1:${port}`;
140
+ opts.onOpenExternal(`http://${host}/viewer?path=${encodeURIComponent(target)}`);
141
+ }
142
+ res.writeHead(200, { "content-type": "application/json" });
143
+ res.end(JSON.stringify({ ok: canOpen, reason: canOpen ? undefined : opts.onOpenExternal ? "unknown_path" : "no_desktop" }));
144
+ return;
145
+ }
50
146
  if (url.pathname === "/reveal") {
51
147
  const target = url.searchParams.get("path") || "";
52
148
  // Only reveal paths we actually track — never open an arbitrary path from a query string.
@@ -61,6 +157,28 @@ export async function createHudServer(opts = {}) {
61
157
  res.end(JSON.stringify({ ok: canReveal, reason: canReveal ? undefined : opts.onReveal ? "unknown_path" : "no_desktop" }));
62
158
  return;
63
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
+ }
64
182
  if (url.pathname === "/capsule") {
65
183
  const active = latest.active;
66
184
  if (!active) {
@@ -98,21 +216,313 @@ export async function createHudServer(opts = {}) {
98
216
  }),
99
217
  };
100
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
+ }
101
509
  function serveHudAsset(pathname, res) {
102
510
  const assets = {
103
- "/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" },
104
514
  };
105
515
  const asset = assets[pathname];
106
516
  if (!asset)
107
517
  return false;
108
- const assetPath = fileURLToPath(new URL(asset, import.meta.url));
518
+ const assetPath = fileURLToPath(new URL(asset.path, import.meta.url));
109
519
  if (!fs.existsSync(assetPath)) {
110
520
  res.writeHead(404, { "content-type": "text/plain; charset=utf-8" });
111
521
  res.end("HUD asset not found");
112
522
  return true;
113
523
  }
114
524
  res.writeHead(200, {
115
- "content-type": "image/png",
525
+ "content-type": asset.type,
116
526
  "cache-control": "public, max-age=31536000, immutable",
117
527
  });
118
528
  fs.createReadStream(assetPath).pipe(res);