@echomem/mcp 1.4.8 → 1.4.10

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.
Files changed (64) hide show
  1. package/README.md +23 -3
  2. package/assets/canonical-scorer/README.md +18 -0
  3. package/assets/canonical-scorer/analyze-10-problems.mjs +857 -0
  4. package/assets/canonical-scorer/build-session-waste-dashboard.mjs +1628 -0
  5. package/assets/canonical-scorer/golden_anchors.mjs +83 -0
  6. package/assets/canonical-scorer/optimizable_detail.mjs +633 -0
  7. package/assets/hud/github.svg +1 -0
  8. package/dist/city/README.md +9 -0
  9. package/dist/city/echo-ai-city-only.html +1067 -107
  10. package/dist/codex-session-files.js +283 -0
  11. package/dist/codex-sync.js +7 -2
  12. package/dist/context-analysis/canonical-golden.js +47 -0
  13. package/dist/context-analysis/claude-canonical-adapter.js +315 -0
  14. package/dist/context-analysis/claude-native-canonical.js +1216 -0
  15. package/dist/context-analysis/vendored-canonical.js +793 -0
  16. package/dist/context-analysis/workspace-report.js +1838 -0
  17. package/dist/context-metrics/calculate.js +43 -0
  18. package/dist/context-metrics/estimator.js +45 -0
  19. package/dist/context-metrics/ledger.js +507 -0
  20. package/dist/context-metrics/model-limits.js +26 -0
  21. package/dist/context-metrics/parse-claude.js +227 -0
  22. package/dist/context-metrics/parse-codex.js +276 -0
  23. package/dist/context-metrics/types.js +1 -0
  24. package/dist/forensics-10-problems.js +7 -6
  25. package/dist/forensics.js +863 -132
  26. package/dist/hud/adapters.js +77 -202
  27. package/dist/hud/cli.js +0 -0
  28. package/dist/hud/efficiency.js +447 -0
  29. package/dist/hud/electron-main.js +3 -2
  30. package/dist/hud/fs.js +14 -0
  31. package/dist/hud/metric.js +17 -102
  32. package/dist/hud/monitor.js +136 -28
  33. package/dist/hud/render.js +4 -3
  34. package/dist/hud/server.js +30 -0
  35. package/dist/hud/web.js +622 -353
  36. package/dist/index.js +7 -3
  37. package/dist/local-data-paths.js +87 -0
  38. package/dist/migrate.js +37 -29
  39. package/dist/report.js +101 -40
  40. package/dist/setup-page/client-core.js +475 -0
  41. package/dist/setup-page/client-extraction.js +550 -0
  42. package/dist/setup-page/client-lifecycle.js +116 -0
  43. package/dist/setup-page/client-report-audit.js +818 -0
  44. package/dist/setup-page/client-report-city.js +204 -0
  45. package/dist/setup-page/client-report.js +6 -0
  46. package/dist/setup-page/client.js +15 -0
  47. package/dist/setup-page/document.js +37 -0
  48. package/dist/setup-page/styles-city-report.js +880 -0
  49. package/dist/setup-page/styles-context-audit.js +470 -0
  50. package/dist/setup-page/styles-extraction.js +821 -0
  51. package/dist/setup-page/styles-foundation.js +231 -0
  52. package/dist/setup-page/styles.js +11 -0
  53. package/dist/setup-page.js +15 -2538
  54. package/dist/setup-preview.js +245 -0
  55. package/dist/setup.js +456 -44
  56. package/package.json +8 -7
  57. package/templates/echomem-recall.md +2 -2
  58. package/dist/city/10-problems-report.html +0 -649
  59. package/dist/city/_live.html +0 -37
  60. package/dist/city/_serve.mjs +0 -45
  61. package/dist/city/card-data.json +0 -15
  62. package/dist/city/city-data.json +0 -248
  63. package/dist/city/echo-ai-city-only.template.html +0 -1272
  64. package/dist/city/generate-echo-city-only.mjs +0 -112
package/dist/hud/web.js CHANGED
@@ -1,4 +1,3 @@
1
- import { MCP_PACKAGE_VERSION } from "../package-metadata.js";
2
1
  export const HUD_HTML = String.raw `<!doctype html>
3
2
  <html lang="en">
4
3
  <head>
@@ -39,6 +38,11 @@ export const HUD_HTML = String.raw `<!doctype html>
39
38
  -webkit-font-smoothing: antialiased;
40
39
  cursor: grab;
41
40
  }
41
+ #hud.capture-preview {
42
+ position: fixed;
43
+ top: 0;
44
+ left: 0;
45
+ }
42
46
  #hud.dragging { cursor: grabbing; }
43
47
  .glance {
44
48
  position: relative;
@@ -110,6 +114,9 @@ export const HUD_HTML = String.raw `<!doctype html>
110
114
  filter: var(--shadow);
111
115
  transition: border-color 0.18s ease, background 0.18s ease, filter 0.18s ease;
112
116
  }
117
+ /* Open = the card is the voice. Echo doesn't say the same thing twice, so the speech
118
+ bubble steps aside while the panel is up and comes back when it closes. */
119
+ #hud.open .status-bubble { display: none; }
113
120
  .status-bubble::after {
114
121
  content: "";
115
122
  position: absolute;
@@ -129,24 +136,31 @@ export const HUD_HTML = String.raw `<!doctype html>
129
136
  min-width: 0;
130
137
  display: grid;
131
138
  grid-template-columns: minmax(0, 1fr);
132
- gap: 4px;
133
- padding: 20px 38px 18px 30px;
139
+ gap: 2px;
140
+ padding: 15px 38px 11px 30px;
134
141
  }
135
142
  .dot {
136
143
  position: absolute;
137
- left: 14px;
138
- top: 23px;
144
+ left: 13px;
145
+ top: 22px;
139
146
  z-index: 3;
140
- width: 10px;
141
- height: 10px;
147
+ width: 12px;
148
+ height: 12px;
142
149
  border: 2px solid #fdfaf2;
143
150
  border-radius: 999px;
144
151
  background: var(--green);
145
152
  box-shadow: 0 0 0 1px rgba(26, 58, 143, 0.14);
146
153
  }
147
154
  .dot.amber { background: var(--amber); }
