@echomem/mcp 1.4.7 → 1.4.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -8
- package/assets/hud/claude.svg +1 -0
- package/assets/hud/codex.svg +1 -0
- package/assets/hud/session-viewer.html +35 -0
- package/dist/city/echo-ai-city-only.html +31 -13
- package/dist/city/echo-ai-city-only.template.html +31 -13
- package/dist/city/echo-face-cutout.png +0 -0
- package/dist/hud/autostart.js +66 -0
- package/dist/hud/cli.js +31 -0
- package/dist/hud/electron-main.js +182 -19
- package/dist/hud/monitor.js +36 -68
- package/dist/hud/preload.cjs +3 -0
- package/dist/hud/server.js +321 -4
- package/dist/hud/web.js +633 -69
- package/dist/index.js +116 -22
- package/dist/migrate.js +18 -0
- package/dist/setup-page.js +978 -60
- package/dist/setup.js +355 -42
- package/dist/v1-contract.js +20 -2
- package/package.json +3 -2
package/dist/hud/web.js
CHANGED
|
@@ -170,6 +170,15 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
170
170
|
text-transform: uppercase;
|
|
171
171
|
color: var(--faint);
|
|
172
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
|
+
}
|
|
173
182
|
.details {
|
|
174
183
|
display: none;
|
|
175
184
|
position: relative;
|
|
@@ -206,19 +215,65 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
206
215
|
}
|
|
207
216
|
.tab:hover { background: rgba(0, 0, 0, .08); }
|
|
208
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; }
|
|
209
219
|
.spin {
|
|
210
220
|
display: inline-block; width: 9px; height: 9px; margin-right: 5px; vertical-align: -1px;
|
|
211
221
|
border: 1.5px solid rgba(0, 0, 0, .2); border-top-color: #1a3a8f; border-radius: 50%;
|
|
212
222
|
animation: echospin .8s linear infinite;
|
|
213
223
|
}
|
|
214
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; }
|
|
215
249
|
.headctl { display: flex; align-items: center; gap: 8px; }
|
|
216
250
|
.switchbtn {
|
|
251
|
+
display: inline-flex; align-items: center; justify-content: center;
|
|
217
252
|
width: 22px; height: 22px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
|
|
218
253
|
border: 1px solid rgba(0, 0, 0, .2); background: rgba(0, 0, 0, .05); color: var(--faint);
|
|
219
254
|
cursor: pointer; font-family: inherit;
|
|
220
255
|
}
|
|
221
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; }
|
|
222
277
|
.reveal {
|
|
223
278
|
margin-top: 8px; font-size: 10.5px; padding: 4px 11px; border-radius: 999px;
|
|
224
279
|
border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04); color: var(--faint);
|
|
@@ -320,23 +375,44 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
320
375
|
background: rgba(199, 55, 47, 0.07);
|
|
321
376
|
color: #7a241e;
|
|
322
377
|
}
|
|
378
|
+
/* Breathing CTA: quiet ghost pill while the session is healthy... */
|
|
323
379
|
button.renew-btn {
|
|
324
380
|
display: none;
|
|
325
|
-
width:
|
|
381
|
+
width: auto;
|
|
326
382
|
margin-top: 12px;
|
|
327
|
-
border: 1px solid rgba(199, 55, 47, 0.
|
|
383
|
+
border: 1px solid rgba(199, 55, 47, 0.28);
|
|
328
384
|
border-radius: 999px;
|
|
329
|
-
padding:
|
|
330
|
-
background:
|
|
385
|
+
padding: 6px 16px;
|
|
386
|
+
background: transparent;
|
|
331
387
|
color: var(--brick);
|
|
332
388
|
font-weight: 750;
|
|
333
|
-
font-size:
|
|
389
|
+
font-size: 11.5px;
|
|
334
390
|
cursor: pointer;
|
|
335
|
-
transition:
|
|
391
|
+
transition: all 0.25s ease;
|
|
336
392
|
}
|
|
337
|
-
|
|
338
|
-
button.renew-btn
|
|
339
|
-
|
|
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; }
|
|
340
416
|
.renew-result {
|
|
341
417
|
display: none;
|
|
342
418
|
margin-top: 10px;
|
|
@@ -427,10 +503,12 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
427
503
|
<span class="main" id="main">Checking context...</span>
|
|
428
504
|
<span class="client" id="client">EchoMem HUD</span>
|
|
429
505
|
</span>
|
|
506
|
+
<button class="cornerbtn" id="minibtn" title="Shrink to mini pill">–</button>
|
|
430
507
|
</div>
|
|
508
|
+
<div id="miniPill" title="EchoMem — click to expand"><span class="mdot" id="mdot"></span><span id="mtext">EchoMem</span></div>
|
|
431
509
|
</div>
|
|
432
510
|
<div class="details">
|
|
433
|
-
<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>
|
|
434
512
|
<div class="tabs" id="tabs"></div>
|
|
435
513
|
<div class="state-row">
|
|
436
514
|
<span class="state-dot" id="stateDot"></span>
|
|
@@ -492,9 +570,42 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
492
570
|
const hudVersion = "v${MCP_PACKAGE_VERSION}";
|
|
493
571
|
let prevTurnState = null;
|
|
494
572
|
let lastCapsule = "";
|
|
573
|
+
let renewResultSessionKey = "";
|
|
495
574
|
let activityEpoch = null;
|
|
496
575
|
let lastState = null;
|
|
497
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;
|
|
592
|
+
|
|
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); });
|
|
498
609
|
let hudDrag = null;
|
|
499
610
|
let suppressNextToggle = false;
|
|
500
611
|
const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .switchbtn, .reveal, .renew-btn, .copy-btn";
|
|
@@ -509,7 +620,8 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
509
620
|
if (event.target.closest(dragBlockSelector)) return;
|
|
510
621
|
if (!window.echomemHud || typeof window.echomemHud.startDrag !== "function") return;
|
|
511
622
|
hudDrag = { pointerId: event.pointerId, startX: event.screenX, startY: event.screenY, moved: false };
|
|
512
|
-
|
|
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.
|
|
513
625
|
window.echomemHud.startDrag(event.screenX, event.screenY);
|
|
514
626
|
}
|
|
515
627
|
|
|
@@ -519,6 +631,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
519
631
|
const dx = event.screenX - hudDrag.startX;
|
|
520
632
|
const dy = event.screenY - hudDrag.startY;
|
|
521
633
|
if (!hudDrag.moved && Math.hypot(dx, dy) < 3) return;
|
|
634
|
+
if (!hudDrag.moved) { try { hud.setPointerCapture(hudDrag.pointerId); } catch {} }
|
|
522
635
|
hudDrag.moved = true;
|
|
523
636
|
hud.classList.add("dragging");
|
|
524
637
|
window.echomemHud.moveDrag(event.screenX, event.screenY);
|
|
@@ -544,17 +657,11 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
544
657
|
fetch("/open-viewer?path=" + encodeURIComponent(currentSrc)).catch(() => {});
|
|
545
658
|
});
|
|
546
659
|
|
|
547
|
-
//
|
|
660
|
+
// Jump to the other agent family while the tabs stay scoped to the family you're viewing.
|
|
548
661
|
if (switchBtn) switchBtn.addEventListener("click", (e) => {
|
|
549
662
|
e.stopPropagation();
|
|
550
663
|
const st = lastState;
|
|
551
|
-
|
|
552
|
-
const clients = [];
|
|
553
|
-
for (const s of st.recentSessions) if (clients.indexOf(s.client) < 0) clients.push(s.client);
|
|
554
|
-
if (clients.length < 2) return;
|
|
555
|
-
const cur = st.active ? st.active.client : clients[0];
|
|
556
|
-
const next = clients[(clients.indexOf(cur) + 1) % clients.length];
|
|
557
|
-
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);
|
|
558
665
|
if (!target) return;
|
|
559
666
|
fetch("/select?id=" + encodeURIComponent(target.id))
|
|
560
667
|
.then(() => fetch("/state"))
|
|
@@ -582,35 +689,93 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
582
689
|
event.stopPropagation();
|
|
583
690
|
return;
|
|
584
691
|
}
|
|
585
|
-
if (event.target instanceof HTMLElement && event.target.closest(".switchbtn")) return;
|
|
692
|
+
if (event.target instanceof HTMLElement && (event.target.closest(".switchbtn") || event.target.closest(".cornerbtn"))) return;
|
|
586
693
|
hud.classList.toggle("open");
|
|
587
694
|
syncHeight();
|
|
588
695
|
});
|
|
589
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
|
+
|
|
590
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);
|
|
591
717
|
renewBtn.classList.add("loading");
|
|
592
|
-
|
|
718
|
+
clearRenewResult();
|
|
719
|
+
startRenewTimer(scoreAtRenew);
|
|
593
720
|
try {
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
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) {
|
|
599
742
|
return;
|
|
600
743
|
}
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
if (
|
|
604
|
-
|
|
605
|
-
|
|
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
|
+
});
|
|
606
762
|
try {
|
|
607
763
|
await navigator.clipboard.writeText(prompt);
|
|
608
|
-
if (renewMsg) renewMsg.textContent =
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
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
|
+
}
|
|
614
779
|
});
|
|
615
780
|
|
|
616
781
|
if (copyBtn) copyBtn.addEventListener("click", async () => {
|
|
@@ -622,19 +787,356 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
622
787
|
} catch {}
|
|
623
788
|
});
|
|
624
789
|
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
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
|
+
}
|
|
628
1131
|
|
|
629
1132
|
function render(state) {
|
|
630
1133
|
const score = state.active || state;
|
|
631
1134
|
lastState = state;
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
}
|
|
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);
|
|
638
1140
|
if (!score || typeof score.usefulPct !== "number") {
|
|
639
1141
|
renderIdle(state);
|
|
640
1142
|
return;
|
|
@@ -656,7 +1158,13 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
656
1158
|
hud.classList.remove("s-green", "s-amber", "s-red");
|
|
657
1159
|
if (!stale) hud.classList.add("s-" + color);
|
|
658
1160
|
main.textContent = (stale ? "Idle" : stateName) + " - " + fmt(score.ctTokens) + " ctx";
|
|
659
|
-
|
|
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);
|
|
660
1168
|
activityEpoch = typeof score.lastActiveMs === "number" ? Date.now() - score.lastActiveMs : null;
|
|
661
1169
|
renderUpdated();
|
|
662
1170
|
turnEl.textContent = typeof score.turn === "number" && score.turn > 0 ? "turn " + score.turn : "";
|
|
@@ -668,16 +1176,28 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
668
1176
|
evidence.textContent = evidenceLine(score, oldPct, repeatCount);
|
|
669
1177
|
usefulbar.style.width = Math.max(0, 100 - oldPct) + "%";
|
|
670
1178
|
oldbar.style.width = oldPct + "%";
|
|
671
|
-
metaEl.textContent = ctSourceLabel(score.ctSource) + "
|
|
1179
|
+
metaEl.textContent = ctSourceLabel(score.ctSource) + " · " + edits + " edits";
|
|
672
1180
|
pathEl.textContent = "";
|
|
673
1181
|
currentSrc = score.sourcePath || null;
|
|
674
|
-
if (revealEl) revealEl.style.display = currentSrc ? "inline-block" : "none";
|
|
1182
|
+
if (revealEl) revealEl.style.display = !DEMO_CLEAN && currentSrc ? "inline-block" : "none";
|
|
675
1183
|
missing.textContent = formatMissing(state.missing);
|
|
676
1184
|
|
|
677
1185
|
renderAlert(score, oldPct, repeatCount);
|
|
678
|
-
|
|
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";
|
|
679
1189
|
|
|
680
|
-
|
|
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
|
+
}
|
|
681
1201
|
prevTurnState = {
|
|
682
1202
|
src: score.sourcePath,
|
|
683
1203
|
turn: score.turn,
|
|
@@ -695,6 +1215,8 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
695
1215
|
hud.classList.remove("s-green", "s-amber", "s-red");
|
|
696
1216
|
prevTurnState = null;
|
|
697
1217
|
main.textContent = "No active session yet";
|
|
1218
|
+
if (mdot) mdot.className = "mdot idle";
|
|
1219
|
+
if (mtext) mtext.textContent = "EchoMem";
|
|
698
1220
|
client.textContent = "EchoMem HUD - " + hudVersion;
|
|
699
1221
|
activityEpoch = null;
|
|
700
1222
|
if (updatedEl) updatedEl.textContent = "";
|
|
@@ -711,7 +1233,7 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
711
1233
|
alertEl.style.display = "none";
|
|
712
1234
|
nudge.style.display = "none";
|
|
713
1235
|
if (renewBtn) renewBtn.style.display = "none";
|
|
714
|
-
|
|
1236
|
+
clearRenewResult();
|
|
715
1237
|
metaEl.textContent = "";
|
|
716
1238
|
pathEl.textContent = "";
|
|
717
1239
|
missing.textContent = formatMissing(state && state.missing);
|
|
@@ -735,12 +1257,8 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
735
1257
|
alertEl.style.display = "block";
|
|
736
1258
|
return;
|
|
737
1259
|
}
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
alertDetail.textContent = "at least " + oldPct + "% old weight is still riding in the window";
|
|
741
|
-
alertEl.style.display = "block";
|
|
742
|
-
return;
|
|
743
|
-
}
|
|
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).
|
|
744
1262
|
alertEl.style.display = "none";
|
|
745
1263
|
}
|
|
746
1264
|
|
|
@@ -765,15 +1283,17 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
765
1283
|
return "Fresh";
|
|
766
1284
|
}
|
|
767
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.
|
|
768
1288
|
function leadLine(color) {
|
|
769
|
-
if (color === "red") return "
|
|
770
|
-
if (color === "amber") return "
|
|
771
|
-
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.";
|
|
772
1292
|
}
|
|
773
1293
|
|
|
774
1294
|
function evidenceLine(score, oldPct, repeatCount) {
|
|
775
|
-
const repeat = repeatCount > 0 ? repeatCount + " repeated read" + (repeatCount === 1 ? "" : "s") : "
|
|
776
|
-
return
|
|
1295
|
+
const repeat = repeatCount > 0 ? repeatCount + " repeated read" + (repeatCount === 1 ? "" : "s") : "no re-reads yet";
|
|
1296
|
+
return fmt(score.pollutionTok) + " old context · " + repeat;
|
|
777
1297
|
}
|
|
778
1298
|
|
|
779
1299
|
function fullnessLine(sat, score) {
|
|
@@ -813,25 +1333,69 @@ export const HUD_HTML = String.raw `<!doctype html>
|
|
|
813
1333
|
return parts.join(" · ");
|
|
814
1334
|
}
|
|
815
1335
|
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
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) {
|
|
819
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);
|
|
820
1369
|
if (list.length <= 1) { tabsEl.innerHTML = ""; return; }
|
|
1370
|
+
const pinnedId = state && state.pinnedId ? state.pinnedId : null;
|
|
821
1371
|
const chips = list.map((s) => {
|
|
822
|
-
const cls = s.id ===
|
|
1372
|
+
const cls = s.id === pinnedId ? "tab active" : !pinnedId && s.id === activeId ? "tab auto-pick" : "tab";
|
|
823
1373
|
const dot = s.live ? '<span class="spin"></span>' : "";
|
|
824
1374
|
const title = s.title || label(s.client);
|
|
825
1375
|
return '<button class="' + cls + '" data-id="' + esc(s.id) + '" title="' + esc(label(s.client) + " · " + title) + '">' + dot + esc(shortTitle(title)) + "</button>";
|
|
826
1376
|
});
|
|
827
|
-
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>');
|
|
828
1378
|
tabsEl.innerHTML = chips.join("");
|
|
829
1379
|
}
|
|
830
|
-
function
|
|
1380
|
+
function visibleRecentSessions(state, activeClient) {
|
|
831
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);
|
|
832
1388
|
const match = list.filter((s) => s.sourcePath === src)[0];
|
|
833
1389
|
return match ? match.id : null;
|
|
834
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
|
+
}
|
|
835
1399
|
function shortTitle(t) {
|
|
836
1400
|
t = String(t || "");
|
|
837
1401
|
return t.length > 22 ? t.slice(0, 21) + "…" : t;
|