somtum 1.0.0 → 1.2.1

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,1602 @@
1
+ <!doctype html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>Somtum — Memory</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" />
8
+ <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
9
+ <link
10
+ href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,400;500;600;700;800&family=JetBrains+Mono:wght@400;500;600&display=swap"
11
+ rel="stylesheet"
12
+ />
13
+ <script src="https://unpkg.com/vis-network/standalone/umd/vis-network.min.js"></script>
14
+
15
+ <style>
16
+ *,
17
+ *::before,
18
+ *::after {
19
+ box-sizing: border-box;
20
+ margin: 0;
21
+ padding: 0;
22
+ }
23
+
24
+ :root {
25
+ --bg: #0b0d12;
26
+ --surface: #111318;
27
+ --surface2: #181c24;
28
+ --surface3: #1d2230;
29
+ --border: #1f2535;
30
+ --border2: #2a3245;
31
+ --text: #dde1ea;
32
+ --muted: #4d5870;
33
+ --muted2: #6b7a96;
34
+ --accent: #4f8dff;
35
+ --accent-g: #27c27d;
36
+ --accent-a: #f0a638;
37
+ --accent-r: #ef5858;
38
+ --accent-v: #9b72f5;
39
+ --accent-c: #22d0c8;
40
+ --radius: 8px;
41
+ --sidebar: 300px;
42
+ --header: 52px;
43
+ }
44
+
45
+ html,
46
+ body {
47
+ height: 100%;
48
+ overflow: hidden;
49
+ background: var(--bg);
50
+ color: var(--text);
51
+ font-family: 'Bricolage Grotesque', sans-serif;
52
+ font-size: 13px;
53
+ line-height: 1.5;
54
+ -webkit-font-smoothing: antialiased;
55
+ }
56
+
57
+ /* ── Scrollbars ─────────────────────────────────────────────── */
58
+ ::-webkit-scrollbar {
59
+ width: 4px;
60
+ height: 4px;
61
+ }
62
+ ::-webkit-scrollbar-track {
63
+ background: transparent;
64
+ }
65
+ ::-webkit-scrollbar-thumb {
66
+ background: var(--border2);
67
+ border-radius: 4px;
68
+ }
69
+ ::-webkit-scrollbar-thumb:hover {
70
+ background: var(--muted);
71
+ }
72
+
73
+ /* ── Mono text ──────────────────────────────────────────────── */
74
+ .mono {
75
+ font-family: 'JetBrains Mono', monospace;
76
+ }
77
+
78
+ /* ── Header ─────────────────────────────────────────────────── */
79
+ #header {
80
+ height: var(--header);
81
+ border-bottom: 1px solid var(--border);
82
+ padding: 0 20px;
83
+ display: flex;
84
+ align-items: center;
85
+ gap: 14px;
86
+ flex-shrink: 0;
87
+ background: var(--surface);
88
+ }
89
+ #logo {
90
+ font-size: 19px;
91
+ font-weight: 800;
92
+ color: var(--accent);
93
+ letter-spacing: -0.04em;
94
+ line-height: 1;
95
+ display: flex;
96
+ align-items: center;
97
+ gap: 7px;
98
+ }
99
+ #logo-mark {
100
+ width: 26px;
101
+ height: 26px;
102
+ border-radius: 7px;
103
+ background: linear-gradient(135deg, var(--accent) 0%, var(--accent-v) 100%);
104
+ display: flex;
105
+ align-items: center;
106
+ justify-content: center;
107
+ flex-shrink: 0;
108
+ }
109
+ #logo-mark svg {
110
+ width: 14px;
111
+ height: 14px;
112
+ color: #fff;
113
+ }
114
+ #header-sep {
115
+ width: 1px;
116
+ height: 20px;
117
+ background: var(--border);
118
+ }
119
+ #project-label {
120
+ font-size: 11px;
121
+ color: var(--muted);
122
+ font-family: 'JetBrains Mono', monospace;
123
+ white-space: nowrap;
124
+ overflow: hidden;
125
+ text-overflow: ellipsis;
126
+ max-width: 240px;
127
+ }
128
+ #stat-bar {
129
+ display: flex;
130
+ gap: 6px;
131
+ margin-left: auto;
132
+ }
133
+ .stat-pill {
134
+ display: flex;
135
+ align-items: center;
136
+ gap: 8px;
137
+ padding: 5px 11px;
138
+ border-radius: 6px;
139
+ background: var(--surface2);
140
+ border: 1px solid var(--border);
141
+ cursor: default;
142
+ }
143
+ .stat-pill .s-lbl {
144
+ font-size: 10px;
145
+ font-weight: 600;
146
+ text-transform: uppercase;
147
+ letter-spacing: 0.08em;
148
+ color: var(--muted);
149
+ }
150
+ .stat-pill .s-val {
151
+ font-family: 'JetBrains Mono', monospace;
152
+ font-size: 13px;
153
+ font-weight: 600;
154
+ }
155
+
156
+ /* ── Layout ─────────────────────────────────────────────────── */
157
+ #layout {
158
+ display: flex;
159
+ height: calc(100vh - var(--header));
160
+ overflow: hidden;
161
+ }
162
+
163
+ /* ── Sidebar ─────────────────────────────────────────────────── */
164
+ #sidebar {
165
+ width: var(--sidebar);
166
+ flex-shrink: 0;
167
+ border-right: 1px solid var(--border);
168
+ display: flex;
169
+ flex-direction: column;
170
+ overflow: hidden;
171
+ background: var(--surface);
172
+ }
173
+
174
+ /* Search */
175
+ #search-wrap {
176
+ padding: 10px;
177
+ border-bottom: 1px solid var(--border);
178
+ display: flex;
179
+ flex-direction: column;
180
+ gap: 8px;
181
+ }
182
+ .search-box {
183
+ position: relative;
184
+ }
185
+ .search-box svg {
186
+ position: absolute;
187
+ left: 9px;
188
+ top: 50%;
189
+ transform: translateY(-50%);
190
+ width: 14px;
191
+ height: 14px;
192
+ color: var(--muted);
193
+ pointer-events: none;
194
+ }
195
+ #search-input {
196
+ width: 100%;
197
+ background: var(--bg);
198
+ border: 1px solid var(--border);
199
+ border-radius: var(--radius);
200
+ padding: 7px 9px 7px 30px;
201
+ font-size: 12.5px;
202
+ font-family: 'JetBrains Mono', monospace;
203
+ color: var(--text);
204
+ outline: none;
205
+ transition:
206
+ border-color 0.15s,
207
+ box-shadow 0.15s;
208
+ }
209
+ #search-input::placeholder {
210
+ color: var(--muted);
211
+ }
212
+ #search-input:focus {
213
+ border-color: var(--accent);
214
+ box-shadow: 0 0 0 2px rgba(79, 141, 255, 0.12);
215
+ }
216
+ #search-meta {
217
+ display: flex;
218
+ align-items: center;
219
+ justify-content: space-between;
220
+ }
221
+ .strat-row {
222
+ display: flex;
223
+ gap: 4px;
224
+ }
225
+ .strat-btn {
226
+ padding: 3px 8px;
227
+ font-size: 10px;
228
+ font-weight: 600;
229
+ border: 1px solid var(--border);
230
+ border-radius: 4px;
231
+ cursor: pointer;
232
+ background: var(--surface2);
233
+ color: var(--muted2);
234
+ transition: all 0.12s;
235
+ font-family: 'Bricolage Grotesque', sans-serif;
236
+ }
237
+ .strat-btn:hover {
238
+ color: var(--text);
239
+ border-color: var(--border2);
240
+ }
241
+ .strat-btn.active {
242
+ background: rgba(79, 141, 255, 0.1);
243
+ border-color: rgba(79, 141, 255, 0.4);
244
+ color: var(--accent);
245
+ }
246
+ #search-spinner {
247
+ display: none;
248
+ }
249
+ #search-spinner svg {
250
+ animation: spin 0.7s linear infinite;
251
+ display: block;
252
+ }
253
+
254
+ /* Kind filters */
255
+ #kind-filters {
256
+ padding: 7px 10px;
257
+ border-bottom: 1px solid var(--border);
258
+ display: flex;
259
+ flex-wrap: wrap;
260
+ gap: 4px;
261
+ flex-shrink: 0;
262
+ }
263
+ .kind-tag {
264
+ padding: 2px 8px;
265
+ font-size: 10px;
266
+ font-weight: 600;
267
+ border-radius: 4px;
268
+ border: 1px solid transparent;
269
+ cursor: pointer;
270
+ transition: all 0.12s;
271
+ color: var(--muted2);
272
+ background: var(--surface2);
273
+ }
274
+ .kind-tag:hover {
275
+ color: var(--text);
276
+ border-color: var(--border2);
277
+ }
278
+ .kind-tag.active {
279
+ color: #fff;
280
+ }
281
+
282
+ /* List header */
283
+ #list-header {
284
+ padding: 6px 10px 5px;
285
+ display: flex;
286
+ align-items: center;
287
+ justify-content: space-between;
288
+ border-bottom: 1px solid var(--border);
289
+ }
290
+ #list-header .h-label {
291
+ font-size: 10px;
292
+ font-weight: 700;
293
+ text-transform: uppercase;
294
+ letter-spacing: 0.08em;
295
+ color: var(--muted);
296
+ }
297
+ #list-count {
298
+ font-size: 10px;
299
+ font-family: 'JetBrains Mono', monospace;
300
+ color: var(--muted);
301
+ }
302
+
303
+ /* Memory list */
304
+ #memory-list {
305
+ flex: 1;
306
+ overflow-y: auto;
307
+ padding: 6px;
308
+ display: flex;
309
+ flex-direction: column;
310
+ gap: 3px;
311
+ }
312
+
313
+ /* Memory card */
314
+ .mem-card {
315
+ padding: 9px 10px;
316
+ border-radius: 6px;
317
+ background: var(--surface2);
318
+ border: 1px solid var(--border);
319
+ cursor: pointer;
320
+ transition:
321
+ border-color 0.12s,
322
+ background 0.12s;
323
+ position: relative;
324
+ overflow: hidden;
325
+ }
326
+ .mem-card::before {
327
+ content: '';
328
+ position: absolute;
329
+ left: 0;
330
+ top: 0;
331
+ bottom: 0;
332
+ width: 3px;
333
+ border-radius: 3px 0 0 3px;
334
+ }
335
+ .mem-card:hover {
336
+ border-color: var(--border2);
337
+ background: var(--surface3);
338
+ }
339
+ .mem-card.selected {
340
+ border-color: var(--accent);
341
+ }
342
+ .mem-card.selected::before {
343
+ background: var(--accent);
344
+ }
345
+
346
+ .mc-head {
347
+ display: flex;
348
+ align-items: flex-start;
349
+ gap: 7px;
350
+ margin-bottom: 3px;
351
+ }
352
+ .mc-title {
353
+ font-size: 12.5px;
354
+ font-weight: 600;
355
+ color: var(--text);
356
+ line-height: 1.35;
357
+ flex: 1;
358
+ min-width: 0;
359
+ }
360
+ .mc-kind {
361
+ flex-shrink: 0;
362
+ font-size: 9px;
363
+ font-weight: 700;
364
+ text-transform: uppercase;
365
+ letter-spacing: 0.08em;
366
+ padding: 2px 6px;
367
+ border-radius: 3px;
368
+ }
369
+ .mc-body {
370
+ font-size: 11px;
371
+ color: var(--muted2);
372
+ line-height: 1.4;
373
+ overflow: hidden;
374
+ display: -webkit-box;
375
+ -webkit-line-clamp: 2;
376
+ -webkit-box-orient: vertical;
377
+ }
378
+ .mc-footer {
379
+ display: flex;
380
+ align-items: center;
381
+ gap: 5px;
382
+ margin-top: 5px;
383
+ }
384
+ .mc-time {
385
+ font-size: 10px;
386
+ color: var(--muted);
387
+ font-family: 'JetBrains Mono', monospace;
388
+ }
389
+ .mc-tags {
390
+ display: flex;
391
+ gap: 3px;
392
+ flex-wrap: wrap;
393
+ flex: 1;
394
+ }
395
+ .mc-tag {
396
+ font-size: 9px;
397
+ font-weight: 600;
398
+ padding: 1px 5px;
399
+ background: var(--surface3);
400
+ border: 1px solid var(--border2);
401
+ border-radius: 3px;
402
+ color: var(--muted2);
403
+ }
404
+ .mc-forget {
405
+ display: none;
406
+ width: 20px;
407
+ height: 20px;
408
+ align-items: center;
409
+ justify-content: center;
410
+ border-radius: 4px;
411
+ cursor: pointer;
412
+ background: rgba(239, 88, 88, 0.08);
413
+ border: 1px solid rgba(239, 88, 88, 0.2);
414
+ color: var(--accent-r);
415
+ font-size: 10px;
416
+ transition: background 0.12s;
417
+ flex-shrink: 0;
418
+ position: absolute;
419
+ top: 7px;
420
+ right: 7px;
421
+ }
422
+ .mem-card:hover .mc-forget {
423
+ display: flex;
424
+ }
425
+ .mc-forget:hover {
426
+ background: rgba(239, 88, 88, 0.18);
427
+ }
428
+
429
+ /* ── Main panel ──────────────────────────────────────────────── */
430
+ #main {
431
+ flex: 1;
432
+ display: flex;
433
+ flex-direction: column;
434
+ overflow: hidden;
435
+ min-width: 0;
436
+ }
437
+
438
+ /* Tab bar */
439
+ #tab-bar {
440
+ height: 42px;
441
+ border-bottom: 1px solid var(--border);
442
+ padding: 0 8px;
443
+ display: flex;
444
+ align-items: center;
445
+ flex-shrink: 0;
446
+ background: var(--surface);
447
+ gap: 2px;
448
+ }
449
+ .tab-btn {
450
+ padding: 6px 14px;
451
+ font-size: 11.5px;
452
+ font-weight: 600;
453
+ color: var(--muted2);
454
+ border: none;
455
+ border-radius: 5px;
456
+ background: none;
457
+ cursor: pointer;
458
+ transition:
459
+ color 0.12s,
460
+ background 0.12s;
461
+ }
462
+ .tab-btn:hover {
463
+ color: var(--text);
464
+ background: var(--surface2);
465
+ }
466
+ .tab-btn.active {
467
+ color: var(--accent);
468
+ background: rgba(79, 141, 255, 0.1);
469
+ }
470
+ #tab-hint {
471
+ margin-left: auto;
472
+ font-size: 10px;
473
+ color: var(--muted);
474
+ padding-right: 10px;
475
+ font-family: 'JetBrains Mono', monospace;
476
+ }
477
+
478
+ /* Tab panes */
479
+ #tab-graph {
480
+ flex: 1;
481
+ display: flex;
482
+ flex-direction: column;
483
+ overflow: hidden;
484
+ }
485
+ #tab-analytics {
486
+ display: none;
487
+ flex: 1;
488
+ overflow-y: auto;
489
+ padding: 14px;
490
+ }
491
+
492
+ /* Graph */
493
+ #graph-container {
494
+ flex: 1;
495
+ margin: 10px;
496
+ border: 1px solid var(--border);
497
+ border-radius: var(--radius);
498
+ background: #08090f;
499
+ overflow: hidden;
500
+ }
501
+
502
+ /* Detail panel */
503
+ #detail-panel {
504
+ display: none;
505
+ border-top: 1px solid var(--border);
506
+ background: var(--surface);
507
+ overflow-y: auto;
508
+ flex-shrink: 0;
509
+ max-height: 38vh;
510
+ }
511
+ #detail-inner {
512
+ padding: 16px 20px;
513
+ }
514
+ .detail-head {
515
+ display: flex;
516
+ align-items: flex-start;
517
+ gap: 10px;
518
+ margin-bottom: 12px;
519
+ }
520
+ .detail-title {
521
+ font-size: 15px;
522
+ font-weight: 700;
523
+ color: var(--text);
524
+ flex: 1;
525
+ line-height: 1.3;
526
+ }
527
+ .detail-meta {
528
+ display: flex;
529
+ gap: 16px;
530
+ margin-bottom: 12px;
531
+ font-size: 11px;
532
+ color: var(--muted);
533
+ font-family: 'JetBrains Mono', monospace;
534
+ }
535
+ .detail-body {
536
+ font-size: 12.5px;
537
+ color: var(--muted2);
538
+ line-height: 1.65;
539
+ white-space: pre-wrap;
540
+ word-break: break-word;
541
+ margin-bottom: 12px;
542
+ }
543
+ .detail-chips {
544
+ display: flex;
545
+ flex-wrap: wrap;
546
+ gap: 5px;
547
+ }
548
+ .detail-chip {
549
+ font-size: 10px;
550
+ font-weight: 500;
551
+ padding: 2px 8px;
552
+ border-radius: 4px;
553
+ background: var(--surface2);
554
+ border: 1px solid var(--border2);
555
+ color: var(--muted2);
556
+ }
557
+ .detail-chip.file {
558
+ color: var(--accent-c);
559
+ border-color: rgba(34, 208, 200, 0.3);
560
+ background: rgba(34, 208, 200, 0.07);
561
+ }
562
+ .detail-close {
563
+ width: 24px;
564
+ height: 24px;
565
+ border-radius: 5px;
566
+ flex-shrink: 0;
567
+ display: flex;
568
+ align-items: center;
569
+ justify-content: center;
570
+ background: var(--surface2);
571
+ border: 1px solid var(--border);
572
+ cursor: pointer;
573
+ color: var(--muted2);
574
+ transition: color 0.12s;
575
+ }
576
+ .detail-close:hover {
577
+ color: var(--text);
578
+ }
579
+
580
+ /* Analytics */
581
+ .a-grid {
582
+ display: grid;
583
+ grid-template-columns: 1fr 1fr;
584
+ gap: 10px;
585
+ }
586
+ @media (max-width: 900px) {
587
+ .a-grid {
588
+ grid-template-columns: 1fr;
589
+ }
590
+ }
591
+ .a-card {
592
+ background: var(--surface);
593
+ border: 1px solid var(--border);
594
+ border-radius: var(--radius);
595
+ padding: 16px;
596
+ }
597
+ .a-title {
598
+ font-size: 10px;
599
+ font-weight: 700;
600
+ text-transform: uppercase;
601
+ letter-spacing: 0.1em;
602
+ color: var(--muted);
603
+ margin-bottom: 14px;
604
+ }
605
+ .bar-row {
606
+ display: flex;
607
+ align-items: center;
608
+ gap: 8px;
609
+ margin-bottom: 8px;
610
+ }
611
+ .bar-label {
612
+ font-size: 11.5px;
613
+ font-weight: 600;
614
+ width: 100px;
615
+ flex-shrink: 0;
616
+ }
617
+ .bar-track {
618
+ flex: 1;
619
+ height: 4px;
620
+ background: var(--surface3);
621
+ border-radius: 2px;
622
+ overflow: hidden;
623
+ }
624
+ .bar-fill {
625
+ height: 100%;
626
+ border-radius: 2px;
627
+ width: 0;
628
+ transition: width 0.65s cubic-bezier(0.16, 1, 0.3, 1);
629
+ }
630
+ .bar-count {
631
+ font-size: 11px;
632
+ font-family: 'JetBrains Mono', monospace;
633
+ color: var(--muted2);
634
+ width: 36px;
635
+ text-align: right;
636
+ flex-shrink: 0;
637
+ }
638
+ .cache-stat {
639
+ display: flex;
640
+ justify-content: space-between;
641
+ align-items: center;
642
+ padding: 7px 0;
643
+ border-bottom: 1px solid var(--border);
644
+ }
645
+ .cache-stat:last-child {
646
+ border-bottom: none;
647
+ }
648
+ .cache-stat-lbl {
649
+ font-size: 12px;
650
+ color: var(--muted2);
651
+ }
652
+ .cache-stat-val {
653
+ font-family: 'JetBrains Mono', monospace;
654
+ font-size: 13px;
655
+ font-weight: 600;
656
+ }
657
+ .file-row {
658
+ display: flex;
659
+ align-items: center;
660
+ gap: 8px;
661
+ padding: 6px 0;
662
+ border-bottom: 1px solid var(--border);
663
+ }
664
+ .file-row:last-child {
665
+ border-bottom: none;
666
+ }
667
+ .file-name {
668
+ font-size: 11px;
669
+ font-family: 'JetBrains Mono', monospace;
670
+ color: var(--text);
671
+ flex: 1;
672
+ overflow: hidden;
673
+ text-overflow: ellipsis;
674
+ white-space: nowrap;
675
+ min-width: 0;
676
+ }
677
+ .file-count {
678
+ font-size: 11px;
679
+ font-family: 'JetBrains Mono', monospace;
680
+ color: var(--muted2);
681
+ flex-shrink: 0;
682
+ }
683
+
684
+ /* Empty state */
685
+ .empty {
686
+ display: flex;
687
+ flex-direction: column;
688
+ align-items: center;
689
+ justify-content: center;
690
+ gap: 10px;
691
+ padding: 48px 24px;
692
+ color: var(--muted);
693
+ text-align: center;
694
+ }
695
+ .empty-icon {
696
+ opacity: 0.25;
697
+ margin-bottom: 2px;
698
+ }
699
+ .empty-title {
700
+ font-size: 13px;
701
+ font-weight: 600;
702
+ color: var(--muted2);
703
+ }
704
+ .empty-sub {
705
+ font-size: 11.5px;
706
+ color: var(--muted);
707
+ max-width: 220px;
708
+ line-height: 1.5;
709
+ }
710
+
711
+ /* Skeleton */
712
+ .skel {
713
+ background: var(--surface2);
714
+ border-radius: 5px;
715
+ animation: shimmer 1.5s ease-in-out infinite;
716
+ }
717
+
718
+ /* Animations */
719
+ @keyframes spin {
720
+ to {
721
+ transform: rotate(360deg);
722
+ }
723
+ }
724
+ @keyframes shimmer {
725
+ 0%,
726
+ 100% {
727
+ opacity: 0.4;
728
+ }
729
+ 50% {
730
+ opacity: 0.7;
731
+ }
732
+ }
733
+ @keyframes fadeIn {
734
+ from {
735
+ opacity: 0;
736
+ transform: translateY(4px);
737
+ }
738
+ to {
739
+ opacity: 1;
740
+ transform: translateY(0);
741
+ }
742
+ }
743
+ .fade-in {
744
+ animation: fadeIn 0.16s ease forwards;
745
+ }
746
+
747
+ /* Kind colour map */
748
+ .kind-decision {
749
+ background: rgba(79, 141, 255, 0.12);
750
+ color: #4f8dff;
751
+ }
752
+ .kind-learning {
753
+ background: rgba(155, 114, 245, 0.12);
754
+ color: #9b72f5;
755
+ }
756
+ .kind-bugfix {
757
+ background: rgba(239, 88, 88, 0.12);
758
+ color: #ef5858;
759
+ }
760
+ .kind-command {
761
+ background: rgba(240, 166, 56, 0.12);
762
+ color: #f0a638;
763
+ }
764
+ .kind-file_summary {
765
+ background: rgba(34, 208, 200, 0.1);
766
+ color: #22d0c8;
767
+ }
768
+ .kind-other {
769
+ background: rgba(107, 122, 150, 0.12);
770
+ color: #6b7a96;
771
+ }
772
+ .kf-decision {
773
+ border-color: rgba(79, 141, 255, 0.3);
774
+ background: rgba(79, 141, 255, 0.08);
775
+ color: #4f8dff;
776
+ }
777
+ .kf-learning {
778
+ border-color: rgba(155, 114, 245, 0.3);
779
+ background: rgba(155, 114, 245, 0.08);
780
+ color: #9b72f5;
781
+ }
782
+ .kf-bugfix {
783
+ border-color: rgba(239, 88, 88, 0.3);
784
+ background: rgba(239, 88, 88, 0.08);
785
+ color: #ef5858;
786
+ }
787
+ .kf-command {
788
+ border-color: rgba(240, 166, 56, 0.3);
789
+ background: rgba(240, 166, 56, 0.08);
790
+ color: #f0a638;
791
+ }
792
+ .kf-file_summary {
793
+ border-color: rgba(34, 208, 200, 0.3);
794
+ background: rgba(34, 208, 200, 0.07);
795
+ color: #22d0c8;
796
+ }
797
+ .kf-other {
798
+ border-color: rgba(107, 122, 150, 0.3);
799
+ background: rgba(107, 122, 150, 0.08);
800
+ color: #6b7a96;
801
+ }
802
+ .kf-all {
803
+ border-color: rgba(79, 141, 255, 0.3);
804
+ background: rgba(79, 141, 255, 0.08);
805
+ color: #4f8dff;
806
+ }
807
+ </style>
808
+ </head>
809
+ <body>
810
+ <!-- ═══ HEADER ══════════════════════════════════════════════════════ -->
811
+ <header id="header">
812
+ <div id="logo">
813
+ <div id="logo-mark">
814
+ <svg
815
+ viewBox="0 0 24 24"
816
+ fill="none"
817
+ stroke="currentColor"
818
+ stroke-width="2.2"
819
+ stroke-linecap="round"
820
+ stroke-linejoin="round"
821
+ >
822
+ <path
823
+ d="M9.5 2A2.5 2.5 0 0 1 12 4.5v15a2.5 2.5 0 0 1-4.96-.46L7 19M14.5 2A2.5 2.5 0 0 0 12 4.5v15a2.5 2.5 0 0 0 4.96-.46l.04-.54"
824
+ />
825
+ </svg>
826
+ </div>
827
+ somtum
828
+ </div>
829
+ <div id="header-sep"></div>
830
+ <span id="project-label">—</span>
831
+ <div id="stat-bar">
832
+ <div class="stat-pill">
833
+ <span class="s-lbl">Memories</span>
834
+ <span id="s-mem" class="s-val" style="color: var(--accent-g)">—</span>
835
+ </div>
836
+ <div class="stat-pill">
837
+ <span class="s-lbl">Saved</span>
838
+ <span id="s-saved" class="s-val" style="color: var(--accent-a)">—</span>
839
+ </div>
840
+ <div class="stat-pill">
841
+ <span class="s-lbl">Spent</span>
842
+ <span id="s-spent" class="s-val" style="color: var(--muted2)">—</span>
843
+ </div>
844
+ <div class="stat-pill">
845
+ <span class="s-lbl">ROI</span>
846
+ <span id="s-roi" class="s-val" style="color: var(--accent)">—</span>
847
+ </div>
848
+ <div class="stat-pill">
849
+ <span class="s-lbl">Cache</span>
850
+ <span id="s-cache" class="s-val" style="color: var(--accent-v)">—</span>
851
+ </div>
852
+ </div>
853
+ </header>
854
+
855
+ <!-- ═══ LAYOUT ══════════════════════════════════════════════════════ -->
856
+ <div id="layout">
857
+ <!-- ─── SIDEBAR ───────────────────────────────────────────────── -->
858
+ <aside id="sidebar">
859
+ <!-- Search -->
860
+ <div id="search-wrap">
861
+ <div class="search-box">
862
+ <svg fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24">
863
+ <circle cx="11" cy="11" r="7" />
864
+ <path d="M21 21l-5-5" stroke-linecap="round" />
865
+ </svg>
866
+ <input
867
+ id="search-input"
868
+ type="text"
869
+ placeholder="Search memories…"
870
+ autocomplete="off"
871
+ spellcheck="false"
872
+ />
873
+ </div>
874
+ <div id="search-meta">
875
+ <div class="strat-row" id="strategy-btns"></div>
876
+ <div id="search-spinner">
877
+ <svg
878
+ width="13"
879
+ height="13"
880
+ viewBox="0 0 24 24"
881
+ fill="none"
882
+ stroke="var(--accent)"
883
+ stroke-width="2.5"
884
+ >
885
+ <path
886
+ 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"
887
+ />
888
+ </svg>
889
+ </div>
890
+ </div>
891
+ </div>
892
+
893
+ <!-- Kind filters -->
894
+ <div id="kind-filters"></div>
895
+
896
+ <!-- List header -->
897
+ <div id="list-header">
898
+ <span class="h-label">Memories</span>
899
+ <span id="list-count"></span>
900
+ </div>
901
+
902
+ <!-- Memory list -->
903
+ <div id="memory-list">
904
+ <div class="skel" style="height: 68px; border-radius: 6px"></div>
905
+ <div class="skel" style="height: 56px; border-radius: 6px; margin-top: 3px"></div>
906
+ <div class="skel" style="height: 72px; border-radius: 6px; margin-top: 3px"></div>
907
+ </div>
908
+ </aside>
909
+
910
+ <!-- ─── MAIN ──────────────────────────────────────────────────── -->
911
+ <main id="main">
912
+ <!-- Tab bar -->
913
+ <div id="tab-bar">
914
+ <button class="tab-btn active" data-tab="graph">Graph</button>
915
+ <button class="tab-btn" data-tab="analytics">Analytics</button>
916
+ <span id="tab-hint"></span>
917
+ </div>
918
+
919
+ <!-- Panes wrapper -->
920
+ <div
921
+ style="flex: 1; display: flex; flex-direction: column; overflow: hidden; min-height: 0"
922
+ >
923
+ <!-- GRAPH TAB -->
924
+ <div id="tab-graph">
925
+ <div id="graph-container"></div>
926
+ </div>
927
+
928
+ <!-- ANALYTICS TAB -->
929
+ <div id="tab-analytics">
930
+ <div class="a-grid" id="analytics-grid">
931
+ <div class="skel" style="height: 180px"></div>
932
+ <div class="skel" style="height: 180px"></div>
933
+ <div class="skel" style="height: 200px"></div>
934
+ <div class="skel" style="height: 200px"></div>
935
+ </div>
936
+ </div>
937
+ </div>
938
+
939
+ <!-- Detail panel -->
940
+ <div id="detail-panel">
941
+ <div id="detail-inner"></div>
942
+ </div>
943
+ </main>
944
+ </div>
945
+
946
+ <script>
947
+ // ─── State ───────────────────────────────────────────────────────────────────
948
+ const S = {
949
+ data: null,
950
+ fullStats: null,
951
+ activeTab: 'graph',
952
+ activeKind: 'all',
953
+ selectedId: null,
954
+ searchQuery: '',
955
+ searchResults: null,
956
+ strategy: 'bm25',
957
+ debounce: null,
958
+ network: null,
959
+ analyticsRendered: false,
960
+ };
961
+
962
+ // ─── Kind colours (for vis-network nodes) ────────────────────────────────────
963
+ const KIND_COLOR = {
964
+ decision: { bg: '#4f8dff', border: '#2d6edd' },
965
+ learning: { bg: '#9b72f5', border: '#7a50d4' },
966
+ bugfix: { bg: '#ef5858', border: '#d43737' },
967
+ command: { bg: '#f0a638', border: '#d48820' },
968
+ file_summary: { bg: '#22d0c8', border: '#16a8a1' },
969
+ other: { bg: '#4d5870', border: '#3a4560' },
970
+ };
971
+
972
+ // ─── Utilities ────────────────────────────────────────────────────────────────
973
+ function fmtK(n) {
974
+ if (n === undefined || n === null) return '—';
975
+ if (Math.abs(n) >= 1e6) return (n / 1e6).toFixed(1) + 'M';
976
+ if (Math.abs(n) >= 1e3) return (n / 1e3).toFixed(1) + 'k';
977
+ return String(n);
978
+ }
979
+
980
+ function relTime(ts) {
981
+ if (!ts) return '';
982
+ const d = Date.now() - new Date(ts).getTime();
983
+ const min = Math.floor(d / 60000);
984
+ if (min < 1) return 'just now';
985
+ if (min < 60) return `${min}m ago`;
986
+ const hr = Math.floor(min / 60);
987
+ if (hr < 24) return `${hr}h ago`;
988
+ return `${Math.floor(hr / 24)}d ago`;
989
+ }
990
+
991
+ function kindClass(k) {
992
+ return 'kind-' + (k || 'other').replace(/[^a-z_]/g, '');
993
+ }
994
+
995
+ function h(tag, attrs, ...children) {
996
+ const el = document.createElement(tag);
997
+ for (const [k, v] of Object.entries(attrs || {})) {
998
+ if (k === 'style' && typeof v === 'object') Object.assign(el.style, v);
999
+ else if (k.startsWith('on')) el.addEventListener(k.slice(2), v);
1000
+ else el.setAttribute(k, v);
1001
+ }
1002
+ for (const c of children.flat()) {
1003
+ if (c == null) continue;
1004
+ el.append(typeof c === 'string' ? c : c);
1005
+ }
1006
+ return el;
1007
+ }
1008
+
1009
+ // ─── Boot ─────────────────────────────────────────────────────────────────────
1010
+ async function boot() {
1011
+ buildStrategyBtns();
1012
+ buildKindFilters([]);
1013
+ bindTabs();
1014
+ try {
1015
+ const res = await fetch('/api/data');
1016
+ S.data = await res.json();
1017
+ applyData();
1018
+ } catch (e) {
1019
+ document.getElementById('memory-list').innerHTML =
1020
+ '<div class="empty"><div class="empty-title">Could not load data</div>' +
1021
+ '<div class="empty-sub">' +
1022
+ e.message +
1023
+ '</div></div>';
1024
+ }
1025
+ }
1026
+
1027
+ function applyData() {
1028
+ const { stats, memories, graph } = S.data;
1029
+ document.getElementById('project-label').textContent = stats.projectId;
1030
+ const saved = stats.tokensSaved ?? 0;
1031
+ const spent = stats.tokensSpent ?? 0;
1032
+ const roi = spent > 0 ? (saved / spent).toFixed(1) + 'x' : '—';
1033
+ document.getElementById('s-mem').textContent = fmtK(stats.memories);
1034
+ document.getElementById('s-saved').textContent = fmtK(saved);
1035
+ document.getElementById('s-spent').textContent = fmtK(spent);
1036
+ document.getElementById('s-roi').textContent = roi;
1037
+ // cache from full stats, placeholder for now
1038
+ document.getElementById('s-cache').textContent = '—';
1039
+
1040
+ buildKindFilters(memories);
1041
+ renderList(memories);
1042
+ buildGraph(graph);
1043
+ // load full stats in background for cache pill
1044
+ loadFullStats();
1045
+ }
1046
+
1047
+ // ─── Strategy buttons ─────────────────────────────────────────────────────────
1048
+ function buildStrategyBtns() {
1049
+ const wrap = document.getElementById('strategy-btns');
1050
+ ['bm25', 'hybrid', 'embeddings'].forEach((s) => {
1051
+ const btn = h(
1052
+ 'button',
1053
+ {
1054
+ class: 'strat-btn' + (s === S.strategy ? ' active' : ''),
1055
+ onclick: () => {
1056
+ S.strategy = s;
1057
+ wrap.querySelectorAll('.strat-btn').forEach((b) => b.classList.remove('active'));
1058
+ btn.classList.add('active');
1059
+ if (S.searchQuery) runSearch(S.searchQuery);
1060
+ },
1061
+ },
1062
+ s,
1063
+ );
1064
+ wrap.append(btn);
1065
+ });
1066
+ }
1067
+
1068
+ // ─── Kind filter chips ────────────────────────────────────────────────────────
1069
+ const KIND_ORDER = [
1070
+ 'all',
1071
+ 'decision',
1072
+ 'learning',
1073
+ 'bugfix',
1074
+ 'command',
1075
+ 'file_summary',
1076
+ 'other',
1077
+ ];
1078
+
1079
+ function buildKindFilters(memories) {
1080
+ const wrap = document.getElementById('kind-filters');
1081
+ wrap.innerHTML = '';
1082
+ const counts = {};
1083
+ memories.forEach((m) => {
1084
+ counts[m.kind] = (counts[m.kind] || 0) + 1;
1085
+ });
1086
+
1087
+ const kinds = [
1088
+ 'all',
1089
+ ...Object.keys(counts).sort((a, b) => KIND_ORDER.indexOf(a) - KIND_ORDER.indexOf(b)),
1090
+ ];
1091
+
1092
+ kinds.forEach((k) => {
1093
+ const isAll = k === 'all';
1094
+ const label = isAll ? 'All' : k.replace('_', ' ');
1095
+ const count = isAll ? memories.length : counts[k] || 0;
1096
+ const btn = h(
1097
+ 'div',
1098
+ {
1099
+ class: 'kind-tag' + (S.activeKind === k ? ' active kf-' + k : ''),
1100
+ onclick: () => {
1101
+ S.activeKind = k;
1102
+ wrap.querySelectorAll('.kind-tag').forEach((b) => {
1103
+ b.className = 'kind-tag';
1104
+ });
1105
+ btn.className = 'kind-tag active kf-' + k;
1106
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1107
+ },
1108
+ },
1109
+ label + (count > 0 ? ' ' + count : ''),
1110
+ );
1111
+ wrap.append(btn);
1112
+ });
1113
+ }
1114
+
1115
+ // ─── Memory list ──────────────────────────────────────────────────────────────
1116
+ function renderList(memories) {
1117
+ const list = document.getElementById('memory-list');
1118
+ const filtered =
1119
+ S.activeKind === 'all' ? memories : memories.filter((m) => m.kind === S.activeKind);
1120
+
1121
+ document.getElementById('list-count').textContent =
1122
+ filtered.length !== memories.length
1123
+ ? filtered.length + ' / ' + memories.length
1124
+ : memories.length.toString();
1125
+
1126
+ if (filtered.length === 0) {
1127
+ list.innerHTML = '';
1128
+ list.append(
1129
+ h(
1130
+ 'div',
1131
+ { class: 'empty' },
1132
+ h('div', { class: 'empty-icon' }, svgIcon('search')),
1133
+ h('div', { class: 'empty-title' }, S.searchQuery ? 'No results' : 'No memories yet'),
1134
+ h(
1135
+ 'div',
1136
+ { class: 'empty-sub' },
1137
+ S.searchQuery
1138
+ ? 'Try a different query or strategy.'
1139
+ : 'End a Claude session — the hook will extract observations automatically.',
1140
+ ),
1141
+ ),
1142
+ );
1143
+ return;
1144
+ }
1145
+
1146
+ list.innerHTML = '';
1147
+ filtered.forEach((m) => list.append(memCard(m)));
1148
+ }
1149
+
1150
+ function memCard(m) {
1151
+ const card = h('div', {
1152
+ class: 'mem-card fade-in' + (m.id === S.selectedId ? ' selected' : ''),
1153
+ onclick: () => selectMemory(m),
1154
+ });
1155
+
1156
+ // Left accent color
1157
+ const kc = KIND_COLOR[m.kind] || KIND_COLOR.other;
1158
+ card.style.setProperty('--card-accent', kc.bg);
1159
+ if (m.id === S.selectedId) {
1160
+ card.style.borderLeftColor = kc.bg;
1161
+ }
1162
+ card.style.cssText += `--before-bg: ${kc.bg};`;
1163
+
1164
+ const forget = h(
1165
+ 'div',
1166
+ {
1167
+ class: 'mc-forget',
1168
+ title: 'Forget this memory',
1169
+ onclick: async (e) => {
1170
+ e.stopPropagation();
1171
+ if (!confirm('Remove this memory?')) return;
1172
+ await fetch('/api/memories/' + m.id, { method: 'DELETE' });
1173
+ S.data.memories = S.data.memories.filter((x) => x.id !== m.id);
1174
+ if (S.selectedId === m.id) {
1175
+ S.selectedId = null;
1176
+ hideDetail();
1177
+ }
1178
+ buildKindFilters(S.data.memories);
1179
+ renderList(S.searchResults ?? S.data.memories);
1180
+ if (S.network) {
1181
+ try {
1182
+ S.network.getBody().data.nodes.remove(m.id);
1183
+ } catch {}
1184
+ }
1185
+ document.getElementById('s-mem').textContent = fmtK(S.data.memories.length);
1186
+ },
1187
+ },
1188
+ '×',
1189
+ );
1190
+
1191
+ const head = h(
1192
+ 'div',
1193
+ { class: 'mc-head' },
1194
+ h('span', { class: 'mc-title' }, m.title),
1195
+ h(
1196
+ 'span',
1197
+ { class: 'mc-kind ' + kindClass(m.kind) },
1198
+ (m.kind || 'other').replace('_', ' '),
1199
+ ),
1200
+ );
1201
+
1202
+ const preview = m.body ? h('div', { class: 'mc-body' }, m.body) : null;
1203
+
1204
+ const tags = (m.tags || []).slice(0, 3).map((t) => h('span', { class: 'mc-tag' }, t));
1205
+ const footer = h(
1206
+ 'div',
1207
+ { class: 'mc-footer' },
1208
+ h('span', { class: 'mc-time' }, relTime(m.created_at)),
1209
+ h('div', { class: 'mc-tags' }, ...tags),
1210
+ );
1211
+
1212
+ card.append(forget, head);
1213
+ if (preview) card.append(preview);
1214
+ card.append(footer);
1215
+ return card;
1216
+ }
1217
+
1218
+ // ─── Detail panel ─────────────────────────────────────────────────────────────
1219
+ function selectMemory(m) {
1220
+ S.selectedId = m.id;
1221
+ document.querySelectorAll('.mem-card').forEach((c) => c.classList.remove('selected'));
1222
+ const cards = document.querySelectorAll('.mem-card');
1223
+ // find by checking onclick would match but instead we just re-render is expensive
1224
+ // so find by matching title as a quick approach
1225
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1226
+ showDetail(m);
1227
+ if (S.network) {
1228
+ try {
1229
+ S.network.selectNodes([m.id]);
1230
+ S.network.focus(m.id, {
1231
+ animation: { duration: 400, easingFunction: 'easeInOutQuad' },
1232
+ });
1233
+ } catch {}
1234
+ }
1235
+ }
1236
+
1237
+ function showDetail(m) {
1238
+ const panel = document.getElementById('detail-panel');
1239
+ const inner = document.getElementById('detail-inner');
1240
+ panel.style.display = 'block';
1241
+
1242
+ const kc = KIND_COLOR[m.kind] || KIND_COLOR.other;
1243
+ const saved = m.tokens_saved ? fmtK(m.tokens_saved) + ' tk saved' : '';
1244
+ const spent = m.tokens_spent ? fmtK(m.tokens_spent) + ' tk spent' : '';
1245
+
1246
+ const fileChips = (m.files || []).map((f) => {
1247
+ const short = f.split('/').slice(-2).join('/');
1248
+ return h('span', { class: 'detail-chip file', title: f }, short);
1249
+ });
1250
+ const tagChips = (m.tags || []).map((t) => h('span', { class: 'detail-chip' }, t));
1251
+
1252
+ inner.innerHTML = '';
1253
+ inner.append(
1254
+ h(
1255
+ 'div',
1256
+ { class: 'detail-head' },
1257
+ h('span', { class: 'detail-title' }, m.title),
1258
+ h(
1259
+ 'span',
1260
+ { class: 'mc-kind ' + kindClass(m.kind) },
1261
+ (m.kind || 'other').replace('_', ' '),
1262
+ ),
1263
+ h(
1264
+ 'div',
1265
+ {
1266
+ class: 'detail-close',
1267
+ title: 'Close',
1268
+ onclick: () => {
1269
+ S.selectedId = null;
1270
+ hideDetail();
1271
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1272
+ },
1273
+ },
1274
+ h(
1275
+ 'svg',
1276
+ {
1277
+ width: '10',
1278
+ height: '10',
1279
+ viewBox: '0 0 10 10',
1280
+ fill: 'none',
1281
+ stroke: 'currentColor',
1282
+ 'stroke-width': '1.6',
1283
+ 'stroke-linecap': 'round',
1284
+ },
1285
+ h('path', { d: 'M1 1l8 8M9 1L1 9' }),
1286
+ ),
1287
+ ),
1288
+ ),
1289
+ h(
1290
+ 'div',
1291
+ { class: 'detail-meta' },
1292
+ h('span', {}, relTime(m.created_at)),
1293
+ saved ? h('span', { style: 'color:var(--accent-g)' }, saved) : null,
1294
+ spent ? h('span', {}, spent) : null,
1295
+ ),
1296
+ h('div', { class: 'detail-body' }, m.body || ''),
1297
+ fileChips.length || tagChips.length
1298
+ ? h('div', { class: 'detail-chips' }, ...fileChips, ...tagChips)
1299
+ : null,
1300
+ );
1301
+ }
1302
+
1303
+ function hideDetail() {
1304
+ document.getElementById('detail-panel').style.display = 'none';
1305
+ if (S.network) {
1306
+ try {
1307
+ S.network.unselectAll();
1308
+ } catch {}
1309
+ }
1310
+ }
1311
+
1312
+ // ─── Search ────────────────────────────────────────────────────────────────────
1313
+ document.getElementById('search-input').addEventListener('input', (e) => {
1314
+ const q = e.target.value.trim();
1315
+ S.searchQuery = q;
1316
+ clearTimeout(S.debounce);
1317
+ if (!q) {
1318
+ S.searchResults = null;
1319
+ renderList(S.data?.memories ?? []);
1320
+ return;
1321
+ }
1322
+ S.debounce = setTimeout(() => runSearch(q), 320);
1323
+ });
1324
+
1325
+ async function runSearch(q) {
1326
+ const spin = document.getElementById('search-spinner');
1327
+ spin.style.display = 'block';
1328
+ try {
1329
+ const res = await fetch(
1330
+ `/api/search?q=${encodeURIComponent(q)}&strategy=${S.strategy}&k=20`,
1331
+ );
1332
+ const json = await res.json();
1333
+ S.searchResults = json.results;
1334
+ document.getElementById('tab-hint').textContent =
1335
+ `${json.strategy} · ${json.results.length} results`;
1336
+ renderList(json.results);
1337
+ } catch {
1338
+ S.searchResults = null;
1339
+ renderList(S.data?.memories ?? []);
1340
+ } finally {
1341
+ spin.style.display = 'none';
1342
+ }
1343
+ }
1344
+
1345
+ // ─── Tabs ─────────────────────────────────────────────────────────────────────
1346
+ function bindTabs() {
1347
+ document.querySelectorAll('.tab-btn[data-tab]').forEach((btn) => {
1348
+ btn.addEventListener('click', () => {
1349
+ const t = btn.dataset.tab;
1350
+ S.activeTab = t;
1351
+ document
1352
+ .querySelectorAll('.tab-btn[data-tab]')
1353
+ .forEach((b) => b.classList.remove('active'));
1354
+ btn.classList.add('active');
1355
+ document.getElementById('tab-graph').style.display = t === 'graph' ? 'flex' : 'none';
1356
+ document.getElementById('tab-analytics').style.display =
1357
+ t === 'analytics' ? 'flex' : 'none';
1358
+ if (t === 'analytics' && !S.analyticsRendered && S.fullStats) {
1359
+ renderAnalytics(S.fullStats);
1360
+ }
1361
+ });
1362
+ });
1363
+ }
1364
+
1365
+ // ─── Knowledge graph ──────────────────────────────────────────────────────────
1366
+ function buildGraph({ nodes, edges }) {
1367
+ const container = document.getElementById('graph-container');
1368
+ if (!nodes?.length) {
1369
+ container.innerHTML =
1370
+ '<div class="empty" style="height:100%">' +
1371
+ svgIcon('graph') +
1372
+ '<div class="empty-title">No graph yet</div>' +
1373
+ '<div class="empty-sub">Memories with shared files or tags will appear here.</div></div>';
1374
+ return;
1375
+ }
1376
+
1377
+ const vNodes = new vis.DataSet(
1378
+ nodes.map((n) => {
1379
+ const kc = KIND_COLOR[n.kind] || KIND_COLOR.other;
1380
+ return {
1381
+ id: n.id,
1382
+ label: n.label,
1383
+ title: n.title,
1384
+ color: {
1385
+ background: kc.bg,
1386
+ border: kc.border,
1387
+ highlight: { background: kc.bg, border: '#fff' },
1388
+ },
1389
+ font: { color: '#dde1ea', size: 11, face: 'Bricolage Grotesque' },
1390
+ shape: 'dot',
1391
+ size: 10,
1392
+ borderWidth: 1.5,
1393
+ };
1394
+ }),
1395
+ );
1396
+
1397
+ const vEdges = new vis.DataSet(
1398
+ edges.map((e, i) => ({
1399
+ id: i,
1400
+ from: e.from,
1401
+ to: e.to,
1402
+ color: { color: '#1f2535', highlight: '#4f8dff', opacity: 0.8 },
1403
+ width: 1,
1404
+ smooth: { type: 'dynamic' },
1405
+ })),
1406
+ );
1407
+
1408
+ S.network = new vis.Network(
1409
+ container,
1410
+ { nodes: vNodes, edges: vEdges },
1411
+ {
1412
+ physics: {
1413
+ stabilization: { iterations: 100 },
1414
+ barnesHut: { gravitationalConstant: -2000, springLength: 120 },
1415
+ },
1416
+ interaction: { hover: true, tooltipDelay: 200 },
1417
+ nodes: { borderWidthSelected: 2 },
1418
+ },
1419
+ );
1420
+
1421
+ S.network.on('click', ({ nodes: selected }) => {
1422
+ if (!selected.length) return;
1423
+ const id = selected[0];
1424
+ const mem = S.data?.memories?.find((m) => m.id === id);
1425
+ if (mem) {
1426
+ S.selectedId = id;
1427
+ renderList(S.searchResults ?? S.data?.memories ?? []);
1428
+ showDetail(mem);
1429
+ }
1430
+ });
1431
+
1432
+ document.getElementById('tab-hint').textContent =
1433
+ `${nodes.length} nodes · ${edges.length} edges`;
1434
+ }
1435
+
1436
+ // ─── Full stats ────────────────────────────────────────────────────────────────
1437
+ async function loadFullStats() {
1438
+ try {
1439
+ const res = await fetch('/api/stats/full');
1440
+ S.fullStats = await res.json();
1441
+ // Update cache pill
1442
+ const hit = S.fullStats.cache?.hit_rate;
1443
+ document.getElementById('s-cache').textContent =
1444
+ hit != null ? (hit * 100).toFixed(0) + '%' : '—';
1445
+ if (S.activeTab === 'analytics') renderAnalytics(S.fullStats);
1446
+ } catch {}
1447
+ }
1448
+
1449
+ function renderAnalytics(fs) {
1450
+ S.analyticsRendered = true;
1451
+ const grid = document.getElementById('analytics-grid');
1452
+ grid.innerHTML = '';
1453
+
1454
+ // 1. Kind breakdown
1455
+ const kb = fs.kindBreakdown || {};
1456
+ const kbMax = Math.max(...Object.values(kb).map(Number), 1);
1457
+ const kbCard = card('Memory kinds');
1458
+ Object.entries(kb)
1459
+ .sort((a, b) => b[1] - a[1])
1460
+ .forEach(([kind, count]) => {
1461
+ const kc = KIND_COLOR[kind] || KIND_COLOR.other;
1462
+ const row = h(
1463
+ 'div',
1464
+ { class: 'bar-row' },
1465
+ h(
1466
+ 'span',
1467
+ { class: 'bar-label', style: 'font-size:11.5px;font-weight:600' },
1468
+ kind.replace('_', ' '),
1469
+ ),
1470
+ h(
1471
+ 'div',
1472
+ { class: 'bar-track' },
1473
+ h('div', { class: 'bar-fill', style: `background:${kc.bg}` }),
1474
+ ),
1475
+ h('span', { class: 'bar-count' }, String(count)),
1476
+ );
1477
+ kbCard.append(row);
1478
+ requestAnimationFrame(() => {
1479
+ row.querySelector('.bar-fill').style.width = (count / kbMax) * 100 + '%';
1480
+ });
1481
+ });
1482
+ if (!Object.keys(kb).length)
1483
+ kbCard.append(
1484
+ h('div', { class: 'empty' }, h('div', { class: 'empty-sub' }, 'No data yet')),
1485
+ );
1486
+ grid.append(wrap(kbCard));
1487
+
1488
+ // 2. Cache performance
1489
+ const cc = fs.cache || {};
1490
+ const cacheCard = card('Cache performance');
1491
+ [
1492
+ [
1493
+ 'Hit rate',
1494
+ cc.hit_rate != null ? (cc.hit_rate * 100).toFixed(1) + '%' : '—',
1495
+ 'var(--accent-g)',
1496
+ ],
1497
+ ['Hits', cc.hit_count ?? 0, 'var(--accent-g)'],
1498
+ ['Misses', cc.miss_count ?? 0, 'var(--muted2)'],
1499
+ ['Entries', cc.entry_count ?? 0, 'var(--accent)'],
1500
+ ].forEach(([lbl, val, color]) => {
1501
+ cacheCard.append(
1502
+ h(
1503
+ 'div',
1504
+ { class: 'cache-stat' },
1505
+ h('span', { class: 'cache-stat-lbl' }, lbl),
1506
+ h('span', { class: 'cache-stat-val', style: `color:${color}` }, String(val)),
1507
+ ),
1508
+ );
1509
+ });
1510
+ grid.append(wrap(cacheCard));
1511
+
1512
+ // 3. Retrieval usage
1513
+ const ru = fs.retrieval || [];
1514
+ const ruMax = Math.max(...ru.map((r) => r.call_count), 1);
1515
+ const ruCard = card('Retrieval strategy');
1516
+ ru.sort((a, b) => b.call_count - a.call_count).forEach((r) => {
1517
+ const row = h(
1518
+ 'div',
1519
+ { class: 'bar-row' },
1520
+ h('span', { class: 'bar-label' }, r.strategy),
1521
+ h(
1522
+ 'div',
1523
+ { class: 'bar-track' },
1524
+ h('div', { class: 'bar-fill', style: 'background:var(--accent-v)' }),
1525
+ ),
1526
+ h('span', { class: 'bar-count' }, String(r.call_count)),
1527
+ );
1528
+ ruCard.append(row);
1529
+ requestAnimationFrame(() => {
1530
+ row.querySelector('.bar-fill').style.width = (r.call_count / ruMax) * 100 + '%';
1531
+ });
1532
+ });
1533
+ if (!ru.length)
1534
+ ruCard.append(
1535
+ h('div', { class: 'empty' }, h('div', { class: 'empty-sub' }, 'No retrieval data yet')),
1536
+ );
1537
+ grid.append(wrap(ruCard));
1538
+
1539
+ // 4. Top files
1540
+ const tf = fs.topFiles || [];
1541
+ const tfCard = card('Top files referenced');
1542
+ tf.slice(0, 12).forEach((f) => {
1543
+ tfCard.append(
1544
+ h(
1545
+ 'div',
1546
+ { class: 'file-row' },
1547
+ h(
1548
+ 'span',
1549
+ { class: 'file-name', title: f.file_path },
1550
+ f.file_path.split('/').slice(-2).join('/'),
1551
+ ),
1552
+ h('span', { class: 'file-count' }, String(f.reference_count) + 'x'),
1553
+ ),
1554
+ );
1555
+ });
1556
+ if (!tf.length)
1557
+ tfCard.append(
1558
+ h(
1559
+ 'div',
1560
+ { class: 'empty' },
1561
+ h('div', { class: 'empty-sub' }, 'No file references yet'),
1562
+ ),
1563
+ );
1564
+ grid.append(wrap(tfCard));
1565
+ }
1566
+
1567
+ function card(title) {
1568
+ const el = h('div', { class: 'a-card' });
1569
+ el.append(h('div', { class: 'a-title' }, title));
1570
+ return el;
1571
+ }
1572
+ function wrap(el) {
1573
+ const w = h('div', {});
1574
+ w.append(el);
1575
+ return w;
1576
+ }
1577
+
1578
+ // ─── SVG icons ────────────────────────────────────────────────────────────────
1579
+ function svgIcon(name) {
1580
+ const el = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1581
+ el.setAttribute('width', '32');
1582
+ el.setAttribute('height', '32');
1583
+ el.setAttribute('viewBox', '0 0 24 24');
1584
+ el.setAttribute('fill', 'none');
1585
+ el.setAttribute('stroke', 'currentColor');
1586
+ el.setAttribute('stroke-width', '1.5');
1587
+ el.setAttribute('stroke-linecap', 'round');
1588
+ el.classList.add('empty-icon');
1589
+ const paths = {
1590
+ search: '<circle cx="11" cy="11" r="7"/><path d="M21 21l-5-5"/>',
1591
+ graph:
1592
+ '<circle cx="5" cy="12" r="2"/><circle cx="19" cy="5" r="2"/><circle cx="19" cy="19" r="2"/><path d="M7 11.5L17 6M7 12.5L17 18"/>',
1593
+ };
1594
+ el.innerHTML = paths[name] || '';
1595
+ return el;
1596
+ }
1597
+
1598
+ // ─── Init ─────────────────────────────────────────────────────────────────────
1599
+ boot();
1600
+ </script>
1601
+ </body>
1602
+ </html>