148
- .dot.red { background: #ff5f57; }
149
- .dot.idle { background: #c9c5b8; }
155
+ .dot.red, .state-dot.red, #miniPill .mdot.red {
156
+ background: #ff5f57;
157
+ animation: red-pulse 1.15s ease-in-out infinite;
158
+ }
159
+ .dot.idle { background: var(--green); }
160
+ @keyframes red-pulse {
161
+ 0%, 100% { box-shadow: 0 0 0 1px rgba(199, 55, 47, 0.18), 0 0 0 0 rgba(255, 95, 87, 0.34); transform: scale(1); }
162
+ 50% { box-shadow: 0 0 0 1px rgba(199, 55, 47, 0.18), 0 0 0 7px rgba(255, 95, 87, 0); transform: scale(1.18); }
163
+ }
150
164
  .main {
151
165
  min-width: 0;
152
166
  overflow: hidden;
@@ -158,19 +172,30 @@ export const HUD_HTML = String.raw `<!doctype html>
158
172
  line-height: 1.1;
159
173
  color: var(--ink);
160
174
  }
175
+ .main-rest { font-size: 10.5px; font-weight: 680; color: var(--muted); }
176
+ .client-line { display: flex; align-items: center; gap: 5px; min-width: 0; }
177
+ .line-icon { width: 11px; height: 11px; flex: 0 0 auto; object-fit: contain; opacity: .85; }
161
178
  .client {
162
179
  min-width: 0;
163
180
  overflow: hidden;
164
181
  text-overflow: ellipsis;
165
182
  white-space: nowrap;
166
- font-family: "JetBrains Mono", ui-monospace, "SF Mono", Menlo, monospace;
167
- font-size: 10px;
168
- letter-spacing: 0.12em;
183
+ font-size: 10.5px;
184
+ font-weight: 720;
185
+ letter-spacing: 0;
186
+ line-height: 1;
187
+ color: var(--faint);
188
+ }
189
+ .client-repo {
190
+ min-width: 0;
191
+ overflow: hidden;
192
+ text-overflow: ellipsis;
193
+ white-space: nowrap;
194
+ font-size: 9.5px;
195
+ font-weight: 620;
169
196
  line-height: 1;
170
- text-transform: uppercase;
171
197
  color: var(--faint);
172
198
  }
173
- /* When drifted, the bubble's second line becomes Echo speaking, not a log line. */
174
199
  .client.voice {
175
200
  font-family: inherit;
176
201
  font-size: 11.5px;
@@ -182,61 +207,191 @@ export const HUD_HTML = String.raw `<!doctype html>
182
207
  .details {
183
208
  display: none;
184
209
  position: relative;
185
- margin: 2px 8px 0 24px;
210
+ margin: 2px 8px 0 8px;
186
211
  padding: 16px 17px 15px;
187
212
  border: 1px solid var(--line);
188
- border-radius: 16px;
213
+ border-radius: 12px;
189
214
  background: var(--card);
190
215
  color: var(--ink);
191
216
  filter: var(--shadow);
192
217
  cursor: grab;
193
218
  }
194
219
  #hud.open .details { display: block; }
195
- .head {
220
+ /* The current session is the one object every number and action belongs to — one card. */
221
+ .now-card {
222
+ border: 1px solid rgba(26, 58, 143, 0.24);
223
+ border-radius: 12px;
224
+ padding: 12px 13px 13px;
225
+ background: #fff;
226
+ box-shadow: 0 1px 5px rgba(26, 58, 143, 0.07);
227
+ }
228
+ #hud.s-amber .now-card { border-color: rgba(233, 185, 73, 0.55); }
229
+ #hud.s-red .now-card { border-color: rgba(199, 55, 47, 0.45); }
230
+ .now-sess {
231
+ display: flex;
232
+ align-items: center;
233
+ gap: 8px;
234
+ margin-bottom: 9px;
235
+ }
236
+ .now-icon { width: 18px; height: 18px; flex: 0 0 auto; object-fit: contain; }
237
+ .now-copy { min-width: 0; flex: 1; display: grid; gap: 1px; line-height: 1.15; }
238
+ .now-title, .now-repo { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
239
+ .now-title { font-size: 12px; font-weight: 800; color: var(--ink); }
240
+ .now-repo { font-size: 10px; font-weight: 600; color: var(--faint); }
241
+ .verdict {
242
+ overflow: hidden;
243
+ text-overflow: ellipsis;
244
+ white-space: nowrap;
245
+ font-size: 11.5px;
246
+ font-weight: 650;
247
+ color: var(--muted);
248
+ }
249
+ .verdict:empty { display: none; }
250
+ .meta-row {
196
251
  display: flex;
252
+ align-items: center;
197
253
  justify-content: space-between;
198
- align-items: baseline;
199
- min-height: 15px;
200
- margin-bottom: 10px;
254
+ gap: 8px;
255
+ margin-top: 10px;
201
256
  }
202
- .turn, .upd {
257
+ .meta-line {
258
+ min-width: 0;
203
259
  overflow: hidden;
204
260
  text-overflow: ellipsis;
205
261
  white-space: nowrap;
206
- font-size: 11px;
262
+ font-family: "JetBrains Mono", ui-monospace, monospace;
263
+ font-size: 9.5px;
264
+ font-weight: 600;
265
+ letter-spacing: 0.02em;
266
+ color: var(--faint);
267
+ }
268
+ .session-actions {
269
+ display: flex;
270
+ align-items: center;
271
+ gap: 6px;
272
+ margin: 0 0 8px 26px;
273
+ }
274
+ .pin-chip, .jump-chip {
275
+ display: none;
276
+ align-items: center;
277
+ justify-content: center;
278
+ min-height: 22px;
279
+ padding: 3px 9px;
280
+ border: 1px solid rgba(26, 58, 143, .18);
281
+ border-radius: 999px;
282
+ background: rgba(255, 255, 255, .78);
207
283
  color: var(--faint);
284
+ cursor: pointer;
285
+ font-family: inherit;
286
+ font-size: 10px;
287
+ font-weight: 760;
288
+ letter-spacing: 0;
289
+ line-height: 1;
208
290
  }
209
- .tabs { display: flex; gap: 6px; overflow-x: auto; margin: 2px 0 12px; padding-bottom: 2px; }
210
- .tabs::-webkit-scrollbar { height: 0; }
291
+ .pin-chip:hover, .jump-chip:hover {
292
+ border-color: rgba(26, 58, 143, .34);
293
+ background: #fff;
294
+ color: var(--blue);
295
+ }
296
+ .jump-chip {
297
+ width: 22px;
298
+ min-width: 22px;
299
+ padding: 0;
300
+ border-radius: 50%;
301
+ font-size: 12px;
302
+ }
303
+ .tabs {
304
+ display: grid;
305
+ gap: 6px;
306
+ max-height: 259px;
307
+ margin: 10px 0 0;
308
+ padding-right: 3px;
309
+ overflow-y: auto;
310
+ overscroll-behavior: contain;
311
+ }
312
+ .tabs:empty { display: none; }
313
+ .tabs::-webkit-scrollbar { width: 4px; }
314
+ .tabs::-webkit-scrollbar-track { background: transparent; }
315
+ .tabs::-webkit-scrollbar-thumb { border-radius: 999px; background: rgba(26, 58, 143, .2); }
316
+ /* History rows: everything that is NOT the current session. Quiet on purpose. */
211
317
  .tab {
212
- flex: 0 0 auto; font-size: 10.5px; line-height: 1; padding: 5px 10px; border-radius: 999px;
213
- border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04);
214
- color: var(--faint); cursor: pointer; white-space: nowrap; font-family: inherit;
318
+ display: grid; grid-template-columns: 16px minmax(0, 1fr) auto auto auto; align-items: center; column-gap: 8px;
319
+ width: 100%; min-height: 30px; font-size: 10.5px; line-height: 1.15; padding: 5px 9px; border-radius: 8px;
320
+ border: 1px solid transparent; background: transparent;
321
+ color: var(--faint); cursor: pointer; text-align: left; font-family: inherit;
322
+ }
323
+ .tab:hover { border-color: rgba(0, 0, 0, .1); background: rgba(0, 0, 0, .05); }
324
+ .tab-icon { width: 14px; height: 14px; object-fit: contain; opacity: .75; }
325
+ .tab-copy { min-width: 0; display: grid; gap: 1px; }
326
+ .tab-title { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); font-weight: 680; }
327
+ .tab-repo { display: flex; align-items: center; gap: 4px; min-width: 0; color: var(--faint); font-size: 9.5px; font-weight: 600; }
328
+ .tab-reponame { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
329
+ .tab-gh { width: 10px; height: 10px; flex: 0 0 auto; object-fit: contain; opacity: .7; }
330
+ /* When you last worked here — sits just left of the jump arrow. */
331
+ .tab-time { justify-self: end; font-family: "JetBrains Mono", ui-monospace, monospace; font-size: 9px; font-weight: 600; color: var(--faint); white-space: nowrap; }
332
+ .tab-status {
333
+ width: 10px;
334
+ height: 10px;
335
+ justify-self: end;
336
+ border: 2px solid #fff;
337
+ border-radius: 50%;
338
+ background: var(--green);
339
+ box-shadow: 0 0 0 1px rgba(26, 58, 143, .18);
340
+ }
341
+ .tab-status.amber { background: var(--amber); }
342
+ .tab-status.red { background: #ff5f57; box-shadow: 0 0 0 1px rgba(199, 55, 47, .28); }
343
+ .tab-status.idle { background: var(--faint); }
344
+ .tab-health {
345
+ display: inline-flex;
346
+ align-items: center;
347
+ gap: 5px;
348
+ justify-self: end;
349
+ font-family: "JetBrains Mono", ui-monospace, monospace;
350
+ font-size: 9.5px;
351
+ font-weight: 750;
352
+ color: var(--muted);
215
353
  }
216
- .tab:hover { background: rgba(0, 0, 0, .08); }
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; }
354
+ .tab-health .tab-status { width: 8px; height: 8px; border-width: 1.5px; }
355
+ /* Jump straight to this conversation in its own app. */
356
+ .tab-jump {
357
+ display: inline-flex; align-items: center; justify-content: center;
358
+ width: 18px; height: 18px; justify-self: end; border-radius: 50%;
359
+ color: var(--faint); font-size: 11px; font-weight: 800;
360
+ }
361
+ .tab-jump:hover { background: rgba(26, 58, 143, 0.1); color: var(--blue); }
219
362
  .spin {
220
- display: inline-block; width: 9px; height: 9px; margin-right: 5px; vertical-align: -1px;
363
+ display: inline-block; width: 8px; height: 8px; margin-left: 4px; vertical-align: -1px;
221
364
  border: 1.5px solid rgba(0, 0, 0, .2); border-top-color: #1a3a8f; border-radius: 50%;
222
365
  animation: echospin .8s linear infinite;
223
366
  }
224
367
  @keyframes echospin { to { transform: rotate(360deg); } }
225
368
  /* Mini mode: a small always-on pill instead of the full mascot+bubble. Opening the panel
226
369
  temporarily shows the full layout; closing returns to the pill. */
370
+ /* One line, one reading order: dot · score · agent icon · session · repo icon · repo.
371
+ The session name gets the space; the repo shrinks first. */
227
372
  #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;
373
+ display: none; align-items: center; gap: 7px; width: calc(100% - 16px); min-height: 38px;
374
+ margin: 7px 0 0 8px; padding: 5px 13px; cursor: pointer; user-select: none;
230
375
  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;
376
+ filter: var(--shadow); color: var(--ink); letter-spacing: 0;
233
377
  }
234
378
  #miniPill .mdot { width: 8px; height: 8px; border-radius: 50%; background: var(--green); flex: 0 0 auto; }
235
379
  #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; }
380
+ #miniPill .mdot.idle { background: var(--green); }
381
+ .micon { width: 13px; height: 13px; object-fit: contain; flex: 0 0 auto; opacity: .85; }
382
+ #mscore {
383
+ flex: 0 0 auto;
384
+ font-family: "JetBrains Mono", ui-monospace, monospace;
385
+ font-size: 12px;
386
+ font-weight: 850;
387
+ }
388
+ .mcopy { min-width: 0; flex: 1; display: grid; gap: 2px; line-height: 1.1; }
389
+ .mrow { display: flex; align-items: center; gap: 5px; min-width: 0; }
390
+ #msession, #mrepo { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
391
+ #msession { font-size: 11.5px; font-weight: 760; color: var(--ink); }
392
+ #mrepo { font-size: 10px; font-weight: 650; color: var(--faint); }
393
+ .mrow .micon { width: 11px; height: 11px; }
394
+ #hud.mini:not(.open) .glance { height: 52px; }
240
395
  #hud.mini:not(.open) .mascot-stage, #hud.mini:not(.open) .status-bubble, #hud.mini:not(.open) .drag { display: none; }
241
396
  #hud.mini:not(.open) #miniPill { display: inline-flex; }
242
397
  .cornerbtn {
@@ -246,7 +401,6 @@ export const HUD_HTML = String.raw `<!doctype html>
246
401
  cursor: pointer; font-family: inherit;
247
402
  }
248
403
  .cornerbtn:hover { background: #fff; color: #111; }
249
- .headctl { display: flex; align-items: center; gap: 8px; }
250
404
  .switchbtn {
251
405
  display: inline-flex; align-items: center; justify-content: center;
252
406
  width: 22px; height: 22px; border-radius: 50%; padding: 0; line-height: 1; font-size: 12px;
@@ -254,13 +408,12 @@ export const HUD_HTML = String.raw `<!doctype html>
254
408
  cursor: pointer; font-family: inherit;
255
409
  }
256
410
  .switchbtn:hover { background: rgba(0, 0, 0, .1); color: #111; }
411
+ /* Explicit family switch: a labeled pill, not a mystery icon. */
257
412
  .agent-switch {
258
413
  width: auto;
259
- min-width: 116px;
260
- max-width: 154px;
261
- height: 28px;
414
+ height: 26px;
262
415
  gap: 6px;
263
- padding: 0 10px 0 7px;
416
+ padding: 0 11px 0 8px;
264
417
  border-radius: 999px;
265
418
  background: rgba(255, 255, 255, .78);
266
419
  color: #1f1f1f;
@@ -268,12 +421,13 @@ export const HUD_HTML = String.raw `<!doctype html>
268
421
  font-weight: 760;
269
422
  letter-spacing: 0;
270
423
  white-space: nowrap;
424
+ flex: 0 0 auto;
271
425
  }
272
426
  .agent-switch:hover { background: #fff; border-color: rgba(26, 58, 143, .34); }
273
427
  .agent-switch[data-target-family="claude"] { border-color: rgba(217, 119, 87, .42); }
274
428
  .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; }
429
+ .switch-icon { width: 14px; height: 14px; flex: 0 0 auto; object-fit: contain; }
430
+ .switch-copy { display: inline; }
277
431
  .reveal {
278
432
  margin-top: 8px; font-size: 10.5px; padding: 4px 11px; border-radius: 999px;
279
433
  border: 1px solid rgba(0, 0, 0, .12); background: rgba(0, 0, 0, .04); color: var(--faint);
@@ -284,87 +438,62 @@ export const HUD_HTML = String.raw `<!doctype html>
284
438
  display: flex;
285
439
  align-items: center;
286
440
  gap: 9px;
287
- margin-bottom: 7px;
441
+ margin-bottom: 4px;
288
442
  }
289
443
  .state-dot {
290
- width: 11px;
291
- height: 11px;
444
+ width: 12px;
445
+ height: 12px;
292
446
  flex: none;
293
447
  border-radius: 999px;
294
448
  background: var(--green);
295
449
  box-shadow: 0 0 0 1px rgba(26, 58, 143, 0.12);
296
450
  }
297
451
  .state-dot.amber { background: var(--amber); }
298
- .state-dot.red { background: #ff5f57; }
299
452
  .state-word {
300
453
  min-width: 0;
301
454
  overflow: hidden;
302
455
  text-overflow: ellipsis;
303
456
  white-space: nowrap;
304
- font-size: 16px;
305
- font-weight: 800;
306
- }
307
- .full {
308
- min-height: 17px;
309
- margin-bottom: 14px;
310
- font-size: 12px;
311
- font-weight: 650;
312
- color: #4a7a3e;
313
- }
314
- .lead {
315
- margin-bottom: 5px;
316
- font-size: 13.5px;
317
- font-weight: 750;
318
- color: #2f7d4f;
457
+ font-family: "JetBrains Mono", ui-monospace, monospace;
458
+ font-size: 20px;
459
+ font-weight: 850;
319
460
  }
320
- .evidence {
321
- min-height: 38px;
322
- margin-bottom: 12px;
461
+ .why-btn {
462
+ margin-left: auto;
463
+ display: inline-flex;
464
+ align-items: center;
465
+ justify-content: center;
466
+ width: 20px;
467
+ height: 20px;
468
+ padding: 0;
469
+ line-height: 1;
470
+ border: 1px solid rgba(26, 58, 143, 0.16);
471
+ border-radius: 50%;
472
+ background: rgba(26, 58, 143, 0.04);
323
473
  color: var(--muted);
324
- font-size: 12.5px;
325
- line-height: 1.5;
326
- }
327
- .oldbar {
328
- display: flex;
329
- height: 9px;
330
- margin: 0;
331
- overflow: hidden;
332
- border-radius: 999px;
333
- background: var(--track);
334
- }
335
- .oldbar span {
336
- display: block;
337
- height: 100%;
338
- min-width: 0;
339
- transition: width 0.35s ease;
474
+ font-family: inherit;
475
+ font-size: 15px;
476
+ font-weight: 700;
477
+ cursor: pointer;
340
478
  }
341
- .oldbar .useful { width: 100%; background: var(--blue); }
342
- .oldbar .old { width: 0; background: var(--brick); }
343
- .alert {
479
+ .why-btn:hover { background: rgba(26, 58, 143, 0.09); color: var(--ink); }
480
+ .evidence {
344
481
  display: none;
345
- margin-top: 13px;
346
- padding: 9px 0 9px 12px;
347
- border-left: 3px solid rgba(199, 55, 47, 0.42);
348
- }
349
- .alert-title {
350
- margin-bottom: 4px;
351
- color: var(--brick);
352
- font-size: 12.5px;
353
- font-weight: 750;
354
- }
355
- .alert-detail {
482
+ margin: 11px 0 12px;
483
+ padding: 9px 10px;
484
+ border: 1px solid rgba(26, 58, 143, 0.12);
485
+ border-radius: 8px;
486
+ background: rgba(26, 58, 143, 0.035);
356
487
  color: var(--muted);
357
488
  font-size: 12px;
358
- line-height: 1.45;
359
- }
360
- .nudge {
361
- display: none;
362
- margin-top: 13px;
363
- padding: 10px 12px;
364
- border-radius: 12px;
365
- font-size: 12.5px;
366
- line-height: 1.45;
489
+ line-height: 1.5;
367
490
  }
491
+ #hud.show-why .evidence { display: block; }
492
+ .why-line { display: flex; justify-content: space-between; gap: 12px; }
493
+ .why-line + .why-line { margin-top: 3px; }
494
+ .why-k { color: var(--faint); }
495
+ .why-v { color: var(--ink); font-weight: 750; text-align: right; }
496
+ .nudge { display: none; }
368
497
  .nudge.calm {
369
498
  border: 1px solid rgba(26, 58, 143, 0.14);
370
499
  background: rgba(26, 58, 143, 0.05);
@@ -375,34 +504,31 @@ export const HUD_HTML = String.raw `<!doctype html>
375
504
  background: rgba(199, 55, 47, 0.07);
376
505
  color: #7a241e;
377
506
  }
378
- /* Breathing CTA: quiet ghost pill while the session is healthy... */
379
507
  button.renew-btn {
380
508
  display: none;
381
- width: auto;
509
+ width: 100%;
382
510
  margin-top: 12px;
383
- border: 1px solid rgba(199, 55, 47, 0.28);
511
+ border: 1px solid rgba(26, 58, 143, 0.26);
384
512
  border-radius: 999px;
385
- padding: 6px 16px;
386
- background: transparent;
387
- color: var(--brick);
513
+ padding: 9px 14px;
514
+ background: rgba(26, 58, 143, 0.07);
515
+ color: var(--blue);
388
516
  font-weight: 750;
389
- font-size: 11.5px;
517
+ font-size: 12px;
390
518
  cursor: pointer;
391
519
  transition: all 0.25s ease;
392
520
  }
393
- /* ...and a full-size primary the moment the session drifts. */
394
521
  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);
522
+ border-color: var(--blue);
523
+ padding: 11px 14px;
524
+ background: var(--blue);
525
+ color: #fff;
526
+ font-size: 13px;
527
+ font-weight: 800;
528
+ box-shadow: 0 4px 12px rgba(26, 58, 143, 0.22);
402
529
  }
403
530
  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 {
531
+ button.renew-btn small {
406
532
  display: block;
407
533
  margin-top: 3px;
408
534
  font-size: 10.5px;
@@ -410,37 +536,46 @@ export const HUD_HTML = String.raw `<!doctype html>
410
536
  opacity: 0.85;
411
537
  white-space: normal;
412
538
  }
413
- button.renew-btn:hover { background: rgba(199, 55, 47, 0.12); }
414
- button.renew-btn.urgent:hover { background: #b32e27; }
539
+ button.renew-btn:hover { background: rgba(26, 58, 143, 0.12); }
540
+ button.renew-btn.urgent:hover { background: #102d78; }
415
541
  button.renew-btn.loading { width: 100%; opacity: 0.75; cursor: wait; }
416
542
  .renew-result {
417
543
  display: none;
418
544
  margin-top: 10px;
419
545
  border: 1px solid rgba(26, 58, 143, 0.18);
420
- border-radius: 12px;
546
+ border-radius: 8px;
421
547
  background: rgba(26, 58, 143, 0.04);
422
548
  padding: 10px 12px;
423
549
  }
424
550
  .renew-result .renew-msg {
425
- margin-bottom: 6px;
551
+ margin-bottom: 0;
426
552
  color: var(--blue);
427
553
  font-size: 11.5px;
428
554
  font-weight: 750;
429
555
  }
556
+ /* Preview of the generated context: a few lines by default, "Read all" opens the whole
557
+ thing right here. */
430
558
  .renew-result pre {
431
- max-height: 120px;
432
- overflow: auto;
433
- margin: 0;
559
+ max-height: 92px;
560
+ overflow: hidden;
561
+ margin: 7px 0 0;
434
562
  white-space: pre-wrap;
435
563
  word-break: break-word;
436
564
  color: var(--ink);
437
565
  font-family: "JetBrains Mono", ui-monospace, monospace;
438
566
  font-size: 9.5px;
567
+ line-height: 1.45;
568
+ -webkit-mask-image: linear-gradient(to bottom, #000 62%, transparent 100%);
569
+ }
570
+ .renew-result.expanded pre {
571
+ max-height: 300px;
572
+ overflow: auto;
573
+ -webkit-mask-image: none;
439
574
  }
575
+ .renew-actions { display: flex; align-items: center; gap: 6px; margin-top: 8px; }
440
576
  .renew-result .copy-btn {
441
577
  display: inline-block;
442
578
  width: auto;
443
- margin-top: 6px;
444
579
  border: 1px solid rgba(26, 58, 143, 0.22);
445
580
  border-radius: 999px;
446
581
  padding: 5px 14px;
@@ -450,7 +585,19 @@ export const HUD_HTML = String.raw `<!doctype html>
450
585
  font-size: 11px;
451
586
  cursor: pointer;
452
587
  }
453
- .meta,
588
+ .ghost-btn {
589
+ border: 1px solid rgba(26, 58, 143, 0.14);
590
+ border-radius: 999px;
591
+ padding: 5px 12px;
592
+ background: transparent;
593
+ color: var(--muted);
594
+ font-family: inherit;
595
+ font-size: 11px;
596
+ font-weight: 750;
597
+ cursor: pointer;
598
+ }
599
+ .ghost-btn:hover { background: rgba(26, 58, 143, 0.06); color: var(--ink); }
600
+ #renewagainbtn { margin-left: auto; }
454
601
  .path,
455
602
  .missing {
456
603
  overflow: hidden;
@@ -459,7 +606,6 @@ export const HUD_HTML = String.raw `<!doctype html>
459
606
  color: var(--faint);
460
607
  font-size: 11px;
461
608
  }
462
- .meta { margin-top: 13px; }
463
609
  .path { margin-top: 8px; }
464
610
  .missing { margin-top: 6px; }
465
611
  #hud.s-green .mascot-stage { filter: drop-shadow(0 2px 6px rgba(67, 209, 122, 0.3)); }
@@ -501,38 +647,51 @@ export const HUD_HTML = String.raw `<!doctype html>
501
647
  <span class="dot" id="dot"></span>
502
648
  <span class="copy">
503
649
  <span class="main" id="main">Checking context...</span>
504
- <span class="client" id="client">EchoMem HUD</span>
650
+ <span class="client-line"><img class="line-icon" id="bubbleagent" src="/assets/hud/codex.svg" alt="" style="display:none" /><span class="client" id="client">EchoMem HUD</span></span>
651
+ <span class="client-line" id="repoline" style="display:none"><img class="line-icon" src="/assets/hud/github.svg" alt="" /><span class="client-repo" id="clientrepo"></span></span>
505
652
  </span>
506
653
  <button class="cornerbtn" id="minibtn" title="Shrink to mini pill">&ndash;</button>
507
654
  </div>
508
- <div id="miniPill" title="EchoMem — click to expand"><span class="mdot" id="mdot"></span><span id="mtext">EchoMem</span></div>
655
+ <div id="miniPill" title="EchoMem — click to expand">
656
+ <span class="mdot" id="mdot"></span>
657
+ <span id="mscore">on</span>
658
+ <span class="mcopy">
659
+ <span class="mrow"><img class="micon" id="micon" src="/assets/hud/codex.svg" alt="" style="display:none" /><span id="msession">EchoMem</span></span>
660
+ <span class="mrow" id="mrepoline" style="display:none"><img class="micon" id="mghicon" src="/assets/hud/github.svg" alt="" /><span id="mrepo"></span></span>
661
+ </span>
662
+ </div>
509
663
  </div>
510
664
  <div class="details">
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>
512
- <div class="tabs" id="tabs"></div>
513
- <div class="state-row">
514
- <span class="state-dot" id="stateDot"></span>
515
- <span class="state-word" id="stateWord">Fresh</span>
516
- </div>
517
- <div class="full" id="fullness"></div>
518
- <div class="lead" id="lead">Clean and roomy</div>
519
- <div class="evidence" id="evidence">Collecting enough local context to judge the window.</div>
520
- <div class="oldbar" aria-hidden="true">
521
- <span class="useful" id="usefulbar"></span>
522
- <span class="old" id="oldbar"></span>
665
+ <div class="now-card" id="nowcard">
666
+ <div class="now-sess">
667
+ <img class="now-icon" id="nowicon" src="/assets/hud/codex.svg" alt="" />
668
+ <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>
669
+ </div>
670
+ <div class="session-actions">
671
+ <button class="pin-chip" id="pinchip" type="button" title="Return to auto-follow">pinned x</button>
672
+ <button class="jump-chip" id="jumpchip" type="button" title="Open this conversation">&#8599;</button>
673
+ </div>
674
+ <div class="state-row">
675
+ <span class="state-dot" id="stateDot"></span>
676
+ <span class="state-word" id="stateWord">--%</span>
677
+ <button class="why-btn" id="whybtn" type="button" title="Details" aria-label="Details" style="display:none">+</button>
678
+ </div>
679
+ <div class="verdict" id="verdict"></div>
680
+ <div class="evidence" id="evidence"></div>
681
+ <div class="nudge" id="nudge"></div>
682
+ <button class="renew-btn" id="renewbtn"><b>Start new session with context</b><small>carry 100% signal, drop the junk</small></button>
683
+ <div class="renew-result" id="renewresult">
684
+ <div class="renew-msg" id="renewmsg"></div>
685
+ <pre id="renewpre"></pre>
686
+ <div class="renew-actions">
687
+ <button class="copy-btn" id="copybtn">Copy</button>
688
+ <button class="ghost-btn" id="expandbtn">Read all</button>
689
+ <button class="ghost-btn" id="renewagainbtn" title="Generate a fresh context from the latest turns">Renew</button>
690
+ </div>
691
+ </div>
523
692
  </div>
524
- <div class="alert" id="alert">
525
- <div class="alert-title" id="alertTitle"></div>
526
- <div class="alert-detail" id="alertDetail"></div>
527
- </div>
528
- <div class="nudge" id="nudge"></div>
529
- <button class="renew-btn" id="renewbtn">Renew session</button>
530
- <div class="renew-result" id="renewresult">
531
- <div class="renew-msg" id="renewmsg"></div>
532
- <pre id="renewpre"></pre>
533
- <button class="copy-btn" id="copybtn">Copy to clipboard</button>
534
- </div>
535
- <div class="meta" id="meta"></div>
693
+ <div class="tabs" id="tabs"></div>
694
+ <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>
536
695
  <div class="path" id="path"></div>
537
696
  <button class="reveal" id="reveal" style="display:none">↗ Open session in viewer</button>
538
697
  <div class="missing" id="missing"></div>
@@ -543,35 +702,39 @@ export const HUD_HTML = String.raw `<!doctype html>
543
702
  const dot = document.getElementById("dot");
544
703
  const main = document.getElementById("main");
545
704
  const client = document.getElementById("client");
546
- const turnEl = document.getElementById("turn");
705
+ const clientRepo = document.getElementById("clientrepo");
547
706
  const stateDot = document.getElementById("stateDot");
548
707
  const stateWordEl = document.getElementById("stateWord");
549
- const fullness = document.getElementById("fullness");
550
- const lead = document.getElementById("lead");
708
+ const verdictEl = document.getElementById("verdict");
709
+ const nowIcon = document.getElementById("nowicon");
710
+ const nowTitle = document.getElementById("nowtitle");
711
+ const nowRepo = document.getElementById("nowrepo");
712
+ const nowRepoLine = document.getElementById("nowrepoline");
713
+ const pinChip = document.getElementById("pinchip");
714
+ const jumpChip = document.getElementById("jumpchip");
715
+ const whyBtn = document.getElementById("whybtn");
551
716
  const evidence = document.getElementById("evidence");
552
- const usefulbar = document.getElementById("usefulbar");
553
- const oldbar = document.getElementById("oldbar");
554
- const alertEl = document.getElementById("alert");
555
- const alertTitle = document.getElementById("alertTitle");
556
- const alertDetail = document.getElementById("alertDetail");
557
717
  const nudge = document.getElementById("nudge");
558
718
  const renewBtn = document.getElementById("renewbtn");
559
719
  const renewResult = document.getElementById("renewresult");
560
720
  const renewMsg = document.getElementById("renewmsg");
561
721
  const renewPre = document.getElementById("renewpre");
562
722
  const copyBtn = document.getElementById("copybtn");
563
- const metaEl = document.getElementById("meta");
723
+ const expandBtn = document.getElementById("expandbtn");
724
+ const renewAgainBtn = document.getElementById("renewagainbtn");
725
+ const metaLineEl = document.getElementById("metaline");
726
+ const bubbleAgent = document.getElementById("bubbleagent");
727
+ const repoLine = document.getElementById("repoline");
564
728
  const pathEl = document.getElementById("path");
565
- const updatedEl = document.getElementById("updated");
566
729
  const tabsEl = document.getElementById("tabs");
567
730
  const switchBtn = document.getElementById("switchbtn");
568
731
  const revealEl = document.getElementById("reveal");
569
732
  const missing = document.getElementById("missing");
570
- const hudVersion = "v${MCP_PACKAGE_VERSION}";
571
733
  let prevTurnState = null;
572
734
  let lastCapsule = "";
573
735
  let renewResultSessionKey = "";
574
736
  let activityEpoch = null;
737
+ let metaTail = ""; // "turn 2 · 178k tokens" — composed in render(), joined with "updated …" each second
575
738
  let lastState = null;
576
739
  let currentSrc = null;
577
740
  let renewTimer = null;
@@ -594,8 +757,13 @@ export const HUD_HTML = String.raw `<!doctype html>
594
757
  // returns to the pill. Preference persists (localStorage here, window size in main's bounds file).
595
758
  const miniBtn = document.getElementById("minibtn");
596
759
  const mdot = document.getElementById("mdot");
597
- const mtext = document.getElementById("mtext");
760
+ const micon = document.getElementById("micon");
761
+ const mscore = document.getElementById("mscore");
762
+ const msession = document.getElementById("msession");
763
+ const mrepo = document.getElementById("mrepo");
764
+ const mrepoLine = document.getElementById("mrepoline");
598
765
  let miniOn = false;
766
+ let whyOpen = false;
599
767
  try { miniOn = localStorage.getItem("echomemHudMini") === "1"; } catch {}
600
768
  if (miniOn) hud.classList.add("mini");
601
769
  function applyMini(next) {
@@ -606,6 +774,13 @@ export const HUD_HTML = String.raw `<!doctype html>
606
774
  if (window.echomemHud && typeof window.echomemHud.setMini === "function") window.echomemHud.setMini(next);
607
775
  }
608
776
  if (miniBtn) miniBtn.addEventListener("click", (e) => { e.stopPropagation(); applyMini(true); });
777
+ if (whyBtn) whyBtn.addEventListener("click", (e) => {
778
+ e.stopPropagation();
779
+ whyOpen = !whyOpen;
780
+ hud.classList.toggle("show-why", whyOpen);
781
+ whyBtn.textContent = whyOpen ? "−" : "+";
782
+ syncHeight();
783
+ });
609
784
  let hudDrag = null;
610
785
  let suppressNextToggle = false;
611
786
  const dragBlockSelector = "button, input, textarea, select, a, pre, .tab, .switchbtn, .reveal, .renew-btn, .copy-btn";
@@ -670,17 +845,40 @@ export const HUD_HTML = String.raw `<!doctype html>
670
845
  .catch(() => {});
671
846
  });
672
847
 
673
- // Click a tab → pin that session as the primary view (or "Auto" to return to auto-selection).
848
+ // Click a history row → pin that session as the current one. Auto-follow is the default;
849
+ // the card's "pinned ×" chip releases the pin. The ↗ corner jumps to the conversation in
850
+ // its own app instead of switching the HUD view.
674
851
  if (tabsEl) tabsEl.addEventListener("click", (e) => {
852
+ const jump = e.target && e.target.closest ? e.target.closest("[data-jump]") : null;
853
+ if (jump) {
854
+ e.stopPropagation();
855
+ jumpToSession(jump.getAttribute("data-jump"));
856
+ return;
857
+ }
675
858
  const btn = e.target && e.target.closest ? e.target.closest(".tab") : null;
676
859
  if (!btn) return;
677
- const id = btn.getAttribute("data-id");
860
+ selectSession(btn.getAttribute("data-id"));
861
+ });
862
+ if (jumpChip) jumpChip.addEventListener("click", (e) => {
863
+ e.stopPropagation();
864
+ jumpToSession(jumpChip.getAttribute("data-jump"));
865
+ });
866
+ function jumpToSession(id) {
867
+ if (!id) return;
868
+ fetch("/jump?id=" + encodeURIComponent(id)).catch(() => {});
869
+ }
870
+ if (pinChip) pinChip.addEventListener("click", (e) => {
871
+ e.stopPropagation();
872
+ selectSession("auto");
873
+ });
874
+ function selectSession(id) {
875
+ if (!id) return;
678
876
  fetch("/select?id=" + encodeURIComponent(id))
679
877
  .then(() => fetch("/state"))
680
878
  .then((r) => r.json())
681
879
  .then(render)
682
880
  .catch(() => {});
683
- });
881
+ }
684
882
 
685
883
  document.getElementById("toggle").addEventListener("click", (event) => {
686
884
  if (suppressNextToggle) {
@@ -694,8 +892,6 @@ export const HUD_HTML = String.raw `<!doctype html>
694
892
  syncHeight();
695
893
  });
696
894
 
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
895
  let renewUrgent = false;
700
896
  function refreshRenewLabel() {
701
897
  if (!renewBtn) return;
@@ -704,9 +900,7 @@ export const HUD_HTML = String.raw `<!doctype html>
704
900
  renderRenewLoadingLabel();
705
901
  return;
706
902
  }
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";
903
+ renewBtn.innerHTML = "<b>Start new session with context</b><small>carry 100% signal, drop the junk</small>";
710
904
  }
711
905
 
712
906
  if (renewBtn) renewBtn.addEventListener("click", async () => {
@@ -751,23 +945,18 @@ export const HUD_HTML = String.raw `<!doctype html>
751
945
  }
752
946
  saveCachedCheckpoint(renewSessionKey, prompt, promptKind, meta);
753
947
  if (promptKind === "cloud") saveCheckpointStatus(renewSessionKey, statusFromRenewData(renewData, meta));
948
+ const doneSummary = checkpointSuffix(checkpointSummary({ renewedAt: Date.now(), memoryCount: meta.sessionMemoryCount }));
754
949
  showRenewResult({
755
950
  sessionKey: renewSessionKey,
756
951
  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.",
952
+ message: (promptKind === "local" ? "Local capsule ready" : "Decision log ready") + doneSummary,
760
953
  canCopy: true,
761
954
  });
762
955
  try {
763
956
  await navigator.clipboard.writeText(prompt);
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.";
957
+ if (renewMsg) renewMsg.textContent = (promptKind === "local" ? "Local capsule copied" : "Decision log copied") + doneSummary;
767
958
  } 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.";
959
+ if (renewMsg) renewMsg.textContent = (promptKind === "local" ? "Local capsule ready" : "Decision log ready") + doneSummary;
771
960
  }
772
961
  } catch {
773
962
  } finally {
@@ -783,10 +972,29 @@ export const HUD_HTML = String.raw `<!doctype html>
783
972
  try {
784
973
  await navigator.clipboard.writeText(lastCapsule);
785
974
  copyBtn.textContent = "Copied";
786
- setTimeout(() => { copyBtn.textContent = "Copy to clipboard"; }, 1500);
975
+ setTimeout(() => { copyBtn.textContent = "Copy"; }, 1500);
787
976
  } catch {}
788
977
  });
789
978
 
979
+ if (expandBtn) expandBtn.addEventListener("click", (e) => {
980
+ e.stopPropagation();
981
+ if (!renewResult) return;
982
+ const expanded = renewResult.classList.toggle("expanded");
983
+ expandBtn.textContent = expanded ? "Collapse" : "Read all";
984
+ syncHeight();
985
+ });
986
+
987
+ // Escape hatch: the session moved on and the saved context is stale — clear it and
988
+ // run the renew flow again.
989
+ if (renewAgainBtn) renewAgainBtn.addEventListener("click", (e) => {
990
+ e.stopPropagation();
991
+ clearRenewResult();
992
+ if (renewBtn) {
993
+ renewBtn.style.display = "block";
994
+ renewBtn.click();
995
+ }
996
+ });
997
+
790
998
  function activeRenewScore() {
791
999
  const st = lastState || {};
792
1000
  return st.active || st;
@@ -806,16 +1014,22 @@ export const HUD_HTML = String.raw `<!doctype html>
806
1014
  renewResultSessionKey = "";
807
1015
  if (renewPre) {
808
1016
  renewPre.textContent = "";
809
- renewPre.style.display = "";
1017
+ renewPre.style.display = "none";
810
1018
  }
811
1019
  if (renewMsg) renewMsg.textContent = "";
812
1020
  if (copyBtn) {
813
- copyBtn.textContent = "Copy to clipboard";
1021
+ copyBtn.textContent = "Copy";
814
1022
  copyBtn.style.display = "";
815
1023
  }
816
- if (renewResult) renewResult.style.display = "none";
1024
+ if (renewResult) {
1025
+ renewResult.style.display = "none";
1026
+ renewResult.classList.remove("expanded");
1027
+ }
1028
+ if (expandBtn) expandBtn.textContent = "Read all";
817
1029
  }
818
1030
 
1031
+ // The result replaces the action: once a context exists, the big renew button steps aside
1032
+ // and this card carries the preview + timestamp + copy.
819
1033
  function showRenewResult(opts) {
820
1034
  const prompt = String((opts && opts.prompt) || "");
821
1035
  const body = String((opts && opts.body) || prompt || "");
@@ -826,11 +1040,19 @@ export const HUD_HTML = String.raw `<!doctype html>
826
1040
  renewPre.textContent = body;
827
1041
  renewPre.style.display = body ? "block" : "none";
828
1042
  }
1043
+ if (expandBtn) {
1044
+ expandBtn.style.display = body ? "inline-block" : "none";
1045
+ expandBtn.textContent = "Read all";
1046
+ }
829
1047
  if (copyBtn) {
830
- copyBtn.textContent = "Copy to clipboard";
1048
+ copyBtn.textContent = "Copy";
831
1049
  copyBtn.style.display = opts && opts.canCopy ? "inline-block" : "none";
832
1050
  }
833
- if (renewResult) renewResult.style.display = "block";
1051
+ if (renewResult) {
1052
+ renewResult.classList.remove("expanded");
1053
+ renewResult.style.display = "block";
1054
+ }
1055
+ if (renewBtn && !renewStartMs) renewBtn.style.display = "none";
834
1056
  syncHeight();
835
1057
  }
836
1058
 
@@ -895,13 +1117,11 @@ export const HUD_HTML = String.raw `<!doctype html>
895
1117
  const stale = savedTurn > 0 && currentTurn > savedTurn;
896
1118
  const summary = checkpointSummary(cached);
897
1119
  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.";
1120
+ return savedTurn > 0 ? "Local capsule from turn " + savedTurn + checkpointSuffix(summary) : "Local capsule ready" + checkpointSuffix(summary);
901
1121
  }
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.";
1122
+ return savedTurn > 0 && stale
1123
+ ? "Decision log from turn " + savedTurn + checkpointSuffix(summary)
1124
+ : "Decision log ready" + checkpointSuffix(summary);
905
1125
  }
906
1126
 
907
1127
  function statusFromRenewData(data, meta) {
@@ -918,10 +1138,14 @@ export const HUD_HTML = String.raw `<!doctype html>
918
1138
  function checkpointSummary(item) {
919
1139
  const parts = [];
920
1140
  const renewedAt = timestampMs(item && item.renewedAt);
921
- if (renewedAt) parts.push("renewed " + relTime(Date.now() - renewedAt));
1141
+ if (renewedAt) parts.push("generated " + relTime(Date.now() - renewedAt));
922
1142
  const count = Number(item && (item.memoryCount ?? item.sessionMemoryCount));
923
1143
  if (Number.isFinite(count) && count > 0) parts.push(count + " " + (count === 1 ? "memory" : "memories") + " saved");
924
- return parts.length ? parts.join(" · ") + "." : "";
1144
+ return parts.join(" · ");
1145
+ }
1146
+
1147
+ function checkpointSuffix(summary) {
1148
+ return summary ? " · " + summary : "";
925
1149
  }
926
1150
 
927
1151
  function timestampMs(value) {
@@ -981,7 +1205,7 @@ export const HUD_HTML = String.raw `<!doctype html>
981
1205
  showRenewResult({
982
1206
  sessionKey: key,
983
1207
  prompt: body,
984
- message: "Decision log already saved. " + summary,
1208
+ message: "Decision log ready" + checkpointSuffix(summary),
985
1209
  canCopy: true,
986
1210
  });
987
1211
  }
@@ -1071,7 +1295,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1071
1295
  const elapsed = Date.now() - renewStartMs;
1072
1296
  const remaining = Math.max(0, Number(renewEta && renewEta.ms) || 20000) - elapsed;
1073
1297
  const detail = remaining > 0 ? "ETA " + fmtDuration(remaining) + " left" : "ETA any moment";
1074
- renewBtn.innerHTML = "<b>Packing your checkpoint&hellip;</b><small>" + esc(detail) + "</small>";
1298
+ renewBtn.innerHTML = "<b>Preparing context&hellip;</b><small>" + esc(detail) + "</small>";
1075
1299
  }
1076
1300
 
1077
1301
  function fallbackEtaForScore(score) {
@@ -1094,13 +1318,17 @@ export const HUD_HTML = String.raw `<!doctype html>
1094
1318
 
1095
1319
  // Dev/design preview: ?preview=green|amber|red renders that health state with sample data and
1096
1320
  // skips the live feed — same pattern as the extraction page's ?preview hook.
1097
- const previewColor = new URLSearchParams(location.search).get("preview");
1321
+ const previewParams = new URLSearchParams(location.search);
1322
+ const previewColor = previewParams.get("preview");
1323
+ const previewPinned = previewParams.get("pinned") === "1";
1324
+ if (previewParams.get("capture") === "1") hud.classList.add("capture-preview");
1098
1325
  if (previewColor === "green" || previewColor === "amber" || previewColor === "red") {
1099
1326
  const sample = {
1100
1327
  client: "codex",
1101
1328
  label: "EchoMem-Chrome",
1102
- sourcePath: "/preview/session.jsonl",
1329
+ sourcePath: previewPinned ? "/preview/b.jsonl" : "/preview/session.jsonl",
1103
1330
  usefulPct: previewColor === "red" ? 55 : previewColor === "amber" ? 75 : 95,
1331
+ healthScorePct: previewColor === "red" ? 48 : previewColor === "amber" ? 72 : 96,
1104
1332
  color: previewColor,
1105
1333
  ctTokens: previewColor === "red" ? 165000 : previewColor === "amber" ? 110000 : 24000,
1106
1334
  modelContextWindow: 258000,
@@ -1114,11 +1342,21 @@ export const HUD_HTML = String.raw `<!doctype html>
1114
1342
  };
1115
1343
  render({
1116
1344
  active: sample,
1345
+ pinnedId: previewPinned ? "p2" : null,
1346
+ sessions: [
1347
+ sample,
1348
+ { client: "codex", sourcePath: "/preview/b.jsonl", usefulPct: 71, healthScorePct: 71, color: "amber" },
1349
+ { client: "codex", sourcePath: "/preview/c.jsonl", usefulPct: 94, healthScorePct: 94, color: "green" },
1350
+ ],
1117
1351
  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 },
1352
+ { id: "p1", client: "codex", title: "Simplify HUD session display", label: "EchoMem-Chrome", sourcePath: "/preview/session.jsonl", live: true, lastActiveMs: 4000 },
1353
+ { id: "p2", client: "codex", title: "修复 HUD 切换和图标", label: "EchoMem-Chrome", sourcePath: "/preview/b.jsonl", live: false, lastActiveMs: 480000 },
1354
+ { id: "p3", client: "codex", title: "Fix extraction preview", label: "EchoMem-Chrome", sourcePath: "/preview/c.jsonl", live: false, lastActiveMs: 2400000 },
1355
+ { id: "p4", client: "codex", title: "Port Claude work", label: "EchoForPeople", sourcePath: "/preview/d.jsonl", live: false, lastActiveMs: 9000000 },
1356
+ { id: "p5", client: "codex", title: "Stamp fixes with Echo mascot", label: "EchoMem-Chrome", sourcePath: "/preview/e.jsonl", live: false, lastActiveMs: 16200000 },
1357
+ { id: "p6", client: "codex", title: "Review migration checks", label: "EchoForPeople", sourcePath: "/preview/f.jsonl", live: false, lastActiveMs: 21600000 },
1358
+ { id: "p7", client: "codex", title: "Polish setup story", label: "EchoMem-Chrome", sourcePath: "/preview/g.jsonl", live: false, lastActiveMs: 34200000 },
1359
+ { id: "p8", client: "claude-code", title: "DG-Web refactor", label: "DG-Web", sourcePath: "/preview/h.jsonl", live: false, lastActiveMs: 39600000 },
1122
1360
  ],
1123
1361
  });
