@echomem/mcp 1.4.17 → 1.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/hud/web.js CHANGED
@@ -212,7 +212,7 @@ export const HUD_HTML = String.raw `<!doctype html>
212
212
  display: none;
213
213
  position: relative;
214
214
  margin: 2px 8px 0 8px;
215
- padding: 16px 17px 15px;
215
+ padding: 16px 17px 10px;
216
216
  border: 1px solid var(--line);
217
217
  border-radius: 12px;
218
218
  background: var(--card);
@@ -268,22 +268,7 @@ export const HUD_HTML = String.raw `<!doctype html>
268
268
  .now-title, .now-repo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
269
269
  .now-title { font-size: 12px; font-weight: 800; color: var(--ink); }
270
270
  .now-repo { font-size: 10px; font-weight: 600; color: var(--faint); }
271
- .verdict {
272
- overflow: hidden;
273
- text-overflow: ellipsis;
274
- white-space: nowrap;
275
- font-size: 11.5px;
276
- font-weight: 650;
277
- color: var(--muted);
278
- }
279
- .verdict:empty { display: none; }
280
- .meta-row {
281
- display: flex;
282
- align-items: center;
283
- justify-content: space-between;
284
- gap: 8px;
285
- margin-top: 10px;
286
- }
271
+ .signal-meta { min-height: 12px; margin: -1px 0 5px 21px; }
287
272
  .meta-line {
288
273
  min-width: 0;
289
274
  overflow: hidden;
@@ -333,16 +318,59 @@ export const HUD_HTML = String.raw `<!doctype html>
333
318
  .tabs {
334
319
  display: grid;
335
320
  gap: 6px;
321
+ height: 259px;
336
322
  max-height: 259px;
337
323
  margin: 10px 0 0;
338
324
  padding-right: 3px;
339
325
  overflow-y: auto;
326
+ overflow-anchor: none;
340
327
  overscroll-behavior: contain;
328
+ scrollbar-gutter: stable;
329
+ scrollbar-width: none;
341
330
  }
342
331
  .tabs:empty { display: none; }
343
- .tabs::-webkit-scrollbar { width: 4px; }
344
- .tabs::-webkit-scrollbar-track { background: transparent; }
345
- .tabs::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(26, 58, 143, .2); }
332
+ /* Let roughly two rows taper at each scroll edge while keeping the edge rows visible.
333
+ At mid-scroll both cues are active; reaching either end restores that edge to full opacity. */
334
+ .tabs.has-more-below:not(.has-more-above) {
335
+ -webkit-mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 82px), rgba(0, 0, 0, .82) calc(100% - 54px), rgba(0, 0, 0, .58) calc(100% - 24px), rgba(0, 0, 0, .42) 100%);
336
+ mask-image: linear-gradient(to bottom, #000 0, #000 calc(100% - 82px), rgba(0, 0, 0, .82) calc(100% - 54px), rgba(0, 0, 0, .58) calc(100% - 24px), rgba(0, 0, 0, .42) 100%);
337
+ }
338
+ .tabs.has-more-above:not(.has-more-below) {
339
+ -webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, .42) 0, rgba(0, 0, 0, .58) 24px, rgba(0, 0, 0, .82) 54px, #000 82px, #000 100%);
340
+ mask-image: linear-gradient(to bottom, rgba(0, 0, 0, .42) 0, rgba(0, 0, 0, .58) 24px, rgba(0, 0, 0, .82) 54px, #000 82px, #000 100%);
341
+ }
342
+ .tabs.has-more-above.has-more-below {
343
+ -webkit-mask-image: linear-gradient(to bottom, rgba(0, 0, 0, .42) 0, rgba(0, 0, 0, .58) 24px, rgba(0, 0, 0, .82) 54px, #000 82px, #000 calc(100% - 82px), rgba(0, 0, 0, .82) calc(100% - 54px), rgba(0, 0, 0, .58) calc(100% - 24px), rgba(0, 0, 0, .42) 100%);
344
+ mask-image: linear-gradient(to bottom, rgba(0, 0, 0, .42) 0, rgba(0, 0, 0, .58) 24px, rgba(0, 0, 0, .82) 54px, #000 82px, #000 calc(100% - 82px), rgba(0, 0, 0, .82) calc(100% - 54px), rgba(0, 0, 0, .58) calc(100% - 24px), rgba(0, 0, 0, .42) 100%);
345
+ }
346
+ .tabs::-webkit-scrollbar { width: 0; height: 0; }
347
+ /* One scrollbar for the whole white window. It mirrors the stable timeline viewport below
348
+ instead of shrinking to the list's own 259px box. */
349
+ .timeline-scrollbar {
350
+ position: absolute;
351
+ top: 8px;
352
+ right: 4px;
353
+ bottom: 8px;
354
+ z-index: 5;
355
+ width: 4px;
356
+ border-radius: 999px;
357
+ background: rgba(26, 58, 143, .055);
358
+ cursor: pointer;
359
+ touch-action: none;
360
+ transition: opacity .16s ease;
361
+ }
362
+ .timeline-scrollbar.hidden { opacity: 0; pointer-events: none; }
363
+ .timeline-thumb {
364
+ position: absolute;
365
+ top: 0;
366
+ left: 0;
367
+ width: 100%;
368
+ min-height: 30px;
369
+ border-radius: 999px;
370
+ background: rgba(26, 58, 143, .24);
371
+ box-shadow: 0 0 0 1px rgba(255, 255, 255, .5);
372
+ will-change: transform, height;
373
+ }
346
374
  /* History rows: everything that is NOT the current session. Quiet on purpose. */
347
375
  .tab {
348
376
  display: grid; grid-template-columns: 16px minmax(0, 1fr) auto auto auto; align-items: center; column-gap: 8px;
@@ -351,6 +379,16 @@ export const HUD_HTML = String.raw `<!doctype html>
351
379
  color: var(--faint); cursor: pointer; text-align: left; font-family: inherit;
352
380
  }
353
381
  .tab:hover { border-color: rgba(0, 0, 0, .1); background: rgba(0, 0, 0, .05); }
382
+ .tab.expanded {
383
+ grid-template-rows: auto auto;
384
+ min-height: 78px;
385
+ padding-top: 8px;
386
+ padding-bottom: 8px;
387
+ border-color: rgba(26, 58, 143, .24);
388
+ background: rgba(255, 255, 255, .82);
389
+ box-shadow: 0 1px 4px rgba(26, 58, 143, .06);
390
+ }
391
+ .tab.expanded .tab-title { color: var(--ink); font-weight: 760; }
354
392
  .tab-icon { width: 14px; height: 14px; object-fit: contain; opacity: .75; }
355
393
  .tab-copy { min-width: 0; display: grid; gap: 1px; }
356
394
  .tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-weight: 680; }
@@ -382,6 +420,36 @@ export const HUD_HTML = String.raw `<!doctype html>
382
420
  color: var(--muted);
383
421
  }
384
422
  .tab-health .tab-status { width: 8px; height: 8px; border-width: 1.5px; }
423
+ .tab-detail {
424
+ grid-column: 2 / -1;
425
+ display: flex;
426
+ align-items: baseline;
427
+ justify-content: space-between;
428
+ gap: 8px;
429
+ min-width: 0;
430
+ margin-top: 5px;
431
+ padding-top: 7px;
432
+ border-top: 1px solid rgba(26, 58, 143, .11);
433
+ text-align: left;
434
+ }
435
+ .tab-detail-score {
436
+ flex: 0 0 auto;
437
+ font-family: "JetBrains Mono", ui-monospace, monospace;
438
+ font-size: 10px;
439
+ font-weight: 800;
440
+ color: var(--muted);
441
+ white-space: nowrap;
442
+ }
443
+ .tab-detail-meta {
444
+ min-width: 0;
445
+ overflow: hidden;
446
+ text-overflow: ellipsis;
447
+ font-family: "JetBrains Mono", ui-monospace, monospace;
448
+ font-size: 9px;
449
+ font-weight: 600;
450
+ color: var(--faint);
451
+ white-space: nowrap;
452
+ }
385
453
  /* Jump straight to this conversation in its own app. */
386
454
  .tab-jump {
387
455
  display: inline-flex; align-items: center; justify-content: center;
@@ -431,33 +499,6 @@ export const HUD_HTML = String.raw `<!doctype html>
431
499
  cursor: pointer; font-family: inherit;
