@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.
@@ -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": "image/png",
525
+ "content-type": asset.type,
209
526
  "cache-control": "public, max-age=31536000, immutable",
210
527
  });
211
528
  fs.createReadStream(assetPath).pipe(res);