1124
1362
  hud.classList.add("open");
@@ -1134,8 +1372,10 @@ export const HUD_HTML = String.raw `<!doctype html>
1134
1372
  lastState = state;
1135
1373
  const activeClient = score && score.client ? score.client : state && state.active && state.active.client;
1136
1374
  const currentSessionKey = sessionKeyFor(state, score);
1375
+ const viewColor = score && typeof score.usefulPct === "number" ? (score.color || "green") : "idle";
1137
1376
  if (renewResultSessionKey && renewResultSessionKey !== currentSessionKey) clearRenewResult();
1138
- renderTabs(state, score && score.sourcePath ? activeIdFor(state, score.sourcePath, activeClient) : null, activeClient);
1377
+ const activeId = score && score.sourcePath ? activeIdFor(state, score.sourcePath, activeClient) : null;
1378
+ renderTabs(state, activeId, activeClient);
1139
1379
  renderSwitchButton(state, activeClient);
1140
1380
  if (!score || typeof score.usefulPct !== "number") {
1141
1381
  renderIdle(state);
@@ -1143,61 +1383,65 @@ export const HUD_HTML = String.raw `<!doctype html>
1143
1383
  }
1144
1384
 
1145
1385
  const color = score.color || "green";
1146
- const stateName = stateWord(color);
1147
1386
  const sat = fullnessPct(score);
