@echomem/mcp 1.4.17 → 1.4.19

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.
@@ -30,9 +30,9 @@ export class HudMonitor extends EventEmitter {
30
30
  claudeTitlesCheckedAt = 0;
31
31
  claudeTitleCache = new Map();
32
32
  lastPersistedRecent = "";
33
- pinnedId = null;
34
- pinnedScore = null;
35
- pinnedFromFamily = null;
33
+ responseTimes = new Map();
34
+ expandedId = null;
35
+ expandedScore = null;
36
36
  constructor(mode = "auto", pollMs = 750) {
37
37
  super();
38
38
  this.mode = mode;
@@ -53,37 +53,37 @@ export class HudMonitor extends EventEmitter {
53
53
  snapshot() {
54
54
  const focusedFamily = this.frontmostPreferredFamily();
55
55
  this.refreshRecent();
56
- this.releasePinAfterForegroundSwitch(focusedFamily);
57
56
  const now = Date.now();
58
57
  const sessions = [...this.scores.values()].map((score) => {
59
58
  // Liveness uses file mtime (real last write), not score.updatedAt — the Claude cache stamps
60
59
  // updatedAt = now on every read, which would make an idle session look permanently live.
61
60
  const mtimeMs = this.mtimes.get(score.client) ?? (Date.parse(score.updatedAt) || now);
62
- const lastActiveMs = Math.max(0, now - mtimeMs);
61
+ const responseAtMs = this.responseTimeFor(score.client, score.sourcePath, mtimeMs);
62
+ const lastActiveMs = Math.max(0, now - responseAtMs);
63
63
  return {
64
64
  ...score,
65
- live: lastActiveMs < LIVE_WINDOW_MS,
65
+ live: Math.max(0, now - mtimeMs) < LIVE_WINDOW_MS,
66
66
  focused: focusedFamily === agentFamily(score.client),
67
67
  label: this.labelFor(score),
68
68
  lastActiveMs,
69
69
  };
70
70
  });
71
- // The user's foreground agent is the primary truth. Background agents can keep writing logs, but
72
- // they should never steal the main HUD away from Codex/Claude while that app is what the user opened.
73
- sessions.sort((a, b) => hudSelectionRank(a) - hudSelectionRank(b) || a.lastActiveMs - b.lastActiveMs);
74
- // A pin selects one session while the user remains in the same agent. Crossing to the other
75
- // foreground agent releases it so Auto can follow the workflow again.
76
- const pinned = this.pinnedView(now);
77
- const ordered = pinned ? [pinned, ...sessions.filter((s) => s.sourcePath !== pinned.sourcePath)] : sessions;
78
- const pinnedId = pinned ? this.pinnedId : null;
71
+ // Only a newer assistant response can promote a conversation. Focus, clicks, and expansion are
72
+ // view state and must never rewrite chronology; sourcePath is only a deterministic exact-tie key.
73
+ sessions.sort((a, b) => a.lastActiveMs - b.lastActiveMs || a.sourcePath.localeCompare(b.sourcePath));
74
+ const expanded = this.expandedView(now);
75
+ const views = expanded
76
+ ? [expanded, ...sessions.filter((s) => s.sourcePath !== expanded.sourcePath)]
77
+ : sessions;
79
78
  return {
80
79
  mode: this.mode,
81
- active: ordered[0] || null,
82
- scores: ordered,
83
- sessions: ordered,
80
+ active: sessions[0] || null,
81
+ expanded,
82
+ expandedId: expanded ? this.expandedId : null,
83
+ scores: views,
84
+ sessions: views,
84
85
  threadCounts: this.threadCounts,
85
86
  recentSessions: this.recentSessions,
86
- pinnedId,
87
87
  missing: this.missing,
88
88
  updatedAt: new Date().toISOString(),
89
89
  };
@@ -96,29 +96,20 @@ export class HudMonitor extends EventEmitter {
96
96
  this.labelCache.set(score.sourcePath, label);
97
97
  return label;
98
98
  }
99
- // Pin a recent-session id as the primary view (or null to return to auto-selection).
100
- pin(id) {
101
- if (id === this.pinnedId)
99
+ // Expand one recent-session row in place (or null to collapse it). This deliberately does not
100
+ // participate in active-session selection, so inspecting history cannot move the timeline.
101
+ expand(id) {
102
+ if (id === this.expandedId)
102
103
  return;
103
- this.pinnedId = id;
104
- this.pinnedScore = null;
105
- this.pinnedFromFamily = id ? this.frontmostPreferredFamily() : null;
104
+ this.expandedId = id;
105
+ this.expandedScore = null;
106
106
  }
107
- releasePinAfterForegroundSwitch(focusedFamily) {
108
- if (!this.pinnedId || !this.pinnedFromFamily || !focusedFamily)
109
- return;
110
- if (focusedFamily === this.pinnedFromFamily)
111
- return;
112
- this.pinnedId = null;
113
- this.pinnedScore = null;
114
- this.pinnedFromFamily = null;
115
- }
116
- // The user-selected tab, scored on demand (cached by file signature). null if nothing is pinned or
117
- // the pinned session has aged out of the recent list.
118
- pinnedView(now) {
119
- if (!this.pinnedId)
107
+ // The expanded history row, scored on demand (cached by file signature). null if nothing is
108
+ // expanded or the session has aged out of the recent list.
109
+ expandedView(now) {
110
+ if (!this.expandedId)
120
111
  return null;
121
- const rec = this.recentSessions.find((r) => r.id === this.pinnedId);
112
+ const rec = this.recentSessions.find((r) => r.id === this.expandedId);
122
113
  if (!rec)
123
114
  return null;
124
115
  let stat = null;
@@ -129,16 +120,23 @@ export class HudMonitor extends EventEmitter {
129
120
  return null;
130
121
  }
131
122
  const sig = `${rec.sourcePath}:${stat.size}:${stat.mtimeMs}`;
132
- if (!this.pinnedScore || this.pinnedScore.sig !== sig) {
123
+ if (!this.expandedScore || this.expandedScore.sig !== sig) {
133
124
  try {
134
- this.pinnedScore = { sig, score: adapters[rec.client].score(rec.sourcePath) };
125
+ this.expandedScore = { sig, score: adapters[rec.client].score(rec.sourcePath) };
135
126
  }
136
127
  catch {
137
128
  return null;
138
129
  }
139
130
  }
140
- const lastActiveMs = Math.max(0, now - stat.mtimeMs);
141
- return { ...this.pinnedScore.score, live: lastActiveMs < LIVE_WINDOW_MS, focused: true, label: rec.label || rec.title, lastActiveMs };
131
+ const activityMtimeMs = liveMtime(rec.client, rec.sourcePath, stat.mtimeMs);
132
+ const lastActiveMs = Math.max(0, now - this.responseTimeFor(rec.client, rec.sourcePath, activityMtimeMs));
133
+ return {
134
+ ...this.expandedScore.score,
135
+ live: Math.max(0, now - activityMtimeMs) < LIVE_WINDOW_MS,
136
+ focused: false,
137
+ label: rec.label || rec.title,
138
+ lastActiveMs,
139
+ };
142
140
  }
143
141
  frontmostPreferredFamily() {
144
142
  if (this.mode !== "auto" && this.mode !== "both")
@@ -189,8 +187,10 @@ export class HudMonitor extends EventEmitter {
189
187
  const newest = newestByClient.get(adapter.client);
190
188
  if (!newest || mtimeMs > newest.mtimeMs)
191
189
  newestByClient.set(adapter.client, { file, mtimeMs });
192
- if (age < RECENT_WINDOW_MS)
193
- recent.push(this.buildRecent(adapter.client, file, age));
190
+ if (age < RECENT_WINDOW_MS) {
191
+ const responseAtMs = this.responseTimeFor(adapter.client, file, mtimeMs);
192
+ recent.push(this.buildRecent(adapter.client, file, age, Math.max(0, now - responseAtMs)));
193
+ }
194
194
  }
195
195
  counts[adapter.client] = n;
196
196
  }
@@ -199,24 +199,44 @@ export class HudMonitor extends EventEmitter {
199
199
  // Always keep each agent's latest session reachable as a tab (so you can switch to "my last Codex"
200
200
  // even if it's been idle longer than the window). Appended if the window didn't already include it.
201
201
  for (const [client, info] of newestByClient) {
202
- if (!list.some((r) => r.client === client))
203
- list.push(this.buildRecent(client, info.file, now - info.mtimeMs));
202
+ if (!list.some((r) => r.client === client)) {
203
+ const activityAgeMs = now - info.mtimeMs;
204
+ const responseAgeMs = Math.max(0, now - this.responseTimeFor(client, info.file, info.mtimeMs));
205
+ list.push(this.buildRecent(client, info.file, activityAgeMs, responseAgeMs));
206
+ }
204
207
  }
205
208
  this.threadCounts = counts;
206
209
  this.recentSessions = list;
207
210
  this.persistRecent();
208
211
  }
209
- buildRecent(client, file, age) {
212
+ buildRecent(client, file, activityAgeMs, responseAgeMs) {
210
213
  return {
211
- id: sessionIdFromPath(file),
214
+ id: recentSessionId(client, file),
212
215
  client,
213
216
  title: this.titleFor(client, file),
214
217
  label: sessionLabel(file, client),
215
218
  sourcePath: file,
216
- lastActiveMs: age,
217
- live: age < LIVE_WINDOW_MS,
219
+ lastActiveMs: responseAgeMs,
220
+ live: activityAgeMs < LIVE_WINDOW_MS,
218
221
  };
219
222
  }
223
+ responseTimeFor(client, file, fallbackMtimeMs) {
224
+ const transcript = client === "codex" ? file : resolveClaudeTranscript(file) ?? file;
225
+ let signature = "";
226
+ try {
227
+ const stat = fs.statSync(transcript);
228
+ signature = `${transcript}:${stat.size}:${stat.mtimeMs}`;
229
+ }
230
+ catch {
231
+ return fallbackMtimeMs;
232
+ }
233
+ const cached = this.responseTimes.get(transcript);
234
+ if (cached?.signature === signature)
235
+ return cached.timestampMs;
236
+ const timestampMs = latestAssistantResponseAt(transcript, client) || cached?.timestampMs || fallbackMtimeMs;
237
+ this.responseTimes.set(transcript, { signature, timestampMs });
238
+ return timestampMs;
239
+ }
220
240
  titleFor(client, file) {
221
241
  if (client === "codex") {
222
242
  const uuid = codexUuidFromPath(file);
@@ -414,12 +434,106 @@ export function agentFamilyForApplication(name, bundleId = "") {
414
434
  export function agentFamily(client) {
415
435
  return client === "codex" ? "codex" : "claude";
416
436
  }
437
+ // Return the timestamp of the newest assistant text response, ignoring user turns, tool calls,
438
+ // tool results, and other transcript writes. Scanning backwards keeps the common path cheap while
439
+ // making ordering depend on conversation responses instead of filesystem activity.
440
+ export function latestAssistantResponseAt(file, client) {
441
+ const chunkSize = 256 * 1024;
442
+ const maxScanBytes = 8 * 1024 * 1024;
443
+ let fd = null;
444
+ try {
445
+ fd = fs.openSync(file, "r");
446
+ const size = fs.fstatSync(fd).size;
447
+ let position = size;
448
+ let scanned = 0;
449
+ let carry = "";
450
+ while (position > 0 && scanned < maxScanBytes) {
451
+ const bytes = Math.min(chunkSize, position, maxScanBytes - scanned);
452
+ position -= bytes;
453
+ scanned += bytes;
454
+ const buffer = Buffer.allocUnsafe(bytes);
455
+ fs.readSync(fd, buffer, 0, bytes, position);
456
+ const lines = (buffer.toString("utf8") + carry).split("\n");
457
+ carry = lines.shift() || "";
458
+ for (let i = lines.length - 1; i >= 0; i -= 1) {
459
+ const timestampMs = assistantResponseTimestamp(lines[i], client);
460
+ if (timestampMs !== null)
461
+ return timestampMs;
462
+ }
463
+ }
464
+ return assistantResponseTimestamp(carry, client);
465
+ }
466
+ catch {
467
+ return null;
468
+ }
469
+ finally {
470
+ if (fd !== null)
471
+ fs.closeSync(fd);
472
+ }
473
+ }
474
+ function assistantResponseTimestamp(line, client) {
475
+ if (!line.trim())
476
+ return null;
477
+ let row;
478
+ try {
479
+ row = JSON.parse(line);
480
+ }
481
+ catch {
482
+ return null;
483
+ }
484
+ if (!isRecord(row))
485
+ return null;
486
+ const payload = isRecord(row.payload) ? row.payload : {};
487
+ const timestamp = typeof row.timestamp === "string"
488
+ ? Date.parse(row.timestamp)
489
+ : typeof payload.timestamp === "string"
490
+ ? Date.parse(payload.timestamp)
491
+ : Number.NaN;
492
+ if (!Number.isFinite(timestamp))
493
+ return null;
494
+ if (client === "codex") {
495
+ const type = typeof payload.type === "string" ? payload.type : "";
496
+ if ((type === "agent_message" || type === "assistant_message") && hasText(payload.message ?? payload.content)) {
497
+ return timestamp;
498
+ }
499
+ if (type === "message" && payload.role === "assistant" && hasText(payload.content ?? payload.message)) {
500
+ return timestamp;
501
+ }
502
+ return null;
503
+ }
504
+ if (row.type !== "assistant")
505
+ return null;
506
+ const message = isRecord(row.message) ? row.message : {};
507
+ if (message.role !== undefined && message.role !== "assistant")
508
+ return null;
509
+ return hasText(message.content) ? timestamp : null;
510
+ }
511
+ function hasText(value) {
512
+ if (typeof value === "string")
513
+ return Boolean(value.trim());
514
+ if (!Array.isArray(value))
515
+ return false;
516
+ return value.some((block) => {
517
+ if (typeof block === "string")
518
+ return Boolean(block.trim());
519
+ if (!isRecord(block))
520
+ return false;
521
+ const type = typeof block.type === "string" ? block.type : "";
522
+ return (type === "text" || type === "output_text") && typeof block.text === "string" && Boolean(block.text.trim());
523
+ });
524
+ }
417
525
  function isRecord(value) {
418
526
  return typeof value === "object" && value !== null && !Array.isArray(value);
419
527
  }
420
528
  function sessionIdFromPath(file) {
421
529
  return codexUuidFromPath(file) || path.basename(file).replace(/\.jsonl$|\.json$/, "");
422
530
  }
531
+ // Provider-qualified identity keeps one combined session feed safe when two clients reuse the same
532
+ // UUID (for example, a Claude Code session mirrored into Claude Desktop). The source id remains
533
+ // recoverable from sourcePath for provider-specific deep links and title metadata joins.
534
+ export function recentSessionId(client, file) {
535
+ return `${client}:${sessionIdFromPath(file)}`;
536
+ }
423
537
  function codexUuidFromPath(file) {
424
538
  const match = path.basename(file).match(/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}/);
425
539
  return match ? match[0] : "";
@@ -11,6 +11,7 @@ import { homePath, newestFile, walkFiles } from "./fs.js";
11
11
  import { KeyStore } from "../keystore.js";
12
12
  import { assembleCodex, assembleClaude } from "../migrate.js";
13
13
  import { readBillingAlert } from "../billing-alert.js";
14
+ import { readSessionWorkingDirectory, } from "./session-launcher.js";
14
15
  const API_BASE = (process.env.ECHO_API_BASE_URL || "https://echo-mem-chrome.vercel.app").replace(/\/$/, "");
15
16
  const PRICING_URL = (process.env.ECHO_PRICING_URL || "https://echoknows.com/pricing").replace(/\/$/, "");
16
17
  export async function createHudServer(opts = {}) {
@@ -89,15 +90,17 @@ export async function createHudServer(opts = {}) {
89
90
  });
90
91
  return;
91
92
  }
92
- if (url.pathname === "/select") {
93
+ if (url.pathname === "/expand" || url.pathname === "/select") {
93
94
  const id = url.searchParams.get("id");
94
- monitor.pin(id && id !== "auto" ? id : null);
95
+ // /select remains as a compatibility alias, but selection no longer replaces the top card:
96
+ // it only expands that timeline row in place.
97
+ monitor.expand(id && id !== "auto" ? id : null);
95
98
  latest = monitor.snapshot();
96
99
  const payload = `data: ${JSON.stringify(latest)}\n\n`;
97
100
  for (const client of clients)
98
101
  client.write(payload);
99
102
  res.writeHead(200, { "content-type": "application/json" });
100
- res.end(JSON.stringify({ ok: true, pinned: id && id !== "auto" ? id : null }));
103
+ res.end(JSON.stringify({ ok: true, expanded: id && id !== "auto" ? id : null }));
101
104
  return;
102
105
  }
103
106
  if (url.pathname === "/viewer") {
@@ -242,6 +245,15 @@ export async function createHudServer(opts = {}) {
242
245
  });
243
246
  return;
244
247
  }
248
+ if (url.pathname === "/launch") {
249
+ handleLaunch(req, latest, opts.onLaunchSession, res).catch((error) => {
250
+ if (res.writableEnded)
251
+ return;
252
+ res.writeHead(200, { "content-type": "application/json" });
253
+ res.end(JSON.stringify({ ok: false, reason: error instanceof Error ? error.message : "launch_failed" }));
254
+ });
255
+ return;
256
+ }
245
257
  if (url.pathname === "/checkpoint-status") {
246
258
  handleCheckpointStatus(latest, res).catch((error) => {
247
259
  if (res.writableEnded)
@@ -307,6 +319,9 @@ async function handleBillingStatus(res) {
307
319
  }
308
320
  let plan = "unknown";
309
321
  let paid = false;
322
+ let historicalConversationQuota = null;
323
+ let memoryProcessingQuota = null;
324
+ let memorySearchQuota = null;
310
325
  try {
311
326
  const response = await fetch(`${API_BASE}/api/extension/account/bootstrap`, {
312
327
  headers: { Authorization: `Bearer ${token}` },
@@ -315,34 +330,35 @@ async function handleBillingStatus(res) {
315
330
  if (response.ok) {
316
331
  plan = typeof data.plan === "string" ? data.plan.toLowerCase() : "free";
317
332
  paid = ["pro", "power", "team", "enterprise"].includes(plan);
333
+ historicalConversationQuota = data.historicalConversationQuota ?? null;
334
+ memoryProcessingQuota = data.memoryProcessingQuota ?? null;
335
+ memorySearchQuota = data.memorySearchQuota ?? null;
318
336
  }
319
337
  }
320
338
  catch {
321
339
  /* Local alert still gives the HUD something useful. */
322
340
  }
323
- if (alert && (alert.kind === "quota_exceeded" || !paid)) {
341
+ const quotaPayload = {
342
+ historicalConversationQuota,
343
+ memoryProcessingQuota,
344
+ memorySearchQuota,
345
+ };
346
+ // Free now includes recall. Ignore old plan_required alerts that may still
347
+ // be present on disk from a previous bridge version.
348
+ if (alert && (alert.kind === "quota_exceeded" || alert.kind === "billing_attention")) {
324
349
  return respond({
325
350
  ok: true,
326
351
  state: alert.kind,
327
352
  plan: alert.plan || plan,
328
- message: alert.kind === "quota_exceeded" ? "Recall limit reached." : "Recall needs a plan.",
353
+ message: alert.kind === "quota_exceeded" ? "Plan limit reached." : "Billing needs attention.",
329
354
  detail: alert.message,
330
355
  code: alert.code,
331
356
  pricingUrl: appendSource(alert.pricingUrl || pricingUrl, "hud"),
332
357
  updatedAt: alert.updatedAt,
358
+ ...quotaPayload,
333
359
  });
334
360
  }
335
- if (!paid && plan === "free") {
336
- return respond({
337
- ok: true,
338
- state: "plan_required",
339
- plan,
340
- message: "Recall needs a plan.",
341
- detail: "Search and recall require Echo Pro or Power. Saving conversations keeps working.",
342
- pricingUrl,
343
- });
344
- }
345
- respond({ ok: true, state: "ok", plan, paid, pricingUrl });
361
+ respond({ ok: true, state: "ok", plan, paid, pricingUrl, ...quotaPayload });
346
362
  }
347
363
  function appendSource(url, source) {
348
364
  try {
@@ -355,6 +371,44 @@ function appendSource(url, source) {
355
371
  return url;
356
372
  }
357
373
  }
374
+ async function handleLaunch(req, latest, launch, res) {
375
+ const respond = (obj) => {
376
+ if (res.writableEnded)
377
+ return;
378
+ res.writeHead(200, { "content-type": "application/json", "cache-control": "no-store" });
379
+ res.end(JSON.stringify(obj));
380
+ };
381
+ if (req.method !== "POST")
382
+ return respond({ ok: false, reason: "method_not_allowed" });
383
+ const origin = req.headers.origin;
384
+ if (origin && !/^http:\/\/(?:127\.0\.0\.1|localhost)(?::\d+)?$/.test(origin)) {
385
+ return respond({ ok: false, reason: "origin_not_allowed" });
386
+ }
387
+ if (!launch)
388
+ return respond({ ok: false, reason: "no_desktop" });
389
+ const active = latest.active;
390
+ if (!active || !active.sourcePath)
391
+ return respond({ ok: false, reason: "no_active_session" });
392
+ const body = await readRequestJson(req, 600_000);
393
+ const prompt = typeof body.prompt === "string" ? body.prompt.trim() : "";
394
+ const expectedSource = typeof body.sourcePath === "string" ? body.sourcePath : "";
395
+ if (!prompt || prompt.length > 512_000)
396
+ return respond({ ok: false, reason: "invalid_context" });
397
+ if (!expectedSource || expectedSource !== active.sourcePath)
398
+ return respond({ ok: false, reason: "session_changed" });
399
+ const sessionFile = resolveSessionFile(active.client, active.sourcePath);
400
+ const assembled = active.client === "codex" ? assembleCodex(sessionFile) : assembleClaude(sessionFile);
401
+ const cwd = readSessionWorkingDirectory(sessionFile) || (assembled?.cwd && path.isAbsolute(assembled.cwd) ? assembled.cwd : "");
402
+ if (!assembled || !cwd)
403
+ return respond({ ok: false, reason: "workspace_missing" });
404
+ const result = await launch({
405
+ client: active.client,
406
+ cwd,
407
+ prompt,
408
+ title: assembled.title,
409
+ });
410
+ respond(result);
411
+ }
358
412
  async function handleCheckpointStatus(latest, res) {
359
413
  const respond = (obj) => {
360
414
  if (res.writableEnded)
@@ -668,3 +722,29 @@ function serveHudAsset(pathname, res) {
668
722
  fs.createReadStream(assetPath).pipe(res);
669
723
  return true;
670
724
  }
725
+ function readRequestJson(req, maxBytes) {
726
+ return new Promise((resolve, reject) => {
727
+ const chunks = [];
728
+ let size = 0;
729
+ req.on("data", (chunk) => {
730
+ const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
731
+ size += buffer.length;
732
+ if (size > maxBytes) {
733
+ reject(new Error("request_too_large"));
734
+ req.destroy();
735
+ return;
736
+ }
737
+ chunks.push(buffer);
738
+ });
739
+ req.on("end", () => {
740
+ try {
741
+ const parsed = JSON.parse(Buffer.concat(chunks).toString("utf8") || "{}");
742
+ resolve(typeof parsed === "object" && parsed !== null && !Array.isArray(parsed) ? parsed : {});
743
+ }
744
+ catch {
745
+ reject(new Error("invalid_json"));
746
+ }
747
+ });
748
+ req.on("error", reject);
749
+ });
750
+ }