somtum 0.1.3 → 1.2.0

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.
@@ -0,0 +1,1290 @@
1
+ <!doctype html>
2
+ <html lang="en" class="dark">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Somtum</title>
7
+ <script src="https://cdn.tailwindcss.com"></script>
8
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Syne:wght@400;600;700;800&family=JetBrains+Mono:wght@400;500&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <script>
14
+ tailwind.config = {
15
+ darkMode: 'class',
16
+ theme: {
17
+ extend: {
18
+ colors: {
19
+ bg: '#07080b',
20
+ surface: '#0d0f14',
21
+ surface2: '#131720',
22
+ border: '#1a1f2c',
23
+ 'border-s': '#252d3e',
24
+ papaya: '#4ade80',
25
+ chili: '#f87171',
26
+ carrot: '#fbbf24',
27
+ teal: '#22d3ee',
28
+ cobalt: '#60a5fa',
29
+ violet: '#a78bfa',
30
+ },
31
+ fontFamily: {
32
+ display: ['Syne', 'sans-serif'],
33
+ mono: ['JetBrains Mono', 'monospace'],
34
+ },
35
+ },
36
+ },
37
+ };
38
+ </script>
39
+ <style>
40
+ *,
41
+ *::before,
42
+ *::after {
43
+ box-sizing: border-box;
44
+ }
45
+ html,
46
+ body {
47
+ height: 100%;
48
+ overflow: hidden;
49
+ margin: 0;
50
+ }
51
+ body {
52
+ background: #07080b;
53
+ color: #c5c9d4;
54
+ font-family: 'JetBrains Mono', monospace;
55
+ font-size: 12.5px;
56
+ -webkit-font-smoothing: antialiased;
57
+ }
58
+
59
+ /* ── Scrollbars ────────────────────────────────────────────── */
60
+ ::-webkit-scrollbar {
61
+ width: 3px;
62
+ height: 3px;
63
+ }
64
+ ::-webkit-scrollbar-track {
65
+ background: transparent;
66
+ }
67
+ ::-webkit-scrollbar-thumb {
68
+ background: #252d3e;
69
+ border-radius: 2px;
70
+ }
71
+ ::-webkit-scrollbar-thumb:hover {
72
+ background: #333d52;
73
+ }
74
+
75
+ /* ── Stat chips ────────────────────────────────────────────── */
76
+ .stat-chip {
77
+ display: flex;
78
+ flex-direction: column;
79
+ align-items: center;
80
+ padding: 5px 12px;
81
+ background: #0d0f14;
82
+ border: 1px solid #1a1f2c;
83
+ border-radius: 6px;
84
+ min-width: 76px;
85
+ cursor: default;
86
+ }
87
+ .stat-chip .lbl {
88
+ font-size: 8.5px;
89
+ text-transform: uppercase;
90
+ letter-spacing: 0.1em;
91
+ color: #333d52;
92
+ font-family: 'Syne', sans-serif;
93
+ font-weight: 800;
94
+ }
95
+ .stat-chip .val {
96
+ font-size: 15px;
97
+ font-weight: 700;
98
+ font-family: 'Syne', sans-serif;
99
+ line-height: 1.2;
100
+ margin-top: 1px;
101
+ }
102
+
103
+ /* ── Tab buttons ───────────────────────────────────────────── */
104
+ .tab-btn {
105
+ padding: 9px 16px;
106
+ font-family: 'Syne', sans-serif;
107
+ font-size: 11px;
108
+ font-weight: 800;
109
+ letter-spacing: 0.06em;
110
+ text-transform: uppercase;
111
+ color: #333d52;
112
+ border: none;
113
+ border-bottom: 2px solid transparent;
114
+ background: none;
115
+ cursor: pointer;
116
+ transition:
117
+ color 0.15s,
118
+ border-color 0.15s;
119
+ }
120
+ .tab-btn:hover {
121
+ color: #7a8299;
122
+ }
123
+ .tab-btn.active {
124
+ color: #4ade80;
125
+ border-bottom-color: #4ade80;
126
+ }
127
+
128
+ /* ── Strategy buttons ──────────────────────────────────────── */
129
+ .strat-btn {
130
+ padding: 2px 8px;
131
+ font-size: 9.5px;
132
+ font-family: 'Syne', sans-serif;
133
+ font-weight: 800;
134
+ letter-spacing: 0.04em;
135
+ border: 1px solid #1a1f2c;
136
+ border-radius: 4px;
137
+ cursor: pointer;
138
+ background: #0d0f14;
139
+ color: #333d52;
140
+ transition: all 0.12s;
141
+ }
142
+ .strat-btn:hover {
143
+ border-color: #252d3e;
144
+ color: #7a8299;
145
+ }
146
+ .strat-btn.active {
147
+ background: rgba(34, 211, 238, 0.08);
148
+ border-color: rgba(34, 211, 238, 0.4);
149
+ color: #22d3ee;
150
+ }
151
+
152
+ /* ── Kind filter chips ─────────────────────────────────────── */
153
+ .kind-chip {
154
+ padding: 2px 7px;
155
+ font-size: 9.5px;
156
+ font-family: 'Syne', sans-serif;
157
+ font-weight: 800;
158
+ border: 1px solid transparent;
159
+ border-radius: 3px;
160
+ cursor: pointer;
161
+ transition: all 0.12s;
162
+ background: #131720;
163
+ color: #333d52;
164
+ }
165
+ .kind-chip:hover {
166
+ color: #7a8299;
167
+ border-color: #252d3e;
168
+ }
169
+
170
+ /* ── Memory cards ──────────────────────────────────────────── */
171
+ .mem-card {
172
+ padding: 9px 10px;
173
+ background: #0d0f14;
174
+ border: 1px solid #1a1f2c;
175
+ border-radius: 6px;
176
+ cursor: pointer;
177
+ transition:
178
+ border-color 0.12s,
179
+ background 0.12s;
180
+ position: relative;
181
+ }
182
+ .mem-card:hover {
183
+ border-color: #252d3e;
184
+ background: #101318;
185
+ }
186
+ .mem-card.selected {
187
+ border-color: #4ade80 !important;
188
+ background: rgba(74, 222, 128, 0.035) !important;
189
+ }
190
+ .forget-btn {
191
+ display: none;
192
+ position: absolute;
193
+ top: 7px;
194
+ right: 7px;
195
+ width: 18px;
196
+ height: 18px;
197
+ align-items: center;
198
+ justify-content: center;
199
+ border-radius: 3px;
200
+ background: rgba(248, 113, 113, 0.08);
201
+ border: 1px solid rgba(248, 113, 113, 0.2);
202
+ color: #f87171;
203
+ font-size: 9px;
204
+ cursor: pointer;
205
+ transition: background 0.12s;
206
+ font-family: 'Syne', sans-serif;
207
+ font-weight: 800;
208
+ }
209
+ .mem-card:hover .forget-btn {
210
+ display: flex;
211
+ }
212
+ .forget-btn:hover {
213
+ background: rgba(248, 113, 113, 0.16) !important;
214
+ }
215
+
216
+ /* ── Analytics cards ───────────────────────────────────────── */
217
+ .a-card {
218
+ background: #0d0f14;
219
+ border: 1px solid #1a1f2c;
220
+ border-radius: 8px;
221
+ padding: 16px;
222
+ }
223
+ .a-title {
224
+ font-size: 9px;
225
+ font-family: 'Syne', sans-serif;
226
+ font-weight: 800;
227
+ text-transform: uppercase;
228
+ letter-spacing: 0.1em;
229
+ color: #333d52;
230
+ margin-bottom: 14px;
231
+ }
232
+
233
+ /* ── Progress bars ─────────────────────────────────────────── */
234
+ .bar-track {
235
+ height: 3px;
236
+ background: #1a1f2c;
237
+ border-radius: 2px;
238
+ flex: 1;
239
+ overflow: hidden;
240
+ }
241
+ .bar-fill {
242
+ height: 100%;
243
+ border-radius: 2px;
244
+ width: 0;
245
+ transition: width 0.7s cubic-bezier(0.16, 1, 0.3, 1);
246
+ }
247
+
248
+ /* ── Graph ─────────────────────────────────────────────────── */
249
+ #graph-wrap {
250
+ flex: 1;
251
+ min-height: 0;
252
+ margin: 10px;
253
+ border: 1px solid #1a1f2c;
254
+ border-radius: 8px;
255
+ background: #09090d;
256
+ overflow: hidden;
257
+ }
258
+
259
+ /* ── Detail drawer ─────────────────────────────────────────── */
260
+ #detail-drawer {
261
+ border-top: 1px solid #1a1f2c;
262
+ background: #0b0d11;
263
+ overflow-y: auto;
264
+ flex-shrink: 0;
265
+ max-height: 40vh;
266
+ }
267
+
268
+ /* ── Animations ────────────────────────────────────────────── */
269
+ @keyframes fadeUp {
270
+ from {
271
+ opacity: 0;
272
+ transform: translateY(5px);
273
+ }
274
+ to {
275
+ opacity: 1;
276
+ transform: translateY(0);
277
+ }
278
+ }
279
+ .fade-up {
280
+ animation: fadeUp 0.18s ease forwards;
281
+ }
282
+
283
+ @keyframes shimmer {
284
+ 0%,
285
+ 100% {
286
+ opacity: 0.35;
287
+ }
288
+ 50% {
289
+ opacity: 0.65;
290
+ }
291
+ }
292
+ .skel {
293
+ background: #131720;
294
+ border-radius: 5px;
295
+ animation: shimmer 1.4s ease-in-out infinite;
296
+ }
297
+
298
+ /* ── Empty states ──────────────────────────────────────────── */
299
+ .empty {
300
+ display: flex;
301
+ flex-direction: column;
302
+ align-items: center;
303
+ justify-content: center;
304
+ padding: 40px 20px;
305
+ color: #333d52;
306
+ font-family: 'Syne', sans-serif;
307
+ font-weight: 700;
308
+ font-size: 11px;
309
+ text-align: center;
310
+ gap: 8px;
311
+ }
312
+ .empty svg {
313
+ opacity: 0.25;
314
+ }
315
+
316
+ /* ── File rows ─────────────────────────────────────────────── */
317
+ .file-row {
318
+ display: flex;
319
+ align-items: center;
320
+ gap: 8px;
321
+ padding: 5px 0;
322
+ border-bottom: 1px solid #13161e;
323
+ }
324
+ .file-row:last-child {
325
+ border-bottom: none;
326
+ }
327
+ </style>
328
+ </head>
329
+ <body>
330
+ <!-- ═══════════════════════ HEADER ═══════════════════════════ -->
331
+ <header
332
+ style="
333
+ height: 49px;
334
+ border-bottom: 1px solid #1a1f2c;
335
+ padding: 0 18px;
336
+ display: flex;
337
+ align-items: center;
338
+ justify-content: space-between;
339
+ flex-shrink: 0;
340
+ "
341
+ >
342
+ <div style="display: flex; align-items: center; gap: 10px">
343
+ <span
344
+ style="
345
+ font-family: Syne;
346
+ font-size: 18px;
347
+ font-weight: 800;
348
+ color: #4ade80;
349
+ letter-spacing: -0.02em;
350
+ "
351
+ >somtum</span
352
+ >
353
+ <span style="width: 1px; height: 16px; background: #1a1f2c"></span>
354
+ <span
355
+ id="project-id"
356
+ style="
357
+ font-size: 10px;
358
+ color: #2e3547;
359
+ max-width: 280px;
360
+ overflow: hidden;
361
+ text-overflow: ellipsis;
362
+ white-space: nowrap;
363
+ "
364
+ ></span>
365
+ </div>
366
+ <div id="stat-chips" style="display: flex; gap: 6px">
367
+ <div class="stat-chip">
368
+ <span class="lbl">memories</span
369
+ ><span id="s-mem" class="val" style="color: #4ade80">—</span>
370
+ </div>
371
+ <div class="stat-chip">
372
+ <span class="lbl">saved</span
373
+ ><span id="s-saved" class="val" style="color: #fbbf24">—</span>
374
+ </div>
375
+ <div class="stat-chip">
376
+ <span class="lbl">spent</span
377
+ ><span id="s-spent" class="val" style="color: #c5c9d4">—</span>
378
+ </div>
379
+ <div class="stat-chip">
380
+ <span class="lbl">roi</span><span id="s-roi" class="val" style="color: #22d3ee">—</span>
381
+ </div>
382
+ <div class="stat-chip">
383
+ <span class="lbl">cache hit</span
384
+ ><span id="s-cache" class="val" style="color: #a78bfa">—</span>
385
+ </div>
386
+ </div>
387
+ </header>
388
+
389
+ <!-- ═══════════════════════ BODY ════════════════════════════= -->
390
+ <div style="display: flex; height: calc(100vh - 49px); overflow: hidden">
391
+ <!-- ─── LEFT SIDEBAR: Memory browser ────────────────────── -->
392
+ <aside
393
+ style="
394
+ width: 296px;
395
+ flex-shrink: 0;
396
+ border-right: 1px solid #1a1f2c;
397
+ display: flex;
398
+ flex-direction: column;
399
+ overflow: hidden;
400
+ "
401
+ >
402
+ <!-- Search + strategy -->
403
+ <div
404
+ style="
405
+ padding: 10px;
406
+ border-bottom: 1px solid #1a1f2c;
407
+ display: flex;
408
+ flex-direction: column;
409
+ gap: 7px;
410
+ "
411
+ >
412
+ <div style="position: relative">
413
+ <svg
414
+ style="
415
+ position: absolute;
416
+ left: 8px;
417
+ top: 6.5px;
418
+ width: 13px;
419
+ height: 13px;
420
+ color: #2e3547;
421
+ pointer-events: none;
422
+ "
423
+ fill="none"
424
+ stroke="currentColor"
425
+ viewBox="0 0 24 24"
426
+ >
427
+ <path
428
+ stroke-linecap="round"
429
+ stroke-linejoin="round"
430
+ stroke-width="2"
431
+ d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0"
432
+ />
433
+ </svg>
434
+ <input
435
+ id="search-input"
436
+ type="text"
437
+ placeholder="search memories…"
438
+ autocomplete="off"
439
+ style="
440
+ width: 100%;
441
+ background: #0a0c10;
442
+ border: 1px solid #1a1f2c;
443
+ border-radius: 5px;
444
+ padding: 6px 8px 6px 26px;
445
+ font-size: 12px;
446
+ font-family: 'JetBrains Mono', monospace;
447
+ color: #c5c9d4;
448
+ outline: none;
449
+ transition: border-color 0.15s;
450
+ "
451
+ onfocus="this.style.borderColor = '#22d3ee'"
452
+ onblur="this.style.borderColor = '#1a1f2c'"
453
+ />
454
+ </div>
455
+ <div style="display: flex; align-items: center; gap: 6px">
456
+ <span
457
+ style="
458
+ font-size: 8.5px;
459
+ color: #2e3547;
460
+ font-family: Syne;
461
+ font-weight: 800;
462
+ text-transform: uppercase;
463
+ letter-spacing: 0.1em;
464
+ white-space: nowrap;
465
+ "
466
+ >via</span
467
+ >
468
+ <div id="strategy-btns" style="display: flex; gap: 4px"></div>
469
+ <div id="search-spinner" style="display: none; margin-left: 4px">
470
+ <svg
471
+ width="11"
472
+ height="11"
473
+ viewBox="0 0 24 24"
474
+ fill="none"
475
+ stroke="#22d3ee"
476
+ stroke-width="2.5"
477
+ style="animation: spin 0.8s linear infinite"
478
+ >
479
+ <path
480
+ d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83"
481
+ />
482
+ </svg>
483
+ </div>
484
+ </div>
485
+ </div>
486
+
487
+ <!-- Kind filter -->
488
+ <div
489
+ id="kind-filter"
490
+ style="
491
+ padding: 8px 10px;
492
+ border-bottom: 1px solid #1a1f2c;
493
+ display: flex;
494
+ flex-wrap: wrap;
495
+ gap: 4px;
496
+ flex-shrink: 0;
497
+ "
498
+ ></div>
499
+
500
+ <!-- List header -->
501
+ <div
502
+ style="
503
+ padding: 6px 10px;
504
+ display: flex;
505
+ align-items: center;
506
+ justify-content: space-between;
507
+ border-bottom: 1px solid #13161e;
508
+ "
509
+ >
510
+ <span
511
+ style="
512
+ font-size: 9px;
513
+ font-family: Syne;
514
+ font-weight: 800;
515
+ text-transform: uppercase;
516
+ letter-spacing: 0.1em;
517
+ color: #2e3547;
518
+ "
519
+ >memories</span
520
+ >
521
+ <span
522
+ id="list-count"
523
+ style="font-size: 9px; font-family: 'JetBrains Mono'; color: #2e3547"
524
+ ></span>
525
+ </div>
526
+
527
+ <!-- Memory list -->
528
+ <div
529
+ id="memory-list"
530
+ style="
531
+ flex: 1;
532
+ overflow-y: auto;
533
+ padding: 6px;
534
+ display: flex;
535
+ flex-direction: column;
536
+ gap: 4px;
537
+ "
538
+ ></div>
539
+ </aside>
540
+
541
+ <!-- ─── RIGHT PANEL ───────────────────────────────────────── -->
542
+ <main style="flex: 1; display: flex; flex-direction: column; overflow: hidden; min-width: 0">
543
+ <!-- Tab bar -->
544
+ <div
545
+ style="
546
+ border-bottom: 1px solid #1a1f2c;
547
+ padding: 0 6px;
548
+ display: flex;
549
+ align-items: center;
550
+ flex-shrink: 0;
551
+ background: #07080b;
552
+ "
553
+ >
554
+ <button class="tab-btn active" data-tab="graph">Graph</button>
555
+ <button class="tab-btn" data-tab="analytics">Analytics</button>
556
+ <div style="flex: 1"></div>
557
+ <span
558
+ id="tab-info"
559
+ style="
560
+ font-size: 9px;
561
+ font-family: Syne;
562
+ font-weight: 700;
563
+ color: #2e3547;
564
+ padding-right: 14px;
565
+ "
566
+ ></span>
567
+ </div>
568
+
569
+ <!-- Tab content -->
570
+ <div
571
+ style="flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0"
572
+ >
573
+ <!-- GRAPH TAB -->
574
+ <div
575
+ id="tab-graph"
576
+ style="flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0"
577
+ >
578
+ <div id="graph-wrap"></div>
579
+ </div>
580
+
581
+ <!-- ANALYTICS TAB (hidden) -->
582
+ <div id="tab-analytics" style="display: none; flex: 1; overflow-y: auto; padding: 12px">
583
+ <div
584
+ id="analytics-grid"
585
+ style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px"
586
+ >
587
+ <div class="skel" style="height: 180px"></div>
588
+ <div class="skel" style="height: 180px"></div>
589
+ <div class="skel" style="height: 200px"></div>
590
+ <div class="skel" style="height: 200px"></div>
591
+ </div>
592
+ </div>
593
+ </div>
594
+
595
+ <!-- Detail drawer (hidden by default) -->
596
+ <div id="detail-drawer" style="display: none">
597
+ <div style="padding: 14px 18px">
598
+ <div
599
+ style="
600
+ display: flex;
601
+ justify-content: space-between;
602
+ align-items: flex-start;
603
+ margin-bottom: 10px;
604
+ "
605
+ >
606
+ <div>
607
+ <span
608
+ id="d-kind"
609
+ style="
610
+ font-family: Syne;
611
+ font-size: 9px;
612
+ font-weight: 800;
613
+ text-transform: uppercase;
614
+ letter-spacing: 0.08em;
615
+ padding: 2px 6px;
616
+ border-radius: 3px;
617
+ display: inline-block;
618
+ margin-bottom: 5px;
619
+ "
620
+ ></span>
621
+ <h3
622
+ id="d-title"
623
+ style="
624
+ font-family: Syne;
625
+ font-size: 15px;
626
+ font-weight: 700;
627
+ color: #dde2ee;
628
+ line-height: 1.3;
629
+ margin: 0;
630
+ "
631
+ ></h3>
632
+ <div style="display: flex; gap: 14px; margin-top: 5px">
633
+ <span id="d-date" style="font-size: 10px; color: #2e3547"></span>
634
+ <span id="d-tokens" style="font-size: 10px; color: #333d52"></span>
635
+ </div>
636
+ </div>
637
+ <button
638
+ onclick="closeDetail()"
639
+ style="
640
+ color: #2e3547;
641
+ background: none;
642
+ border: none;
643
+ cursor: pointer;
644
+ padding: 3px;
645
+ border-radius: 4px;
646
+ line-height: 1;
647
+ flex-shrink: 0;
648
+ transition: color 0.12s;
649
+ "
650
+ onmouseover="this.style.color = '#7a8299'"
651
+ onmouseout="this.style.color = '#2e3547'"
652
+ >
653
+ <svg width="13" height="13" fill="none" stroke="currentColor" viewBox="0 0 24 24">
654
+ <path
655
+ stroke-linecap="round"
656
+ stroke-linejoin="round"
657
+ stroke-width="2.5"
658
+ d="M6 18L18 6M6 6l12 12"
659
+ />
660
+ </svg>
661
+ </button>
662
+ </div>
663
+ <div
664
+ id="d-body"
665
+ style="
666
+ color: #8a91a8;
667
+ font-size: 12px;
668
+ line-height: 1.75;
669
+ white-space: pre-wrap;
670
+ margin-bottom: 12px;
671
+ max-height: 180px;
672
+ overflow-y: auto;
673
+ "
674
+ ></div>
675
+ <div
676
+ id="d-footer"
677
+ style="
678
+ display: flex;
679
+ flex-wrap: wrap;
680
+ gap: 5px;
681
+ padding-top: 10px;
682
+ border-top: 1px solid #13161e;
683
+ "
684
+ ></div>
685
+ </div>
686
+ </div>
687
+ </main>
688
+ </div>
689
+
690
+ <!-- spin keyframes (for search spinner) -->
691
+ <style>
692
+ @keyframes spin {
693
+ from {
694
+ transform: rotate(0deg);
695
+ }
696
+ to {
697
+ transform: rotate(360deg);
698
+ }
699
+ }
700
+ </style>
701
+
702
+ <script>
703
+ // ──────────────────────────────────────────────────────────────────────────────
704
+ // State
705
+ // ──────────────────────────────────────────────────────────────────────────────
706
+ const S = {
707
+ data: null, // /api/data response
708
+ fullStats: null, // /api/stats/full response
709
+ activeTab: 'graph',
710
+ activeKind: null, // kind filter
711
+ selectedId: null,
712
+ searchQuery: '',
713
+ searchResults: null,
714
+ strategy: 'bm25',
715
+ debounce: null,
716
+ network: null,
717
+ analyticsRendered: false,
718
+ };
719
+
720
+ // ──────────────────────────────────────────────────────────────────────────────
721
+ // Kind → color
722
+ // ──────────────────────────────────────────────────────────────────────────────
723
+ const KC = {
724
+ decision: '#4ade80',
725
+ bugfix: '#f87171',
726
+ learning: '#fbbf24',
727
+ command: '#60a5fa',
728
+ file_summary: '#a78bfa',
729
+ other: '#52576a',
730
+ };
731
+ function kc(kind) {
732
+ return KC[kind] ?? '#52576a';
733
+ }
734
+
735
+ // ──────────────────────────────────────────────────────────────────────────────
736
+ // Helpers
737
+ // ──────────────────────────────────────────────────────────────────────────────
738
+ function fmtN(n) {
739
+ if (n >= 1_000_000) return (n / 1_000_000).toFixed(1) + 'M';
740
+ if (n >= 1000) return (n / 1000).toFixed(1) + 'k';
741
+ return n.toLocaleString();
742
+ }
743
+
744
+ function relTime(ts) {
745
+ const d = Date.now() - ts;
746
+ const s = d / 1000,
747
+ m = s / 60,
748
+ h = m / 60,
749
+ day = h / 24;
750
+ if (s < 60) return 'just now';
751
+ if (m < 60) return Math.floor(m) + 'm ago';
752
+ if (h < 24) return Math.floor(h) + 'h ago';
753
+ if (day < 30) return Math.floor(day) + 'd ago';
754
+ return new Date(ts).toLocaleDateString();
755
+ }
756
+
757
+ function trunc(s, n) {
758
+ return s.length > n ? s.slice(0, n - 1) + '…' : s;
759
+ }
760
+
761
+ function esc(s) {
762
+ return String(s)
763
+ .replace(/&/g, '&amp;')
764
+ .replace(/</g, '&lt;')
765
+ .replace(/>/g, '&gt;')
766
+ .replace(/"/g, '&quot;');
767
+ }
768
+
769
+ // ──────────────────────────────────────────────────────────────────────────────
770
+ // Boot
771
+ // ──────────────────────────────────────────────────────────────────────────────
772
+ async function init() {
773
+ buildStrategyButtons();
774
+ buildKindFilter([]);
775
+
776
+ try {
777
+ const r = await fetch('/api/data');
778
+ S.data = await r.json();
779
+ renderHeaderStats(S.data.stats);
780
+ buildKindFilter(Object.keys(KC));
781
+ renderList(visibleMemories());
782
+ renderGraph(S.data.graph);
783
+ updateListCount();
784
+ } catch (e) {
785
+ console.error('init failed', e);
786
+ }
787
+
788
+ // Load cache hit for header without blocking
789
+ loadFullStats().catch(() => {});
790
+ }
791
+
792
+ // ──────────────────────────────────────────────────────────────────────────────
793
+ // Header
794
+ // ──────────────────────────────────────────────────────────────────────────────
795
+ function renderHeaderStats(stats) {
796
+ document.getElementById('project-id').textContent = stats.projectId;
797
+ document.getElementById('s-mem').textContent = fmtN(stats.memories);
798
+ document.getElementById('s-saved').textContent = fmtN(stats.tokensSaved);
799
+ document.getElementById('s-spent').textContent = fmtN(stats.tokensSpent);
800
+ const roi =
801
+ stats.tokensSpent > 0 ? '×' + (stats.tokensSaved / stats.tokensSpent).toFixed(1) : '—';
802
+ document.getElementById('s-roi').textContent = roi;
803
+ }
804
+
805
+ async function loadFullStats() {
806
+ if (S.fullStats) return S.fullStats;
807
+ const r = await fetch('/api/stats/full');
808
+ S.fullStats = await r.json();
809
+ const hr = S.fullStats.cache.hit_rate;
810
+ document.getElementById('s-cache').textContent =
811
+ hr !== null ? Math.round(hr * 100) + '%' : '—';
812
+ return S.fullStats;
813
+ }
814
+
815
+ // ──────────────────────────────────────────────────────────────────────────────
816
+ // Strategy buttons
817
+ // ──────────────────────────────────────────────────────────────────────────────
818
+ function buildStrategyButtons() {
819
+ const el = document.getElementById('strategy-btns');
820
+ el.innerHTML = '';
821
+ ['bm25', 'hybrid', 'embeddings'].forEach((s) => {
822
+ const b = document.createElement('button');
823
+ b.className = 'strat-btn' + (s === S.strategy ? ' active' : '');
824
+ b.textContent = s;
825
+ b.onclick = () => {
826
+ S.strategy = s;
827
+ buildStrategyButtons();
828
+ if (S.searchQuery) doSearch();
829
+ };
830
+ el.appendChild(b);
831
+ });
832
+ }
833
+
834
+ // ──────────────────────────────────────────────────────────────────────────────
835
+ // Kind filter
836
+ // ──────────────────────────────────────────────────────────────────────────────
837
+ function buildKindFilter(kinds) {
838
+ const el = document.getElementById('kind-filter');
839
+ el.innerHTML = '';
840
+ const chips = [
841
+ { kind: null, label: 'all' },
842
+ ...kinds.map((k) => ({ kind: k, label: k.replace('_', ' ') })),
843
+ ];
844
+ chips.forEach(({ kind, label }) => {
845
+ const b = document.createElement('button');
846
+ b.className = 'kind-chip';
847
+ b.textContent = label;
848
+ const active = S.activeKind === kind;
849
+ if (active) {
850
+ const c = kind ? kc(kind) : '#4ade80';
851
+ b.style.cssText = `background:${c}14;border-color:${c}50;color:${c};`;
852
+ }
853
+ b.onclick = () => {
854
+ S.activeKind = kind;
855
+ buildKindFilter(kinds);
856
+ renderList(visibleMemories());
857
+ updateListCount();
858
+ };
859
+ el.appendChild(b);
860
+ });
861
+ }
862
+
863
+ // ──────────────────────────────────────────────────────────────────────────────
864
+ // Visible memory list
865
+ // ──────────────────────────────────────────────────────────────────────────────
866
+ function visibleMemories() {
867
+ let mems =
868
+ S.searchResults !== null
869
+ ? S.searchResults.map((r) => r.observation ?? r)
870
+ : (S.data?.memories ?? []);
871
+ if (S.activeKind) mems = mems.filter((m) => m.kind === S.activeKind);
872
+ return mems;
873
+ }
874
+
875
+ function updateListCount() {
876
+ const vis = visibleMemories().length;
877
+ const total = S.data?.stats?.memories ?? 0;
878
+ const el = document.getElementById('list-count');
879
+ el.textContent = S.searchQuery || S.activeKind ? `${vis} / ${total}` : `${total}`;
880
+ }
881
+
882
+ // ──────────────────────────────────────────────────────────────────────────────
883
+ // Memory list
884
+ // ──────────────────────────────────────────────────────────────────────────────
885
+ function renderList(mems) {
886
+ const el = document.getElementById('memory-list');
887
+ if (!mems.length) {
888
+ el.innerHTML = `<div class="empty">
889
+ <svg width="22" height="22" fill="none" stroke="currentColor" viewBox="0 0 24 24">
890
+ <circle cx="11" cy="11" r="8"/><path d="m21 21-4.35-4.35"/>
891
+ </svg>
892
+ <span>${S.searchQuery ? 'no results' : 'no memories'}</span>
893
+ </div>`;
894
+ return;
895
+ }
896
+
897
+ el.innerHTML = '';
898
+ mems.forEach((m) => {
899
+ const card = document.createElement('div');
900
+ card.className = 'mem-card fade-up' + (S.selectedId === m.id ? ' selected' : '');
901
+ card.dataset.id = m.id;
902
+
903
+ const c = kc(m.kind);
904
+ const tags = (m.tags ?? [])
905
+ .slice(0, 3)
906
+ .map(
907
+ (t) =>
908
+ `<span style="font-size:9px;background:#131720;border:1px solid #1a1f2c;color:#3d4757;padding:1px 5px;border-radius:3px;">${esc(t)}</span>`,
909
+ )
910
+ .join('');
911
+ const score =
912
+ m.score != null
913
+ ? `<span style="font-size:9px;color:#2e3547;font-family:JetBrains Mono;">↑${m.score.toFixed(2)}</span>`
914
+ : '';
915
+
916
+ card.innerHTML = `
917
+ <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:5px;padding-right:20px;">
918
+ <span style="font-size:9px;font-family:Syne;font-weight:800;text-transform:uppercase;letter-spacing:0.06em;padding:1px 5px;border-radius:3px;background:${c}12;color:${c};border:1px solid ${c}28;">${esc(m.kind.replace('_', ' '))}</span>
919
+ <div style="display:flex;align-items:center;gap:6px;">${score}<span style="font-size:9px;color:#2e3547;">${relTime(m.created_at)}</span></div>
920
+ </div>
921
+ <div style="font-size:12px;font-weight:600;color:#b8bfd4;line-height:1.35;margin-bottom:${tags ? '6px' : '0'};font-family:Syne;padding-right:4px;">${esc(trunc(m.title, 65))}</div>
922
+ ${tags ? `<div style="display:flex;flex-wrap:wrap;gap:3px;">${tags}</div>` : ''}
923
+ <button class="forget-btn" onclick="event.stopPropagation();forgetMemory('${esc(m.id)}')">✕</button>
924
+ `;
925
+ card.onclick = () => selectMemory(m.id);
926
+ el.appendChild(card);
927
+ });
928
+ }
929
+
930
+ // ──────────────────────────────────────────────────────────────────────────────
931
+ // Search
932
+ // ──────────────────────────────────────────────────────────────────────────────
933
+ document.getElementById('search-input').addEventListener('input', (e) => {
934
+ S.searchQuery = e.target.value.trim();
935
+ clearTimeout(S.debounce);
936
+ if (!S.searchQuery) {
937
+ S.searchResults = null;
938
+ renderList(visibleMemories());
939
+ updateListCount();
940
+ document.getElementById('tab-info').textContent = '';
941
+ return;
942
+ }
943
+ S.debounce = setTimeout(doSearch, 320);
944
+ });
945
+
946
+ async function doSearch() {
947
+ if (!S.searchQuery) return;
948
+ const spinner = document.getElementById('search-spinner');
949
+ spinner.style.display = 'block';
950
+ try {
951
+ const url = `/api/search?q=${encodeURIComponent(S.searchQuery)}&strategy=${S.strategy}&k=20`;
952
+ const r = await fetch(url);
953
+ const d = await r.json();
954
+ S.searchResults = d.results ?? [];
955
+ // Attach score to each result's observation for display
956
+ S.searchResults = S.searchResults.map((r) => ({
957
+ ...r.observation,
958
+ score: r.score,
959
+ id: r.id,
960
+ }));
961
+ renderList(visibleMemories());
962
+ updateListCount();
963
+ document.getElementById('tab-info').textContent =
964
+ `${S.searchResults.length} hits via ${d.strategy}`;
965
+ } catch (e) {
966
+ console.error('search error', e);
967
+ } finally {
968
+ spinner.style.display = 'none';
969
+ }
970
+ }
971
+
972
+ // ──────────────────────────────────────────────────────────────────────────────
973
+ // Graph
974
+ // ──────────────────────────────────────────────────────────────────────────────
975
+ function renderGraph(graph) {
976
+ const wrap = document.getElementById('graph-wrap');
977
+ if (!graph?.nodes?.length) {
978
+ wrap.innerHTML = `<div class="empty" style="height:100%;">
979
+ <svg width="24" height="24" fill="none" stroke="currentColor" viewBox="0 0 24 24" style="opacity:0.2;">
980
+ <circle cx="5" cy="12" r="2"/><circle cx="12" cy="5" r="2"/><circle cx="12" cy="19" r="2"/><circle cx="19" cy="12" r="2"/>
981
+ <line x1="7" y1="12" x2="10" y2="12"/><line x1="12" y1="7" x2="12" y2="10"/>
982
+ <line x1="12" y1="14" x2="12" y2="17"/><line x1="14" y1="12" x2="17" y2="12"/>
983
+ </svg>
984
+ <span>no connections yet</span>
985
+ </div>`;
986
+ return;
987
+ }
988
+
989
+ const nodes = new vis.DataSet(
990
+ graph.nodes.map((n) => ({
991
+ ...n,
992
+ font: { color: '#636b85', face: 'JetBrains Mono', size: 10.5 },
993
+ shape: 'dot',
994
+ size: 9,
995
+ borderWidth: 1.5,
996
+ borderWidthSelected: 3,
997
+ color: {
998
+ background: kc(n.kind),
999
+ border: '#1a1f2c',
1000
+ highlight: { background: kc(n.kind), border: '#dde2ee' },
1001
+ hover: { background: kc(n.kind), border: '#252d3e' },
1002
+ },
1003
+ })),
1004
+ );
1005
+
1006
+ const edges = new vis.DataSet(
1007
+ graph.edges.map((e) => ({
1008
+ ...e,
1009
+ color: { color: '#1a1f2c', opacity: 0.7, highlight: '#4ade80' },
1010
+ width: 1,
1011
+ smooth: { type: 'curvedCW', roundness: 0.08 },
1012
+ })),
1013
+ );
1014
+
1015
+ S.network = new vis.Network(
1016
+ wrap,
1017
+ { nodes, edges },
1018
+ {
1019
+ physics: {
1020
+ forceAtlas2Based: {
1021
+ gravitationalConstant: -42,
1022
+ centralGravity: 0.012,
1023
+ springLength: 130,
1024
+ springConstant: 0.055,
1025
+ },
1026
+ maxVelocity: 50,
1027
+ solver: 'forceAtlas2Based',
1028
+ timestep: 0.35,
1029
+ stabilization: { iterations: 130 },
1030
+ },
1031
+ interaction: { hover: true, tooltipDelay: 120 },
1032
+ },
1033
+ );
1034
+
1035
+ S.network.on('click', (params) => {
1036
+ if (params.nodes.length) selectMemory(params.nodes[0]);
1037
+ });
1038
+ }
1039
+
1040
+ // ──────────────────────────────────────────────────────────────────────────────
1041
+ // Select memory (syncs list ↔ graph ↔ detail)
1042
+ // ──────────────────────────────────────────────────────────────────────────────
1043
+ function selectMemory(id) {
1044
+ S.selectedId = id;
1045
+ document
1046
+ .querySelectorAll('.mem-card')
1047
+ .forEach((c) => c.classList.toggle('selected', c.dataset.id === id));
1048
+ const card = document.querySelector(`.mem-card[data-id="${id}"]`);
1049
+ if (card) card.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
1050
+ if (S.network) {
1051
+ try {
1052
+ S.network.selectNodes([id], true);
1053
+ S.network.focus(id, {
1054
+ animation: { duration: 350, easingFunction: 'easeInOutQuad' },
1055
+ scale: 1.2,
1056
+ });
1057
+ } catch {}
1058
+ }
1059
+ const obs = findMemory(id);
1060
+ if (obs) showDetail(obs);
1061
+ }
1062
+
1063
+ function findMemory(id) {
1064
+ return (
1065
+ S.data?.memories?.find((m) => m.id === id) ??
1066
+ S.searchResults?.find((m) => m.id === id) ??
1067
+ null
1068
+ );
1069
+ }
1070
+
1071
+ // ──────────────────────────────────────────────────────────────────────────────
1072
+ // Detail drawer
1073
+ // ──────────────────────────────────────────────────────────────────────────────
1074
+ function showDetail(obs) {
1075
+ const drawer = document.getElementById('detail-drawer');
1076
+ drawer.style.display = 'block';
1077
+ const c = kc(obs.kind);
1078
+ const kindEl = document.getElementById('d-kind');
1079
+ kindEl.textContent = obs.kind.replace('_', ' ');
1080
+ kindEl.style.cssText = `font-family:Syne;font-size:9px;font-weight:800;text-transform:uppercase;letter-spacing:0.08em;padding:2px 6px;border-radius:3px;display:inline-block;margin-bottom:5px;background:${c}12;color:${c};border:1px solid ${c}28;`;
1081
+ document.getElementById('d-title').textContent = obs.title;
1082
+ document.getElementById('d-date').textContent = new Date(obs.created_at).toLocaleString();
1083
+ const ts = obs.tokens_saved ?? 0;
1084
+ document.getElementById('d-tokens').textContent = ts ? fmtN(ts) + ' tokens saved' : '';
1085
+ document.getElementById('d-body').textContent = obs.body;
1086
+
1087
+ const footer = document.getElementById('d-footer');
1088
+ footer.innerHTML = '';
1089
+ (obs.files ?? []).forEach((f) => {
1090
+ const s = document.createElement('span');
1091
+ s.style.cssText =
1092
+ 'font-size:10px;font-family:JetBrains Mono;background:#131720;border:1px solid #1a1f2c;color:#4a5168;padding:2px 7px;border-radius:3px;';
1093
+ s.textContent = f;
1094
+ footer.appendChild(s);
1095
+ });
1096
+ (obs.tags ?? []).forEach((t) => {
1097
+ const s = document.createElement('span');
1098
+ s.style.cssText =
1099
+ 'font-size:9.5px;font-family:Syne;font-weight:700;background:#0a0c10;border:1px solid #1a1f2c;color:#2e3547;padding:2px 7px;border-radius:3px;';
1100
+ s.textContent = '#' + t;
1101
+ footer.appendChild(s);
1102
+ });
1103
+ drawer.scrollTop = 0;
1104
+ }
1105
+
1106
+ function closeDetail() {
1107
+ document.getElementById('detail-drawer').style.display = 'none';
1108
+ S.selectedId = null;
1109
+ document.querySelectorAll('.mem-card').forEach((c) => c.classList.remove('selected'));
1110
+ if (S.network)
1111
+ try {
1112
+ S.network.unselectAll();
1113
+ } catch {}
1114
+ }
1115
+
1116
+ // ──────────────────────────────────────────────────────────────────────────────
1117
+ // Forget
1118
+ // ──────────────────────────────────────────────────────────────────────────────
1119
+ async function forgetMemory(id) {
1120
+ try {
1121
+ const r = await fetch(`/api/memories/${id}`, { method: 'DELETE' });
1122
+ const d = await r.json();
1123
+ if (!d.ok) return;
1124
+ if (S.data) {
1125
+ S.data.memories = S.data.memories.filter((m) => m.id !== id);
1126
+ S.data.stats.memories = S.data.memories.length;
1127
+ document.getElementById('s-mem').textContent = fmtN(S.data.stats.memories);
1128
+ }
1129
+ if (S.searchResults) S.searchResults = S.searchResults.filter((m) => m.id !== id);
1130
+ if (S.selectedId === id) closeDetail();
1131
+ renderList(visibleMemories());
1132
+ updateListCount();
1133
+ if (S.network)
1134
+ try {
1135
+ S.network.body.data.nodes.remove(id);
1136
+ } catch {}
1137
+ } catch (e) {
1138
+ console.error('forget failed', e);
1139
+ }
1140
+ }
1141
+
1142
+ // ──────────────────────────────────────────────────────────────────────────────
1143
+ // Tabs
1144
+ // ──────────────────────────────────────────────────────────────────────────────
1145
+ document.querySelectorAll('.tab-btn').forEach((btn) => {
1146
+ btn.addEventListener('click', () => {
1147
+ const tab = btn.dataset.tab;
1148
+ S.activeTab = tab;
1149
+ document
1150
+ .querySelectorAll('.tab-btn')
1151
+ .forEach((b) => b.classList.toggle('active', b.dataset.tab === tab));
1152
+ document.getElementById('tab-graph').style.display = tab === 'graph' ? 'flex' : 'none';
1153
+ document.getElementById('tab-analytics').style.display =
1154
+ tab === 'analytics' ? 'block' : 'none';
1155
+ if (tab === 'analytics' && !S.analyticsRendered) openAnalytics();
1156
+ if (tab !== 'graph') document.getElementById('tab-info').textContent = '';
1157
+ });
1158
+ });
1159
+
1160
+ // ──────────────────────────────────────────────────────────────────────────────
1161
+ // Analytics
1162
+ // ──────────────────────────────────────────────────────────────────────────────
1163
+ async function openAnalytics() {
1164
+ try {
1165
+ const stats = await loadFullStats();
1166
+ renderAnalytics(stats);
1167
+ S.analyticsRendered = true;
1168
+ } catch (e) {
1169
+ document.getElementById('analytics-grid').innerHTML =
1170
+ '<div class="empty" style="grid-column:span 2;">failed to load analytics</div>';
1171
+ }
1172
+ }
1173
+
1174
+ function renderAnalytics({ kindBreakdown, topFiles, retrieval, cache }) {
1175
+ const grid = document.getElementById('analytics-grid');
1176
+
1177
+ // ── Kind breakdown ───────────────────────────────────────────
1178
+ const total = Object.values(kindBreakdown).reduce((a, b) => a + b, 0);
1179
+ const maxKind = Math.max(...Object.values(kindBreakdown), 1);
1180
+ const kindRows = Object.entries(kindBreakdown)
1181
+ .filter(([, n]) => n > 0)
1182
+ .sort(([, a], [, b]) => b - a)
1183
+ .map(([kind, n]) => {
1184
+ const c = kc(kind);
1185
+ return `
1186
+ <div style="display:flex;align-items:center;gap:8px;padding:4px 0;">
1187
+ <span style="font-size:9.5px;font-family:Syne;font-weight:800;width:76px;color:${c};flex-shrink:0;letter-spacing:0.02em;">${kind.replace('_', ' ')}</span>
1188
+ <div class="bar-track"><div class="bar-fill" data-w="${Math.round((n / maxKind) * 100)}" style="background:${c};"></div></div>
1189
+ <span style="font-size:11px;font-family:JetBrains Mono;color:#3d4757;width:24px;text-align:right;">${n}</span>
1190
+ </div>`;
1191
+ })
1192
+ .join('');
1193
+
1194
+ // ── Cache stats ──────────────────────────────────────────────
1195
+ const hr = cache.hit_rate;
1196
+ const hrPct = hr !== null ? Math.round(hr * 100) : null;
1197
+ const cColor =
1198
+ hrPct !== null
1199
+ ? hrPct >= 60
1200
+ ? '#4ade80'
1201
+ : hrPct >= 30
1202
+ ? '#fbbf24'
1203
+ : '#f87171'
1204
+ : '#52576a';
1205
+ const cacheBar =
1206
+ hrPct !== null
1207
+ ? `<div style="margin-top:12px;"><div style="display:flex;justify-content:space-between;margin-bottom:4px;"><span style="font-size:9px;font-family:Syne;font-weight:800;color:#2e3547;text-transform:uppercase;letter-spacing:0.08em;">hit rate</span><span style="font-size:10px;font-family:JetBrains Mono;color:${cColor};">${hrPct}%</span></div><div class="bar-track" style="height:5px;"><div class="bar-fill" data-w="${hrPct}" style="background:${cColor};height:5px;"></div></div></div>`
1208
+ : '';
1209
+
1210
+ // ── Retrieval usage ──────────────────────────────────────────
1211
+ const maxRetr = Math.max(...retrieval.map((r) => r.call_count), 1);
1212
+ const retriRows = retrieval.length
1213
+ ? retrieval
1214
+ .map(
1215
+ (r) => `
1216
+ <div style="display:flex;align-items:center;gap:8px;padding:4px 0;">
1217
+ <span style="font-size:9.5px;font-family:Syne;font-weight:800;color:#22d3ee;width:70px;flex-shrink:0;">${r.strategy}</span>
1218
+ <div class="bar-track"><div class="bar-fill" data-w="${Math.round((r.call_count / maxRetr) * 100)}" style="background:#22d3ee88;"></div></div>
1219
+ <span style="font-size:11px;font-family:JetBrains Mono;color:#3d4757;width:24px;text-align:right;">${r.call_count}</span>
1220
+ </div>`,
1221
+ )
1222
+ .join('')
1223
+ : '<div style="font-size:10px;font-family:Syne;font-weight:700;color:#2e3547;">no retrieval calls recorded</div>';
1224
+
1225
+ // ── Top files ────────────────────────────────────────────────
1226
+ const fileRows = topFiles.length
1227
+ ? topFiles
1228
+ .slice(0, 14)
1229
+ .map(
1230
+ (f) => `
1231
+ <div class="file-row">
1232
+ <span style="font-size:10px;font-family:JetBrains Mono;color:#7a8299;flex:1;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;" title="${esc(f.file)}">${esc(f.file)}</span>
1233
+ <span style="font-size:11px;font-family:JetBrains Mono;color:#3d4757;flex-shrink:0;padding-left:8px;">${f.count}</span>
1234
+ </div>`,
1235
+ )
1236
+ .join('')
1237
+ : '<div style="font-size:10px;font-family:Syne;font-weight:700;color:#2e3547;">no file references</div>';
1238
+
1239
+ grid.innerHTML = `
1240
+ <div class="a-card">
1241
+ <div class="a-title">memories by kind ${total ? `<span style="color:#1e2330;font-weight:600;">· ${total}</span>` : ''}</div>
1242
+ ${total ? kindRows : '<div style="font-size:10px;font-family:Syne;font-weight:700;color:#2e3547;">no memories yet</div>'}
1243
+ </div>
1244
+
1245
+ <div class="a-card">
1246
+ <div class="a-title">prompt cache</div>
1247
+ <div style="display:flex;gap:16px;align-items:flex-start;">
1248
+ <div>
1249
+ <div style="font-family:Syne;font-size:32px;font-weight:800;color:${cColor};line-height:1;">${hrPct !== null ? hrPct + '%' : '—'}</div>
1250
+ <div style="font-size:8.5px;font-family:Syne;font-weight:800;color:#2e3547;text-transform:uppercase;letter-spacing:0.1em;margin-top:3px;">hit rate</div>
1251
+ </div>
1252
+ <div style="border-left:1px solid #1a1f2c;padding-left:16px;display:flex;flex-direction:column;gap:5px;margin-top:3px;">
1253
+ <div style="font-family:JetBrains Mono;font-size:12px;color:#b8bfd4;">${cache.hit_count.toLocaleString()} <span style="color:#4ade80;font-size:9.5px;">hits</span></div>
1254
+ <div style="font-family:JetBrains Mono;font-size:12px;color:#b8bfd4;">${cache.miss_count.toLocaleString()} <span style="color:#f87171;font-size:9.5px;">misses</span></div>
1255
+ <div style="font-family:JetBrains Mono;font-size:12px;color:#b8bfd4;">${cache.entry_count.toLocaleString()} <span style="color:#52576a;font-size:9.5px;">entries</span></div>
1256
+ </div>
1257
+ </div>
1258
+ ${cacheBar}
1259
+ </div>
1260
+
1261
+ <div class="a-card">
1262
+ <div class="a-title">retrieval usage</div>
1263
+ ${retriRows}
1264
+ </div>
1265
+
1266
+ <div class="a-card">
1267
+ <div class="a-title">top files <span style="color:#1e2330;font-weight:600;">· by reference count</span></div>
1268
+ ${fileRows}
1269
+ </div>
1270
+ `;
1271
+
1272
+ // Animate bars on next frame
1273
+ requestAnimationFrame(() => {
1274
+ document.querySelectorAll('.bar-fill[data-w]').forEach((el) => {
1275
+ const w = el.dataset.w;
1276
+ el.style.width = '0';
1277
+ requestAnimationFrame(() => {
1278
+ el.style.width = w + '%';
1279
+ });
1280
+ });
1281
+ });
1282
+ }
1283
+
1284
+ // ──────────────────────────────────────────────────────────────────────────────
1285
+ // Go
1286
+ // ──────────────────────────────────────────────────────────────────────────────
1287
+ init();
1288
+ </script>
1289
+ </body>
1290
+ </html>