1148
1387
  const oldPct = clamp(score.pollutionPct || 0, 0, 95);
1388
+ const health = healthScore(score);
1149
1389
  const repeatCount = score.buckets && score.buckets.range_redundant ? score.buckets.range_redundant.count || 0 : 0;
1150
- const edits = score.stats && score.stats.patchEdits ? Number(score.stats.patchEdits) : 0;
1151
-
1152
- // A session idle for >10 min shouldn't wear the cheerful "Fresh/clean" look — its quality
1153
- // numbers are stale. Show it muted as Idle so a 47h-old pinned session never reads as current.
1154
- const stale = typeof score.lastActiveMs === "number" && score.lastActiveMs > 600000;
1155
- const shownColor = stale ? "idle" : color;
1156
- dot.className = "dot " + shownColor;
1157
- stateDot.className = stale ? "state-dot" : "state-dot " + color;
1390
+ const info = activeSessionInfo(score, state);
1391
+
1392
+ dot.className = "dot " + color;
1393
+ stateDot.className = "state-dot " + color;
1158
1394
  hud.classList.remove("s-green", "s-amber", "s-red");
1159
- if (!stale) hud.classList.add("s-" + color);
1160
- main.textContent = (stale ? "Idle" : stateName) + " - " + fmt(score.ctTokens) + " ctx";
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);
1395
+ hud.classList.add("s-" + color);
1396
+ main.innerHTML = health + '%<span class="main-rest"> of tokens are useful</span>';
1397
+ renderMini(color, health + "%", info);
1398
+ client.textContent = info.title || info.repo;
1399
+ if (bubbleAgent) {
1400
+ bubbleAgent.src = iconForFamily(agentFamily(score.client) || "codex");
1401
+ bubbleAgent.style.display = "inline-block";
1402
+ }
1403
+ const bubbleRepo = info.repo && info.repo !== info.title ? info.repo : "";
1404
+ if (clientRepo) clientRepo.textContent = bubbleRepo;
1405
+ if (repoLine) repoLine.style.display = bubbleRepo ? "flex" : "none";
1406
+ client.classList.remove("voice");
1168
1407
  activityEpoch = typeof score.lastActiveMs === "number" ? Date.now() - score.lastActiveMs : null;
