@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.
- 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 +1734 -0
- package/dist/city/10-problems-report.html +649 -0
- package/dist/city/echo-ai-city-only.html +63 -653
- package/dist/city/echo-ai-city-only.template.html +35 -17
- package/dist/city/echo-face-cutout.png +0 -0
- package/dist/forensics-10-problems.js +632 -0
- package/dist/hud/autostart.js +66 -0
- package/dist/hud/cli.js +31 -0
- package/dist/hud/electron-main.js +229 -22
- package/dist/hud/monitor.js +36 -68
- package/dist/hud/preload.cjs +12 -0
- package/dist/hud/server.js +413 -3
- package/dist/hud/web.js +713 -74
- package/dist/index.js +355 -28
- package/dist/migrate.js +18 -0
- package/dist/setup-page.js +978 -60
- package/dist/setup.js +353 -40
- package/dist/v1-contract.js +154 -5
- package/package.json +3 -2
package/dist/hud/web.js
CHANGED
|
@@ -37,12 +37,14 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
37
37
|
width: min(360px, calc(100vw - 16px));
|
|
38
38
|
background: transparent;
|
|
39
39
|
-webkit-font-smoothing: antialiased;
|
|
40
|
+
cursor: grab;
|
|
40
41
|
}
|
|
42
|
+
#hud.dragging { cursor: grabbing; }
|
|
41
43
|
.glance {
|
|
42
44
|
position: relative;
|
|
43
45
|
height: 112px;
|
|
44
46
|
user-select: none;
|
|
45
|
-
cursor:
|
|
47
|
+
cursor: grab;
|
|
46
48
|
}
|
|
47
49
|
.drag {
|
|
48
50
|
position: absolute;
|
|
@@ -51,8 +53,8 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
51
53
|
width: 20px;
|
|
52
54
|
height: 32px;
|
|
53
55
|
border-radius: 8px;
|
|
54
|
-
-webkit-app-region: drag;
|
|
55
56
|
opacity: 0.78;
|
|
57
|
+
pointer-events: none;
|
|
56
58
|
z-index: 5;
|
|
57
59
|
}
|
|
58
60
|
.drag::before {
|
|
@@ -75,7 +77,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
75
77
|
height: 70px;
|
|
76
78
|
overflow: hidden;
|
|
77
79
|
border-radius: 18px;
|
|
78
|
-
transition: filter 0.45s ease;
|
|
80
|
+
transition: filter 0.45s ease, transform 0.18s ease;
|
|
79
81
|
}
|
|
80
82
|
@keyframes echo-bob {
|
|
81
83
|
0%, 100% { transform: translateY(0); }
|
|
@@ -106,6 +108,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
106
108
|
border-radius: 34px;
|
|
107
109
|
background: #fdfaf2;
|
|
108
110
|
filter: var(--shadow);
|
|
111
|
+
transition: border-color 0.18s ease, background 0.18s ease, filter 0.18s ease;
|
|
109
112
|
}
|
|
110
113
|
.status-bubble::after {
|
|
111
114
|
content: "";
|
|
@@ -167,6 +170,15 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
167
170
|
text-transform: uppercase;
|
|
168
171
|
color: var(--faint);
|
|
169
172
|
}
|
|
173
|
+
/* When drifted, the bubble's second line becomes Echo speaking, not a log line. */
|
|
174
|
+
.client.voice {
|
|
175
|
+
font-family: inherit;
|
|
176
|
+
font-size: 11.5px;
|
|
177
|
+
font-weight: 750;
|
|
178
|
+
letter-spacing: 0.01em;
|
|
179
|
+
text-transform: none;
|
|
180
|
+
color: var(--brick);
|
|
181
|
+
}
|
|
170
182
|
.details {
|
|
171
183
|
display: none;
|
|
172
184
|
position: relative;
|
|
@@ -177,6 +189,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
177
189
|
background: var(--card);
|
|
178
190
|
color: var(--ink);
|
|
179
191
|
filter: var(--shadow);
|
|
192
|
+
cursor: grab;
|
|
180
193
|
}
|
|
181
194
|
#hud.open .details { display: block; }
|
|
182
195
|
.head {
|
|
@@ -202,19 +215,65 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
202
215
|
}
|
|
203
216
|
.tab:hover { background: rgba(0, 0, 0, .08); }
|
|
204
217
|
.tab.active { border-color: rgba(0, 0, 0, .38); background: rgba(0, 0, 0, .10); color: #111; font-weight: 700; }
|
|
218
|
+
.tab.auto-pick { border-color: rgba(26, 58, 143, .2); background: rgba(26, 58, 143, .045); color: var(--muted); font-weight: 650; }
|
|
205
219
|
.spin {
|
|
206
220
|
display: inline-block; width: 9px; height: 9px; margin-right: 5px; vertical-align: -1px;
|
|
207
221
|
border: 1.5px solid rgba(0, 0, 0, .2); border-top-color: #1a3a8f; border-radius: 50%;
|
|
208
222
|
animation: echospin .8s linear infinite;
|
|
209
223
|
}
|
|
210
224
|
@keyframes echospin { to { transform: rotate(360deg); } }
|
|
225
|
+
/* Mini mode: a small always-on pill instead of the full mascot+bubble. Opening the panel
|
|
226
|
+
temporarily shows the full layout; closing returns to the pill. */
|
|
227
|
+
#miniPill {
|
|
228
|
+
display: none; align-items: center; justify-content: center; gap: 7px; width: calc(100% - 16px); height: 34px;
|
|
229
|
+
margin: 7px 0 0 8px; padding: 0 13px; cursor: pointer; user-select: none;
|
|
230
|
+
border: 1.6px solid rgba(26, 58, 143, 0.75); border-radius: 999px; background: #fdfaf2;
|
|
231
|
+
filter: var(--shadow); font-family: "JetBrains Mono", ui-monospace, monospace;
|
|
232
|
+
font-size: 11.5px; font-weight: 700; color: var(--ink); letter-spacing: 0.02em;
|
|
233
|
+
}
|
|
234
|
+
#miniPill .mdot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
|
|
235
|
+
#miniPill .mdot.amber { background: var(--amber); }
|
|
236
|
+
#miniPill .mdot.red { background: #ff5f57; }
|
|
237
|
+
#miniPill .mdot.idle { background: #c9c5b8; }
|
|
238
|
+
#mtext { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
239
|
+
#hud.mini:not(.open) .glance { height: 48px; }
|
|
240
|
+
#hud.mini:not(.open) .mascot-stage, #hud.mini:not(.open) .status-bubble, #hud.mini:not(.open) .drag { display: none; }
|
|
241
|
+
#hud.mini:not(.open) #miniPill { display: inline-flex; }
|
|
242
|
+
.cornerbtn {
|
|
243
|
+
position: absolute; top: 7px; right: 10px; z-index: 3;
|
|
244
|
+
width: 20px; height: 20px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
|
|
245
|
+
border: 1px solid rgba(0, 0, 0, .16); background: rgba(255, 255, 255, .7); color: var(--faint);
|
|
246
|
+
cursor: pointer; font-family: inherit;
|
|
247
|
+
}
|
|
248
|
+
.cornerbtn:hover { background: #fff; color: #111; }
|
|
211
249
|
.headctl { display: flex; align-items: center; gap: 8px; }
|
|
212
250
|
.switchbtn {
|
|
251
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
213
252
|
width: 22px; height: 22px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
|
|
214
253
|
border: 1px solid rgba(0, 0, 0, .2); background: rgba(0, 0, 0, .05); color: var(--faint);
|
|
215
254
|
cursor: pointer; font-family: inherit;
|
|
216
255
|
}
|
|
217
256
|
.switchbtn:hover { background: rgba(0, 0, 0, .1); color: #111; }
|
|
257
|
+
.agent-switch {
|
|
258
|
+
width: auto;
|
|
259
|
+
min-width: 116px;
|
|
260
|
+
max-width: 154px;
|
|
261
|
+
height: 28px;
|
|
262
|
+
gap: 6px;
|
|
263
|
+
padding: 0 10px 0 7px;
|
|
264
|
+
border-radius: 999px;
|
|
265
|
+
background: rgba(255, 255, 255, .78);
|
|
266
|
+
color: #1f1f1f;
|
|
267
|
+
font-size: 10.5px;
|
|
268
|
+
font-weight: 760;
|
|
269
|
+
letter-spacing: 0;
|
|
270
|
+
white-space: nowrap;
|
|
271
|
+
}
|
|
272
|
+
.agent-switch:hover { background: #fff; border-color: rgba(26, 58, 143, .34); }
|
|
273
|
+
.agent-switch[data-target-family="claude"] { border-color: rgba(217, 119, 87, .42); }
|
|
274
|
+
.agent-switch[data-target-family="codex"] { border-color: rgba(57, 65, 255, .34); }
|
|
275
|
+
.switch-icon { width: 16px; height: 16px; flex: 0 0 auto; object-fit: contain; }
|
|
276
|
+
.switch-copy { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
218
277
|
.reveal {
|
|
219
278
|
margin-top: 8px; font-size: 10.5px; padding: 4px 11px; border-radius: 999px;
|
|
220
279
|
border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04); color: var(--faint);
|
|
@@ -316,23 +375,44 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
316
375
|
background: rgba(199, 55, 47, 0.07);
|
|
317
376
|
color: #7a241e;
|
|
318
377
|
}
|
|
378
|
+
/* Breathing CTA: quiet ghost pill while the session is healthy... */
|
|
319
379
|
button.renew-btn {
|
|
320
380
|
display: none;
|
|
321
|
-
width:
|
|
381
|
+
width: auto;
|
|
322
382
|
margin-top: 12px;
|
|
323
|
-
border: 1px solid rgba(199, 55, 47, 0.
|
|
383
|
+
border: 1px solid rgba(199, 55, 47, 0.28);
|
|
324
384
|
border-radius: 999px;
|
|
325
|
-
padding:
|
|
326
|
-
background:
|
|
385
|
+
padding: 6px 16px;
|
|
386
|
+
background: transparent;
|
|
327
387
|
color: var(--brick);
|
|
328
388
|
font-weight: 750;
|
|
329
|
-
font-size:
|
|
389
|
+
font-size: 11.5px;
|
|
330
390
|
cursor: pointer;
|
|
331
|
-
transition:
|
|
391
|
+
transition: all 0.25s ease;
|
|
332
392
|
}
|
|
333
|
-
|
|
334
|
-
button.renew-btn
|
|
335
|
-
|
|
393
|
+
/* ...and a full-size primary the moment the session drifts. */
|
|
394
|
+
button.renew-btn.urgent {
|
|
395
|
+
width: 100%;
|
|
396
|
+
padding: 11px 12px;
|
|
397
|
+
border-color: var(--brick);
|
|
398
|
+
background: var(--brick);
|
|
399
|
+
color: #fff7ef;
|
|
400
|
+
font-size: 14px;
|
|
401
|
+
box-shadow: 0 10px 22px -14px rgba(199, 55, 47, 0.7);
|
|
402
|
+
}
|
|
403
|
+
button.renew-btn b { font-weight: 800; }
|
|
404
|
+
button.renew-btn small { display: none; }
|
|
405
|
+
button.renew-btn.urgent small, button.renew-btn.loading small {
|
|
406
|
+
display: block;
|
|
407
|
+
margin-top: 3px;
|
|
408
|
+
font-size: 10.5px;
|
|
409
|
+
font-weight: 600;
|
|
410
|
+
opacity: 0.85;
|
|
411
|
+
white-space: normal;
|
|
412
|
+
}
|
|
413
|
+
button.renew-btn:hover { background: rgba(199, 55, 47, 0.12); }
|
|
414
|
+
button.renew-btn.urgent:hover { background: #b32e27; }
|
|
415
|
+
button.renew-btn.loading { width: 100%; opacity: 0.75; cursor: wait; }
|
|
336
416
|
.renew-result {
|
|
337
417
|
display: none;
|
|
338
418
|
margin-top: 10px;
|
|
@@ -387,6 +467,29 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
387
467
|
#hud.s-red .mascot-stage { filter: drop-shadow(0 3px 8px rgba(199, 55, 47, 0.48)); }
|
|
388
468
|
#hud.s-amber .mascot { animation-duration: 5.6s; }
|
|
389
469
|
#hud.s-red .mascot { animation-duration: 6.8s; }
|
|
470
|
+
#hud:hover .status-bubble {
|
|
471
|
+
border-color: rgba(26, 58, 143, 0.96);
|
|
472
|
+
background: #fff7e8;
|
|
473
|
+
filter: drop-shadow(0 3px 10px rgba(26, 58, 143, 0.18));
|
|
474
|
+
}
|
|
475
|
+
#hud:hover .drag::before {
|
|
476
|
+
background: rgba(26, 58, 143, 0.72);
|
|
477
|
+
box-shadow: 0 10px 0 rgba(26, 58, 143, 0.72), 10px 0 0 rgba(26, 58, 143, 0.72), 10px 10px 0 rgba(26, 58, 143, 0.72);
|
|
478
|
+
}
|
|
479
|
+
#hud:hover .mascot-stage { transform: translateY(-1px); }
|
|
480
|
+
#hud.dragging .glance,
|
|
481
|
+
#hud.dragging .details,
|
|
482
|
+
#hud.dragging button { cursor: grabbing; }
|
|
483
|
+
#hud.dragging .status-bubble {
|
|
484
|
+
border-color: rgba(199, 55, 47, 0.86);
|
|
485
|
+
background: #fff1df;
|
|
486
|
+
filter: drop-shadow(0 4px 12px rgba(199, 55, 47, 0.22));
|
|
487
|
+
}
|
|
488
|
+
#hud.dragging .drag::before {
|
|
489
|
+
background: rgba(199, 55, 47, 0.92);
|
|
490
|
+
box-shadow: 0 10px 0 rgba(199, 55, 47, 0.92), 10px 0 0 rgba(199, 55, 47, 0.92), 10px 10px 0 rgba(199, 55, 47, 0.92);
|
|
491
|
+
}
|
|
492
|
+
#hud.dragging .mascot-stage { transform: translateY(-2px) scale(1.02); }
|
|
390
493
|
</style>
|
|
391
494
|
</head>
|
|
392
495
|
<body>
|
|
@@ -400,10 +503,12 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
400
503
|
<span class="main" id="main">Checking context...</span>
|
|
401
504
|
<span class="client" id="client">EchoMem HUD</span>
|
|
402
505
|
</span>
|
|
506
|
+
<button class="cornerbtn" id="minibtn" title="Shrink to mini pill">–</button>
|
|
403
507
|
</div>
|
|
508
|
+
<div id="miniPill" title="EchoMem — click to expand"><span class="mdot" id="mdot"></span><span id="mtext">EchoMem</span></div>
|
|
404
509
|
</div>
|
|
405
510
|
<div class="details">
|
|
406
|
-
<div class="head"><span class="upd" id="updated"></span><span class="headctl"><span class="turn" id="turn"></span><button class="switchbtn" id="switchbtn" title="Switch agent" style="display:none"
|
|
511
|
+
<div class="head"><span class="upd" id="updated"></span><span class="headctl"><span class="turn" id="turn"></span><button class="switchbtn agent-switch" id="switchbtn" title="Switch agent" style="display:none" type="button"></button></span></div>
|
|
407
512
|
<div class="tabs" id="tabs"></div>
|
|
408
513
|
<div class="state-row">
|
|
409
514
|
<span class="state-dot" id="stateDot"></span>
|
|
@@ -429,7 +534,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
429
534
|
</div>
|
|
430
535
|
<div class="meta" id="meta"></div>
|
|
431
536
|
<div class="path" id="path"></div>
|
|
432
|
-
<button class="reveal" id="reveal" style="display:none">↗
|
|
537
|
+
<button class="reveal" id="reveal" style="display:none">↗ Open session in viewer</button>
|
|
433
538
|
<div class="missing" id="missing"></div>
|
|
434
539
|
</div>
|
|
435
540
|
</section>
|
|
@@ -465,27 +570,98 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
465
570
|
const hudVersion = "v${MCP_PACKAGE_VERSION}";
|
|
466
571
|
let prevTurnState = null;
|
|
467
572
|
let lastCapsule = "";
|
|
573
|
+
let renewResultSessionKey = "";
|
|
468
574
|
let activityEpoch = null;
|
|
469
575
|
let lastState = null;
|
|
470
576
|
let currentSrc = null;
|
|
577
|
+
let renewTimer = null;
|
|
578
|
+
let renewStartMs = 0;
|
|
579
|
+
let renewEta = null;
|
|
580
|
+
let renewEstimateSeq = 0;
|
|
581
|
+
const CHECKPOINT_CACHE_KEY = "echomemHudCheckpointCacheV2";
|
|
582
|
+
const CHECKPOINT_CACHE_LIMIT = 24;
|
|
583
|
+
const CHECKPOINT_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
584
|
+
const CHECKPOINT_STATUS_CACHE_KEY = "echomemHudCheckpointStatusV2";
|
|
585
|
+
const CHECKPOINT_STATUS_CACHE_LIMIT = 48;
|
|
586
|
+
const CHECKPOINT_STATUS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
587
|
+
const statusFetchInFlight = {};
|
|
588
|
+
// Demo-clean mode: hide only the research-y session VIEWER (raw log / payload). Keep the session
|
|
589
|
+
// tabs + agent-switch — they're the manual override for "we can't detect which session you're
|
|
590
|
+
// viewing", so the user can always point the HUD at the right session. Flip false to show the viewer.
|
|
591
|
+
const DEMO_CLEAN = true;
|
|
471
592
|
|
|
472
|
-
//
|
|
593
|
+
// Mini mode: resting state is a small pill; opening the panel shows the full layout, closing
|
|
594
|
+
// returns to the pill. Preference persists (localStorage here, window size in main's bounds file).
|
|
595
|
+
const miniBtn = document.getElementById("minibtn");
|
|
596
|
+
const mdot = document.getElementById("mdot");
|
|
597
|
+
const mtext = document.getElementById("mtext");
|
|
598
|
+
let miniOn = false;
|
|
599
|
+
try { miniOn = localStorage.getItem("echomemHudMini") === "1"; } catch {}
|
|
600
|
+
if (miniOn) hud.classList.add("mini");
|
|
601
|
+
function applyMini(next) {
|
|
602
|
+
miniOn = next;
|
|
603
|
+
try { localStorage.setItem("echomemHudMini", next ? "1" : "0"); } catch {}
|
|
604
|
+
hud.classList.toggle("mini", next);
|
|
605
|
+
if (next) hud.classList.remove("open");
|
|
606
|
+
if (window.echomemHud && typeof window.echomemHud.setMini === "function") window.echomemHud.setMini(next);
|
|
607
|
+
}
|
|
608
|
+
if (miniBtn) miniBtn.addEventListener("click", (e) => { e.stopPropagation(); applyMini(true); });
|
|
609
|
+
let hudDrag = null;
|
|
610
|
+
let suppressNextToggle = false;
|
|
611
|
+
const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .switchbtn, .reveal, .renew-btn, .copy-btn";
|
|
612
|
+
|
|
613
|
+
hud.addEventListener("pointerdown", beginHudDrag);
|
|
614
|
+
window.addEventListener("pointermove", moveHudDrag);
|
|
615
|
+
window.addEventListener("pointerup", endHudDrag);
|
|
616
|
+
window.addEventListener("pointercancel", endHudDrag);
|
|
617
|
+
|
|
618
|
+
function beginHudDrag(event) {
|
|
619
|
+
if (event.button !== 0 || !(event.target instanceof HTMLElement)) return;
|
|
620
|
+
if (event.target.closest(dragBlockSelector)) return;
|
|
621
|
+
if (!window.echomemHud || typeof window.echomemHud.startDrag !== "function") return;
|
|
622
|
+
hudDrag = { pointerId: event.pointerId, startX: event.screenX, startY: event.screenY, moved: false };
|
|
623
|
+
// NOTE: do NOT setPointerCapture here — capturing on pointerdown re-targets the click to #hud,
|
|
624
|
+
// so #toggle never gets it and the panel can't expand. Capture only once a real drag starts.
|
|
625
|
+
window.echomemHud.startDrag(event.screenX, event.screenY);
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
function moveHudDrag(event) {
|
|
629
|
+
if (!hudDrag || event.pointerId !== hudDrag.pointerId) return;
|
|
630
|
+
if (!window.echomemHud || typeof window.echomemHud.moveDrag !== "function") return;
|
|
631
|
+
const dx = event.screenX - hudDrag.startX;
|
|
632
|
+
const dy = event.screenY - hudDrag.startY;
|
|
633
|
+
if (!hudDrag.moved && Math.hypot(dx, dy) < 3) return;
|
|
634
|
+
if (!hudDrag.moved) { try { hud.setPointerCapture(hudDrag.pointerId); } catch {} }
|
|
635
|
+
hudDrag.moved = true;
|
|
636
|
+
hud.classList.add("dragging");
|
|
637
|
+
window.echomemHud.moveDrag(event.screenX, event.screenY);
|
|
638
|
+
event.preventDefault();
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function endHudDrag(event) {
|
|
642
|
+
if (!hudDrag || event.pointerId !== hudDrag.pointerId) return;
|
|
643
|
+
const moved = hudDrag.moved;
|
|
644
|
+
hudDrag = null;
|
|
645
|
+
hud.classList.remove("dragging");
|
|
646
|
+
try { hud.releasePointerCapture(event.pointerId); } catch {}
|
|
647
|
+
if (window.echomemHud && typeof window.echomemHud.endDrag === "function") window.echomemHud.endDrag();
|
|
648
|
+
if (moved) {
|
|
649
|
+
suppressNextToggle = true;
|
|
650
|
+
setTimeout(() => { suppressNextToggle = false; }, 180);
|
|
651
|
+
}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
// Open the active session in the session viewer (Electron opens the browser; no-op otherwise).
|
|
473
655
|
if (revealEl) revealEl.addEventListener("click", () => {
|
|
474
656
|
if (!currentSrc) return;
|
|
475
|
-
fetch("/
|
|
657
|
+
fetch("/open-viewer?path=" + encodeURIComponent(currentSrc)).catch(() => {});
|
|
476
658
|
});
|
|
477
659
|
|
|
478
|
-
//
|
|
660
|
+
// Jump to the other agent family while the tabs stay scoped to the family you're viewing.
|
|
479
661
|
if (switchBtn) switchBtn.addEventListener("click", (e) => {
|
|
480
662
|
e.stopPropagation();
|
|
481
663
|
const st = lastState;
|
|
482
|
-
|
|
483
|
-
const clients = [];
|
|
484
|
-
for (const s of st.recentSessions) if (clients.indexOf(s.client) < 0) clients.push(s.client);
|
|
485
|
-
if (clients.length < 2) return;
|
|
486
|
-
const cur = st.active ? st.active.client : clients[0];
|
|
487
|
-
const next = clients[(clients.indexOf(cur) + 1) % clients.length];
|
|
488
|
-
const target = st.recentSessions.filter((s) => s.client === next)[0]; // newest of that agent
|
|
664
|
+
const target = switchTarget(st, st && st.active && st.active.client);
|
|
489
665
|
if (!target) return;
|
|
490
666
|
fetch("/select?id=" + encodeURIComponent(target.id))
|
|
491
667
|
.then(() => fetch("/state"))
|
|
@@ -507,35 +683,99 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
507
683
|
});
|
|
508
684
|
|
|
509
685
|
document.getElementById("toggle").addEventListener("click", (event) => {
|
|
510
|
-
if (
|
|
686
|
+
if (suppressNextToggle) {
|
|
687
|
+
suppressNextToggle = false;
|
|
688
|
+
event.preventDefault();
|
|
689
|
+
event.stopPropagation();
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
if (event.target instanceof HTMLElement && (event.target.closest(".switchbtn") || event.target.closest(".cornerbtn"))) return;
|
|
511
693
|
hud.classList.toggle("open");
|
|
512
694
|
syncHeight();
|
|
513
695
|
});
|
|
514
696
|
|
|
697
|
+
// Breathing CTA state: quiet while fresh, primary while drifted. Label carries the payoff so
|
|
698
|
+
// the user knows renewing does not lose their trail.
|
|
699
|
+
let renewUrgent = false;
|
|
700
|
+
function refreshRenewLabel() {
|
|
701
|
+
if (!renewBtn) return;
|
|
702
|
+
renewBtn.classList.toggle("urgent", renewUrgent);
|
|
703
|
+
if (renewStartMs) {
|
|
704
|
+
renderRenewLoadingLabel();
|
|
705
|
+
return;
|
|
706
|
+
}
|
|
707
|
+
renewBtn.innerHTML = renewUrgent
|
|
708
|
+
? '<b>Renew session</b><small>I pack a checkpoint and carry it into your fresh start</small>'
|
|
709
|
+
: "Renew session";
|
|
710
|
+
}
|
|
711
|
+
|
|
515
712
|
if (renewBtn) renewBtn.addEventListener("click", async () => {
|
|
713
|
+
if (renewStartMs) return;
|
|
714
|
+
const stateAtRenew = lastState;
|
|
715
|
+
const scoreAtRenew = activeRenewScore();
|
|
716
|
+
const renewSessionKey = sessionKeyFor(stateAtRenew, scoreAtRenew);
|
|
516
717
|
renewBtn.classList.add("loading");
|
|
517
|
-
|
|
718
|
+
clearRenewResult();
|
|
719
|
+
startRenewTimer(scoreAtRenew);
|
|
518
720
|
try {
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
721
|
+
// Cloud extraction (coding checkpoint) produces the real decision-log carryover. If that is
|
|
722
|
+
// unavailable, fall back transparently to a basic local capsule instead of the same artifact.
|
|
723
|
+
let prompt = "";
|
|
724
|
+
let promptKind = "cloud";
|
|
725
|
+
let renewData = null;
|
|
726
|
+
try {
|
|
727
|
+
const res = await fetch("/renew");
|
|
728
|
+
const data = await res.json();
|
|
729
|
+
if (data.ok && data.carryover) {
|
|
730
|
+
prompt = data.carryover;
|
|
731
|
+
renewData = data;
|
|
732
|
+
}
|
|
733
|
+
} catch {}
|
|
734
|
+
if (!prompt) {
|
|
735
|
+
const cap = await (await fetch("/capsule")).json();
|
|
736
|
+
if (cap.ok && cap.capsule) {
|
|
737
|
+
promptKind = "local";
|
|
738
|
+
prompt = localCapsulePrompt(cap.capsule);
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
if (!prompt) {
|
|
524
742
|
return;
|
|
525
743
|
}
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
if (
|
|
529
|
-
|
|
530
|
-
|
|
744
|
+
if (renewSessionKey !== sessionKeyFor(lastState, activeRenewScore())) return;
|
|
745
|
+
const meta = checkpointMetaFor(scoreAtRenew);
|
|
746
|
+
if (renewData) {
|
|
747
|
+
meta.contextId = typeof renewData.contextId === "string" ? renewData.contextId : "";
|
|
748
|
+
meta.sessionMemoryCount = Number.isFinite(Number(renewData.sessionMemoryCount)) ? Number(renewData.sessionMemoryCount) : 0;
|
|
749
|
+
meta.saved = Number.isFinite(Number(renewData.saved)) ? Number(renewData.saved) : 0;
|
|
750
|
+
meta.renewedAt = Date.now();
|
|
751
|
+
}
|
|
752
|
+
saveCachedCheckpoint(renewSessionKey, prompt, promptKind, meta);
|
|
753
|
+
if (promptKind === "cloud") saveCheckpointStatus(renewSessionKey, statusFromRenewData(renewData, meta));
|
|
754
|
+
showRenewResult({
|
|
755
|
+
sessionKey: renewSessionKey,
|
|
756
|
+
prompt,
|
|
757
|
+
message: promptKind === "local"
|
|
758
|
+
? "Basic local capsule ready. Cloud decision extraction was unavailable; copy it below and paste it into a fresh session."
|
|
759
|
+
: "Decision log ready, copied to your clipboard. Open a fresh session and paste it as the first message.",
|
|
760
|
+
canCopy: true,
|
|
761
|
+
});
|
|
531
762
|
try {
|
|
532
763
|
await navigator.clipboard.writeText(prompt);
|
|
533
|
-
if (renewMsg) renewMsg.textContent =
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
764
|
+
if (renewMsg) renewMsg.textContent = promptKind === "local"
|
|
765
|
+
? "Basic local capsule copied. Cloud decision extraction was unavailable; this includes goal, recent instruction, and file anchors, but may miss decisions."
|
|
766
|
+
: "Decision log ready, copied to your clipboard. Open a fresh session and paste it as the first message.";
|
|
767
|
+
} catch {
|
|
768
|
+
if (renewMsg) renewMsg.textContent = promptKind === "local"
|
|
769
|
+
? "Basic local capsule ready. Cloud decision extraction was unavailable; copy it below and paste it into a fresh session."
|
|
770
|
+
: "Decision log ready. Copy it below, then paste it into a fresh session.";
|
|
771
|
+
}
|
|
772
|
+
} catch {
|
|
773
|
+
} finally {
|
|
774
|
+
stopRenewTimer();
|
|
775
|
+
renewBtn.classList.remove("loading");
|
|
776
|
+
refreshRenewLabel();
|
|
777
|
+
syncHeight();
|
|
778
|
+
}
|
|
539
779
|
});
|
|
540
780
|
|
|
541
781
|
if (copyBtn) copyBtn.addEventListener("click", async () => {
|
|
@@ -547,19 +787,356 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
547
787
|
} catch {}
|
|
548
788
|
});
|
|
549
789
|
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
790
|
+
function activeRenewScore() {
|
|
791
|
+
const st = lastState || {};
|
|
792
|
+
return st.active || st;
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
function sessionKeyFor(state, score) {
|
|
796
|
+
if (!score || !score.sourcePath) return "";
|
|
797
|
+
const clientName = String(score.client || "");
|
|
798
|
+
const src = String(score.sourcePath || "");
|
|
799
|
+
const list = (state && state.recentSessions) || [];
|
|
800
|
+
const match = list.filter((s) => s.client === clientName && s.sourcePath === src)[0];
|
|
801
|
+
return clientName + "::" + (match && match.id ? match.id : src);
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function clearRenewResult() {
|
|
805
|
+
lastCapsule = "";
|
|
806
|
+
renewResultSessionKey = "";
|
|
807
|
+
if (renewPre) {
|
|
808
|
+
renewPre.textContent = "";
|
|
809
|
+
renewPre.style.display = "";
|
|
810
|
+
}
|
|
811
|
+
if (renewMsg) renewMsg.textContent = "";
|
|
812
|
+
if (copyBtn) {
|
|
813
|
+
copyBtn.textContent = "Copy to clipboard";
|
|
814
|
+
copyBtn.style.display = "";
|
|
815
|
+
}
|
|
816
|
+
if (renewResult) renewResult.style.display = "none";
|
|
817
|
+
}
|
|
818
|
+
|
|
819
|
+
function showRenewResult(opts) {
|
|
820
|
+
const prompt = String((opts && opts.prompt) || "");
|
|
821
|
+
const body = String((opts && opts.body) || prompt || "");
|
|
822
|
+
lastCapsule = prompt;
|
|
823
|
+
renewResultSessionKey = String((opts && opts.sessionKey) || "");
|
|
824
|
+
if (renewMsg) renewMsg.textContent = String((opts && opts.message) || "");
|
|
825
|
+
if (renewPre) {
|
|
826
|
+
renewPre.textContent = body;
|
|
827
|
+
renewPre.style.display = body ? "block" : "none";
|
|
828
|
+
}
|
|
829
|
+
if (copyBtn) {
|
|
830
|
+
copyBtn.textContent = "Copy to clipboard";
|
|
831
|
+
copyBtn.style.display = opts && opts.canCopy ? "inline-block" : "none";
|
|
832
|
+
}
|
|
833
|
+
if (renewResult) renewResult.style.display = "block";
|
|
834
|
+
syncHeight();
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function checkpointMetaFor(score) {
|
|
838
|
+
return {
|
|
839
|
+
turn: Number(score && score.turn) || 0,
|
|
840
|
+
ctTokens: Number(score && score.ctTokens) || 0,
|
|
841
|
+
createdAt: Date.now(),
|
|
842
|
+
};
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
function saveCachedCheckpoint(key, prompt, kind, meta) {
|
|
846
|
+
if (!key || !prompt) return;
|
|
847
|
+
const next = readCheckpointCache().filter((item) => item.key !== key);
|
|
848
|
+
next.unshift({
|
|
849
|
+
key,
|
|
850
|
+
prompt: String(prompt),
|
|
851
|
+
kind: kind === "local" ? "local" : "cloud",
|
|
852
|
+
turn: Number(meta && meta.turn) || 0,
|
|
853
|
+
ctTokens: Number(meta && meta.ctTokens) || 0,
|
|
854
|
+
createdAt: Number(meta && meta.createdAt) || Date.now(),
|
|
855
|
+
renewedAt: Number(meta && meta.renewedAt) || Number(meta && meta.createdAt) || Date.now(),
|
|
856
|
+
contextId: typeof (meta && meta.contextId) === "string" ? meta.contextId : "",
|
|
857
|
+
sessionMemoryCount: Number.isFinite(Number(meta && meta.sessionMemoryCount)) ? Number(meta.sessionMemoryCount) : null,
|
|
858
|
+
saved: Number.isFinite(Number(meta && meta.saved)) ? Number(meta.saved) : null,
|
|
859
|
+
});
|
|
860
|
+
try { localStorage.setItem(CHECKPOINT_CACHE_KEY, JSON.stringify(next.slice(0, CHECKPOINT_CACHE_LIMIT))); } catch {}
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function readCheckpointCache() {
|
|
864
|
+
try {
|
|
865
|
+
const cutoff = Date.now() - CHECKPOINT_CACHE_TTL_MS;
|
|
866
|
+
const raw = JSON.parse(localStorage.getItem(CHECKPOINT_CACHE_KEY) || "[]");
|
|
867
|
+
return Array.isArray(raw)
|
|
868
|
+
? raw.filter((item) => item && typeof item.key === "string" && typeof item.prompt === "string" && Number(item.createdAt || 0) >= cutoff)
|
|
869
|
+
: [];
|
|
870
|
+
} catch {
|
|
871
|
+
return [];
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function cachedCheckpointFor(key) {
|
|
876
|
+
return readCheckpointCache().filter((item) => item.key === key)[0] || null;
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
function restoreCachedCheckpointFor(score, key) {
|
|
880
|
+
if (!key || renewStartMs || renewResultSessionKey === key) return false;
|
|
881
|
+
const cached = cachedCheckpointFor(key);
|
|
882
|
+
if (!cached) return false;
|
|
883
|
+
showRenewResult({
|
|
884
|
+
sessionKey: key,
|
|
885
|
+
prompt: cached.prompt,
|
|
886
|
+
message: cachedCheckpointMessage(score, cached),
|
|
887
|
+
canCopy: true,
|
|
888
|
+
});
|
|
889
|
+
return true;
|
|
890
|
+
}
|
|
891
|
+
|
|
892
|
+
function cachedCheckpointMessage(score, cached) {
|
|
893
|
+
const savedTurn = Number(cached && cached.turn) || 0;
|
|
894
|
+
const currentTurn = Number(score && score.turn) || 0;
|
|
895
|
+
const stale = savedTurn > 0 && currentTurn > savedTurn;
|
|
896
|
+
const summary = checkpointSummary(cached);
|
|
897
|
+
if (cached && cached.kind === "local") {
|
|
898
|
+
return stale
|
|
899
|
+
? "Basic local capsule from turn " + savedTurn + ". Renew again for the latest cloud decision log."
|
|
900
|
+
: "Basic local capsule already ready for this session. Cloud decision extraction was unavailable when it was made.";
|
|
901
|
+
}
|
|
902
|
+
return stale
|
|
903
|
+
? "Decision log from turn " + savedTurn + ". " + summary + " Copy it below, or Renew again for the latest."
|
|
904
|
+
: "Decision log already ready for this session. " + summary + " Copy it below, or Renew again to replace it.";
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function statusFromRenewData(data, meta) {
|
|
908
|
+
return {
|
|
909
|
+
fetched: true,
|
|
910
|
+
hasCheckpoint: Boolean(data && data.ok),
|
|
911
|
+
contextId: typeof (data && data.contextId) === "string" ? data.contextId : String((meta && meta.contextId) || ""),
|
|
912
|
+
renewedAt: Number(meta && meta.renewedAt) || Date.now(),
|
|
913
|
+
memoryCount: Number.isFinite(Number(data && data.sessionMemoryCount)) ? Number(data.sessionMemoryCount) : Number(meta && meta.sessionMemoryCount) || 0,
|
|
914
|
+
checkpointMemoryId: "",
|
|
915
|
+
};
|
|
916
|
+
}
|
|
917
|
+
|
|
918
|
+
function checkpointSummary(item) {
|
|
919
|
+
const parts = [];
|
|
920
|
+
const renewedAt = timestampMs(item && item.renewedAt);
|
|
921
|
+
if (renewedAt) parts.push("renewed " + relTime(Date.now() - renewedAt));
|
|
922
|
+
const count = Number(item && (item.memoryCount ?? item.sessionMemoryCount));
|
|
923
|
+
if (Number.isFinite(count) && count > 0) parts.push(count + " " + (count === 1 ? "memory" : "memories") + " saved");
|
|
924
|
+
return parts.length ? parts.join(" · ") + "." : "";
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
function timestampMs(value) {
|
|
928
|
+
if (typeof value === "number" && Number.isFinite(value) && value > 0) return value;
|
|
929
|
+
if (typeof value === "string" && value) {
|
|
930
|
+
const ms = Date.parse(value);
|
|
931
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
932
|
+
}
|
|
933
|
+
return 0;
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
function readCheckpointStatusCache() {
|
|
937
|
+
try {
|
|
938
|
+
const cutoff = Date.now() - CHECKPOINT_STATUS_CACHE_TTL_MS;
|
|
939
|
+
const raw = JSON.parse(localStorage.getItem(CHECKPOINT_STATUS_CACHE_KEY) || "[]");
|
|
940
|
+
return Array.isArray(raw)
|
|
941
|
+
? raw.filter((item) => item && typeof item.key === "string" && item.fetched === true && Number(item.fetchedAt || 0) >= cutoff)
|
|
942
|
+
: [];
|
|
943
|
+
} catch {
|
|
944
|
+
return [];
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
function cachedCheckpointStatusFor(key) {
|
|
949
|
+
return readCheckpointStatusCache().filter((item) => item.key === key)[0] || null;
|
|
950
|
+
}
|
|
951
|
+
|
|
952
|
+
function saveCheckpointStatus(key, status) {
|
|
953
|
+
if (!key || !status) return;
|
|
954
|
+
const next = readCheckpointStatusCache().filter((item) => item.key !== key);
|
|
955
|
+
const fetchedAt = status.unavailable ? Date.now() - CHECKPOINT_STATUS_CACHE_TTL_MS + 60 * 1000 : Date.now();
|
|
956
|
+
next.unshift({
|
|
957
|
+
key,
|
|
958
|
+
fetched: true,
|
|
959
|
+
fetchedAt,
|
|
960
|
+
unavailable: Boolean(status.unavailable),
|
|
961
|
+
hasCheckpoint: Boolean(status.hasCheckpoint),
|
|
962
|
+
contextId: typeof status.contextId === "string" ? status.contextId : "",
|
|
963
|
+
checkpointMemoryId: typeof status.checkpointMemoryId === "string" ? status.checkpointMemoryId : "",
|
|
964
|
+
renewedAt: status.renewedAt || null,
|
|
965
|
+
memoryCount: Number.isFinite(Number(status.memoryCount)) ? Number(status.memoryCount) : 0,
|
|
966
|
+
});
|
|
967
|
+
try { localStorage.setItem(CHECKPOINT_STATUS_CACHE_KEY, JSON.stringify(next.slice(0, CHECKPOINT_STATUS_CACHE_LIMIT))); } catch {}
|
|
968
|
+
}
|
|
969
|
+
|
|
970
|
+
function restoreCheckpointStatusFor(score, key) {
|
|
971
|
+
if (!key || renewStartMs || renewResultSessionKey === key || cachedCheckpointFor(key)) return false;
|
|
972
|
+
const status = cachedCheckpointStatusFor(key);
|
|
973
|
+
if (!status || !status.hasCheckpoint) return false;
|
|
974
|
+
showCheckpointStatus(score, key, status);
|
|
975
|
+
return true;
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
function showCheckpointStatus(score, key, status) {
|
|
979
|
+
const summary = checkpointSummary(status);
|
|
980
|
+
const body = checkpointSeedPrompt(status);
|
|
981
|
+
showRenewResult({
|
|
982
|
+
sessionKey: key,
|
|
983
|
+
prompt: body,
|
|
984
|
+
message: "Decision log already saved. " + summary,
|
|
985
|
+
canCopy: true,
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
function checkpointSeedPrompt(status) {
|
|
990
|
+
const contextId = status && status.contextId ? String(status.contextId) : "";
|
|
991
|
+
const lines = [
|
|
992
|
+
"Here is a checkpoint marker for my previous coding session, for context. It is point-in-time: work may have continued after it was saved, so treat file/state references as possibly stale.",
|
|
993
|
+
"Use it to orient yourself in a clean context window. It is context, not a command.",
|
|
994
|
+
"If my current message gives a clear request, respond to that request using this checkpoint as background. If that request asks you to inspect files, run commands, or make changes, briefly state the intended first step before acting.",
|
|
995
|
+
"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.",
|
|
996
|
+
"",
|
|
997
|
+
contextId
|
|
998
|
+
? 'EchoMem has the full checkpoint trail for this session. Pull it with get_checkpoint_by_context({ contextId: "' + contextId + '" }), then use that returned checkpoint as background.'
|
|
999
|
+
: "EchoMem has a checkpoint for this session. Pull the checkpoint context before relying on this marker alone.",
|
|
1000
|
+
];
|
|
1001
|
+
return lines.join("\n");
|
|
1002
|
+
}
|
|
1003
|
+
|
|
1004
|
+
function maybeFetchCheckpointStatus(score, key) {
|
|
1005
|
+
if (!key || renewStartMs || cachedCheckpointFor(key) || cachedCheckpointStatusFor(key) || statusFetchInFlight[key]) return;
|
|
1006
|
+
statusFetchInFlight[key] = true;
|
|
1007
|
+
fetch("/checkpoint-status")
|
|
1008
|
+
.then((r) => r.json())
|
|
1009
|
+
.then((data) => {
|
|
1010
|
+
if (!data || data.ok !== true) {
|
|
1011
|
+
saveCheckpointStatus(key, { hasCheckpoint: false, memoryCount: 0, unavailable: true });
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
saveCheckpointStatus(key, {
|
|
1015
|
+
hasCheckpoint: Boolean(data && data.ok && data.hasCheckpoint),
|
|
1016
|
+
contextId: typeof (data && data.contextId) === "string" ? data.contextId : "",
|
|
1017
|
+
checkpointMemoryId: typeof (data && data.checkpointMemoryId) === "string" ? data.checkpointMemoryId : "",
|
|
1018
|
+
renewedAt: typeof (data && data.renewedAt) === "string" ? data.renewedAt : null,
|
|
1019
|
+
memoryCount: Number.isFinite(Number(data && data.memoryCount)) ? Number(data.memoryCount) : 0,
|
|
1020
|
+
});
|
|
1021
|
+
const stillKey = sessionKeyFor(lastState, activeRenewScore());
|
|
1022
|
+
if (stillKey !== key || renewResultSessionKey === key || cachedCheckpointFor(key)) return;
|
|
1023
|
+
const status = cachedCheckpointStatusFor(key);
|
|
1024
|
+
if (status && status.hasCheckpoint) showCheckpointStatus(score, key, status);
|
|
1025
|
+
})
|
|
1026
|
+
.catch(() => {
|
|
1027
|
+
saveCheckpointStatus(key, { hasCheckpoint: false, memoryCount: 0, unavailable: true });
|
|
1028
|
+
})
|
|
1029
|
+
.finally(() => { delete statusFetchInFlight[key]; });
|
|
1030
|
+
}
|
|
1031
|
+
|
|
1032
|
+
function localCapsulePrompt(capsule) {
|
|
1033
|
+
return [
|
|
1034
|
+
"Basic local capsule, not a full EchoMem decision log.",
|
|
1035
|
+
"EchoMem cloud decision extraction was unavailable, so this fallback was built from local session signals such as goal, latest instruction, file anchors, and context-health numbers. It may miss decisions or product nuance from the discussion.",
|
|
1036
|
+
"Use it to orient yourself in a clean context window. The user's current message decides whether to continue, redirect, or only preserve context. Do not inspect files, run commands, or start work until intent is clear.",
|
|
1037
|
+
"",
|
|
1038
|
+
String(capsule || ""),
|
|
1039
|
+
].join("\n");
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function startRenewTimer(score) {
|
|
1043
|
+
const seq = ++renewEstimateSeq;
|
|
1044
|
+
renewEta = { ms: fallbackEtaForScore(score || activeRenewScore()) };
|
|
1045
|
+
renewStartMs = Date.now();
|
|
1046
|
+
if (renewTimer) clearInterval(renewTimer);
|
|
1047
|
+
renderRenewLoadingLabel();
|
|
1048
|
+
renewTimer = setInterval(renderRenewLoadingLabel, 1000);
|
|
1049
|
+
fetch("/renew-estimate")
|
|
1050
|
+
.then((r) => r.json())
|
|
1051
|
+
.then((data) => {
|
|
1052
|
+
if (seq !== renewEstimateSeq || !renewStartMs) return;
|
|
1053
|
+
if (data && data.ok && Number.isFinite(Number(data.etaMs))) {
|
|
1054
|
+
renewEta = { ms: Number(data.etaMs) };
|
|
1055
|
+
renderRenewLoadingLabel();
|
|
1056
|
+
}
|
|
1057
|
+
})
|
|
1058
|
+
.catch(() => {});
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
function stopRenewTimer() {
|
|
1062
|
+
if (renewTimer) clearInterval(renewTimer);
|
|
1063
|
+
renewTimer = null;
|
|
1064
|
+
renewStartMs = 0;
|
|
1065
|
+
renewEta = null;
|
|
1066
|
+
renewEstimateSeq += 1;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1069
|
+
function renderRenewLoadingLabel() {
|
|
1070
|
+
if (!renewBtn || !renewStartMs) return;
|
|
1071
|
+
const elapsed = Date.now() - renewStartMs;
|
|
1072
|
+
const remaining = Math.max(0, Number(renewEta && renewEta.ms) || 20000) - elapsed;
|
|
1073
|
+
const detail = remaining > 0 ? "ETA " + fmtDuration(remaining) + " left" : "ETA any moment";
|
|
1074
|
+
renewBtn.innerHTML = "<b>Packing your checkpoint…</b><small>" + esc(detail) + "</small>";
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
function fallbackEtaForScore(score) {
|
|
1078
|
+
const n = Number(score && score.ctTokens) || 0;
|
|
1079
|
+
if (n <= 5000) return 10000;
|
|
1080
|
+
if (n <= 10000) return 12000;
|
|
1081
|
+
if (n <= 25000) return 15000;
|
|
1082
|
+
if (n <= 50000) return 18000;
|
|
1083
|
+
if (n <= 100000) return 20000;
|
|
1084
|
+
return 45000;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
function fmtDuration(ms) {
|
|
1088
|
+
const s = Math.max(0, Math.ceil(Number(ms || 0) / 1000));
|
|
1089
|
+
if (s < 60) return s + "s";
|
|
1090
|
+
const m = Math.floor(s / 60);
|
|
1091
|
+
const r = s % 60;
|
|
1092
|
+
return r ? m + "m " + r + "s" : m + "m";
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
// Dev/design preview: ?preview=green|amber|red renders that health state with sample data and
|
|
1096
|
+
// skips the live feed — same pattern as the extraction page's ?preview hook.
|
|
1097
|
+
const previewColor = new URLSearchParams(location.search).get("preview");
|
|
1098
|
+
if (previewColor === "green" || previewColor === "amber" || previewColor === "red") {
|
|
1099
|
+
const sample = {
|
|
1100
|
+
client: "codex",
|
|
1101
|
+
label: "EchoMem-Chrome",
|
|
1102
|
+
sourcePath: "/preview/session.jsonl",
|
|
1103
|
+
usefulPct: previewColor === "red" ? 55 : previewColor === "amber" ? 75 : 95,
|
|
1104
|
+
color: previewColor,
|
|
1105
|
+
ctTokens: previewColor === "red" ? 165000 : previewColor === "amber" ? 110000 : 24000,
|
|
1106
|
+
modelContextWindow: 258000,
|
|
1107
|
+
pollutionPct: previewColor === "red" ? 45 : previewColor === "amber" ? 24 : 4,
|
|
1108
|
+
pollutionTok: previewColor === "red" ? 74000 : previewColor === "amber" ? 26000 : 900,
|
|
1109
|
+
buckets: { range_redundant: { count: previewColor === "red" ? 61 : previewColor === "amber" ? 18 : 0 } },
|
|
1110
|
+
stats: { patchEdits: 90 },
|
|
1111
|
+
turn: 28,
|
|
1112
|
+
lastActiveMs: 6000,
|
|
1113
|
+
ctSource: "measured",
|
|
1114
|
+
};
|
|
1115
|
+
render({
|
|
1116
|
+
active: sample,
|
|
1117
|
+
recentSessions: [
|
|
1118
|
+
{ id: "p1", client: "codex", title: "EchoMem-Chrome", sourcePath: "/preview/session.jsonl", live: true },
|
|
1119
|
+
{ id: "p2", client: "codex", title: "修复 HUD 切换和图标", sourcePath: "/preview/b.jsonl", live: false },
|
|
1120
|
+
{ id: "p3", client: "codex", title: "Fix extraction preview", sourcePath: "/preview/c.jsonl", live: false },
|
|
1121
|
+
{ id: "p4", client: "claude-code", title: "DG-Web refactor", sourcePath: "/preview/d.jsonl", live: false },
|
|
1122
|
+
],
|
|
1123
|
+
});
|
|
1124
|
+
hud.classList.add("open");
|
|
1125
|
+
syncHeight();
|
|
1126
|
+
} else {
|
|
1127
|
+
const events = new EventSource("/events");
|
|
1128
|
+
events.onmessage = (event) => render(JSON.parse(event.data));
|
|
1129
|
+
fetch("/state").then((r) => r.json()).then(render).catch(() => {});
|
|
1130
|
+
}
|
|
553
1131
|
|
|
554
1132
|
function render(state) {
|
|
555
1133
|
const score = state.active || state;
|
|
556
1134
|
lastState = state;
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
}
|
|
1135
|
+
const activeClient = score && score.client ? score.client : state && state.active && state.active.client;
|
|
1136
|
+
const currentSessionKey = sessionKeyFor(state, score);
|
|
1137
|
+
if (renewResultSessionKey && renewResultSessionKey !== currentSessionKey) clearRenewResult();
|
|
1138
|
+
renderTabs(state, score && score.sourcePath ? activeIdFor(state, score.sourcePath, activeClient) : null, activeClient);
|
|
1139
|
+
renderSwitchButton(state, activeClient);
|
|
563
1140
|
if (!score || typeof score.usefulPct !== "number") {
|
|
564
1141
|
renderIdle(state);
|
|
565
1142
|
return;
|
|
@@ -581,7 +1158,13 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
581
1158
|
hud.classList.remove("s-green", "s-amber", "s-red");
|
|
582
1159
|
if (!stale) hud.classList.add("s-" + color);
|
|
583
1160
|
main.textContent = (stale ? "Idle" : stateName) + " - " + fmt(score.ctTokens) + " ctx";
|
|
584
|
-
|
|
1161
|
+
if (mdot) mdot.className = "mdot " + shownColor;
|
|
1162
|
+
if (mtext) mtext.textContent = (stale ? "Idle" : stateName) + " · " + fmt(score.ctTokens);
|
|
1163
|
+
// Collapsed bubble: normally identifies the session (trust the auto-follow); when drifted
|
|
1164
|
+
// it becomes Echo's nudge — the bubble itself sells the renew.
|
|
1165
|
+
const urgent = !stale && color === "red";
|
|
1166
|
+
client.textContent = urgent ? "let me carry this forward →" : sessionIdentity(score, state);
|
|
1167
|
+
client.classList.toggle("voice", urgent);
|
|
585
1168
|
activityEpoch = typeof score.lastActiveMs === "number" ? Date.now() - score.lastActiveMs : null;
|
|
586
1169
|
renderUpdated();
|
|
587
1170
|
turnEl.textContent = typeof score.turn === "number" && score.turn > 0 ? "turn " + score.turn : "";
|
|
@@ -593,16 +1176,28 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
593
1176
|
evidence.textContent = evidenceLine(score, oldPct, repeatCount);
|
|
594
1177
|
usefulbar.style.width = Math.max(0, 100 - oldPct) + "%";
|
|
595
1178
|
oldbar.style.width = oldPct + "%";
|
|
596
|
-
metaEl.textContent = ctSourceLabel(score.ctSource) + "
|
|
1179
|
+
metaEl.textContent = ctSourceLabel(score.ctSource) + " · " + edits + " edits";
|
|
597
1180
|
pathEl.textContent = "";
|
|
598
1181
|
currentSrc = score.sourcePath || null;
|
|
599
|
-
if (revealEl) revealEl.style.display = currentSrc ? "inline-block" : "none";
|
|
1182
|
+
if (revealEl) revealEl.style.display = !DEMO_CLEAN && currentSrc ? "inline-block" : "none";
|
|
600
1183
|
missing.textContent = formatMissing(state.missing);
|
|
601
1184
|
|
|
602
1185
|
renderAlert(score, oldPct, repeatCount);
|
|
603
|
-
|
|
1186
|
+
// The old advice box repeated the verdict a third time; its job now lives in leadLine + the
|
|
1187
|
+
// renew button's own subtitle.
|
|
1188
|
+
nudge.style.display = "none";
|
|
604
1189
|
|
|
605
|
-
|
|
1190
|
+
// Breathing CTA: quiet ghost while things are fine, full-size primary when drifted.
|
|
1191
|
+
renewUrgent = urgent;
|
|
1192
|
+
if (renewBtn) {
|
|
1193
|
+
renewBtn.style.display = "block"; // always available — warm-start any session, even fresh
|
|
1194
|
+
refreshRenewLabel();
|
|
1195
|
+
}
|
|
1196
|
+
const restoredPrompt = restoreCachedCheckpointFor(score, currentSessionKey);
|
|
1197
|
+
if (!restoredPrompt) {
|
|
1198
|
+
const restoredStatus = restoreCheckpointStatusFor(score, currentSessionKey);
|
|
1199
|
+
if (!restoredStatus) maybeFetchCheckpointStatus(score, currentSessionKey);
|
|
1200
|
+
}
|
|
606
1201
|
prevTurnState = {
|
|
607
1202
|
src: score.sourcePath,
|
|
608
1203
|
turn: score.turn,
|
|
@@ -620,6 +1215,8 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
620
1215
|
hud.classList.remove("s-green", "s-amber", "s-red");
|
|
621
1216
|
prevTurnState = null;
|
|
622
1217
|
main.textContent = "No active session yet";
|
|
1218
|
+
if (mdot) mdot.className = "mdot idle";
|
|
1219
|
+
if (mtext) mtext.textContent = "EchoMem";
|
|
623
1220
|
client.textContent = "EchoMem HUD - " + hudVersion;
|
|
624
1221
|
activityEpoch = null;
|
|
625
1222
|
if (updatedEl) updatedEl.textContent = "";
|
|
@@ -636,7 +1233,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
636
1233
|
alertEl.style.display = "none";
|
|
637
1234
|
nudge.style.display = "none";
|
|
638
1235
|
if (renewBtn) renewBtn.style.display = "none";
|
|
639
|
-
|
|
1236
|
+
clearRenewResult();
|
|
640
1237
|
metaEl.textContent = "";
|
|
641
1238
|
pathEl.textContent = "";
|
|
642
1239
|
missing.textContent = formatMissing(state && state.missing);
|
|
@@ -660,12 +1257,8 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
660
1257
|
alertEl.style.display = "block";
|
|
661
1258
|
return;
|
|
662
1259
|
}
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
alertDetail.textContent = "at least " + oldPct + "% old weight is still riding in the window";
|
|
666
|
-
alertEl.style.display = "block";
|
|
667
|
-
return;
|
|
668
|
-
}
|
|
1260
|
+
// No static "context has drifted" verdict here: the state word, lead line, and bar already
|
|
1261
|
+
// say it. The alert is reserved for genuinely NEW information (a turn that re-read old files).
|
|
669
1262
|
alertEl.style.display = "none";
|
|
670
1263
|
}
|
|
671
1264
|
|
|
@@ -690,15 +1283,17 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
690
1283
|
return "Fresh";
|
|
691
1284
|
}
|
|
692
1285
|
|
|
1286
|
+
// Echo's voice: one line that says how it is AND what to do — replaces the old pile of
|
|
1287
|
+
// metaphor headline + callout + advice box that all repeated the same verdict.
|
|
693
1288
|
function leadLine(color) {
|
|
694
|
-
if (color === "red") return "
|
|
695
|
-
if (color === "amber") return "
|
|
696
|
-
return "
|
|
1289
|
+
if (color === "red") return "Old context is crowding the window. Renew and I carry your work over.";
|
|
1290
|
+
if (color === "amber") return "Getting full. Wrap this thought; a fresh start soon keeps it snappy.";
|
|
1291
|
+
return "All clear. I am taking notes as you go.";
|
|
697
1292
|
}
|
|
698
1293
|
|
|
699
1294
|
function evidenceLine(score, oldPct, repeatCount) {
|
|
700
|
-
const repeat = repeatCount > 0 ? repeatCount + " repeated read" + (repeatCount === 1 ? "" : "s") : "
|
|
701
|
-
return
|
|
1295
|
+
const repeat = repeatCount > 0 ? repeatCount + " repeated read" + (repeatCount === 1 ? "" : "s") : "no re-reads yet";
|
|
1296
|
+
return fmt(score.pollutionTok) + " old context · " + repeat;
|
|
702
1297
|
}
|
|
703
1298
|
|
|
704
1299
|
function fullnessLine(sat, score) {
|
|
@@ -738,25 +1333,69 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
738
1333
|
return parts.join(" · ");
|
|
739
1334
|
}
|
|
740
1335
|
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
1336
|
+
function renderSwitchButton(state, activeClient) {
|
|
1337
|
+
if (!switchBtn) return;
|
|
1338
|
+
const target = switchTarget(state, activeClient);
|
|
1339
|
+
if (!target) {
|
|
1340
|
+
switchBtn.style.display = "none";
|
|
1341
|
+
switchBtn.removeAttribute("data-target-family");
|
|
1342
|
+
switchBtn.removeAttribute("data-target-id");
|
|
1343
|
+
switchBtn.innerHTML = "";
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
const family = agentFamily(target.client);
|
|
1347
|
+
const name = family === "claude" ? "Claude" : "Codex";
|
|
1348
|
+
switchBtn.dataset.targetFamily = family;
|
|
1349
|
+
switchBtn.dataset.targetId = target.id;
|
|
1350
|
+
switchBtn.title = "Switch to " + name;
|
|
1351
|
+
switchBtn.innerHTML = '<img class="switch-icon" src="' + iconForFamily(family) + '" alt="" /><span class="switch-copy">Switch to ' + name + "</span>";
|
|
1352
|
+
switchBtn.style.display = "inline-flex";
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function switchTarget(state, activeClient) {
|
|
744
1356
|
const list = (state && state.recentSessions) || [];
|
|
1357
|
+
if (!list.length) return null;
|
|
1358
|
+
const currentFamily = agentFamily(activeClient || (state.active && state.active.client));
|
|
1359
|
+
const targetFamily = currentFamily === "codex" ? "claude" : currentFamily === "claude" ? "codex" : "";
|
|
1360
|
+
if (!targetFamily) return null;
|
|
1361
|
+
return list.filter((s) => agentFamily(s.client) === targetFamily)[0] || null;
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
// Recent sessions as switchable tabs. "Auto" clears the pin. Hidden when there's only one
|
|
1365
|
+
// session for the active agent family, so Codex never recommends Claude sessions or vice versa.
|
|
1366
|
+
function renderTabs(state, activeId, activeClient) {
|
|
1367
|
+
if (!tabsEl) return;
|
|
1368
|
+
const list = visibleRecentSessions(state, activeClient);
|
|
745
1369
|
if (list.length <= 1) { tabsEl.innerHTML = ""; return; }
|
|
1370
|
+
const pinnedId = state && state.pinnedId ? state.pinnedId : null;
|
|
746
1371
|
const chips = list.map((s) => {
|
|
747
|
-
const cls = s.id ===
|
|
1372
|
+
const cls = s.id === pinnedId ? "tab active" : !pinnedId && s.id === activeId ? "tab auto-pick" : "tab";
|
|
748
1373
|
const dot = s.live ? '<span class="spin"></span>' : "";
|
|
749
1374
|
const title = s.title || label(s.client);
|
|
750
1375
|
return '<button class="' + cls + '" data-id="' + esc(s.id) + '" title="' + esc(label(s.client) + " · " + title) + '">' + dot + esc(shortTitle(title)) + "</button>";
|
|
751
1376
|
});
|
|
752
|
-
chips.unshift('<button class="tab' + (
|
|
1377
|
+
chips.unshift('<button class="tab' + (pinnedId ? "" : " active") + '" data-id="auto" title="Auto-select the current foreground agent session">Auto</button>');
|
|
753
1378
|
tabsEl.innerHTML = chips.join("");
|
|
754
1379
|
}
|
|
755
|
-
function
|
|
1380
|
+
function visibleRecentSessions(state, activeClient) {
|
|
756
1381
|
const list = (state && state.recentSessions) || [];
|
|
1382
|
+
const family = agentFamily(activeClient);
|
|
1383
|
+
if (family !== "codex" && family !== "claude") return list;
|
|
1384
|
+
return list.filter((s) => agentFamily(s.client) === family);
|
|
1385
|
+
}
|
|
1386
|
+
function activeIdFor(state, src, activeClient) {
|
|
1387
|
+
const list = visibleRecentSessions(state, activeClient);
|
|
757
1388
|
const match = list.filter((s) => s.sourcePath === src)[0];
|
|
758
1389
|
return match ? match.id : null;
|
|
759
1390
|
}
|
|
1391
|
+
function agentFamily(clientName) {
|
|
1392
|
+
if (clientName === "codex") return "codex";
|
|
1393
|
+
if (String(clientName || "").indexOf("claude") === 0) return "claude";
|
|
1394
|
+
return "";
|
|
1395
|
+
}
|
|
1396
|
+
function iconForFamily(family) {
|
|
1397
|
+
return family === "claude" ? "/assets/hud/claude.svg" : "/assets/hud/codex.svg";
|
|
1398
|
+
}
|
|
760
1399
|
function shortTitle(t) {
|
|
761
1400
|
t = String(t || "");
|
|
762
1401
|
return t.length > 22 ? t.slice(0, 21) + "…" : t;
|