432
500
  }
433
501
  .cornerbtn:hover { background: #fff; color: #111; }
434
- .switchbtn {
435
- display: inline-flex; align-items: center; justify-content: center;
436
- width: 22px; height: 22px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
437
- border: 1px solid rgba(0, 0, 0, .2); background: rgba(0, 0, 0, .05); color: var(--faint);
438
- cursor: pointer; font-family: inherit;
439
- }
440
- .switchbtn:hover { background: rgba(0, 0, 0, .1); color: #111; }
441
- /* Explicit family switch: a labeled pill, not a mystery icon. */
442
- .agent-switch {
443
- width: auto;
444
- height: 26px;
445
- gap: 6px;
446
- padding: 0 11px 0 8px;
447
- border-radius: 999px;
448
- background: rgba(255, 255, 255, .78);
449
- color: #1f1f1f;
450
- font-size: 10.5px;
451
- font-weight: 760;
452
- letter-spacing: 0;
453
- white-space: nowrap;
454
- flex: 0 0 auto;
455
- }
456
- .agent-switch:hover { background: #fff; border-color: rgba(26, 58, 143, .34); }
457
- .agent-switch[data-target-family="claude"] { border-color: rgba(217, 119, 87, .42); }
458
- .agent-switch[data-target-family="codex"] { border-color: rgba(57, 65, 255, .34); }
459
- .switch-icon { width: 14px; height: 14px; flex: 0 0 auto; object-fit: contain; }
460
- .switch-copy { display: inline; }
461
502
  .reveal {
462
503
  margin-top: 8px; font-size: 10.5px; padding: 4px 11px; border-radius: 999px;
463
504
  border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04); color: var(--faint);
@@ -488,6 +529,20 @@ export const HUD_HTML = String.raw `<!doctype html>
488
529
  font-size: 20px;
489
530
  font-weight: 850;
490
531
  }
532
+ .state-word .signal-label {
533
+ margin-left: 5px;
534
+ font-family: "Avenir Next", Inter, ui-sans-serif, system-ui, sans-serif;
535
+ font-size: 11px;
536
+ font-weight: 700;
537
+ color: var(--muted);
538
+ }
539
+ .state-word .snr-prefix {
540
+ margin-right: 7px;
541
+ font-size: 10.5px;
542
+ font-weight: 800;
543
+ letter-spacing: 0.04em;
544
+ color: var(--faint);
545
+ }
491
546
  .why-btn {
492
547
  margin-left: auto;
493
548
  display: inline-flex;
@@ -519,6 +574,26 @@ export const HUD_HTML = String.raw `<!doctype html>
519
574
  line-height: 1.5;
520
575
  }
521
576
  #hud.show-why .evidence { display: block; }
577
+ .snr-equation {
578
+ display: grid;
579
+ grid-template-columns: auto minmax(0, 1fr) auto;
580
+ align-items: center;
581
+ gap: 7px;
582
+ margin-bottom: 8px;
583
+ color: var(--ink);
584
+ font-family: "JetBrains Mono", ui-monospace, monospace;
585
+ font-size: 9.5px;
586
+ line-height: 1.2;
587
+ }
588
+ .snr-fraction { display: grid; min-width: 0; text-align: center; }
589
+ .snr-numerator {
590
+ padding: 0 4px 3px;
591
+ border-bottom: 1px solid rgba(26, 58, 143, 0.32);
592
+ font-weight: 750;
593
+ white-space: nowrap;
594
+ }
595
+ .snr-denominator { padding: 3px 4px 0; color: var(--muted); white-space: nowrap; }
596
+ .snr-result { color: var(--brick); font-weight: 850; white-space: nowrap; }
522
597
  .why-line { display: flex; justify-content: space-between; gap: 12px; }
523
598
  .why-line + .why-line { margin-top: 3px; }
524
599
  .why-k { color: var(--faint); }
@@ -603,6 +678,85 @@ export const HUD_HTML = String.raw `<!doctype html>
603
678
  box-shadow: 0 12px 20px -16px rgba(122, 36, 30, 0.95);
604
679
  }
605
680
  .billing-link:hover { filter: brightness(0.96); }
681
+ .quota-card {
682
+ display: none;
683
+ margin: 0 0 10px;
684
+ padding: 12px 13px 13px;
685
+ border: 1px solid rgba(26, 58, 143, 0.18);
686
+ border-radius: 12px;
687
+ background: rgba(255, 255, 255, 0.72);
688
+ box-shadow: 0 1px 5px rgba(26, 58, 143, 0.05);
689
+ }
690
+ .quota-head {
691
+ display: flex;
692
+ align-items: center;
693
+ gap: 7px;
694
+ margin-bottom: 9px;
695
+ }
696
+ .quota-title {
697
+ color: var(--ink);
698
+ font-size: 10.5px;
699
+ font-weight: 850;
700
+ letter-spacing: 0.015em;
701
+ }
702
+ .quota-plan {
703
+ border-radius: 999px;
704
+ background: rgba(26, 58, 143, 0.07);
705
+ padding: 2px 7px;
706
+ color: var(--blue);
707
+ font-size: 9px;
708
+ font-weight: 850;
709
+ text-transform: uppercase;
710
+ }
711
+ .quota-manage {
712
+ margin-left: auto;
713
+ border: 0;
714
+ padding: 0;
715
+ background: transparent;
716
+ color: var(--faint);
717
+ font-family: inherit;
718
+ font-size: 9.5px;
719
+ font-weight: 750;
720
+ cursor: pointer;
721
+ }
722
+ .quota-manage:hover { color: var(--blue); }
723
+ .quota-list { display: grid; gap: 9px; }
724
+ .quota-row { --quota-color: var(--blue); }
725
+ .quota-row.is-warn { --quota-color: #c98712; }
726
+ .quota-row.is-limit { --quota-color: #c7372f; }
727
+ .quota-line, .quota-subline {
728
+ display: flex;
729
+ align-items: baseline;
730
+ justify-content: space-between;
731
+ gap: 10px;
732
+ }
733
+ .quota-name { color: var(--muted); font-size: 10px; font-weight: 720; }
734
+ .quota-value {
735
+ color: var(--ink);
736
+ font-family: "JetBrains Mono", ui-monospace, monospace;
737
+ font-size: 9.5px;
738
+ font-weight: 760;
739
+ white-space: nowrap;
740
+ }
741
+ .quota-track {
742
+ height: 4px;
743
+ margin-top: 4px;
744
+ overflow: hidden;
745
+ border-radius: 999px;
746
+ background: rgba(26, 58, 143, 0.11);
747
+ }
748
+ .quota-fill {
749
+ width: 0;
750
+ height: 100%;
751
+ border-radius: inherit;
752
+ background: var(--quota-color);
753
+ transition: width .28s ease, background .2s ease;
754
+ }
755
+ .quota-subline { margin-top: 3px; }
756
+ .quota-reset, .quota-percent { color: var(--faint); font-size: 8.5px; font-weight: 650; }
757
+ .quota-row.is-limit .quota-value,
758
+ .quota-row.is-limit .quota-percent { color: #9d2b25; font-weight: 850; }
759
+ .quota-row.is-unavailable .quota-fill { background: rgba(26, 58, 143, 0.14); }
606
760
  button.renew-btn {
607
761
  display: none;
608
762
  width: 100%;
@@ -707,6 +861,7 @@ export const HUD_HTML = String.raw `<!doctype html>
707
861
  }
708
862
  .path { margin-top: 8px; }
709
863
  .missing { margin-top: 6px; }
864
+ .path:empty, .missing:empty { display: none; }
710
865
  #hud.s-green .mascot-stage { filter: drop-shadow(0 2px 6px rgba(67, 209, 122, 0.3)); }
711
866
  #hud.s-amber .mascot-stage { filter: drop-shadow(0 2px 7px rgba(233, 185, 73, 0.42)); }
712
867
  #hud.s-red .mascot-stage { filter: drop-shadow(0 3px 8px rgba(199, 55, 47, 0.48)); }
@@ -761,13 +916,37 @@ export const HUD_HTML = String.raw `<!doctype html>
761
916
  </div>
762
917
  </div>
763
918
  <div class="details">
919
+ <div class="timeline-scrollbar hidden" id="timelineScrollbar" aria-hidden="true"><span class="timeline-thumb" id="timelineThumb"></span></div>
920
+ <section class="quota-card" id="quotacard" aria-label="Plan usage">
921
+ <div class="quota-head">
922
+ <span class="quota-title">Plan usage</span>
923
+ <span class="quota-plan" id="quotaplan">Free</span>
924
+ <button class="quota-manage" id="quotamanage" type="button">Manage</button>
925
+ </div>
926
+ <div class="quota-list">
927
+ <div class="quota-row" data-quota="processing">
928
+ <div class="quota-line"><span class="quota-name">Processing this week</span><span class="quota-value"></span></div>
929
+ <div class="quota-track"><div class="quota-fill"></div></div>
930
+ <div class="quota-subline"><span class="quota-reset"></span><span class="quota-percent"></span></div>
931
+ </div>
932
+ <div class="quota-row" data-quota="searches">
933
+ <div class="quota-line"><span class="quota-name">Memory searches</span><span class="quota-value"></span></div>
934
+ <div class="quota-track"><div class="quota-fill"></div></div>
935
+ <div class="quota-subline"><span class="quota-reset"></span><span class="quota-percent"></span></div>
936
+ </div>
937
+ <div class="quota-row" data-quota="historical">
938
+ <div class="quota-line"><span class="quota-name">Bulk history imports</span><span class="quota-value"></span></div>
939
+ <div class="quota-track"><div class="quota-fill"></div></div>
940
+ <div class="quota-subline"><span class="quota-reset">Lifetime limit</span><span class="quota-percent"></span></div>
941
+ </div>
942
+ </div>
943
+ </section>
764
944
  <div class="now-card" id="nowcard">
765
945
  <div class="now-sess">
766
946
  <img class="now-icon" id="nowicon" src="/assets/hud/codex.svg" alt="" />
767
947
  <span class="now-copy"><span class="now-title" id="nowtitle">EchoMem</span><span class="client-line" id="nowrepoline" style="display:none"><img class="line-icon" src="/assets/hud/github.svg" alt="" /><span class="now-repo" id="nowrepo"></span></span></span>
768
948
  </div>
769
949
  <div class="session-actions">
770
- <button class="pin-chip" id="pinchip" type="button" title="Return to auto-follow">pinned x</button>
771
950
  <button class="jump-chip" id="jumpchip" type="button" title="Open this conversation">&#8599;</button>
772
951
  </div>
773
952
  <div class="billing-banner" id="billingbanner">
@@ -783,7 +962,7 @@ export const HUD_HTML = String.raw `<!doctype html>
783
962
  <span class="state-word" id="stateWord">--%</span>
784
963
  <button class="why-btn" id="whybtn" type="button" title="Details" aria-label="Details" style="display:none">+</button>
785
964
  </div>
786
- <div class="verdict" id="verdict"></div>
965
+ <div class="signal-meta"><span class="meta-line" id="metaline"></span></div>
787
966
  <div class="evidence" id="evidence"></div>
788
967
  <div class="nudge" id="nudge"></div>
789
968
  <button class="renew-btn" id="renewbtn"><b>Start new session with context</b><small>carry 100% signal, drop the junk</small></button>
@@ -802,7 +981,6 @@ export const HUD_HTML = String.raw `<!doctype html>
802
981
  <div class="diagnostics-body" id="diagnosticsbody"><span class="diagnostics-v">Open this panel to check account status.</span></div>
803
982
  </section>
804
983
  <div class="tabs" id="tabs"></div>
805
- <div class="meta-row"><span class="meta-line" id="metaline"></span><button class="switchbtn agent-switch" id="switchbtn" title="Switch agent" style="display:none" type="button"></button></div>
806
984
  <div class="path" id="path"></div>
807
985
  <button class="reveal" id="reveal" style="display:none">↗ Open session in viewer</button>
808
986
  <div class="missing" id="missing"></div>
@@ -816,12 +994,10 @@ export const HUD_HTML = String.raw `<!doctype html>
816
994
  const clientRepo = document.getElementById("clientrepo");
817
995
  const stateDot = document.getElementById("stateDot");
818
996
  const stateWordEl = document.getElementById("stateWord");
819
- const verdictEl = document.getElementById("verdict");
820
997
  const nowIcon = document.getElementById("nowicon");
821
998
  const nowTitle = document.getElementById("nowtitle");
822
999
  const nowRepo = document.getElementById("nowrepo");
823
1000
  const nowRepoLine = document.getElementById("nowrepoline");
824
- const pinChip = document.getElementById("pinchip");
825
1001
  const jumpChip = document.getElementById("jumpchip");
826
1002
  const whyBtn = document.getElementById("whybtn");
827
1003
  const evidence = document.getElementById("evidence");
@@ -830,6 +1006,9 @@ export const HUD_HTML = String.raw `<!doctype html>
830
1006
  const billingTitle = document.getElementById("billingtitle");
831
1007
  const billingCopy = document.getElementById("billingcopy");
832
1008
  const billingLink = document.getElementById("billinglink");
1009
+ const quotaCard = document.getElementById("quotacard");
1010
+ const quotaPlan = document.getElementById("quotaplan");
1011
+ const quotaManage = document.getElementById("quotamanage");
833
1012
  const renewBtn = document.getElementById("renewbtn");
834
1013
  const renewResult = document.getElementById("renewresult");
835
1014
  const renewMsg = document.getElementById("renewmsg");
@@ -842,7 +1021,8 @@ export const HUD_HTML = String.raw `<!doctype html>
842
1021
  const repoLine = document.getElementById("repoline");
843
1022
  const pathEl = document.getElementById("path");
844
1023
  const tabsEl = document.getElementById("tabs");
845
- const switchBtn = document.getElementById("switchbtn");
1024
+ const timelineScrollbar = document.getElementById("timelineScrollbar");
1025
+ const timelineThumb = document.getElementById("timelineThumb");
846
1026
  const revealEl = document.getElementById("reveal");
847
1027
  const missing = document.getElementById("missing");
848
1028
  const diagnosticsBody = document.getElementById("diagnosticsbody");
@@ -868,9 +1048,9 @@ export const HUD_HTML = String.raw `<!doctype html>
868
1048
  const CHECKPOINT_STATUS_CACHE_LIMIT = 48;
869
1049
  const CHECKPOINT_STATUS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
870
1050
  const statusFetchInFlight = {};
871
- // Demo-clean mode: hide only the research-y session VIEWER (raw log / payload). Keep the session
872
- // tabs + agent-switch they're the manual override for "we can't detect which session you're
873
- // viewing", so the user can always point the HUD at the right session. Flip false to show the viewer.
1051
+ // Demo-clean mode: hide only the research-y session VIEWER (raw log / payload). Keep the unified
1052
+ // session feedit is the manual override for "we can't detect which session you're viewing",
1053
+ // so the user can always point the HUD at the right session. Flip false to show the viewer.
874
1054
  const DEMO_CLEAN = true;
875
1055
 
876
1056
  function diagnosticsLine(label, value, bad, wrap) {
@@ -964,6 +1144,7 @@ export const HUD_HTML = String.raw `<!doctype html>
964
1144
  const mrepoLine = document.getElementById("mrepoline");
965
1145
  let miniOn = false;
966
1146
  let whyOpen = false;
1147
+ let timelineScrollDrag = null;
967
1148
  try { miniOn = localStorage.getItem("echomemHudMini") === "1"; } catch {}
968
1149
  if (miniOn) hud.classList.add("mini");
969
1150
  function applyMini(next) {
@@ -983,7 +1164,7 @@ export const HUD_HTML = String.raw `<!doctype html>
983
1164
  });
984
1165
  let hudDrag = null;
985
1166
  let suppressNextToggle = false;
986
- const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .switchbtn, .reveal, .renew-btn, .copy-btn";
1167
+ const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .timeline-scrollbar, .reveal, .renew-btn, .copy-btn";
987
1168
 
988
1169
  hud.addEventListener("pointerdown", beginHudDrag);
989
1170
  window.addEventListener("pointermove", moveHudDrag);
@@ -1032,22 +1213,8 @@ export const HUD_HTML = String.raw `<!doctype html>
1032
1213
  fetch("/open-viewer?path=" + encodeURIComponent(currentSrc)).catch(() => {});
1033
1214
  });