1169
- renderUpdated();
1170
- turnEl.textContent = typeof score.turn === "number" && score.turn > 0 ? "turn " + score.turn : "";
1171
- stateWordEl.textContent = stale ? "Idle" : stateName;
1172
- fullness.textContent = fullnessLine(sat, score);
1173
- fullness.style.color = stale ? "#6b675d" : tone(color);
1174
- lead.textContent = stale ? "This session is idle - showing its last state" : leadLine(color);
1175
- lead.style.color = stale ? "#6b675d" : tone(color);
1176
- evidence.textContent = evidenceLine(score, oldPct, repeatCount);
1177
- usefulbar.style.width = Math.max(0, 100 - oldPct) + "%";
1178
- oldbar.style.width = oldPct + "%";
1179
- metaEl.textContent = ctSourceLabel(score.ctSource) + " · " + edits + " edits";
1408
+ const metaParts = [];
1409
+ if (typeof score.turn === "number" && score.turn > 0) metaParts.push("turn " + score.turn);
1410
+ if (score.ctTokens > 0) metaParts.push(fmt(score.ctTokens) + " tok");
1411
+ metaTail = metaParts.join(" · ");
1412
+ renderMetaLine();
1413
+ stateWordEl.textContent = health + "%";
1414
+ if (verdictEl) verdictEl.textContent = verdictFor(color, oldPct);
1415
+ if (nowIcon) nowIcon.src = iconForFamily(agentFamily(score.client) || "codex");
1416
+ if (nowTitle) nowTitle.textContent = info.title;
1417
+ if (nowRepo) nowRepo.textContent = bubbleRepo;
1418
+ if (nowRepoLine) nowRepoLine.style.display = bubbleRepo ? "flex" : "none";
1419
+ if (pinChip) pinChip.style.display = state && state.pinnedId ? "inline-block" : "none";
1420
+ if (jumpChip) {
1421
+ if (activeId) jumpChip.setAttribute("data-jump", activeId);
1422
+ jumpChip.style.display = activeId ? "inline-block" : "none";
1423
+ }
1424
+ renderWhy(score, oldPct, repeatCount, sat, health, color);
1180
1425
  pathEl.textContent = "";
1181
1426
  currentSrc = score.sourcePath || null;
1182
1427
  if (revealEl) revealEl.style.display = !DEMO_CLEAN && currentSrc ? "inline-block" : "none";
1183
1428
  missing.textContent = formatMissing(state.missing);
1184
1429
 
1185
- renderAlert(score, oldPct, repeatCount);
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
1430
  nudge.style.display = "none";
1189
1431
 
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
- }
1432
+ renewUrgent = color === "red";
1196
1433
  const restoredPrompt = restoreCachedCheckpointFor(score, currentSessionKey);
1197
1434
  if (!restoredPrompt) {
1198
1435
  const restoredStatus = restoreCheckpointStatusFor(score, currentSessionKey);
1199
1436
  if (!restoredStatus) maybeFetchCheckpointStatus(score, currentSessionKey);
1200
1437
  }
1438
+ // A generated context replaces the call to generate one: while the result card is up
1439
+ // for this session, the big button stays hidden (Renew in the card re-runs the flow).
1440
+ const resultShowing = renewResult && renewResult.style.display === "block" && renewResultSessionKey === currentSessionKey;
1441
+ if (renewBtn) {
1442
+ renewBtn.style.display = resultShowing && !renewStartMs ? "none" : "block";
1443
+ refreshRenewLabel();
1444
+ }
1201
1445
  prevTurnState = {
1202
1446
  src: score.sourcePath,
1203
1447
  turn: score.turn,
@@ -1214,92 +1458,84 @@ export const HUD_HTML = String.raw `<!doctype html>
1214
1458
  stateDot.className = "state-dot";
1215
1459
  hud.classList.remove("s-green", "s-amber", "s-red");
1216
1460
  prevTurnState = null;
1217
- main.textContent = "No active session yet";
1218
- if (mdot) mdot.className = "mdot idle";
1219
- if (mtext) mtext.textContent = "EchoMem";
1220
- client.textContent = "EchoMem HUD - " + hudVersion;
1461
+ main.textContent = "MCP on";
1462
+ renderMini("idle", "on", { client: "EchoMem", title: "MCP", repo: "" });
1463
+ client.textContent = "Waiting for Codex or Claude";
1464
+ if (bubbleAgent) bubbleAgent.style.display = "none";
1465
+ if (clientRepo) clientRepo.textContent = "";
1466
+ if (repoLine) repoLine.style.display = "none";
1467
+ client.classList.remove("voice");
1221
1468
  activityEpoch = null;
1222
- if (updatedEl) updatedEl.textContent = "";
1469
+ metaTail = "";
1470
+ renderMetaLine();
1223
1471
  currentSrc = null;
1224
1472
  if (revealEl) revealEl.style.display = "none";
1225
- turnEl.textContent = "";
1226
- stateWordEl.textContent = "Fresh";
1227
- fullness.textContent = "";
1228
- lead.textContent = "Waiting for a live Codex or Claude session";
1229
- lead.style.color = "#6b675d";
1230
- evidence.textContent = "Start working and EchoMem will show when the context is still clean, drifting, or ready to renew.";
1231
- usefulbar.style.width = "100%";
1232
- oldbar.style.width = "0";
1233
- alertEl.style.display = "none";
1473
+ stateWordEl.textContent = "on";
1474
+ if (verdictEl) verdictEl.textContent = "Waiting for Codex or Claude.";
1475
+ if (nowTitle) nowTitle.textContent = "EchoMem MCP";
1476
+ if (nowRepo) nowRepo.textContent = "";
1477
+ if (nowRepoLine) nowRepoLine.style.display = "none";
1478
+ if (pinChip) pinChip.style.display = "none";
1479
+ if (jumpChip) jumpChip.style.display = "none";
1480
+ evidence.textContent = "";
1481
+ if (whyBtn) whyBtn.style.display = "none";
1482
+ whyOpen = false;
1483
+ hud.classList.remove("show-why");
1234
1484
  nudge.style.display = "none";
1235
1485
  if (renewBtn) renewBtn.style.display = "none";
1236
1486
  clearRenewResult();
1237
- metaEl.textContent = "";
1238
1487
  pathEl.textContent = "";
1239
1488
  missing.textContent = formatMissing(state && state.missing);
1240
1489
  syncHeight();
1241
1490
  }
1242
1491
 
1243
- function renderAlert(score, oldPct, repeatCount) {
1244
- if (!alertEl) return;
1245
- const turnChanged = prevTurnState && prevTurnState.src === score.sourcePath && score.turn !== prevTurnState.turn;
1246
- const dCt = turnChanged ? (score.ctTokens || 0) - prevTurnState.ct : 0;
1247
- const dReads = turnChanged ? (score.reads || 0) - prevTurnState.reads : 0;
1248
- const dRep = turnChanged ? repeatCount - prevTurnState.repeat : 0;
1249
- const dOld = turnChanged ? (score.pollutionTok || 0) - prevTurnState.oldTok : 0;
1250
- if (dRep > 0 || dOld > 2000) {
1251
- alertTitle.textContent = "this turn re-read what it already had";
1252
- alertDetail.textContent = joinParts([
1253
- dOld > 0 ? "+" + fmt(dOld) + " old weight" : "",
1254
- dReads > 0 ? "+" + dReads + " read" + (dReads === 1 ? "" : "s") : "0 new files",
1255
- dCt !== 0 ? signedFmt(dCt) + " ctx" : "",
1256
- ]);
1257
- alertEl.style.display = "block";
1258
- return;
1492
+ function renderMini(color, scoreText, info) {
1493
+ if (mdot) mdot.className = "mdot " + color;
1494
+ const fam = agentFamily(info && info.clientRaw);
1495
+ if (micon) {
1496
+ micon.src = iconForFamily(fam || "codex");
1497
+ micon.style.display = fam ? "inline-block" : "none";
1259
1498
  }
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).
1262
- alertEl.style.display = "none";
1263
- }
1264
-
1265
- function renderNudge(color, sat) {
1266
- let text = "";
1267
- let cls = "nudge";
1268
- if (color === "red") {
1269
- text = "A fresh start now beats pushing past this - keep the goal, drop the clutter.";
1270
- cls += " red";
1271
- } else if (color === "amber" || sat >= 75) {
1272
- text = "Still fine - a fresh start soon will keep this snappy.";
1273
- cls += " calm";
1499
+ if (mscore) mscore.textContent = scoreText;
1500
+ if (msession) msession.textContent = miniTitle(info);
1501
+ const repo = info && info.repo ? info.repo : "";
1502
+ if (mrepo) mrepo.textContent = repo;
1503
+ if (mrepoLine) mrepoLine.style.display = repo ? "flex" : "none";
1504
+ }
1505
+
1506
+ function renderWhy(score, oldPct, repeatCount, sat, health, color) {
1507
+ const rows = [];
1508
+ if (sat > 0) rows.push(["context", score.modelContextWindow ? sat + "% full · " + fmt(score.ctTokens) : fmt(score.ctTokens) + " tokens"]);
1509
+ if (score.pollutionTok > 0) rows.push(["junk", fmt(score.pollutionTok) + " (" + Math.round(oldPct) + "% of window)"]);
1510
+ for (const row of junkBreakdown(score)) rows.push(row);
1511
+ rows.push(["source", ctSourceLabel(score.ctSource)]);
1512
+ 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("");
1513
+ const useful = color !== "green" || oldPct >= 10 || repeatCount > 0 || sat >= 80;
1514
+ if (whyBtn) {
1515
+ whyBtn.style.display = useful ? "inline-flex" : "none";
1516
+ whyBtn.textContent = whyOpen ? "−" : "+";
1517
+ }
1518
+ if (!useful) {
1519
+ whyOpen = false;
1520
+ hud.classList.remove("show-why");
1521
+ } else {
1522
+ hud.classList.toggle("show-why", whyOpen);
1274
1523
  }
1275
- nudge.textContent = text;
1276
- nudge.className = cls;
1277
- nudge.style.display = text ? "block" : "none";
1278
- }
1279
-
1280
- function stateWord(color) {
1281
- if (color === "red") return "Drifted";
1282
- if (color === "amber") return "Drifting";
1283
- return "Fresh";
1284
- }
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.
1288
- function leadLine(color) {
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.";
1292
- }
1293
-
1294
- function evidenceLine(score, oldPct, repeatCount) {
1295
- const repeat = repeatCount > 0 ? repeatCount + " repeated read" + (repeatCount === 1 ? "" : "s") : "no re-reads yet";
1296
- return fmt(score.pollutionTok) + " old context · " + repeat;
1297
1524
  }
1298
1525
 
1299
- function fullnessLine(sat, score) {
1300
- if (!score.modelContextWindow) return fmt(score.ctTokens) + " context tokens";
1301
- const room = sat >= 85 ? " - compaction near" : sat <= 30 ? " - plenty of room" : "";
1302
- return sat + "% full - " + fmt(score.ctTokens) + " / " + fmt(score.modelContextWindow) + room;
1526
+ // Top junk categories from the latest local-live waste partition, biggest first.
1527
+ function junkBreakdown(score) {
1528
+ const buckets = score.buckets || {};
1529
+ const tok = (key) => (buckets[key] && buckets[key].tokens) || 0;
1530
+ const copies = buckets.range_redundant || {};
1531
+ const entries = [
1532
+ ["stale screenshots", tok("superseded_image"), ""],
1533
+ ["superseded copies", tok("range_redundant"), copies.count > 0 ? " · " + copies.count + " re-read" : ""],
1534
+ ["carried tool output", tok("stale_tool_output"), ""],
1535
+ ["outdated reads", tok("stale_read"), ""],
1536
+ ].filter((entry) => entry[1] > 0);
1537
+ entries.sort((a, b) => b[1] - a[1]);
1538
+ return entries.map((entry) => ["· " + entry[0], fmt(entry[1]) + entry[2]]);
1303
1539
  }
1304
1540
 
1305
1541
  function fullnessPct(score) {
@@ -1308,10 +1544,10 @@ export const HUD_HTML = String.raw `<!doctype html>
1308
1544
  return 0;
1309
1545
  }