1034
1215
 
1035
- // Jump to the other agent family while the tabs stay scoped to the family you're viewing.
1036
- if (switchBtn) switchBtn.addEventListener("click", (e) => {
1037
- e.stopPropagation();
1038
- const st = lastState;
1039
- const target = switchTarget(st, st && st.active && st.active.client);
1040
- if (!target) return;
1041
- fetch("/select?id=" + encodeURIComponent(target.id))
1042
- .then(() => fetch("/state"))
1043
- .then((r) => r.json())
1044
- .then(render)
1045
- .catch(() => {});
1046
- });
1047
-
1048
- // Click a history row → pin that session as the current one. Auto-follow is the default;
1049
- // the card's "pinned ×" chip releases the pin. The ↗ corner jumps to the conversation in
1050
- // its own app instead of switching the HUD view.
1216
+ // Click a history row expand it where it already sits. The newest conversation remains in
1217
+ // the top card; the ↗ corner opens the historical conversation in its own app.
1051
1218
  if (tabsEl) tabsEl.addEventListener("click", (e) => {
1052
1219
  const jump = e.target && e.target.closest ? e.target.closest("[data-jump]") : null;
1053
1220
  if (jump) {
@@ -1057,8 +1224,14 @@ export const HUD_HTML = String.raw `<!doctype html>
1057
1224
  }
1058
1225
  const btn = e.target && e.target.closest ? e.target.closest(".tab") : null;
1059
1226
  if (!btn) return;
1060
- selectSession(btn.getAttribute("data-id"));
1227
+ toggleSessionDetails(btn.getAttribute("data-id"));
1061
1228
  });
1229
+ if (tabsEl) tabsEl.addEventListener("scroll", syncTabsOverflowCue, { passive: true });
1230
+ if (timelineScrollbar) timelineScrollbar.addEventListener("pointerdown", beginTimelineScroll);
1231
+ window.addEventListener("pointermove", moveTimelineScroll);
1232
+ window.addEventListener("pointerup", endTimelineScroll);
1233
+ window.addEventListener("pointercancel", endTimelineScroll);
1234
+ window.addEventListener("resize", syncTabsOverflowCue);
1062
1235
  if (jumpChip) jumpChip.addEventListener("click", (e) => {
1063
1236
  e.stopPropagation();
1064
1237
  jumpToSession(jumpChip.getAttribute("data-jump"));
@@ -1067,11 +1240,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1067
1240
  if (!id) return;
1068
1241
  fetch("/jump?id=" + encodeURIComponent(id)).catch(() => {});
1069
1242
  }
1070
- if (pinChip) pinChip.addEventListener("click", (e) => {
1071
- e.stopPropagation();
1072
- selectSession("auto");
1073
- });
1074
- if (billingLink) billingLink.addEventListener("click", (e) => {
1243
+ function openPricing(e) {
1075
1244
  e.stopPropagation();
1076
1245
  const url = billingStatus && billingStatus.pricingUrl;
1077
1246
  if (!url) return;
@@ -1083,10 +1252,13 @@ export const HUD_HTML = String.raw `<!doctype html>
1083
1252
  .catch(() => {
1084
1253
  try { window.open(url, "_blank", "noopener"); } catch {}
1085
1254
  });
1086
- });
1087
- function selectSession(id) {
1255
+ }
1256
+ if (billingLink) billingLink.addEventListener("click", openPricing);
1257
+ if (quotaManage) quotaManage.addEventListener("click", openPricing);
1258
+ function toggleSessionDetails(id) {
1088
1259
  if (!id) return;
1089
- fetch("/select?id=" + encodeURIComponent(id))
1260
+ const nextId = lastState && lastState.expandedId === id ? "auto" : id;
1261
+ fetch("/expand?id=" + encodeURIComponent(nextId))
1090
1262
  .then(() => fetch("/state"))
1091
1263
  .then((r) => r.json())
1092
1264
  .then(render)
@@ -1100,7 +1272,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1100
1272
  event.stopPropagation();
1101
1273
  return;
1102
1274
  }
1103
- if (event.target instanceof HTMLElement && (event.target.closest(".switchbtn") || event.target.closest(".cornerbtn"))) return;
1275
+ if (event.target instanceof HTMLElement && event.target.closest(".cornerbtn")) return;
1104
1276
  hud.classList.toggle("open");
1105
1277
  if (hud.classList.contains("open")) maybeLoadDiagnostics();
1106
1278
  syncHeight();
@@ -1160,18 +1332,31 @@ export const HUD_HTML = String.raw `<!doctype html>
1160
1332
  saveCachedCheckpoint(renewSessionKey, prompt, promptKind, meta);
1161
1333
  if (promptKind === "cloud") saveCheckpointStatus(renewSessionKey, statusFromRenewData(renewData, meta));
1162
1334
  const doneSummary = checkpointSuffix(checkpointSummary({ renewedAt: Date.now(), memoryCount: meta.sessionMemoryCount }));
1335
+ let launchData = null;
1336
+ try {
1337
+ const launchRes = await fetch("/launch", {
1338
+ method: "POST",
1339
+ headers: { "Content-Type": "application/json" },
1340
+ body: JSON.stringify({ prompt, sourcePath: String((scoreAtRenew && scoreAtRenew.sourcePath) || "") }),
1341
+ });
1342
+ launchData = await launchRes.json();
1343
+ } catch {}
1344
+ const opened = Boolean(launchData && launchData.ok);
1345
+ let fallbackCopied = false;
1346
+ if (!opened) {
1347
+ try {
1348
+ await navigator.clipboard.writeText(prompt);
1349
+ fallbackCopied = true;
1350
+ } catch {}
1351
+ }
1352
+ const readyLabel = promptKind === "local" ? "Local capsule" : "Decision log";
1353
+ const openedLabel = launchData && launchData.mode === "claude-code" ? "Carryover sent in a fresh Claude Code session" : "Carryover sent in a fresh Codex session";
1163
1354
  showRenewResult({
1164
1355
  sessionKey: renewSessionKey,
1165
1356
  prompt,
1166
- message: (promptKind === "local" ? "Local capsule ready" : "Decision log ready") + doneSummary,
1357
+ message: (opened ? openedLabel : readyLabel + (fallbackCopied ? " copied" : " ready")) + doneSummary,
1167
1358
  canCopy: true,
1168
1359
  });
1169
- try {
1170
- await navigator.clipboard.writeText(prompt);
1171
- if (renewMsg) renewMsg.textContent = (promptKind === "local" ? "Local capsule copied" : "Decision log copied") + doneSummary;
1172
- } catch {
1173
- if (renewMsg) renewMsg.textContent = (promptKind === "local" ? "Local capsule ready" : "Decision log ready") + doneSummary;
1174
- }
1175
1360
  } catch {
1176
1361
  } finally {
1177
1362
  stopRenewTimer();
@@ -1534,13 +1719,25 @@ export const HUD_HTML = String.raw `<!doctype html>
1534
1719
  // skips the live feed — same pattern as the extraction page's ?preview hook.
1535
1720
  const previewParams = new URLSearchParams(location.search);
1536
1721
  const previewColor = previewParams.get("preview");
1537
- const previewPinned = previewParams.get("pinned") === "1";
1722
+ const previewExpanded = previewParams.get("expanded") === "1" || previewParams.get("pinned") === "1";
1538
1723
  if (previewParams.get("capture") === "1") hud.classList.add("capture-preview");
1539
1724
  if (previewColor === "green" || previewColor === "amber" || previewColor === "red") {
1725
+ const previewProcessingUsed = previewColor === "red" ? 200000 : previewColor === "amber" ? 163000 : 84000;
1726
+ const previewSearchUsed = previewColor === "red" ? 100 : previewColor === "amber" ? 82 : 32;
1727
+ billingStatus = {
1728
+ ok: true,
1729
+ state: "ok",
1730
+ plan: "pro",
1731
+ paid: true,
1732
+ pricingUrl: "https://yeahecho.com/pricing?source=hud_preview",
1733
+ memoryProcessingQuota: { used: previewProcessingUsed, limit: 200000, remaining: 200000 - previewProcessingUsed, resetAt: new Date(Date.now() + 31 * 60 * 60 * 1000).toISOString() },
1734
+ memorySearchQuota: { used: previewSearchUsed, limit: 100, remaining: 100 - previewSearchUsed, resetAt: new Date(Date.now() + 31 * 60 * 60 * 1000).toISOString() },
1735
+ historicalConversationQuota: { used: 191, limit: 250, remaining: 59 },
1736
+ };
1540
1737
  const sample = {
1541
1738
  client: "codex",
1542
1739
  label: "EchoMem-Chrome",
1543
- sourcePath: previewPinned ? "/preview/b.jsonl" : "/preview/session.jsonl",
1740
+ sourcePath: "/preview/session.jsonl",
1544
1741
  usefulPct: previewColor === "red" ? 55 : previewColor === "amber" ? 75 : 95,
1545
1742
  healthScorePct: previewColor === "red" ? 48 : previewColor === "amber" ? 72 : 96,
1546
1743
  color: previewColor,
@@ -1554,19 +1751,37 @@ export const HUD_HTML = String.raw `<!doctype html>
1554
1751
  lastActiveMs: 6000,
1555
1752
  ctSource: "measured",
1556
1753
  };
1754
+ const expandedSample = {
1755
+ client: "codex",
1756
+ label: "EchoMem-Chrome",
1757
+ sourcePath: "/preview/b.jsonl",
1758
+ usefulPct: 71,
1759
+ healthScorePct: 71,
1760
+ color: "amber",
1761
+ ctTokens: 193000,
1762
+ modelContextWindow: 258000,
1763
+ pollutionPct: 29,
1764
+ pollutionTok: 55970,
1765
+ buckets: { range_redundant: { count: 12 } },
1766
+ stats: { patchEdits: 22 },
1767
+ turn: 3,
1768
+ lastActiveMs: 480000,
1769
+ ctSource: "measured",
1770
+ };
1557
1771
  render({
1558
1772
  active: sample,
1559
- pinnedId: previewPinned ? "p2" : null,
1773
+ expanded: previewExpanded ? expandedSample : null,
1774
+ expandedId: previewExpanded ? "p2" : null,
1560
1775
  sessions: [
1561
1776
  sample,
1562
- { client: "codex", sourcePath: "/preview/b.jsonl", usefulPct: 71, healthScorePct: 71, color: "amber" },
1777
+ expandedSample,
1563
1778
  { client: "codex", sourcePath: "/preview/c.jsonl", usefulPct: 94, healthScorePct: 94, color: "green" },
1564
1779
  ],
1565
1780
  recentSessions: [
1566
1781
  { id: "p1", client: "codex", title: "Simplify HUD session display", label: "EchoMem-Chrome", sourcePath: "/preview/session.jsonl", live: true, lastActiveMs: 4000 },
1567
1782
  { id: "p2", client: "codex", title: "修复 HUD 切换和图标", label: "EchoMem-Chrome", sourcePath: "/preview/b.jsonl", live: false, lastActiveMs: 480000 },
1568
1783
  { id: "p3", client: "codex", title: "Fix extraction preview", label: "EchoMem-Chrome", sourcePath: "/preview/c.jsonl", live: false, lastActiveMs: 2400000 },
1569
- { id: "p4", client: "codex", title: "Port Claude work", label: "EchoForPeople", sourcePath: "/preview/d.jsonl", live: false, lastActiveMs: 9000000 },
1784
+ { id: "p4", client: "claude-code", title: "Port Claude work", label: "EchoForPeople", sourcePath: "/preview/d.jsonl", live: false, lastActiveMs: 9000000 },
1570
1785
  { id: "p5", client: "codex", title: "Stamp fixes with Echo mascot", label: "EchoMem-Chrome", sourcePath: "/preview/e.jsonl", live: false, lastActiveMs: 16200000 },
1571
1786
  { id: "p6", client: "codex", title: "Review migration checks", label: "EchoForPeople", sourcePath: "/preview/f.jsonl", live: false, lastActiveMs: 21600000 },
1572
1787
  { id: "p7", client: "codex", title: "Polish setup story", label: "EchoMem-Chrome", sourcePath: "/preview/g.jsonl", live: false, lastActiveMs: 34200000 },
@@ -1601,9 +1816,8 @@ export const HUD_HTML = String.raw `<!doctype html>
1601
1816
  const currentSessionKey = sessionKeyFor(state, score);
1602
1817
  const viewColor = score && typeof score.usefulPct === "number" ? (score.color || "green") : "idle";
1603
1818
  if (renewResultSessionKey && renewResultSessionKey !== currentSessionKey) clearRenewResult();
1604
- const activeId = score && score.sourcePath ? activeIdFor(state, score.sourcePath, activeClient) : null;
1605
- renderTabs(state, activeId, activeClient);
1606
- renderSwitchButton(state, activeClient);
1819
+ const activeId = score && score.sourcePath ? activeIdFor(state, score.sourcePath) : null;
1820
+ renderTabs(state, activeId);
1607
1821
  if (!score || typeof score.usefulPct !== "number") {
1608
1822
  renderIdle(state);
1609
1823
  return;
@@ -1612,7 +1826,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1612
1826
  const color = score.color || "green";
1613
1827
  const sat = fullnessPct(score);
1614
1828
  const oldPct = clamp(score.pollutionPct || 0, 0, 95);
1615
- const health = healthScore(score);
1829
+ const signal = usefulSignalPct(score);
1616
1830
  const repeatCount = score.buckets && score.buckets.range_redundant ? score.buckets.range_redundant.count || 0 : 0;
1617
1831
  const info = activeSessionInfo(score, state);
1618
1832
 
@@ -1620,8 +1834,8 @@ export const HUD_HTML = String.raw `<!doctype html>
1620
1834
  stateDot.className = "state-dot " + color;
1621
1835
  hud.classList.remove("s-green", "s-amber", "s-red");
1622
1836
  hud.classList.add("s-" + color);
1623
- main.innerHTML = health + '%<span class="main-rest"> of tokens are useful</span>';
1624
- renderMini(color, health + "%", info);
1837
+ main.innerHTML = signal + '%<span class="main-rest"> useful</span>';
1838
+ renderMini(color, signal + "%", info);
1625
1839
  client.textContent = info.title || info.repo;
1626
1840
  if (bubbleAgent) {
1627
1841
  bubbleAgent.src = iconForFamily(agentFamily(score.client) || "codex");
@@ -1637,18 +1851,16 @@ export const HUD_HTML = String.raw `<!doctype html>
1637
1851
  if (score.ctTokens > 0) metaParts.push(fmt(score.ctTokens) + " tok");
1638
1852
  metaTail = metaParts.join(" · ");
1639
1853
  renderMetaLine();
1640
- stateWordEl.textContent = health + "%";
1641
- if (verdictEl) verdictEl.textContent = verdictFor(color, oldPct);
1854
+ stateWordEl.innerHTML = '<span class="snr-prefix">SNR</span>' + signal + '%<span class="signal-label">useful</span>';
1642
1855
  if (nowIcon) nowIcon.src = iconForFamily(agentFamily(score.client) || "codex");
1643
1856
  if (nowTitle) nowTitle.textContent = info.title;
1644
1857
  if (nowRepo) nowRepo.textContent = bubbleRepo;
1645
1858
  if (nowRepoLine) nowRepoLine.style.display = bubbleRepo ? "flex" : "none";
1646
- if (pinChip) pinChip.style.display = state && state.pinnedId ? "inline-block" : "none";
1647
1859
  if (jumpChip) {
1648
1860
  if (activeId) jumpChip.setAttribute("data-jump", activeId);
1649
1861
  jumpChip.style.display = activeId ? "inline-block" : "none";
1650
1862
  }
1651
- renderWhy(score, oldPct, repeatCount, sat, health, color);
1863
+ renderWhy(score, oldPct, repeatCount, sat, signal, color);
1652
1864
  renderBillingStatus();
1653
1865
  pathEl.textContent = "";
1654
1866
  currentSrc = score.sourcePath || null;
@@ -1699,11 +1911,9 @@ export const HUD_HTML = String.raw `<!doctype html>
1699
1911
  currentSrc = null;
1700
1912
  if (revealEl) revealEl.style.display = "none";
1701
1913
  stateWordEl.textContent = "on";
1702
- if (verdictEl) verdictEl.textContent = "Waiting for Codex or Claude.";
1703
1914
  if (nowTitle) nowTitle.textContent = "EchoMem MCP";
1704
1915
  if (nowRepo) nowRepo.textContent = "";
1705
1916
  if (nowRepoLine) nowRepoLine.style.display = "none";
1706
- if (pinChip) pinChip.style.display = "none";
1707
1917
  if (jumpChip) jumpChip.style.display = "none";
1708
1918
  evidence.textContent = "";
1709
1919
  if (whyBtn) whyBtn.style.display = "none";
@@ -1734,32 +1944,110 @@ export const HUD_HTML = String.raw `<!doctype html>
1734
1944
 
1735
1945
  function renderBillingStatus() {
1736
1946
  if (!billingBanner) return;
1947
+ renderQuotaUsage();
1737
1948
  const state = billingStatus && billingStatus.state;
1738
- const blocked = state === "plan_required" || state === "quota_exceeded" || state === "billing_attention";
1949
+ const blocked = state === "quota_exceeded" || state === "billing_attention";
1739
1950
  billingBanner.style.display = blocked ? "block" : "none";
1740
1951
  billingBanner.classList.toggle("limit", state === "quota_exceeded");
1741
1952
  if (!blocked) return;
1742
- const title = state === "quota_exceeded"
1743
- ? "Recall limit reached"
1744
- : state === "plan_required"
1745
- ? "Recall paused · Free plan"
1746
- : billingStatus.message || "Billing needs attention";
1747
- const detail = state === "quota_exceeded"
1748
- ? "You used all Pro searches this month. Upgrade or wait for reset."
1749
- : state === "plan_required"
1750
- ? "Search memory requires Pro or Power. Saving still works."
1751
- : billingStatus.detail || "Open pricing to restore memory search.";
1953
+ const title = billingStatus.message || (state === "quota_exceeded" ? "Plan limit reached" : "Billing needs attention");
1954
+ const detail = billingStatus.detail || (state === "quota_exceeded"
1955
+ ? "A weekly limit has been reached. Wait for reset or change your plan."
1956
+ : "Open pricing to review your plan.");
1752
1957
  if (billingTitle) billingTitle.textContent = title;
1753
1958
  if (billingCopy) billingCopy.textContent = detail;
1754
1959
  }
1755
1960
 
1756
- function renderWhy(score, oldPct, repeatCount, sat, health, color) {
1961
+ function renderQuotaUsage() {
1962
+ if (!quotaCard) return;
1963
+ const status = billingStatus || {};
1964
+ const quotaRows = [
1965
+ ["processing", status.memoryProcessingQuota, false],
1966
+ ["searches", status.memorySearchQuota, false],
1967
+ ["historical", status.historicalConversationQuota, true],
1968
+ ];
1969
+ const hasQuota = quotaRows.some((entry) => entry[1] && typeof entry[1] === "object");
1970
+ const visible = hasQuota && status.state !== "not_connected" && status.plan !== "unknown";
1971
+ quotaCard.style.display = visible ? "block" : "none";
1972
+ if (!visible) return;
1973
+ if (quotaPlan) quotaPlan.textContent = planLabel(status.plan || "free");
1974
+ for (const entry of quotaRows) renderQuotaRow(entry[0], entry[1], entry[2]);
1975
+ }
1976
+
1977
+ function renderQuotaRow(key, snapshot, lifetime) {
1978
+ if (!quotaCard) return;
1979
+ const row = quotaCard.querySelector('[data-quota="' + key + '"]');
1980
+ if (!row) return;
1981
+ const valueEl = row.querySelector(".quota-value");
1982
+ const fillEl = row.querySelector(".quota-fill");
1983
+ const resetEl = row.querySelector(".quota-reset");
1984
+ const percentEl = row.querySelector(".quota-percent");
1985
+ const used = quotaNumber(snapshot && snapshot.used);
1986
+ const limit = quotaNumber(snapshot && snapshot.limit);
1987
+ const available = used !== null && limit !== null;
1988
+ const unlimited = available && limit < 0;
1989
+ const percent = available && limit > 0 ? clamp(Math.round(used / limit * 100), 0, 100) : 0;
1990
+
1991
+ row.classList.toggle("is-unavailable", !available);
1992
+ row.classList.toggle("is-warn", available && !unlimited && percent >= 80 && percent < 100);
1993
+ row.classList.toggle("is-limit", available && !unlimited && percent >= 100);
1994
+ if (valueEl) {
1995
+ valueEl.textContent = unlimited
1996
+ ? formatQuotaNumber(used) + " / Unlimited"
1997
+ : (used === null ? "—" : formatQuotaNumber(used)) + " / " + (limit === null ? "—" : formatQuotaNumber(limit));
1998
+ }
1999
+ if (fillEl) fillEl.style.width = unlimited ? "0%" : percent + "%";
2000
+ if (percentEl) percentEl.textContent = !available
2001
+ ? "Usage unavailable"
2002
+ : unlimited
2003
+ ? "Unlimited"
2004
+ : percent >= 100
2005
+ ? "Limit reached"
2006
+ : percent + "%";
2007
+ if (resetEl) resetEl.textContent = lifetime ? "Lifetime limit" : quotaResetLabel(snapshot && snapshot.resetAt);
2008
+ row.setAttribute("aria-label", key + " usage " + (percentEl ? percentEl.textContent : ""));
2009
+ }
2010
+
2011
+ function quotaNumber(value) {
2012
+ return typeof value === "number" && isFinite(value) ? value : null;
2013
+ }
2014
+
2015
+ function formatQuotaNumber(value) {
2016
+ const n = Math.max(0, Number(value) || 0);
2017
+ if (n >= 1000000) return (n / 1000000).toFixed(n < 10000000 ? 1 : 0).replace(/\.0$/, "") + "M";
2018
+ if (n >= 1000) return (n / 1000).toFixed(n < 100000 ? 1 : 0).replace(/\.0$/, "") + "K";
2019
+ return Math.round(n).toLocaleString("en-US");
2020
+ }
2021
+
2022
+ function quotaResetLabel(value) {
2023
+ if (!value) return "Weekly reset";
2024
+ const resetAt = new Date(value);
2025
+ if (!isFinite(resetAt.getTime())) return "Weekly reset";
2026
+ if (resetAt.getTime() <= Date.now()) return "Resetting now";
2027
+ return "Resets " + resetAt.toLocaleString(undefined, {
2028
+ weekday: "short",
2029
+ month: "short",
2030
+ day: "numeric",
2031
+ hour: "numeric",
2032
+ minute: "2-digit",
2033
+ timeZoneName: "short",
2034
+ });
2035
+ }
2036
+
2037
+ function renderWhy(score, oldPct, repeatCount, sat, signal, color) {
1757
2038
  const rows = [];
1758
- if (sat > 0) rows.push(["context", score.modelContextWindow ? sat + "% full · " + fmt(score.ctTokens) : fmt(score.ctTokens) + " tokens"]);
1759
- if (score.pollutionTok > 0) rows.push(["junk", fmt(score.pollutionTok) + " (" + Math.round(oldPct) + "% of window)"]);
1760
2039
  for (const row of junkBreakdown(score)) rows.push(row);
1761
2040
  rows.push(["source", ctSourceLabel(score.ctSource)]);
1762
- evidence.innerHTML = rows.map((row) => '<div class="why-line"><span class="why-k">' + esc(row[0]) + '</span><span class="why-v">' + esc(row[1]) + "</span></div>").join("");
2041
+ const contextTokens = Math.max(0, Number(score.ctTokens) || 0);
2042
+ const junkTokens = Math.min(contextTokens, Math.max(0, Number(score.pollutionTok) || 0));
2043
+ const equation = contextTokens > 0
2044
+ ? '<div class="snr-equation" aria-label="One minus ' + fmtExact(junkTokens) + ' junk divided by ' + fmtExact(contextTokens) + ' in context equals ' + signal + ' percent SNR">' +
2045
+ '<span>1 &minus;</span>' +
2046
+ '<span class="snr-fraction"><span class="snr-numerator">' + fmtExact(junkTokens) + ' junk</span><span class="snr-denominator">' + fmtExact(contextTokens) + ' in context</span></span>' +
2047
+ '<span class="snr-result">= ' + signal + '% SNR</span>' +
2048
+ '</div>'
2049
+ : '';
2050
+ evidence.innerHTML = equation + rows.map((row) => '<div class="why-line"><span class="why-k">' + esc(row[0]) + '</span><span class="why-v">' + esc(row[1]) + "</span></div>").join("");
1763
2051
  const useful = color !== "green" || oldPct >= 10 || repeatCount > 0 || sat >= 80;
1764
2052
  if (whyBtn) {
1765
2053
  whyBtn.style.display = useful ? "inline-flex" : "none";
@@ -1794,9 +2082,10 @@ export const HUD_HTML = String.raw `<!doctype html>
1794
2082
  return 0;
1795
2083
  }
1796
2084
 
1797
- function healthScore(score) {
1798
- if (typeof score.healthScorePct === "number" && isFinite(score.healthScorePct)) return clamp(score.healthScorePct, 0, 100);
2085
+ function usefulSignalPct(score) {
1799
2086
  if (typeof score.usefulPct === "number" && isFinite(score.usefulPct)) return clamp(score.usefulPct, 0, 100);
2087
+ if (typeof score.pollutionPct === "number" && isFinite(score.pollutionPct)) return clamp(100 - score.pollutionPct, 0, 100);
2088
+ if (typeof score.healthScorePct === "number" && isFinite(score.healthScorePct)) return clamp(score.healthScorePct, 0, 100);
1800
2089
  return 0;
1801
2090
  }
1802
2091
 
@@ -1829,44 +2118,23 @@ export const HUD_HTML = String.raw `<!doctype html>
1829
2118
  return title || info.client;
1830
2119
  }
1831
2120
 
1832
- function renderSwitchButton(state, activeClient) {
1833
- if (!switchBtn) return;
1834
- const target = switchTarget(state, activeClient);
1835
- if (!target) {
1836
- switchBtn.style.display = "none";
1837
- switchBtn.removeAttribute("data-target-family");
1838
- switchBtn.removeAttribute("data-target-id");
1839
- switchBtn.innerHTML = "";
2121
+ // History rows: recent sessions that are NOT the newest one in the top card. Clicking a row
2122
+ // expands it in place without changing this newest-first sequence. Codex and Claude share one
2123
+ // timeline; their icons preserve source identity.
2124
+ function renderTabs(state, activeId) {
2125
+ if (!tabsEl) return;
2126
+ const anchor = captureTimelineAnchor();
2127
+ const list = allRecentSessions(state).filter((s) => s.id !== activeId);
2128
+ if (!list.length) {
2129
+ tabsEl.innerHTML = "";
2130
+ tabsEl.scrollTop = 0;
2131
+ tabsEl.classList.remove("has-more-above", "has-more-below");
2132
+ if (timelineScrollbar) timelineScrollbar.classList.add("hidden");
1840
2133
  return;
1841
2134
  }
1842
- const family = agentFamily(target.client);
1843
- const name = family === "claude" ? "Claude" : "Codex";
1844
- switchBtn.dataset.targetFamily = family;
1845
- switchBtn.dataset.targetId = target.id;
1846
- switchBtn.title = "Switch to " + name;
1847
- switchBtn.innerHTML = '<img class="switch-icon" src="' + iconForFamily(family) + '" alt="" /><span class="switch-copy">Switch to ' + name + "</span>";
1848
- switchBtn.style.display = "inline-flex";
1849
- }
1850
-
1851
- function switchTarget(state, activeClient) {
1852
- const list = (state && state.recentSessions) || [];
1853
- if (!list.length) return null;
1854
- const currentFamily = agentFamily(activeClient || (state.active && state.active.client));
1855
- const targetFamily = currentFamily === "codex" ? "claude" : currentFamily === "claude" ? "codex" : "";
1856
- if (!targetFamily) return null;
1857
- return list.filter((s) => agentFamily(s.client) === targetFamily)[0] || null;
1858
- }
1859
-
1860
- // History rows: recent sessions that are NOT the current one (the current session lives in the
1861
- // now-card above — every number and action up there belongs to it). Clicking a row pins it as
1862
- // the new current session; the card's "pinned ×" chip releases back to auto-follow. Scored
1863
- // sessions carry their own health dot + %, so "Echo is following you across agents" reads off
1864
- // the list itself. Family-scoped, so Codex never recommends Claude sessions or vice versa.
1865
- function renderTabs(state, activeId, activeClient) {
1866
- if (!tabsEl) return;
1867
- const list = visibleRecentSessions(state, activeClient).filter((s) => s.id !== activeId);
1868
- if (!list.length) { tabsEl.innerHTML = ""; return; }
1869
2135
  const chips = list.map((s) => {
2136
+ const expanded = Boolean(state && state.expandedId === s.id);
2137
+ const expandedView = expanded ? expandedSessionFor(state, s) : null;
1870
2138
  const activity = s.live ? '<span class="spin"></span>' : "";
1871
2139
  const hv = sessionHealthFor(state, s);
1872
2140
  const status = hv
@@ -1879,12 +2147,114 @@ export const HUD_HTML = String.raw `<!doctype html>
1879
2147
  ? '<span class="tab-repo"><img class="tab-gh" src="/assets/hud/github.svg" alt="" /><span class="tab-reponame">' + esc(repo) + "</span></span>"
1880
2148
  : '<span class="tab-repo"><span class="tab-reponame">' + esc(label(s.client)) + "</span></span>";
1881
2149
  const time = typeof s.lastActiveMs === "number" ? '<span class="tab-time" title="Last active here">' + esc(relTime(s.lastActiveMs)) + "</span>" : "";
1882
- return '<button class="tab" data-id="' + esc(s.id) + '" title="' + esc("Switch to · " + label(s.client) + " · " + title + (repo ? " · " + repo : "")) + '">'
2150
+ const detail = expanded ? expandedSessionDetail(expandedView, s) : "";
2151
+ const action = expanded ? "Hide details" : "Show details";
2152
+ return '<button class="tab' + (expanded ? " expanded" : "") + '" data-id="' + esc(s.id) + '" aria-expanded="' + (expanded ? "true" : "false") + '" title="' + esc(action + " · " + label(s.client) + " · " + title + (repo ? " · " + repo : "")) + '">'
1883
2153
  + '<img class="tab-icon" src="' + iconForFamily(family) + '" alt="" />'
1884
2154
  + '<span class="tab-copy"><span class="tab-title">' + esc(title) + activity + "</span>" + repoCell + "</span>" + status + time
1885
- + '<span class="tab-jump" data-jump="' + esc(s.id) + '" title="Open this conversation in ' + esc(label(s.client)) + '">&#8599;</span></button>';
2155
+ + '<span class="tab-jump" data-jump="' + esc(s.id) + '" title="Open this conversation in ' + esc(label(s.client)) + '">&#8599;</span>'
2156
+ + detail + "</button>";
1886
2157
  });
1887
2158
  tabsEl.innerHTML = chips.join("");
2159
+ restoreTimelineAnchor(anchor);
2160
+ }
2161
+ function expandedSessionFor(state, session) {
2162
+ const view = state && state.expanded;
2163
+ return view && view.sourcePath === session.sourcePath && view.client === session.client ? view : null;
2164
+ }
2165
+ function expandedSessionDetail(view, session) {
2166
+ if (!view) return '<span class="tab-detail"><span class="tab-detail-score">Loading SNR&hellip;</span></span>';
2167
+ const meta = [];
2168
+ if (typeof session.lastActiveMs === "number") meta.push(relTime(session.lastActiveMs));
2169
+ if (typeof view.turn === "number" && view.turn > 0) meta.push("turn " + view.turn);
2170
+ if (Number(view.ctTokens) > 0) meta.push(fmt(view.ctTokens) + " tok");
2171
+ return '<span class="tab-detail"><span class="tab-detail-score">SNR ' + usefulSignalPct(view) + '% useful</span>'
2172
+ + '<span class="tab-detail-meta">' + esc(meta.join(" · ")) + "</span></span>";
2173
+ }
2174
+ function captureTimelineAnchor() {
2175
+ if (!tabsEl || !tabsEl.children.length) return null;
2176
+ const viewportTop = tabsEl.getBoundingClientRect().top;
2177
+ const rows = Array.from(tabsEl.querySelectorAll(".tab"));
2178
+ const row = rows.find((item) => item.getBoundingClientRect().bottom > viewportTop + 1);
2179
+ if (!row) return { id: "", offset: 0, scrollTop: tabsEl.scrollTop };
2180
+ return {
2181
+ id: row.getAttribute("data-id") || "",
2182
+ offset: row.getBoundingClientRect().top - viewportTop,
2183
+ scrollTop: tabsEl.scrollTop,
2184
+ };
2185
+ }
2186
+ function restoreTimelineAnchor(anchor) {
2187
+ if (!tabsEl) return;
2188
+ const maxScroll = Math.max(0, tabsEl.scrollHeight - tabsEl.clientHeight);
2189
+ if (!anchor) {
2190
+ tabsEl.scrollTop = Math.min(tabsEl.scrollTop, maxScroll);
2191
+ requestAnimationFrame(syncTabsOverflowCue);
2192
+ return;
2193
+ }
2194
+ const row = Array.from(tabsEl.querySelectorAll(".tab")).find((item) => item.getAttribute("data-id") === anchor.id);
2195
+ if (row) {
2196
+ const nextOffset = row.getBoundingClientRect().top - tabsEl.getBoundingClientRect().top;
2197
+ tabsEl.scrollTop = clamp(tabsEl.scrollTop + nextOffset - anchor.offset, 0, maxScroll);
2198
+ } else {
2199
+ tabsEl.scrollTop = clamp(anchor.scrollTop, 0, maxScroll);
2200
+ }
2201
+ requestAnimationFrame(syncTabsOverflowCue);
2202
+ }
2203
+ function syncTabsOverflowCue() {
2204
+ if (!tabsEl) return;
2205
+ const remaining = tabsEl.scrollHeight - tabsEl.scrollTop - tabsEl.clientHeight;
2206
+ tabsEl.classList.toggle("has-more-above", tabsEl.scrollTop > 3);
2207
+ tabsEl.classList.toggle("has-more-below", remaining > 3);
2208
+ syncTimelineScrollbar();
2209
+ }
2210
+ function syncTimelineScrollbar() {
2211
+ if (!tabsEl || !timelineScrollbar || !timelineThumb) return;
2212
+ const maxScroll = Math.max(0, tabsEl.scrollHeight - tabsEl.clientHeight);
2213
+ const trackHeight = timelineScrollbar.clientHeight;
2214
+ const scrollable = maxScroll > 1 && trackHeight > 0;
2215
+ timelineScrollbar.classList.toggle("hidden", !scrollable);
2216
+ if (!scrollable) {
2217
+ timelineThumb.style.height = "100%";
2218
+ timelineThumb.style.transform = "translateY(0)";
2219
+ return;
2220
+ }
2221
+ const thumbHeight = Math.max(30, Math.round(trackHeight * tabsEl.clientHeight / tabsEl.scrollHeight));
2222
+ const travel = Math.max(0, trackHeight - thumbHeight);
2223
+ const offset = travel * tabsEl.scrollTop / maxScroll;
2224
+ timelineThumb.style.height = thumbHeight + "px";
2225
+ timelineThumb.style.transform = "translateY(" + Math.round(offset) + "px)";
2226
+ }
2227
+ function beginTimelineScroll(event) {
2228
+ if (!tabsEl || !timelineScrollbar || !timelineThumb || event.button !== 0) return;
2229
+ const thumbRect = timelineThumb.getBoundingClientRect();
2230
+ const onThumb = event.target === timelineThumb;
2231
+ timelineScrollDrag = {
2232
+ pointerId: event.pointerId,
2233
+ grabOffset: onThumb ? event.clientY - thumbRect.top : thumbRect.height / 2,
2234
+ };
2235
+ try { timelineScrollbar.setPointerCapture(event.pointerId); } catch {}
2236
+ if (!onThumb) setTimelineScrollFromPointer(event.clientY);
2237
+ event.preventDefault();
2238
+ event.stopPropagation();
2239
+ }
2240
+ function moveTimelineScroll(event) {
2241
+ if (!timelineScrollDrag || event.pointerId !== timelineScrollDrag.pointerId) return;
2242
+ setTimelineScrollFromPointer(event.clientY);
2243
+ event.preventDefault();
2244
+ }
2245
+ function endTimelineScroll(event) {
2246
+ if (!timelineScrollDrag || event.pointerId !== timelineScrollDrag.pointerId) return;
2247
+ timelineScrollDrag = null;
2248
+ try { timelineScrollbar.releasePointerCapture(event.pointerId); } catch {}
2249
+ }
2250
+ function setTimelineScrollFromPointer(clientY) {
2251
+ if (!tabsEl || !timelineScrollbar || !timelineThumb || !timelineScrollDrag) return;
2252
+ const trackRect = timelineScrollbar.getBoundingClientRect();
2253
+ const thumbHeight = timelineThumb.getBoundingClientRect().height;
2254
+ const travel = Math.max(0, trackRect.height - thumbHeight);
2255
+ const maxScroll = Math.max(0, tabsEl.scrollHeight - tabsEl.clientHeight);
2256
+ const offset = clamp(clientY - trackRect.top - timelineScrollDrag.grabOffset, 0, travel);
2257
+ tabsEl.scrollTop = travel > 0 ? maxScroll * offset / travel : 0;
1888
2258
  }
1889
2259
  // Health for a listed session, joined from the scored views the monitor already computes.
1890
2260
  // Sessions Echo isn't actively scoring get no dot — honest absence, not a fake green.
@@ -1892,16 +2262,13 @@ export const HUD_HTML = String.raw `<!doctype html>
1892
2262
  const views = ((state && state.sessions) || []).concat((state && state.scores) || []);
1893
2263
  const m = views.filter((v) => v && v.sourcePath === s.sourcePath && v.client === s.client && typeof v.usefulPct === "number")[0];
1894
2264
  if (!m) return null;
1895
- return { color: m.color || "green", health: healthScore(m) };
2265
+ return { color: m.color || "green", health: usefulSignalPct(m) };
1896
2266
  }
1897
- function visibleRecentSessions(state, activeClient) {
1898
- const list = (state && state.recentSessions) || [];
1899
- const family = agentFamily(activeClient);
1900
- if (family !== "codex" && family !== "claude") return list;
1901
- return list.filter((s) => agentFamily(s.client) === family);
2267
+ function allRecentSessions(state) {
2268
+ return (state && state.recentSessions) || [];
1902
2269
  }
1903
- function activeIdFor(state, src, activeClient) {
1904
- const list = visibleRecentSessions(state, activeClient);
2270
+ function activeIdFor(state, src) {
2271
+ const list = allRecentSessions(state);
1905
2272
  const match = list.filter((s) => s.sourcePath === src)[0];
1906
2273
  return match ? match.id : null;
1907
2274
  }
@@ -1927,14 +2294,6 @@ export const HUD_HTML = String.raw `<!doctype html>
1927
2294
  const upd = activityEpoch === null ? "" : relTime(Date.now() - activityEpoch);
1928
2295
  metaLineEl.textContent = [upd, metaTail].filter(Boolean).join(" · ");
1929
2296
  }
1930
- // One plain sentence saying what the score means for THIS session. Direct, no hedging,
1931
- // and no second number — the score above is the only number on the card.
1932
- function verdictFor(color, oldPct) {
1933
- const usefulPct = 100 - clamp(oldPct || 0, 0, 100);
1934
- if (color === "red") return usefulPct <= 20 ? "Mostly junk — carry over now." : "Too much junk – consider to carry over.";
1935
- if (color === "amber") return "Junk is piling up.";
1936
- return "Context is clean.";
1937
- }
1938
2297
  function relTime(ms) {
1939
2298
  const s = Math.max(0, Math.round(ms / 1000));
1940
2299
  if (s < 5) return "now";
@@ -1959,6 +2318,10 @@ export const HUD_HTML = String.raw `<!doctype html>
1959
2318
  return String(Math.round(n));
1960
2319
  }
1961
2320
 
2321
+ function fmtExact(tokens) {
2322
+ return Math.max(0, Math.round(Number(tokens) || 0)).toLocaleString("en-US");
2323
+ }
2324
+
1962
2325
  function clamp(value, min, max) {
1963
2326
  return Math.max(min, Math.min(max, Number(value) || 0));
1964
2327
  }