1310
1546
 
1311
- function tone(color) {
1312
- if (color === "red") return "#c7372f";
1313
- if (color === "amber") return "#8a6a12";
1314
- return "#2f7d4f";
1547
+ function healthScore(score) {
1548
+ if (typeof score.healthScorePct === "number" && isFinite(score.healthScorePct)) return clamp(score.healthScorePct, 0, 100);
1549
+ if (typeof score.usefulPct === "number" && isFinite(score.usefulPct)) return clamp(score.usefulPct, 0, 100);
1550
+ return 0;
1315
1551
  }
1316
1552
 
1317
1553
  function ctSourceLabel(src) {
@@ -1324,13 +1560,23 @@ export const HUD_HTML = String.raw `<!doctype html>
1324
1560
  return "Not seeing " + list.map(label).join(", ") + " yet";
1325
1561
  }
1326
1562
 
1327
- // Subtitle: which session this is (client + project) + version. The "other clients working"
1328
- // signal now lives in the live-spinner tabs, so it's dropped here to keep the line short.
1329
- function sessionIdentity(score) {
1330
- const parts = [label(score.client)];
1331
- if (score.label) parts.push(score.label);
1332
- parts.push(hudVersion);
1333
- return parts.join(" · ");
1563
+ function activeSessionInfo(score, state) {
1564
+ const list = (state && state.recentSessions) || [];
1565
+ const match = list.filter((s) => s.client === score.client && s.sourcePath === score.sourcePath)[0];
1566
+ const repo = String((match && match.label) || score.label || "");
1567
+ const title = String((match && match.title) || repo || label(score.client));
1568
+ return {
1569
+ client: label(score.client),
1570
+ clientRaw: score.client,
1571
+ title,
1572
+ repo,
1573
+ };
1574
+ }
1575
+
1576
+ function miniTitle(info) {
1577
+ if (!info) return "EchoMem";
1578
+ const title = info.title && info.title !== info.client ? info.title : info.repo;
1579
+ return title || info.client;
1334
1580
  }
1335
1581
 
1336
1582
  function renderSwitchButton(state, activeClient) {
@@ -1361,22 +1607,43 @@ export const HUD_HTML = String.raw `<!doctype html>
1361
1607
  return list.filter((s) => agentFamily(s.client) === targetFamily)[0] || null;
1362
1608
  }
1363
1609
 
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.
1610
+ // History rows: recent sessions that are NOT the current one (the current session lives in the
1611
+ // now-card above every number and action up there belongs to it). Clicking a row pins it as
1612
+ // the new current session; the card's "pinned ×" chip releases back to auto-follow. Scored
1613
+ // sessions carry their own health dot + %, so "Echo is following you across agents" reads off
1614
+ // the list itself. Family-scoped, so Codex never recommends Claude sessions or vice versa.
1366
1615
  function renderTabs(state, activeId, activeClient) {
1367
1616
  if (!tabsEl) return;
1368
- const list = visibleRecentSessions(state, activeClient);
1369
- if (list.length <= 1) { tabsEl.innerHTML = ""; return; }
1370
- const pinnedId = state && state.pinnedId ? state.pinnedId : null;
1617
+ const list = visibleRecentSessions(state, activeClient).filter((s) => s.id !== activeId);
1618
+ if (!list.length) { tabsEl.innerHTML = ""; return; }
1371
1619
  const chips = list.map((s) => {
1372
- const cls = s.id === pinnedId ? "tab active" : !pinnedId && s.id === activeId ? "tab auto-pick" : "tab";
1373
- const dot = s.live ? '<span class="spin"></span>' : "";
1620
+ const activity = s.live ? '<span class="spin"></span>' : "";
1621
+ const hv = sessionHealthFor(state, s);
1622
+ const status = hv
1623
+ ? '<span class="tab-health"><span class="tab-status ' + esc(hv.color) + '"></span>' + hv.health + "%</span>"
1624
+ : "";
1374
1625
  const title = s.title || label(s.client);
1375
- return '<button class="' + cls + '" data-id="' + esc(s.id) + '" title="' + esc(label(s.client) + " · " + title) + '">' + dot + esc(shortTitle(title)) + "</button>";
1626
+ const repo = s.label && s.label !== title ? s.label : "";
1627
+ const family = agentFamily(s.client);
1628
+ const repoCell = repo
1629
+ ? '<span class="tab-repo"><img class="tab-gh" src="/assets/hud/github.svg" alt="" /><span class="tab-reponame">' + esc(repo) + "</span></span>"
1630
+ : '<span class="tab-repo"><span class="tab-reponame">' + esc(label(s.client)) + "</span></span>";
1631
+ const time = typeof s.lastActiveMs === "number" ? '<span class="tab-time" title="Last active here">' + esc(relTime(s.lastActiveMs)) + "</span>" : "";
1632
+ return '<button class="tab" data-id="' + esc(s.id) + '" title="' + esc("Switch to · " + label(s.client) + " · " + title + (repo ? " · " + repo : "")) + '">'
1633
+ + '<img class="tab-icon" src="' + iconForFamily(family) + '" alt="" />'
1634
+ + '<span class="tab-copy"><span class="tab-title">' + esc(title) + activity + "</span>" + repoCell + "</span>" + status + time
1635
+ + '<span class="tab-jump" data-jump="' + esc(s.id) + '" title="Open this conversation in ' + esc(label(s.client)) + '">&#8599;</span></button>';
1376
1636
  });
1377
- chips.unshift('<button class="tab' + (pinnedId ? "" : " active") + '" data-id="auto" title="Auto-select the current foreground agent session">Auto</button>');
1378
1637
  tabsEl.innerHTML = chips.join("");
1379
1638
  }
1639
+ // Health for a listed session, joined from the scored views the monitor already computes.
1640
+ // Sessions Echo isn't actively scoring get no dot — honest absence, not a fake green.
1641
+ function sessionHealthFor(state, s) {
1642
+ const views = ((state && state.sessions) || []).concat((state && state.scores) || []);
1643
+ const m = views.filter((v) => v && v.sourcePath === s.sourcePath && v.client === s.client && typeof v.usefulPct === "number")[0];
1644
+ if (!m) return null;
1645
+ return { color: m.color || "green", health: healthScore(m) };
1646
+ }
1380
1647
  function visibleRecentSessions(state, activeClient) {
1381
1648
  const list = (state && state.recentSessions) || [];
1382
1649
  const family = agentFamily(activeClient);
@@ -1404,9 +1671,19 @@ export const HUD_HTML = String.raw `<!doctype html>
1404
1671
  return String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
1405
1672
  }
1406
1673
 
1407
- function renderUpdated() {
1408
- if (!updatedEl) return;
1409
- updatedEl.textContent = activityEpoch === null ? "" : "updated " + relTime(Date.now() - activityEpoch);
1674
+ // One quiet mono line for all technical metadata: "16s ago · turn 2 · 178k tok".
1675
+ function renderMetaLine() {
1676
+ if (!metaLineEl) return;
1677
+ const upd = activityEpoch === null ? "" : relTime(Date.now() - activityEpoch);
1678
+ metaLineEl.textContent = [upd, metaTail].filter(Boolean).join(" · ");
1679
+ }
1680
+ // One plain sentence saying what the score means for THIS session. Direct, no hedging,
1681
+ // and no second number — the score above is the only number on the card.
1682
+ function verdictFor(color, oldPct) {
1683
+ const usefulPct = 100 - clamp(oldPct || 0, 0, 100);
1684
+ if (color === "red") return usefulPct <= 20 ? "Mostly junk — carry over now." : "Too much junk – consider to carry over.";
1685
+ if (color === "amber") return "Junk is piling up.";
1686
+ return "Context is clean.";
1410
1687
  }
1411
1688
  function relTime(ms) {
1412
1689
  const s = Math.max(0, Math.round(ms / 1000));
@@ -1416,7 +1693,7 @@ export const HUD_HTML = String.raw `<!doctype html>
1416
1693
  if (m < 60) return m + "m ago";
1417
1694
  return Math.round(m / 60) + "h ago";
1418
1695
  }
1419
- setInterval(renderUpdated, 1000);
1696
+ setInterval(renderMetaLine, 1000);
1420
1697
 
1421
1698
  function label(clientName) {
1422
1699
  if (clientName === "codex") return "Codex";
@@ -1432,14 +1709,6 @@ export const HUD_HTML = String.raw `<!doctype html>
1432
1709
  return String(Math.round(n));
1433
1710
  }
1434
1711
 
1435
- function signedFmt(value) {
1436
- return (value >= 0 ? "+" : "-") + fmt(Math.abs(value));
1437
- }
1438
-
1439
- function joinParts(parts) {
1440
- return parts.filter(Boolean).join(" - ");
1441
- }
1442
-
1443
1712
  function clamp(value, min, max) {
1444
1713
  return Math.max(min, Math.min(max, Number(value) || 0));
1445
1714
  }