@agentmemory/agentmemory 0.8.0 → 0.8.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,2888 @@
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>agentmemory viewer</title>
7
+ <link href="https://fonts.googleapis.com/css2?family=Playfair+Display:wght@400;600;700;900&family=Lora:wght@400;500;600&family=Inter:wght@400;500;600&family=JetBrains+Mono:wght@400;500&display=swap" rel="stylesheet">
8
+ <style>
9
+ :root {
10
+ --bg: #F9F9F7;
11
+ --bg-alt: #F0F0EC;
12
+ --bg-inset: #E8E8E3;
13
+ --border: #111111;
14
+ --border-light: #D4D4CF;
15
+ --border-heavy: #111111;
16
+ --ink: #111111;
17
+ --ink-secondary: #333333;
18
+ --ink-muted: #666666;
19
+ --ink-faint: #999999;
20
+ --accent: #CC0000;
21
+ --accent-light: #FF1A1A;
22
+ --cream: #F5F0E8;
23
+ --node-file: #2D6A4F;
24
+ --node-function: #1D4E89;
25
+ --node-concept: #B8860B;
26
+ --node-error: #CC0000;
27
+ --node-decision: #6B3FA0;
28
+ --node-pattern: #2563EB;
29
+ --node-library: #C2410C;
30
+ --node-person: #111111;
31
+ --green: #2D6A4F;
32
+ --blue: #1D4E89;
33
+ --yellow: #B8860B;
34
+ --red: #CC0000;
35
+ --purple: #6B3FA0;
36
+ --orange: #C2410C;
37
+ --cyan: #0E7490;
38
+ --font-display: 'Playfair Display', Georgia, 'Times New Roman', serif;
39
+ --font-body: 'Lora', Georgia, serif;
40
+ --font-ui: 'Inter', -apple-system, sans-serif;
41
+ --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
42
+ }
43
+ html[data-theme="dark"] {
44
+ --bg: #1a1a1e;
45
+ --bg-alt: #232328;
46
+ --bg-inset: #2a2a30;
47
+ --border: #444;
48
+ --border-light: #3a3a42;
49
+ --border-heavy: #ccc;
50
+ --ink: #eee;
51
+ --ink-secondary: #ccc;
52
+ --ink-muted: #999;
53
+ --ink-faint: #777;
54
+ --cream: #2a2520;
55
+ }
56
+ html[data-theme="dark"] body {
57
+ background-image: radial-gradient(circle, #3a3a42 0.5px, transparent 0.5px);
58
+ }
59
+ html[data-theme="dark"] .graph-tooltip {
60
+ background: rgba(30,30,35,0.92);
61
+ border-color: rgba(255,255,255,0.1);
62
+ box-shadow: 0 8px 32px rgba(0,0,0,0.4);
63
+ }
64
+ html[data-theme="dark"] .graph-controls button {
65
+ background: rgba(30,30,35,0.92);
66
+ border-color: rgba(255,255,255,0.1);
67
+ }
68
+ html[data-theme="dark"] .graph-controls button:hover {
69
+ background: var(--ink);
70
+ color: var(--bg);
71
+ }
72
+ * { margin: 0; padding: 0; box-sizing: border-box; }
73
+ body {
74
+ font-family: var(--font-body);
75
+ background: var(--bg);
76
+ color: var(--ink-secondary);
77
+ line-height: 1.6;
78
+ overflow: hidden;
79
+ height: 100vh;
80
+ background-image: radial-gradient(circle, #D4D4CF 0.5px, transparent 0.5px);
81
+ background-size: 16px 16px;
82
+ }
83
+ ::-webkit-scrollbar { width: 6px; }
84
+ ::-webkit-scrollbar-track { background: var(--bg); }
85
+ ::-webkit-scrollbar-thumb { background: var(--border-light); border-radius: 0; }
86
+ ::-webkit-scrollbar-thumb:hover { background: var(--ink-muted); }
87
+
88
+ .app-header {
89
+ padding: 10px 24px;
90
+ border-bottom: 4px solid var(--border-heavy);
91
+ display: flex;
92
+ align-items: center;
93
+ justify-content: space-between;
94
+ background: var(--bg);
95
+ }
96
+ .app-header .brand {
97
+ display: flex;
98
+ align-items: baseline;
99
+ gap: 10px;
100
+ }
101
+ .app-header .brand h1 {
102
+ font-size: 22px;
103
+ color: var(--ink);
104
+ font-weight: 900;
105
+ font-family: var(--font-display);
106
+ letter-spacing: -0.02em;
107
+ text-transform: lowercase;
108
+ }
109
+ .app-header .brand .version {
110
+ font-size: 10px;
111
+ color: var(--ink-faint);
112
+ font-family: var(--font-mono);
113
+ text-transform: uppercase;
114
+ letter-spacing: 0.1em;
115
+ }
116
+ .header-right {
117
+ display: flex;
118
+ align-items: center;
119
+ gap: 12px;
120
+ }
121
+ .ws-status {
122
+ font-size: 10px;
123
+ padding: 3px 10px;
124
+ display: flex;
125
+ align-items: center;
126
+ gap: 5px;
127
+ font-family: var(--font-ui);
128
+ text-transform: uppercase;
129
+ letter-spacing: 0.08em;
130
+ font-weight: 600;
131
+ border: 1px solid var(--border-light);
132
+ }
133
+ .ws-status::before {
134
+ content: '';
135
+ width: 6px;
136
+ height: 6px;
137
+ display: inline-block;
138
+ }
139
+ .ws-status.connected { border-color: var(--green); color: var(--green); }
140
+ .ws-status.connected::before { background: var(--green); }
141
+ .ws-status.disconnected { border-color: var(--ink-faint); color: var(--ink-faint); }
142
+ .ws-status.disconnected::before { background: var(--ink-faint); }
143
+
144
+ .tab-bar {
145
+ display: flex;
146
+ border-bottom: 1px solid var(--border-light);
147
+ background: var(--bg);
148
+ overflow-x: auto;
149
+ }
150
+ .tab-bar button {
151
+ background: none;
152
+ border: none;
153
+ color: var(--ink-muted);
154
+ padding: 10px 20px;
155
+ font-size: 11px;
156
+ cursor: pointer;
157
+ border-bottom: 2px solid transparent;
158
+ white-space: nowrap;
159
+ font-family: var(--font-ui);
160
+ text-transform: uppercase;
161
+ letter-spacing: 0.12em;
162
+ font-weight: 600;
163
+ transition: color 0.15s, border-color 0.15s;
164
+ }
165
+ .tab-bar button:hover { color: var(--ink); }
166
+ .tab-bar button.active {
167
+ color: var(--ink);
168
+ border-bottom-color: var(--accent);
169
+ }
170
+
171
+ .view { display: none; height: calc(100vh - 90px); overflow-y: auto; padding: 24px; }
172
+ .view.active { display: block; }
173
+
174
+ .stats-grid {
175
+ display: grid;
176
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
177
+ gap: 0;
178
+ margin-bottom: 24px;
179
+ border: 1px solid var(--border);
180
+ }
181
+ .stat-card {
182
+ background: var(--bg);
183
+ padding: 16px 20px;
184
+ border-right: 1px solid var(--border-light);
185
+ border-bottom: 1px solid var(--border-light);
186
+ }
187
+ .stat-card:last-child { border-right: none; }
188
+ .stat-card .label {
189
+ font-size: 9px;
190
+ color: var(--ink-muted);
191
+ text-transform: uppercase;
192
+ letter-spacing: 0.15em;
193
+ margin-bottom: 4px;
194
+ font-family: var(--font-ui);
195
+ font-weight: 600;
196
+ }
197
+ .stat-card .value {
198
+ font-size: 32px;
199
+ font-weight: 900;
200
+ color: var(--ink);
201
+ font-family: var(--font-display);
202
+ line-height: 1.1;
203
+ }
204
+ .stat-card .sub {
205
+ font-size: 11px;
206
+ color: var(--ink-faint);
207
+ margin-top: 2px;
208
+ font-family: var(--font-ui);
209
+ }
210
+
211
+ .card {
212
+ background: var(--bg);
213
+ border: 1px solid var(--border);
214
+ padding: 20px;
215
+ margin-bottom: 16px;
216
+ transition: box-shadow 0.15s;
217
+ }
218
+ .card:hover {
219
+ box-shadow: 4px 4px 0px 0px var(--border);
220
+ }
221
+ .card-title {
222
+ font-size: 13px;
223
+ font-weight: 700;
224
+ color: var(--ink);
225
+ margin-bottom: 12px;
226
+ font-family: var(--font-display);
227
+ text-transform: uppercase;
228
+ letter-spacing: 0.06em;
229
+ padding-bottom: 8px;
230
+ border-bottom: 1px solid var(--border-light);
231
+ }
232
+
233
+ .health-bar {
234
+ display: flex;
235
+ align-items: center;
236
+ gap: 8px;
237
+ margin-bottom: 8px;
238
+ }
239
+ .health-dot {
240
+ width: 10px;
241
+ height: 10px;
242
+ }
243
+ .health-dot.healthy { background: var(--green); }
244
+ .health-dot.degraded { background: var(--yellow); }
245
+ .health-dot.critical { background: var(--accent); }
246
+
247
+ .badge {
248
+ display: inline-block;
249
+ font-size: 9px;
250
+ padding: 2px 8px;
251
+ font-weight: 600;
252
+ font-family: var(--font-ui);
253
+ text-transform: uppercase;
254
+ letter-spacing: 0.08em;
255
+ border: 1px solid;
256
+ }
257
+ .badge-blue { border-color: var(--blue); color: var(--blue); background: transparent; }
258
+ .badge-green { border-color: var(--green); color: var(--green); background: transparent; }
259
+ .badge-yellow { border-color: var(--yellow); color: var(--yellow); background: transparent; }
260
+ .badge-red { border-color: var(--accent); color: var(--accent); background: transparent; }
261
+ .badge-purple { border-color: var(--purple); color: var(--purple); background: transparent; }
262
+ .badge-orange { border-color: var(--orange); color: var(--orange); background: transparent; }
263
+ .badge-cyan { border-color: var(--cyan); color: var(--cyan); background: transparent; }
264
+ .badge-muted { border-color: var(--border-light); color: var(--ink-muted); background: transparent; }
265
+
266
+ table {
267
+ width: 100%;
268
+ border-collapse: collapse;
269
+ font-size: 13px;
270
+ font-family: var(--font-body);
271
+ }
272
+ th {
273
+ text-align: left;
274
+ padding: 8px 12px;
275
+ border-bottom: 2px solid var(--border);
276
+ color: var(--ink);
277
+ font-size: 9px;
278
+ text-transform: uppercase;
279
+ letter-spacing: 0.12em;
280
+ font-weight: 600;
281
+ font-family: var(--font-ui);
282
+ }
283
+ td {
284
+ padding: 8px 12px;
285
+ border-bottom: 1px solid var(--border-light);
286
+ vertical-align: top;
287
+ }
288
+ tr:hover td { background: var(--bg-alt); }
289
+
290
+ .strength-bar {
291
+ width: 60px;
292
+ height: 4px;
293
+ background: var(--bg-inset);
294
+ overflow: hidden;
295
+ display: inline-block;
296
+ vertical-align: middle;
297
+ }
298
+ .strength-bar .fill {
299
+ height: 100%;
300
+ transition: width 0.3s;
301
+ }
302
+
303
+ .toolbar {
304
+ display: flex;
305
+ gap: 10px;
306
+ margin-bottom: 20px;
307
+ align-items: center;
308
+ flex-wrap: wrap;
309
+ }
310
+ .toolbar input, .toolbar select {
311
+ background: var(--bg);
312
+ border: 1px solid var(--border);
313
+ color: var(--ink);
314
+ padding: 7px 12px;
315
+ font-size: 13px;
316
+ outline: none;
317
+ font-family: var(--font-ui);
318
+ }
319
+ .toolbar input:focus, .toolbar select:focus {
320
+ border-color: var(--ink);
321
+ box-shadow: 2px 2px 0px 0px var(--border);
322
+ }
323
+ .toolbar input { flex: 1; min-width: 200px; }
324
+
325
+ .btn {
326
+ background: var(--bg);
327
+ border: 1px solid var(--border);
328
+ color: var(--ink);
329
+ padding: 7px 16px;
330
+ font-size: 11px;
331
+ cursor: pointer;
332
+ transition: box-shadow 0.1s, transform 0.1s;
333
+ font-family: var(--font-ui);
334
+ font-weight: 600;
335
+ text-transform: uppercase;
336
+ letter-spacing: 0.06em;
337
+ }
338
+ .btn:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); }
339
+ .btn:active { box-shadow: none; transform: translate(0, 0); }
340
+ .btn-danger { border-color: var(--accent); color: var(--accent); }
341
+ .btn-danger:hover { background: var(--accent); color: white; box-shadow: 3px 3px 0px 0px var(--border); }
342
+ .btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
343
+ .btn-primary:hover { background: var(--ink-secondary); box-shadow: 3px 3px 0px 0px var(--ink-muted); }
344
+
345
+ .graph-container {
346
+ display: flex;
347
+ height: calc(100vh - 130px);
348
+ margin: -24px;
349
+ border-top: 1px solid var(--border-light);
350
+ }
351
+ .graph-canvas-wrap {
352
+ flex: 1;
353
+ position: relative;
354
+ overflow: hidden;
355
+ background: var(--bg);
356
+ }
357
+ .graph-canvas-wrap canvas {
358
+ display: block;
359
+ width: 100%;
360
+ height: 100%;
361
+ }
362
+ .graph-sidebar {
363
+ width: 260px;
364
+ border-left: 2px solid var(--border);
365
+ padding: 20px;
366
+ overflow-y: auto;
367
+ background: var(--bg);
368
+ }
369
+ .graph-sidebar h3 {
370
+ font-size: 9px;
371
+ color: var(--ink);
372
+ text-transform: uppercase;
373
+ letter-spacing: 0.15em;
374
+ margin-bottom: 12px;
375
+ font-family: var(--font-ui);
376
+ font-weight: 600;
377
+ padding-bottom: 6px;
378
+ border-bottom: 1px solid var(--border-light);
379
+ }
380
+ .filter-item {
381
+ display: flex;
382
+ align-items: center;
383
+ gap: 6px;
384
+ padding: 4px 0;
385
+ font-size: 12px;
386
+ cursor: pointer;
387
+ font-family: var(--font-ui);
388
+ }
389
+ .filter-item input[type="checkbox"] {
390
+ accent-color: var(--ink);
391
+ }
392
+ .filter-dot {
393
+ width: 8px;
394
+ height: 8px;
395
+ display: inline-block;
396
+ }
397
+ .graph-info {
398
+ margin-top: 16px;
399
+ padding-top: 16px;
400
+ border-top: 1px solid var(--border-light);
401
+ }
402
+ .graph-info .info-row {
403
+ display: flex;
404
+ justify-content: space-between;
405
+ font-size: 12px;
406
+ padding: 3px 0;
407
+ font-family: var(--font-ui);
408
+ }
409
+ .graph-info .info-row .info-label { color: var(--ink-muted); }
410
+ .graph-info .info-row .info-value { color: var(--ink); font-weight: 600; font-family: var(--font-mono); }
411
+
412
+ .obs-card {
413
+ background: var(--bg);
414
+ border: 1px solid var(--border-light);
415
+ padding: 16px 20px;
416
+ margin-bottom: 12px;
417
+ border-left: 3px solid var(--border-light);
418
+ transition: box-shadow 0.15s;
419
+ }
420
+ .obs-card:hover { box-shadow: 3px 3px 0px 0px var(--border-light); }
421
+ .obs-card.imp-high { border-left-color: var(--accent); }
422
+ .obs-card.imp-med { border-left-color: var(--yellow); }
423
+ .obs-card.imp-low { border-left-color: var(--green); }
424
+ .obs-card .obs-head {
425
+ display: flex;
426
+ justify-content: space-between;
427
+ align-items: center;
428
+ margin-bottom: 6px;
429
+ }
430
+ .obs-card .obs-title {
431
+ font-size: 14px;
432
+ font-weight: 700;
433
+ color: var(--ink);
434
+ font-family: var(--font-display);
435
+ }
436
+ .obs-card .obs-time {
437
+ font-size: 10px;
438
+ color: var(--ink-faint);
439
+ font-family: var(--font-mono);
440
+ letter-spacing: 0.04em;
441
+ }
442
+ .obs-card .obs-narrative {
443
+ font-size: 13px;
444
+ color: var(--ink-muted);
445
+ margin-bottom: 6px;
446
+ }
447
+ .obs-card .obs-facts {
448
+ margin: 6px 0 6px 16px;
449
+ font-size: 12px;
450
+ color: var(--ink-muted);
451
+ }
452
+ .obs-card .obs-facts li { margin-bottom: 2px; }
453
+ .tag-list { display: flex; gap: 4px; flex-wrap: wrap; margin-top: 6px; }
454
+ mark { background: rgba(204, 0, 0, 0.12); color: var(--ink); padding: 0 2px; border-radius: 2px; }
455
+ .tag {
456
+ font-size: 10px;
457
+ padding: 1px 6px;
458
+ border: 1px solid var(--blue);
459
+ color: var(--blue);
460
+ font-family: var(--font-mono);
461
+ font-weight: 500;
462
+ }
463
+ .tag.file-tag { border-color: var(--green); color: var(--green); }
464
+
465
+ .session-list { display: flex; flex-direction: column; gap: 0; }
466
+ .session-item {
467
+ background: var(--bg);
468
+ border: 1px solid var(--border-light);
469
+ border-bottom: none;
470
+ padding: 14px 20px;
471
+ cursor: pointer;
472
+ transition: background 0.1s;
473
+ }
474
+ .session-item:last-child { border-bottom: 1px solid var(--border-light); }
475
+ .session-item:hover { background: var(--bg-alt); }
476
+ .session-item.selected { background: var(--bg-alt); border-left: 3px solid var(--accent); }
477
+ .session-item .session-top {
478
+ display: flex;
479
+ justify-content: space-between;
480
+ align-items: center;
481
+ margin-bottom: 4px;
482
+ }
483
+ .session-item .session-project {
484
+ font-weight: 700;
485
+ color: var(--ink);
486
+ font-size: 14px;
487
+ font-family: var(--font-display);
488
+ }
489
+ .session-item .session-meta {
490
+ font-size: 11px;
491
+ color: var(--ink-muted);
492
+ font-family: var(--font-mono);
493
+ }
494
+
495
+ .detail-panel {
496
+ background: var(--bg);
497
+ border: 1px solid var(--border);
498
+ padding: 24px;
499
+ margin-top: 20px;
500
+ }
501
+ .detail-panel h3 {
502
+ font-size: 15px;
503
+ font-weight: 700;
504
+ color: var(--ink);
505
+ margin-bottom: 16px;
506
+ font-family: var(--font-display);
507
+ text-transform: uppercase;
508
+ letter-spacing: 0.04em;
509
+ padding-bottom: 8px;
510
+ border-bottom: 2px solid var(--border);
511
+ }
512
+ .detail-row {
513
+ display: flex;
514
+ padding: 6px 0;
515
+ font-size: 13px;
516
+ border-bottom: 1px solid var(--bg-inset);
517
+ }
518
+ .detail-row .dl { color: var(--ink-muted); width: 140px; flex-shrink: 0; font-family: var(--font-ui); font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; font-weight: 600; padding-top: 2px; }
519
+ .detail-row .dv { color: var(--ink); font-family: var(--font-body); }
520
+
521
+ .audit-entry {
522
+ padding: 12px 0;
523
+ border-bottom: 1px solid var(--border-light);
524
+ font-size: 13px;
525
+ }
526
+ .audit-entry:last-child { border-bottom: none; }
527
+ .audit-head {
528
+ display: flex;
529
+ align-items: center;
530
+ gap: 8px;
531
+ margin-bottom: 4px;
532
+ }
533
+ .audit-detail {
534
+ font-size: 12px;
535
+ color: var(--ink-faint);
536
+ margin-top: 4px;
537
+ max-height: 0;
538
+ overflow: hidden;
539
+ transition: max-height 0.2s;
540
+ }
541
+ .audit-detail.open { max-height: 200px; }
542
+ .audit-detail pre {
543
+ font-family: var(--font-mono);
544
+ font-size: 11px;
545
+ background: var(--bg-alt);
546
+ padding: 10px;
547
+ border: 1px solid var(--border-light);
548
+ overflow-x: auto;
549
+ }
550
+
551
+ .bar-chart { margin-top: 8px; }
552
+ .bar-row {
553
+ display: flex;
554
+ align-items: center;
555
+ gap: 8px;
556
+ margin-bottom: 6px;
557
+ font-size: 12px;
558
+ }
559
+ .bar-label { width: 120px; color: var(--ink-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-family: var(--font-mono); font-size: 11px; }
560
+ .bar-track {
561
+ flex: 1;
562
+ height: 6px;
563
+ background: var(--bg-inset);
564
+ overflow: hidden;
565
+ }
566
+ .bar-fill {
567
+ height: 100%;
568
+ transition: width 0.3s;
569
+ }
570
+ .bar-value { width: 30px; text-align: right; color: var(--ink-muted); font-size: 11px; font-family: var(--font-mono); font-weight: 500; }
571
+
572
+ .empty-state {
573
+ text-align: center;
574
+ padding: 60px 20px;
575
+ color: var(--ink-faint);
576
+ }
577
+ .empty-state .empty-icon { font-size: 36px; margin-bottom: 10px; opacity: 0.4; }
578
+ .empty-state p { font-size: 14px; font-family: var(--font-body); font-style: italic; }
579
+
580
+ .loading { color: var(--ink-faint); padding: 20px; text-align: center; font-style: italic; font-family: var(--font-body); }
581
+
582
+ .metric-table { width: 100%; border-collapse: collapse; font-size: 12px; }
583
+ .metric-table th { padding: 6px 8px; font-size: 9px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--ink-muted); border-bottom: 2px solid var(--border); text-align: left; font-family: var(--font-ui); font-weight: 600; }
584
+ .metric-table td { padding: 5px 8px; border-bottom: 1px solid var(--border-light); }
585
+ .metric-table tr:hover td { background: var(--bg-alt); }
586
+ .metric-fn { font-family: var(--font-mono); font-size: 11px; color: var(--blue); }
587
+ .metric-num { font-family: var(--font-mono); color: var(--ink); text-align: right; }
588
+
589
+ .cb-indicator { display: inline-flex; align-items: center; gap: 6px; padding: 3px 10px; font-size: 10px; font-weight: 600; font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; border: 1px solid; }
590
+ .cb-closed { border-color: var(--green); color: var(--green); }
591
+ .cb-open { border-color: var(--accent); color: var(--accent); }
592
+ .cb-half-open { border-color: var(--yellow); color: var(--yellow); }
593
+
594
+ .worker-row { display: flex; align-items: center; gap: 8px; padding: 4px 0; font-size: 12px; font-family: var(--font-ui); }
595
+ .worker-dot { width: 8px; height: 8px; }
596
+ .worker-dot.running { background: var(--green); }
597
+ .worker-dot.stopped { background: var(--accent); }
598
+ .worker-dot.starting { background: var(--yellow); }
599
+
600
+ .gauge { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
601
+ .gauge-bar { flex: 1; height: 6px; background: var(--bg-inset); overflow: hidden; }
602
+ .gauge-fill { height: 100%; transition: width 0.5s; }
603
+ .gauge-label { width: 90px; font-size: 10px; color: var(--ink-muted); font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.08em; font-weight: 600; }
604
+ .gauge-value { width: 70px; font-size: 11px; color: var(--ink); text-align: right; font-family: var(--font-mono); }
605
+
606
+ .obs-type-icon { font-size: 16px; margin-right: 4px; }
607
+ .obs-subtitle { font-size: 12px; color: var(--ink-faint); margin-top: 2px; font-style: italic; }
608
+ .obs-importance { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; font-size: 11px; font-weight: 700; font-family: var(--font-mono); border: 1px solid; }
609
+ .imp-1, .imp-2, .imp-3 { border-color: var(--green); color: var(--green); }
610
+ .imp-4, .imp-5, .imp-6 { border-color: var(--yellow); color: var(--yellow); }
611
+ .imp-7, .imp-8, .imp-9, .imp-10 { border-color: var(--accent); color: var(--accent); }
612
+
613
+ .three-col { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 16px; }
614
+ @media (max-width: 1100px) { .three-col { grid-template-columns: 1fr 1fr; } }
615
+ @media (max-width: 768px) { .three-col { grid-template-columns: 1fr; } }
616
+
617
+ .pagination {
618
+ display: flex;
619
+ justify-content: center;
620
+ gap: 8px;
621
+ margin-top: 20px;
622
+ }
623
+
624
+ .modal-overlay {
625
+ display: none;
626
+ position: fixed;
627
+ inset: 0;
628
+ background: rgba(0,0,0,0.3);
629
+ z-index: 100;
630
+ align-items: center;
631
+ justify-content: center;
632
+ }
633
+ .modal-overlay.open { display: flex; }
634
+ .modal {
635
+ background: var(--bg);
636
+ border: 2px solid var(--border);
637
+ padding: 28px;
638
+ max-width: 460px;
639
+ width: 90%;
640
+ box-shadow: 6px 6px 0px 0px var(--border);
641
+ }
642
+ .modal h3 {
643
+ font-size: 18px;
644
+ font-weight: 700;
645
+ color: var(--ink);
646
+ margin-bottom: 12px;
647
+ font-family: var(--font-display);
648
+ }
649
+ .modal p { font-size: 13px; color: var(--ink-muted); margin-bottom: 16px; }
650
+ .modal-actions {
651
+ display: flex;
652
+ justify-content: flex-end;
653
+ gap: 8px;
654
+ }
655
+ .selected-node-info {
656
+ margin-top: 16px;
657
+ padding-top: 16px;
658
+ border-top: 1px solid var(--border-light);
659
+ }
660
+ .selected-node-info h4 {
661
+ font-size: 13px;
662
+ font-weight: 700;
663
+ color: var(--ink);
664
+ margin-bottom: 6px;
665
+ font-family: var(--font-display);
666
+ }
667
+ .selected-node-info .prop {
668
+ font-size: 12px;
669
+ color: var(--ink-muted);
670
+ padding: 2px 0;
671
+ font-family: var(--font-ui);
672
+ }
673
+ .two-col {
674
+ display: grid;
675
+ grid-template-columns: 1fr 1fr;
676
+ gap: 16px;
677
+ }
678
+ @media (max-width: 768px) {
679
+ .two-col { grid-template-columns: 1fr; }
680
+ .graph-sidebar { width: 200px; }
681
+ .stats-grid { grid-template-columns: repeat(auto-fit, minmax(140px, 1fr)); }
682
+ }
683
+
684
+ .section-rule {
685
+ border: none;
686
+ border-top: 1px solid var(--border-light);
687
+ margin: 20px 0;
688
+ }
689
+ .dateline {
690
+ font-family: var(--font-mono);
691
+ font-size: 10px;
692
+ color: var(--ink-faint);
693
+ text-transform: uppercase;
694
+ letter-spacing: 0.1em;
695
+ }
696
+
697
+ .timeline-container { position: relative; padding: 20px 0; }
698
+ .timeline-container::before { content: ''; position: absolute; left: 50%; top: 0; bottom: 0; width: 2px; background: var(--border-light); transform: translateX(-50%); }
699
+ .timeline-item { position: relative; width: 45%; margin-bottom: 20px; }
700
+ .timeline-item.tl-left { margin-left: 0; margin-right: auto; text-align: right; padding-right: 30px; }
701
+ .timeline-item.tl-right { margin-left: auto; margin-right: 0; padding-left: 30px; }
702
+ .timeline-dot { position: absolute; width: 12px; height: 12px; border-radius: 50%; top: 16px; z-index: 1; border: 2px solid var(--bg); }
703
+ .timeline-item.tl-left .timeline-dot { right: -6px; transform: translateX(50%); }
704
+ .timeline-item.tl-right .timeline-dot { left: -6px; transform: translateX(-50%); }
705
+ .timeline-connector { position: absolute; top: 21px; height: 1px; background: var(--border-light); width: 24px; }
706
+ .timeline-item.tl-left .timeline-connector { right: 0; }
707
+ .timeline-item.tl-right .timeline-connector { left: 0; }
708
+ .timeline-date-marker { text-align: center; position: relative; margin: 24px 0 16px; z-index: 2; }
709
+ .timeline-date-marker span { background: var(--bg); padding: 4px 16px; font-size: 10px; font-family: var(--font-mono); text-transform: uppercase; letter-spacing: 0.1em; color: var(--ink-muted); border: 1px solid var(--border-light); }
710
+
711
+ .heatmap-wrap { overflow-x: auto; padding: 8px 0; }
712
+ .heatmap-grid { display: grid; grid-template-rows: repeat(7, 1fr); grid-auto-flow: column; grid-auto-columns: 12px; gap: 2px; }
713
+ .heatmap-cell { width: 10px; height: 10px; background: var(--bg-inset); cursor: default; }
714
+ .heatmap-cell[title] { cursor: pointer; }
715
+ .heatmap-cell.level-1 { background: rgba(45,106,79,0.2); }
716
+ .heatmap-cell.level-2 { background: rgba(45,106,79,0.4); }
717
+ .heatmap-cell.level-3 { background: rgba(45,106,79,0.65); }
718
+ .heatmap-cell.level-4 { background: var(--green); }
719
+ .heatmap-labels { display: flex; gap: 2px; font-size: 9px; color: var(--ink-faint); font-family: var(--font-mono); margin-bottom: 4px; }
720
+
721
+ .graph-search { width: 100%; background: var(--bg); border: 1px solid var(--border); padding: 7px 12px; font-size: 12px; font-family: var(--font-ui); margin-bottom: 12px; outline: none; }
722
+ .graph-search:focus { border-color: var(--ink); box-shadow: 2px 2px 0px 0px var(--border); }
723
+ .graph-legend { margin-top: 16px; padding-top: 16px; border-top: 1px solid var(--border-light); }
724
+ .graph-legend-item { display: flex; align-items: center; gap: 6px; padding: 3px 0; font-size: 11px; font-family: var(--font-ui); color: var(--ink-muted); }
725
+ .graph-legend-shape { width: 16px; height: 16px; display: flex; align-items: center; justify-content: center; }
726
+ .graph-tooltip { position: absolute; background: rgba(255,255,255,0.88); backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px); border: 1px solid rgba(17,17,17,0.08); padding: 12px 16px; font-size: 11px; font-family: var(--font-ui); pointer-events: none; z-index: 10; box-shadow: 0 8px 32px rgba(0,0,0,0.12), 0 2px 8px rgba(0,0,0,0.06); max-width: 260px; display: none; border-radius: 8px; transition: opacity 0.15s ease; }
727
+ .graph-tooltip.visible { display: block; opacity: 1; }
728
+ .graph-tooltip .tt-name { font-weight: 700; color: var(--ink); margin-bottom: 4px; font-family: var(--font-display); font-size: 13px; }
729
+ .graph-tooltip .tt-type { font-size: 9px; text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 6px; font-weight: 600; padding: 2px 6px; border-radius: 3px; display: inline-block; }
730
+ .graph-tooltip .tt-prop { font-size: 10px; color: var(--ink-muted); padding: 1px 0; }
731
+ .graph-tooltip .tt-conns { font-size: 10px; color: var(--ink-faint); margin-top: 6px; border-top: 1px solid rgba(17,17,17,0.08); padding-top: 6px; font-family: var(--font-mono); }
732
+ .graph-controls { position: absolute; bottom: 16px; right: 16px; display: flex; flex-direction: column; gap: 2px; z-index: 5; }
733
+ .graph-controls button { width: 36px; height: 36px; font-size: 18px; cursor: pointer; background: rgba(255,255,255,0.92); backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px); border: 1px solid rgba(17,17,17,0.1); color: var(--ink); display: flex; align-items: center; justify-content: center; font-weight: 500; font-family: var(--font-ui); border-radius: 6px; transition: all 0.15s ease; box-shadow: 0 2px 8px rgba(0,0,0,0.06); }
734
+ .graph-controls button:hover { background: var(--ink); color: var(--bg); transform: scale(1.05); box-shadow: 0 4px 12px rgba(0,0,0,0.15); }
735
+ .graph-controls .ctrl-divider { height: 1px; background: var(--border-light); margin: 2px 4px; }
736
+
737
+ .type-chips { display: flex; gap: 6px; flex-wrap: wrap; margin-bottom: 16px; }
738
+ .type-chip { font-size: 10px; padding: 3px 10px; border: 1px solid var(--border-light); cursor: pointer; font-family: var(--font-ui); text-transform: uppercase; letter-spacing: 0.06em; font-weight: 600; transition: all 0.15s; background: var(--bg); }
739
+ .type-chip:hover { border-color: var(--ink); }
740
+ .type-chip.active { background: var(--ink); color: var(--bg); border-color: var(--ink); }
741
+
742
+ .memory-fact { padding: 8px 0; border-bottom: 1px solid var(--border-light); font-size: 13px; display: flex; justify-content: space-between; align-items: center; gap: 8px; }
743
+ .memory-fact:last-child { border-bottom: none; }
744
+ .procedure-item { padding: 10px 0; border-bottom: 1px solid var(--border-light); }
745
+ .procedure-item:last-child { border-bottom: none; }
746
+ .procedure-steps { margin: 6px 0 0 16px; font-size: 12px; color: var(--ink-muted); }
747
+ .procedure-steps li { margin-bottom: 2px; }
748
+ .consolidation-row { display: flex; justify-content: space-between; padding: 4px 0; font-size: 12px; font-family: var(--font-ui); }
749
+ .consolidation-row .cl { color: var(--ink-muted); }
750
+ .consolidation-row .cv { color: var(--ink); font-weight: 600; font-family: var(--font-mono); }
751
+
752
+ .activity-feed-item { display: flex; gap: 10px; padding: 10px 0; border-bottom: 1px solid var(--border-light); font-size: 13px; }
753
+ .activity-feed-item:last-child { border-bottom: none; }
754
+ .activity-feed-icon { width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; font-size: 14px; flex-shrink: 0; border: 1px solid var(--border-light); }
755
+ .activity-feed-body { flex: 1; min-width: 0; }
756
+ .activity-feed-title { font-weight: 600; color: var(--ink); font-family: var(--font-display); font-size: 13px; }
757
+ .activity-feed-meta { font-size: 10px; color: var(--ink-faint); font-family: var(--font-mono); margin-top: 2px; }
758
+ </style>
759
+ </head>
760
+ <body>
761
+ <div class="app-header">
762
+ <div class="brand">
763
+ <h1>agentmemory</h1>
764
+ <span class="version">v0.7.0</span>
765
+ </div>
766
+ <div class="header-right">
767
+ <span class="dateline" id="dateline"></span>
768
+ <button id="theme-toggle" class="btn" style="font-size:9px;padding:3px 10px;letter-spacing:0.1em;margin-right:8px;" onclick="toggleTheme()">DARK</button>
769
+ <span id="ws-status" class="ws-status disconnected">live updates off</span>
770
+ </div>
771
+ </div>
772
+
773
+ <div class="tab-bar" id="tab-bar">
774
+ <button class="active" data-tab="dashboard">Dashboard</button>
775
+ <button data-tab="graph">Graph</button>
776
+ <button data-tab="memories">Memories</button>
777
+ <button data-tab="timeline">Timeline</button>
778
+ <button data-tab="sessions">Sessions</button>
779
+ <button data-tab="lessons">Lessons</button>
780
+ <button data-tab="actions">Actions</button>
781
+ <button data-tab="crystals">Crystals</button>
782
+ <button data-tab="audit">Audit</button>
783
+ <button data-tab="activity">Activity</button>
784
+ <button data-tab="profile">Profile</button>
785
+ </div>
786
+
787
+ <div id="view-dashboard" class="view active"></div>
788
+ <div id="view-graph" class="view"></div>
789
+ <div id="view-memories" class="view"></div>
790
+ <div id="view-lessons" class="view"></div>
791
+ <div id="view-actions" class="view"></div>
792
+ <div id="view-crystals" class="view"></div>
793
+ <div id="view-timeline" class="view"></div>
794
+ <div id="view-sessions" class="view"></div>
795
+ <div id="view-audit" class="view"></div>
796
+ <div id="view-activity" class="view"></div>
797
+ <div id="view-profile" class="view"></div>
798
+
799
+ <div id="modal-overlay" class="modal-overlay">
800
+ <div class="modal" id="modal"></div>
801
+ </div>
802
+
803
+ <script>
804
+ var params = new URLSearchParams(window.location.search);
805
+ var viewerPort = params.get('port') || window.location.port || '3113';
806
+ var iiiPort = parseInt(viewerPort);
807
+ if (iiiPort === 3111) viewerPort = '3113';
808
+ var REST = window.location.protocol + '//' + window.location.hostname + ':' + viewerPort;
809
+ var wsProto = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
810
+ var wsPort = params.get('wsPort') || String(parseInt(viewerPort) - 1);
811
+ var WS_URL = wsProto + '//' + window.location.hostname + ':' + wsPort;
812
+
813
+ var dateEl = document.getElementById('dateline');
814
+ if (dateEl) dateEl.textContent = new Date().toLocaleDateString('en-US', { weekday: 'short', year: 'numeric', month: 'short', day: 'numeric' });
815
+
816
+ function isDarkMode() { return document.documentElement.dataset.theme === 'dark'; }
817
+ function applyTheme(dark, persist) {
818
+ document.documentElement.dataset.theme = dark ? 'dark' : 'light';
819
+ var btn = document.getElementById('theme-toggle');
820
+ if (btn) btn.textContent = dark ? 'LIGHT' : 'DARK';
821
+ if (persist) localStorage.setItem('agentmemory-theme', dark ? 'dark' : 'light');
822
+ }
823
+ window.toggleTheme = function() { applyTheme(!isDarkMode(), true); };
824
+ var savedTheme = localStorage.getItem('agentmemory-theme');
825
+ if (savedTheme) {
826
+ applyTheme(savedTheme === 'dark', false);
827
+ } else if (window.matchMedia('(prefers-color-scheme: dark)').matches) {
828
+ applyTheme(true, false);
829
+ }
830
+
831
+ var NODE_COLORS = {
832
+ file: '#2D6A4F', function: '#1D4E89', concept: '#B8860B', error: '#CC0000',
833
+ decision: '#6B3FA0', pattern: '#2563EB', library: '#C2410C', person: '#111111'
834
+ };
835
+ var OP_BADGES = {
836
+ observe: 'badge-blue', compress: 'badge-cyan', remember: 'badge-green',
837
+ forget: 'badge-red', evolve: 'badge-purple', consolidate: 'badge-yellow',
838
+ share: 'badge-orange', delete: 'badge-red', import: 'badge-blue', export: 'badge-blue'
839
+ };
840
+ var TYPE_BADGES = {
841
+ pattern: 'badge-purple', preference: 'badge-blue', architecture: 'badge-cyan',
842
+ bug: 'badge-red', workflow: 'badge-green', fact: 'badge-yellow'
843
+ };
844
+ var OBS_TYPE_COLORS = {
845
+ file_read: '#1D4E89', file_write: '#2D6A4F', file_edit: '#B8860B',
846
+ command_run: '#C2410C', search: '#2563EB', web_fetch: '#6B3FA0',
847
+ conversation: '#111111', error: '#CC0000', decision: '#B8860B',
848
+ discovery: '#2D6A4F', subagent: '#6B3FA0', notification: '#0E7490',
849
+ task: '#1D4E89', other: '#666666'
850
+ };
851
+ var OBS_TYPE_ICONS = {
852
+ file_read: '&#128196;', file_write: '&#9999;', file_edit: '&#128221;',
853
+ command_run: '&#9889;', search: '&#128270;', web_fetch: '&#127760;',
854
+ conversation: '&#128172;', error: '&#9888;', decision: '&#129300;',
855
+ discovery: '&#128161;', subagent: '&#129302;', notification: '&#128276;',
856
+ task: '&#9745;', other: '&#128196;'
857
+ };
858
+ var CB_STATE_COLORS = { closed: 'badge-green', open: 'badge-red', 'half-open': 'badge-yellow' };
859
+
860
+ var state = {
861
+ activeTab: 'dashboard',
862
+ dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [] },
863
+ graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null },
864
+ memories: { loaded: false, items: [], search: '', typeFilter: '' },
865
+ timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 },
866
+ sessions: { loaded: false, items: [], selectedId: null },
867
+ audit: { loaded: false, entries: [], opFilter: '' },
868
+ activity: { loaded: false, observations: [], sessions: [], typeFilter: '' },
869
+ lessons: { loaded: false, items: [], search: '' },
870
+ actions: { loaded: false, items: [], frontier: [], statusFilter: '', search: '' },
871
+ crystals: { loaded: false, items: [], search: '' },
872
+ profile: { loaded: false, projects: [], selectedProject: '', data: null },
873
+ ws: null
874
+ };
875
+
876
+ function esc(s) {
877
+ if (!s) return '';
878
+ var d = document.createElement('div');
879
+ d.textContent = String(s);
880
+ return d.innerHTML;
881
+ }
882
+ function formatTime(ts) {
883
+ if (!ts) return '';
884
+ try { return new Date(ts).toLocaleString(); } catch { return ts; }
885
+ }
886
+ function shortTime(ts) {
887
+ if (!ts) return '';
888
+ try { return new Date(ts).toLocaleTimeString(); } catch { return ts; }
889
+ }
890
+ function truncate(s, n) {
891
+ if (!s) return '';
892
+ return s.length > n ? s.slice(0, n) + '...' : s;
893
+ }
894
+ function debounce(fn, ms) {
895
+ var t;
896
+ return function() {
897
+ var args = arguments, ctx = this;
898
+ clearTimeout(t);
899
+ t = setTimeout(function() { fn.apply(ctx, args); }, ms);
900
+ };
901
+ }
902
+
903
+ async function api(path, opts) {
904
+ try {
905
+ var url = REST + '/agentmemory/' + path;
906
+ var headers = Object.assign({ 'Cache-Control': 'no-cache' }, (opts && opts.headers) || {});
907
+ var fetchOpts = Object.assign({}, opts || {}, { headers: headers });
908
+ var res = await fetch(url, fetchOpts);
909
+ if (!res.ok) {
910
+ console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status);
911
+ return null;
912
+ }
913
+ return await res.json();
914
+ } catch (err) {
915
+ console.warn('[viewer] API error on ' + path + ':', err);
916
+ return null;
917
+ }
918
+ }
919
+ async function apiGet(path) { return api(path); }
920
+ async function apiPost(path, body) {
921
+ return api(path, {
922
+ method: 'POST',
923
+ headers: { 'Content-Type': 'application/json' },
924
+ body: JSON.stringify(body || {})
925
+ });
926
+ }
927
+ async function apiDelete(path, body) {
928
+ return api(path, {
929
+ method: 'DELETE',
930
+ headers: { 'Content-Type': 'application/json' },
931
+ body: JSON.stringify(body || {})
932
+ });
933
+ }
934
+
935
+ function switchTab(tab) {
936
+ state.activeTab = tab;
937
+ document.querySelectorAll('.tab-bar button').forEach(function(b) {
938
+ b.classList.toggle('active', b.dataset.tab === tab);
939
+ });
940
+ document.querySelectorAll('.view').forEach(function(v) {
941
+ v.classList.toggle('active', v.id === 'view-' + tab);
942
+ });
943
+ loadTab(tab);
944
+ }
945
+
946
+ async function loadTab(tab) {
947
+ switch(tab) {
948
+ case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break;
949
+ case 'graph': if (!state.graph.loaded) await loadGraph(); break;
950
+ case 'memories': if (!state.memories.loaded) await loadMemories(); break;
951
+ case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break;
952
+ case 'sessions': if (!state.sessions.loaded) await loadSessions(); break;
953
+ case 'lessons': if (!state.lessons.loaded) await loadLessons(); break;
954
+ case 'actions': if (!state.actions.loaded) await loadActions(); break;
955
+ case 'crystals': if (!state.crystals.loaded) await loadCrystals(); break;
956
+ case 'audit': if (!state.audit.loaded) await loadAudit(); break;
957
+ case 'activity': if (!state.activity.loaded) await loadActivity(); break;
958
+ case 'profile': if (!state.profile.loaded) await loadProfile(); break;
959
+ }
960
+ }
961
+
962
+ async function loadDashboard() {
963
+ var el = document.getElementById('view-dashboard');
964
+ el.innerHTML = '<div class="loading">Loading dashboard...</div>';
965
+ var results = await Promise.all([
966
+ apiGet('health'),
967
+ apiGet('sessions'),
968
+ apiGet('memories?latest=true'),
969
+ apiGet('graph/stats'),
970
+ apiGet('audit?limit=5'),
971
+ apiGet('semantic'),
972
+ apiGet('procedural'),
973
+ apiGet('relations'),
974
+ apiGet('lessons'),
975
+ apiGet('crystals')
976
+ ]);
977
+ state.dashboard.health = results[0];
978
+ state.dashboard.sessions = (results[1] && results[1].sessions) || [];
979
+ state.dashboard.memories = (results[2] && results[2].memories) || [];
980
+ state.dashboard.graphStats = results[3];
981
+ state.dashboard.recentAudit = (results[4] && results[4].entries) || [];
982
+ state.dashboard.semantic = (results[5] && results[5].facts) || (results[5] && results[5].semantic) || [];
983
+ state.dashboard.procedural = (results[6] && results[6].procedures) || (results[6] && results[6].procedural) || [];
984
+ state.dashboard.lessons = (results[8] && results[8].lessons) || [];
985
+ state.dashboard.crystals = (results[9] && results[9].crystals) || [];
986
+ state.dashboard.relations = (results[7] && results[7].relations) || [];
987
+ state.dashboard.loaded = true;
988
+ renderDashboard();
989
+ }
990
+
991
+ function renderDashboard() {
992
+ var el = document.getElementById('view-dashboard');
993
+ var d = state.dashboard;
994
+ var h = d.health || {};
995
+ var snap = h.health || {};
996
+ var healthStatus = h.status || 'unknown';
997
+ var dotClass = healthStatus === 'healthy' ? 'healthy' : healthStatus === 'degraded' ? 'degraded' : healthStatus === 'critical' ? 'critical' : '';
998
+ var activeSessions = d.sessions.filter(function(s) { return s.status === 'active'; }).length;
999
+ var gs = d.graphStats || {};
1000
+ var nodeCount = (gs.nodes !== undefined) ? gs.nodes : (gs.nodeCount || 0);
1001
+ var edgeCount = (gs.edges !== undefined) ? gs.edges : (gs.edgeCount || 0);
1002
+ var fMetrics = h.functionMetrics || [];
1003
+ var cb = h.circuitBreaker || null;
1004
+ var workers = snap.workers || [];
1005
+
1006
+ var html = '<div class="stats-grid">';
1007
+ html += '<div class="stat-card"><div class="label">Sessions</div><div class="value">' + d.sessions.length + '</div><div class="sub">' + activeSessions + ' active</div></div>';
1008
+ html += '<div class="stat-card"><div class="label">Memories</div><div class="value">' + d.memories.length + '</div><div class="sub">latest versions</div></div>';
1009
+ var lessonCount = (d.lessons || []).length;
1010
+ var crystalCount = (d.crystals || []).length;
1011
+ html += '<div class="stat-card"><div class="label">Lessons</div><div class="value">' + lessonCount + '</div><div class="sub">confidence-scored</div></div>';
1012
+ html += '<div class="stat-card"><div class="label">Crystals</div><div class="value">' + crystalCount + '</div><div class="sub">action digests</div></div>';
1013
+ html += '<div class="stat-card"><div class="label">Graph Nodes</div><div class="value">' + nodeCount + '</div><div class="sub">' + edgeCount + ' edges</div></div>';
1014
+ html += '<div class="stat-card"><div class="label">Health</div><div class="value"><div class="health-bar"><span class="health-dot ' + dotClass + '"></span> ' + esc(healthStatus) + '</div></div>';
1015
+ html += '<div class="sub">' + esc(snap.connectionState || 'unknown') + '</div></div>';
1016
+ var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0);
1017
+ html += '<div class="stat-card"><div class="label">Function Calls</div><div class="value">' + totalCalls + '</div><div class="sub">' + fMetrics.length + ' functions tracked</div></div>';
1018
+ if (cb) {
1019
+ var cbClass = cb.state === 'closed' ? 'cb-closed' : cb.state === 'open' ? 'cb-open' : 'cb-half-open';
1020
+ html += '<div class="stat-card"><div class="label">Circuit Breaker</div><div class="value"><span class="cb-indicator ' + cbClass + '">' + esc(cb.state) + '</span></div>';
1021
+ html += '<div class="sub">' + (cb.failures || 0) + ' failures</div></div>';
1022
+ }
1023
+ var totalObs = d.sessions.reduce(function(a, s) { return a + (s.observationCount || 0); }, 0);
1024
+ var tokenBudget = parseInt(new URLSearchParams(window.location.search).get('tokenBudget') || '2000', 10) || 2000;
1025
+ var estFull = totalObs * 80;
1026
+ var estInjected = d.sessions.length * tokenBudget;
1027
+ var savings = estFull > 0 ? Math.round((1 - estInjected / Math.max(estFull, 1)) * 100) : 0;
1028
+ if (savings < 0) savings = 0;
1029
+ html += '<div class="stat-card"><div class="label">Token Savings</div><div class="value">' + savings + '%</div><div class="sub">~' + estInjected.toLocaleString() + ' vs ~' + estFull.toLocaleString() + ' full (budget: ' + tokenBudget + ')</div></div>';
1030
+ html += '</div>';
1031
+
1032
+ if (snap.memory || snap.cpu) {
1033
+ html += '<div class="card" style="margin-bottom:16px"><div class="card-title">System Resources</div>';
1034
+ if (snap.memory) {
1035
+ var heapUsed = Math.round((snap.memory.heapUsed || 0) / 1024 / 1024);
1036
+ var heapTotal = Math.round((snap.memory.heapTotal || 0) / 1024 / 1024);
1037
+ var rss = Math.round((snap.memory.rss || 0) / 1024 / 1024);
1038
+ var heapPct = heapTotal > 0 ? Math.round((heapUsed / heapTotal) * 100) : 0;
1039
+ var heapColor = heapPct > 80 ? 'var(--red)' : heapPct > 60 ? 'var(--yellow)' : 'var(--green)';
1040
+ html += '<div class="gauge"><span class="gauge-label">Heap</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + heapPct + '%;background:' + heapColor + '"></div></div><span class="gauge-value">' + heapUsed + ' / ' + heapTotal + ' MB</span></div>';
1041
+ html += '<div class="gauge"><span class="gauge-label">RSS</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, Math.round(rss / 512 * 100)) + '%;background:var(--blue)"></div></div><span class="gauge-value">' + rss + ' MB</span></div>';
1042
+ if (snap.memory.external) {
1043
+ var ext = Math.round(snap.memory.external / 1024 / 1024);
1044
+ html += '<div class="gauge"><span class="gauge-label">External</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, Math.round(ext / 128 * 100)) + '%;background:var(--purple)"></div></div><span class="gauge-value">' + ext + ' MB</span></div>';
1045
+ }
1046
+ }
1047
+ if (snap.cpu) {
1048
+ var cpuPct = snap.cpu.percent || 0;
1049
+ var cpuColor = cpuPct > 80 ? 'var(--red)' : cpuPct > 50 ? 'var(--yellow)' : 'var(--green)';
1050
+ html += '<div class="gauge"><span class="gauge-label">CPU</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, cpuPct) + '%;background:' + cpuColor + '"></div></div><span class="gauge-value">' + cpuPct.toFixed(1) + '%</span></div>';
1051
+ }
1052
+ if (snap.eventLoopLagMs !== undefined) {
1053
+ var lag = snap.eventLoopLagMs;
1054
+ var lagColor = lag > 100 ? 'var(--red)' : lag > 20 ? 'var(--yellow)' : 'var(--green)';
1055
+ html += '<div class="gauge"><span class="gauge-label">Event Loop</span><div class="gauge-bar"><div class="gauge-fill" style="width:' + Math.min(100, lag) + '%;background:' + lagColor + '"></div></div><span class="gauge-value">' + lag.toFixed(1) + ' ms</span></div>';
1056
+ }
1057
+ if (snap.uptimeSeconds) {
1058
+ var mins = Math.floor(snap.uptimeSeconds / 60);
1059
+ var hrs = Math.floor(mins / 60);
1060
+ var upStr = hrs > 0 ? hrs + 'h ' + (mins % 60) + 'm' : mins + 'm';
1061
+ html += '<div style="font-size:10px;color:var(--ink-faint);margin-top:6px;font-family:var(--font-mono);letter-spacing:0.04em;">UPTIME: ' + upStr + '</div>';
1062
+ }
1063
+ html += '</div>';
1064
+ }
1065
+
1066
+ if (snap.alerts && snap.alerts.length > 0) {
1067
+ html += '<div class="card" style="margin-bottom:16px;border-color:var(--accent);border-width:2px;"><div class="card-title" style="color:var(--accent);border-bottom-color:var(--accent);">Alerts (' + snap.alerts.length + ')</div>';
1068
+ snap.alerts.forEach(function(al) {
1069
+ html += '<div style="font-size:12px;color:var(--accent);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(al) + '</div>';
1070
+ });
1071
+ html += '</div>';
1072
+ }
1073
+
1074
+ html += '<div class="two-col">';
1075
+
1076
+ html += '<div class="card"><div class="card-title">Recent Sessions</div>';
1077
+ if (d.sessions.length === 0) {
1078
+ html += '<div class="empty-state"><p>No sessions yet. Start a coding session with agentmemory hooks enabled.</p></div>';
1079
+ } else {
1080
+ var recent = d.sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).slice(0, 5);
1081
+ html += '<table><tr><th>Project</th><th>Status</th><th>Obs</th><th>Started</th></tr>';
1082
+ recent.forEach(function(s) {
1083
+ var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
1084
+ html += '<tr><td style="color:var(--ink);font-weight:500;">' + esc(s.project ? s.project.split('/').pop() : s.id.slice(0,8)) + '</td>';
1085
+ html += '<td><span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></td>';
1086
+ html += '<td style="color:var(--ink-muted);font-family:var(--font-mono);font-size:12px;">' + (s.observationCount || 0) + '</td>';
1087
+ html += '<td style="font-family:var(--font-mono);font-size:11px;color:var(--ink-faint);">' + esc(shortTime(s.startedAt)) + '</td></tr>';
1088
+ });
1089
+ html += '</table>';
1090
+ }
1091
+ html += '</div>';
1092
+
1093
+ html += '<div class="card"><div class="card-title">Recent Activity</div>';
1094
+ if (d.recentAudit.length === 0) {
1095
+ html += '<div class="empty-state"><p>No activity recorded yet</p></div>';
1096
+ } else {
1097
+ d.recentAudit.forEach(function(a) {
1098
+ var badgeClass = OP_BADGES[a.operation] || 'badge-muted';
1099
+ html += '<div style="padding:6px 0;border-bottom:1px solid var(--border-light);font-size:13px;">';
1100
+ html += '<span class="badge ' + badgeClass + '">' + esc(a.operation) + '</span> ';
1101
+ if (a.functionId) html += '<span style="font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(a.functionId) + '</span> ';
1102
+ html += '<span style="color:var(--ink-faint);font-size:10px;font-family:var(--font-mono);">' + esc(shortTime(a.timestamp)) + '</span>';
1103
+ if (a.targetIds && a.targetIds.length) html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:4px;">(' + a.targetIds.length + ' targets)</span>';
1104
+ html += '</div>';
1105
+ });
1106
+ }
1107
+ html += '</div>';
1108
+
1109
+ html += '</div>';
1110
+
1111
+ if (fMetrics.length > 0) {
1112
+ var sorted = fMetrics.slice().sort(function(a, b) { return (b.totalCalls || 0) - (a.totalCalls || 0); });
1113
+ html += '<div class="card" style="margin-top:16px"><div class="card-title">Function Metrics (OTel)</div>';
1114
+ html += '<table class="metric-table"><tr><th>Function</th><th style="text-align:right">Calls</th><th style="text-align:right">Success</th><th style="text-align:right">Fail</th><th style="text-align:right">Avg Latency</th><th style="text-align:right">Quality</th></tr>';
1115
+ sorted.forEach(function(m) {
1116
+ var successRate = m.totalCalls > 0 ? Math.round((m.successCount / m.totalCalls) * 100) : 0;
1117
+ var rateColor = successRate >= 95 ? 'var(--green)' : successRate >= 80 ? 'var(--yellow)' : 'var(--red)';
1118
+ var latencyColor = m.avgLatencyMs > 1000 ? 'var(--red)' : m.avgLatencyMs > 200 ? 'var(--yellow)' : 'var(--green)';
1119
+ html += '<tr>';
1120
+ html += '<td class="metric-fn">' + esc(m.functionId) + '</td>';
1121
+ html += '<td class="metric-num">' + m.totalCalls + '</td>';
1122
+ html += '<td class="metric-num" style="color:' + rateColor + '">' + m.successCount + ' (' + successRate + '%)</td>';
1123
+ html += '<td class="metric-num" style="color:' + (m.failureCount > 0 ? 'var(--red)' : 'var(--ink-faint)') + '">' + m.failureCount + '</td>';
1124
+ html += '<td class="metric-num" style="color:' + latencyColor + '">' + Math.round(m.avgLatencyMs) + ' ms</td>';
1125
+ html += '<td class="metric-num">' + (m.avgQualityScore > 0 ? m.avgQualityScore.toFixed(2) : '-') + '</td>';
1126
+ html += '</tr>';
1127
+ });
1128
+ html += '</table></div>';
1129
+ }
1130
+
1131
+ if (workers.length > 0) {
1132
+ html += '<div class="card" style="margin-top:16px"><div class="card-title">Workers</div>';
1133
+ workers.forEach(function(w) {
1134
+ var statusClass = w.status === 'running' ? 'running' : w.status === 'starting' ? 'starting' : 'stopped';
1135
+ html += '<div class="worker-row"><span class="worker-dot ' + statusClass + '"></span>';
1136
+ html += '<span style="color:var(--ink);font-weight:600;font-family:var(--font-ui);font-size:12px;">' + esc(w.name) + '</span>';
1137
+ html += '<span class="badge ' + (w.status === 'running' ? 'badge-green' : 'badge-muted') + '">' + esc(w.status) + '</span>';
1138
+ html += '<span style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(w.id) + '</span></div>';
1139
+ });
1140
+ html += '</div>';
1141
+ }
1142
+
1143
+ if (cb && cb.state !== 'closed') {
1144
+ html += '<div class="card" style="margin-top:16px;border-color:var(--accent);border-width:2px;"><div class="card-title" style="color:var(--accent);">Circuit Breaker Details</div>';
1145
+ html += '<div class="detail-row"><div class="dl">State</div><div class="dv"><span class="cb-indicator ' + (cb.state === 'open' ? 'cb-open' : 'cb-half-open') + '">' + esc(cb.state) + '</span></div></div>';
1146
+ html += '<div class="detail-row"><div class="dl">Failures</div><div class="dv" style="color:var(--accent);font-family:var(--font-mono);">' + (cb.failures || 0) + '</div></div>';
1147
+ if (cb.lastFailureAt) html += '<div class="detail-row"><div class="dl">Last Failure</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(cb.lastFailureAt)) + '</div></div>';
1148
+ if (cb.openedAt) html += '<div class="detail-row"><div class="dl">Opened At</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(cb.openedAt)) + '</div></div>';
1149
+ html += '</div>';
1150
+ }
1151
+
1152
+ var semFacts = d.semantic || [];
1153
+ var procItems = d.procedural || [];
1154
+ var relItems = d.relations || [];
1155
+
1156
+ html += '<hr class="section-rule">';
1157
+ html += '<div class="two-col">';
1158
+
1159
+ html += '<div class="card"><div class="card-title">Semantic Memory</div>';
1160
+ if (semFacts.length === 0) {
1161
+ html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No semantic facts yet. Observations will be consolidated into semantic memories over time.</div>';
1162
+ } else {
1163
+ semFacts.slice(0, 5).forEach(function(f) {
1164
+ var conf = typeof f.confidence === 'number' ? Math.round(f.confidence * 100) : null;
1165
+ var str = typeof f.strength === 'number' ? Math.round(f.strength * 100) : null;
1166
+ var barColor = (str || 0) > 70 ? 'var(--green)' : (str || 0) > 40 ? 'var(--yellow)' : 'var(--red)';
1167
+ html += '<div class="memory-fact">';
1168
+ html += '<span style="color:var(--ink);">' + esc(f.fact || f.content || f.title || 'Fact') + '</span>';
1169
+ html += '<span style="display:flex;align-items:center;gap:6px;">';
1170
+ if (str !== null) html += '<span class="strength-bar" style="width:40px;"><span class="fill" style="width:' + str + '%;background:' + barColor + '"></span></span>';
1171
+ if (conf !== null) html += '<span style="font-size:10px;font-family:var(--font-mono);color:var(--ink-faint);">' + conf + '%</span>';
1172
+ html += '</span></div>';
1173
+ });
1174
+ }
1175
+ html += '</div>';
1176
+
1177
+ html += '<div class="card"><div class="card-title">Procedural Memory</div>';
1178
+ if (procItems.length === 0) {
1179
+ html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No procedures yet. Repeated patterns will be extracted as procedures.</div>';
1180
+ } else {
1181
+ procItems.slice(0, 5).forEach(function(p) {
1182
+ html += '<div class="procedure-item">';
1183
+ html += '<div style="font-weight:600;color:var(--ink);font-family:var(--font-display);font-size:13px;">' + esc(p.name || p.title || 'Procedure') + '</div>';
1184
+ if (p.trigger || p.triggerCondition) html += '<div style="font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);margin-top:2px;">Trigger: ' + esc(p.trigger || p.triggerCondition) + '</div>';
1185
+ if (p.frequency) html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">Freq: ' + p.frequency + '</div>';
1186
+ if (p.steps && p.steps.length > 0) {
1187
+ html += '<ol class="procedure-steps">';
1188
+ p.steps.slice(0, 4).forEach(function(s) { html += '<li>' + esc(typeof s === 'string' ? s : s.description || s.action || JSON.stringify(s)) + '</li>'; });
1189
+ if (p.steps.length > 4) html += '<li style="color:var(--ink-faint);font-style:italic;">+ ' + (p.steps.length - 4) + ' more...</li>';
1190
+ html += '</ol>';
1191
+ }
1192
+ html += '</div>';
1193
+ });
1194
+ }
1195
+ html += '</div>';
1196
+
1197
+ html += '</div>';
1198
+
1199
+ html += '<div class="card" style="margin-top:16px;"><div class="card-title">Consolidation Status</div>';
1200
+ html += '<div class="consolidation-row"><span class="cl">Semantic facts</span><span class="cv">' + semFacts.length + '</span></div>';
1201
+ html += '<div class="consolidation-row"><span class="cl">Procedures</span><span class="cv">' + procItems.length + '</span></div>';
1202
+ html += '<div class="consolidation-row"><span class="cl">Relations</span><span class="cv">' + relItems.length + '</span></div>';
1203
+ html += '</div>';
1204
+
1205
+ if (relItems.length > 0) {
1206
+ html += '<div class="card" style="margin-top:16px;"><div class="card-title">Memory Relations</div>';
1207
+ relItems.slice(0, 8).forEach(function(r) {
1208
+ var relType = r.type || r.relationType || 'related';
1209
+ var badgeClass = relType === 'supersedes' ? 'badge-red' : relType === 'extends' ? 'badge-green' : relType === 'contradicts' ? 'badge-yellow' : 'badge-muted';
1210
+ html += '<div style="padding:4px 0;border-bottom:1px solid var(--border-light);font-size:12px;display:flex;align-items:center;gap:6px;">';
1211
+ html += '<span style="font-family:var(--font-mono);color:var(--blue);font-size:11px;">' + esc(truncate(r.sourceId || r.fromId || '', 8)) + '</span>';
1212
+ html += '<span class="badge ' + badgeClass + '">' + esc(relType) + '</span>';
1213
+ html += '<span style="font-family:var(--font-mono);color:var(--blue);font-size:11px;">' + esc(truncate(r.targetId || r.toId || '', 8)) + '</span>';
1214
+ html += '</div>';
1215
+ });
1216
+ html += '</div>';
1217
+ }
1218
+
1219
+ html += '<div style="text-align:center;margin-top:20px;"><button class="btn btn-primary" onclick="refreshDashboard()">Refresh</button>';
1220
+ html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:10px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.08em;">Auto-refresh 30s</span></div>';
1221
+
1222
+ el.innerHTML = html;
1223
+ }
1224
+
1225
+ var dashboardTimer = null;
1226
+ function refreshDashboard() {
1227
+ state.dashboard.loaded = false;
1228
+ loadDashboard();
1229
+ }
1230
+ function startDashboardAutoRefresh() {
1231
+ if (dashboardTimer) clearInterval(dashboardTimer);
1232
+ dashboardTimer = setInterval(function() {
1233
+ if (state.activeTab === 'dashboard') refreshDashboard();
1234
+ }, 30000);
1235
+ }
1236
+
1237
+ var graphSim = { nodes: [], edges: [], running: false, canvas: null, ctx: null, raf: null, panX: 0, panY: 0, zoom: 1, dragNode: null, mouseX: 0, mouseY: 0 };
1238
+
1239
+ async function loadGraph() {
1240
+ var el = document.getElementById('view-graph');
1241
+ el.innerHTML = '<div class="graph-container"><div class="graph-canvas-wrap"><canvas id="graph-canvas"></canvas><div class="graph-controls"><button title="Zoom In" onclick="zoomGraph(1)">+</button><button title="Zoom Out" onclick="zoomGraph(-1)">&minus;</button><div class="ctrl-divider"></div><button title="Recenter" onclick="recenterGraph()">⌖</button></div><div class="graph-tooltip" id="graph-tooltip"></div></div><div class="graph-sidebar" id="graph-sidebar"></div></div>';
1242
+
1243
+ var results = await Promise.all([
1244
+ apiPost('graph/query', {}),
1245
+ apiGet('graph/stats')
1246
+ ]);
1247
+ var queryResult = results[0] || { nodes: [], edges: [] };
1248
+ state.graph.nodes = queryResult.nodes || [];
1249
+ state.graph.edges = queryResult.edges || [];
1250
+ state.graph.stats = results[1] || {};
1251
+
1252
+ if (state.graph.nodes.length === 0) {
1253
+ var sb = document.getElementById('graph-sidebar');
1254
+ if (sb) sb.innerHTML = '<h3>Graph</h3><p style="font-size:12px;color:var(--ink-faint);margin:8px 0;font-style:italic;">No graph data yet. Building from observations and memories...</p>';
1255
+ var buildResult = await apiPost('graph/build', {});
1256
+ if (buildResult && buildResult.success && buildResult.nodes > 0) {
1257
+ var freshResults = await Promise.all([
1258
+ apiPost('graph/query', {}),
1259
+ apiGet('graph/stats')
1260
+ ]);
1261
+ var freshQuery = freshResults[0] || { nodes: [], edges: [] };
1262
+ state.graph.nodes = freshQuery.nodes || [];
1263
+ state.graph.edges = freshQuery.edges || [];
1264
+ state.graph.stats = freshResults[1] || {};
1265
+ }
1266
+ }
1267
+
1268
+ state.graph.loaded = true;
1269
+ var types = {};
1270
+ state.graph.nodes.forEach(function(n) { types[n.type] = true; });
1271
+ state.graph.filters = types;
1272
+
1273
+ renderGraphSidebar();
1274
+ initGraph();
1275
+ }
1276
+
1277
+ var NODE_SHAPES = {
1278
+ file: 'rect', function: 'circle', concept: 'circle', error: 'diamond',
1279
+ decision: 'diamond', pattern: 'circle', library: 'hexagon', person: 'circle'
1280
+ };
1281
+ var graphSearchTerm = '';
1282
+
1283
+ function renderGraphSidebar() {
1284
+ var sb = document.getElementById('graph-sidebar');
1285
+ if (!sb) return;
1286
+ var gs = state.graph.stats || {};
1287
+ var nodeCount = gs.nodes !== undefined ? gs.nodes : (gs.nodeCount || state.graph.nodes.length);
1288
+ var edgeCount = gs.edges !== undefined ? gs.edges : (gs.edgeCount || state.graph.edges.length);
1289
+
1290
+ var html = '<input type="text" class="graph-search" id="graph-search" placeholder="Search nodes...">';
1291
+
1292
+ html += '<h3 style="margin-top:16px;font-size:10px;text-transform:uppercase;letter-spacing:0.12em;color:var(--ink-muted);font-family:var(--font-ui);font-weight:700;">Graph Stats</h3>';
1293
+ html += '<div style="display:flex;gap:20px;margin:10px 0 16px;padding:12px;background:var(--bg-alt);border:1px solid var(--border-light);border-radius:4px;">';
1294
+ html += '<div style="text-align:center;flex:1;"><span style="font-size:28px;font-weight:900;font-family:var(--font-display);color:var(--ink);line-height:1;">' + nodeCount + '</span><div style="font-size:8px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:0.12em;font-family:var(--font-ui);font-weight:600;margin-top:4px;">Nodes</div></div>';
1295
+ html += '<div style="width:1px;background:var(--border-light);"></div>';
1296
+ html += '<div style="text-align:center;flex:1;"><span style="font-size:28px;font-weight:900;font-family:var(--font-display);color:var(--ink);line-height:1;">' + edgeCount + '</span><div style="font-size:8px;color:var(--ink-faint);text-transform:uppercase;letter-spacing:0.12em;font-family:var(--font-ui);font-weight:600;margin-top:4px;">Edges</div></div>';
1297
+ html += '</div>';
1298
+
1299
+ html += '<h3 style="margin-top:12px;font-size:10px;text-transform:uppercase;letter-spacing:0.12em;color:var(--ink-muted);font-family:var(--font-ui);font-weight:700;">Filter by Type</h3>';
1300
+ Object.keys(state.graph.filters).forEach(function(type) {
1301
+ var color = NODE_COLORS[type] || '#666666';
1302
+ html += '<label class="filter-item"><input type="checkbox" checked data-type="' + esc(type) + '"><span class="filter-dot" style="background:' + color + '"></span>' + esc(type) + '</label>';
1303
+ });
1304
+
1305
+ html += '<div class="graph-legend"><h3>Legend</h3>';
1306
+ var shapeLabels = { rect: '&#9645;', circle: '&#9679;', diamond: '&#9670;', hexagon: '&#11042;' };
1307
+ var shownShapes = {};
1308
+ Object.keys(NODE_COLORS).forEach(function(type) {
1309
+ var shape = NODE_SHAPES[type] || 'circle';
1310
+ var color = NODE_COLORS[type];
1311
+ var key = type;
1312
+ if (shownShapes[key]) return;
1313
+ shownShapes[key] = true;
1314
+ html += '<div class="graph-legend-item"><span class="graph-legend-shape" style="color:' + color + ';font-size:14px;">' + (shapeLabels[shape] || '&#9679;') + '</span><span>' + esc(type) + '</span></div>';
1315
+ });
1316
+ html += '</div>';
1317
+
1318
+ html += '<button class="btn" style="margin-top:14px;width:100%;font-size:11px;padding:8px;letter-spacing:0.06em;transition:all 0.15s ease;" onclick="rebuildGraph()" onmouseover="this.style.background=\'var(--ink)\';this.style.color=\'var(--bg)\'" onmouseout="this.style.background=\'\';this.style.color=\'\'">↻ Rebuild Graph</button>';
1319
+ html += '<div id="selected-node-panel"></div>';
1320
+ sb.innerHTML = html;
1321
+
1322
+ sb.querySelectorAll('input[type="checkbox"]').forEach(function(cb) {
1323
+ cb.addEventListener('change', function() {
1324
+ state.graph.filters[this.dataset.type] = this.checked;
1325
+ renderGraph();
1326
+ });
1327
+ });
1328
+
1329
+ var searchInput = document.getElementById('graph-search');
1330
+ if (searchInput) {
1331
+ searchInput.addEventListener('input', debounce(function() {
1332
+ graphSearchTerm = this.value.toLowerCase();
1333
+ renderGraph();
1334
+ }, 150));
1335
+ }
1336
+ }
1337
+
1338
+ function initGraph() {
1339
+ var canvas = document.getElementById('graph-canvas');
1340
+ if (!canvas) return;
1341
+ graphSim.canvas = canvas;
1342
+ graphSim.ctx = canvas.getContext('2d');
1343
+
1344
+ function resize() {
1345
+ var r = canvas.parentElement.getBoundingClientRect();
1346
+ canvas.width = r.width * window.devicePixelRatio;
1347
+ canvas.height = r.height * window.devicePixelRatio;
1348
+ canvas.style.width = r.width + 'px';
1349
+ canvas.style.height = r.height + 'px';
1350
+ graphSim.ctx.setTransform(window.devicePixelRatio, 0, 0, window.devicePixelRatio, 0, 0);
1351
+ }
1352
+ resize();
1353
+ window.addEventListener('resize', resize);
1354
+
1355
+ var cw = canvas.width / window.devicePixelRatio;
1356
+ var ch = canvas.height / window.devicePixelRatio;
1357
+ graphSim.panX = cw / 2;
1358
+ graphSim.panY = ch / 2;
1359
+
1360
+ var edgeMap = {};
1361
+ state.graph.edges.forEach(function(e) {
1362
+ edgeMap[e.sourceNodeId] = (edgeMap[e.sourceNodeId] || 0) + 1;
1363
+ edgeMap[e.targetNodeId] = (edgeMap[e.targetNodeId] || 0) + 1;
1364
+ });
1365
+
1366
+ graphSim.nodes = state.graph.nodes.map(function(n, i) {
1367
+ var angle = (2 * Math.PI * i) / Math.max(state.graph.nodes.length, 1);
1368
+ var radius = Math.min(cw, ch) * 0.3;
1369
+ var deg = edgeMap[n.id] || 0;
1370
+ return {
1371
+ id: n.id, type: n.type, name: n.name, properties: n.properties,
1372
+ x: Math.cos(angle) * radius + (Math.random() - 0.5) * 50,
1373
+ y: Math.sin(angle) * radius + (Math.random() - 0.5) * 50,
1374
+ vx: 0, vy: 0,
1375
+ r: Math.max(8, Math.min(22, 8 + deg * 2.5))
1376
+ };
1377
+ });
1378
+ graphSim.edges = state.graph.edges.slice();
1379
+ graphSim.running = true;
1380
+ graphSim.dragNode = null;
1381
+
1382
+ setupGraphInteraction(canvas);
1383
+ runSimulation();
1384
+ }
1385
+
1386
+ function setupGraphInteraction(canvas) {
1387
+ var isPanning = false;
1388
+ var lastMX = 0, lastMY = 0;
1389
+
1390
+ function canvasCoords(e) {
1391
+ var rect = canvas.getBoundingClientRect();
1392
+ return {
1393
+ x: (e.clientX - rect.left - graphSim.panX) / graphSim.zoom,
1394
+ y: (e.clientY - rect.top - graphSim.panY) / graphSim.zoom
1395
+ };
1396
+ }
1397
+ function findNode(cx, cy) {
1398
+ for (var i = graphSim.nodes.length - 1; i >= 0; i--) {
1399
+ var n = graphSim.nodes[i];
1400
+ if (!state.graph.filters[n.type]) continue;
1401
+ var dx = n.x - cx, dy = n.y - cy;
1402
+ if (dx * dx + dy * dy < n.r * n.r + 25) return n;
1403
+ }
1404
+ return null;
1405
+ }
1406
+
1407
+ canvas.addEventListener('mousedown', function(e) {
1408
+ var c = canvasCoords(e);
1409
+ var node = findNode(c.x, c.y);
1410
+ if (node) {
1411
+ graphSim.dragNode = node;
1412
+ } else {
1413
+ isPanning = true;
1414
+ }
1415
+ lastMX = e.clientX;
1416
+ lastMY = e.clientY;
1417
+ });
1418
+ canvas.addEventListener('mousemove', function(e) {
1419
+ var dx = e.clientX - lastMX;
1420
+ var dy = e.clientY - lastMY;
1421
+ if (graphSim.dragNode) {
1422
+ graphSim.dragNode.x += dx / graphSim.zoom;
1423
+ graphSim.dragNode.y += dy / graphSim.zoom;
1424
+ graphSim.dragNode.vx = 0;
1425
+ graphSim.dragNode.vy = 0;
1426
+ } else if (isPanning) {
1427
+ graphSim.panX += dx;
1428
+ graphSim.panY += dy;
1429
+ }
1430
+ lastMX = e.clientX;
1431
+ lastMY = e.clientY;
1432
+ graphSim.mouseX = e.clientX;
1433
+ graphSim.mouseY = e.clientY;
1434
+
1435
+ var c = canvasCoords(e);
1436
+ var hoverNode = findNode(c.x, c.y);
1437
+ var tooltip = document.getElementById('graph-tooltip');
1438
+ if (tooltip) {
1439
+ if (hoverNode && !graphSim.dragNode && !isPanning) {
1440
+ var conns = graphSim.edges.filter(function(ed) { return ed.sourceNodeId === hoverNode.id || ed.targetNodeId === hoverNode.id; }).length;
1441
+ var ttHtml = '<div class="tt-name">' + esc(hoverNode.name) + '</div>';
1442
+ ttHtml += '<div class="tt-type" style="color:' + (NODE_COLORS[hoverNode.type] || '#666') + '">' + esc(hoverNode.type) + '</div>';
1443
+ if (hoverNode.properties) {
1444
+ var propKeys = Object.keys(hoverNode.properties).slice(0, 3);
1445
+ propKeys.forEach(function(k) {
1446
+ ttHtml += '<div class="tt-prop">' + esc(k) + ': ' + esc(truncate(String(hoverNode.properties[k]), 30)) + '</div>';
1447
+ });
1448
+ }
1449
+ ttHtml += '<div class="tt-conns">' + conns + ' connection' + (conns !== 1 ? 's' : '') + '</div>';
1450
+ tooltip.innerHTML = ttHtml;
1451
+ var rect = canvas.getBoundingClientRect();
1452
+ tooltip.style.left = (e.clientX - rect.left + 12) + 'px';
1453
+ tooltip.style.top = (e.clientY - rect.top + 12) + 'px';
1454
+ tooltip.classList.add('visible');
1455
+ canvas.style.cursor = 'pointer';
1456
+ } else {
1457
+ tooltip.classList.remove('visible');
1458
+ canvas.style.cursor = graphSim.dragNode || isPanning ? 'grabbing' : 'grab';
1459
+ }
1460
+ }
1461
+ });
1462
+ canvas.addEventListener('mouseup', function(e) {
1463
+ if (graphSim.dragNode && !isPanning) {
1464
+ selectGraphNode(graphSim.dragNode);
1465
+ }
1466
+ graphSim.dragNode = null;
1467
+ isPanning = false;
1468
+ });
1469
+ canvas.addEventListener('wheel', function(e) {
1470
+ e.preventDefault();
1471
+ var factor = e.deltaY > 0 ? 0.9 : 1.1;
1472
+ graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor));
1473
+ }, { passive: false });
1474
+ canvas.addEventListener('dblclick', function(e) {
1475
+ var c = canvasCoords(e);
1476
+ var node = findNode(c.x, c.y);
1477
+ if (node) {
1478
+ selectGraphNode(node);
1479
+ expandNode(node.id);
1480
+ }
1481
+ });
1482
+ }
1483
+
1484
+ window.zoomGraph = function(dir) {
1485
+ var factor = dir > 0 ? 1.25 : 0.8;
1486
+ graphSim.zoom = Math.max(0.1, Math.min(5, graphSim.zoom * factor));
1487
+ };
1488
+ window.recenterGraph = function() {
1489
+ graphSim.zoom = 1;
1490
+ if (graphSim.canvas) {
1491
+ var cw = graphSim.canvas.width / window.devicePixelRatio;
1492
+ var ch = graphSim.canvas.height / window.devicePixelRatio;
1493
+ graphSim.panX = cw / 2;
1494
+ graphSim.panY = ch / 2;
1495
+ }
1496
+ };
1497
+
1498
+ function selectGraphNode(simNode) {
1499
+ state.graph.selectedNode = simNode;
1500
+ var panel = document.getElementById('selected-node-panel');
1501
+ if (!panel) return;
1502
+ var color = NODE_COLORS[simNode.type] || '#666666';
1503
+ var html = '<div class="selected-node-info">';
1504
+ html += '<h4 style="color:' + color + '">' + esc(simNode.name) + '</h4>';
1505
+ html += '<div class="prop">Type: ' + esc(simNode.type) + '</div>';
1506
+ if (simNode.properties) {
1507
+ Object.keys(simNode.properties).forEach(function(k) {
1508
+ html += '<div class="prop">' + esc(k) + ': ' + esc(truncate(simNode.properties[k], 50)) + '</div>';
1509
+ });
1510
+ }
1511
+ var conns = graphSim.edges.filter(function(e) { return e.sourceNodeId === simNode.id || e.targetNodeId === simNode.id; }).length;
1512
+ html += '<div class="prop">Connections: ' + conns + '</div>';
1513
+ html += '<button class="btn btn-primary" style="margin-top:8px;width:100%;" onclick="expandNode(\'' + esc(simNode.id) + '\')">Expand neighbors</button>';
1514
+ html += '</div>';
1515
+ panel.innerHTML = html;
1516
+ }
1517
+
1518
+ async function expandNode(nodeId) {
1519
+ var result = await apiPost('graph/query', { startNodeId: nodeId, maxDepth: 1 });
1520
+ if (!result) return;
1521
+ var existingIds = {};
1522
+ graphSim.nodes.forEach(function(n) { existingIds[n.id] = true; });
1523
+ var parentNode = graphSim.nodes.find(function(n) { return n.id === nodeId; });
1524
+ var px = parentNode ? parentNode.x : 0;
1525
+ var py = parentNode ? parentNode.y : 0;
1526
+
1527
+ (result.nodes || []).forEach(function(n) {
1528
+ if (!existingIds[n.id]) {
1529
+ state.graph.nodes.push(n);
1530
+ if (!state.graph.filters.hasOwnProperty(n.type)) state.graph.filters[n.type] = true;
1531
+ var angle = Math.random() * Math.PI * 2;
1532
+ graphSim.nodes.push({
1533
+ id: n.id, type: n.type, name: n.name, properties: n.properties,
1534
+ x: px + Math.cos(angle) * 80,
1535
+ y: py + Math.sin(angle) * 80,
1536
+ vx: 0, vy: 0, r: 8
1537
+ });
1538
+ }
1539
+ });
1540
+
1541
+ var existingEdges = {};
1542
+ graphSim.edges.forEach(function(e) { existingEdges[e.id] = true; });
1543
+ (result.edges || []).forEach(function(e) {
1544
+ if (!existingEdges[e.id]) {
1545
+ state.graph.edges.push(e);
1546
+ graphSim.edges.push(e);
1547
+ }
1548
+ });
1549
+ renderGraphSidebar();
1550
+ }
1551
+
1552
+ function runSimulation() {
1553
+ if (!graphSim.running) return;
1554
+ var nodes = graphSim.nodes;
1555
+ var edges = graphSim.edges;
1556
+ var nodeCount = nodes.length;
1557
+ var damping = 0.9;
1558
+ var repulsion = nodeCount > 100 ? 2000 : nodeCount > 50 ? 1200 : 800;
1559
+ var attraction = nodeCount > 100 ? 0.002 : 0.005;
1560
+ var centerGravity = nodeCount > 100 ? 0.005 : 0.01;
1561
+
1562
+ var nodeMap = {};
1563
+ nodes.forEach(function(n) { nodeMap[n.id] = n; });
1564
+
1565
+ for (var i = 0; i < nodes.length; i++) {
1566
+ if (graphSim.dragNode === nodes[i]) continue;
1567
+ var n = nodes[i];
1568
+ var fx = 0, fy = 0;
1569
+ for (var j = 0; j < nodes.length; j++) {
1570
+ if (i === j) continue;
1571
+ var dx = n.x - nodes[j].x;
1572
+ var dy = n.y - nodes[j].y;
1573
+ var dist = Math.sqrt(dx * dx + dy * dy) || 1;
1574
+ var force = repulsion / (dist * dist);
1575
+ fx += (dx / dist) * force;
1576
+ fy += (dy / dist) * force;
1577
+ }
1578
+ fx -= n.x * centerGravity;
1579
+ fy -= n.y * centerGravity;
1580
+ n.vx = (n.vx + fx) * damping;
1581
+ n.vy = (n.vy + fy) * damping;
1582
+ }
1583
+
1584
+ edges.forEach(function(e) {
1585
+ var s = nodeMap[e.sourceNodeId];
1586
+ var t = nodeMap[e.targetNodeId];
1587
+ if (!s || !t) return;
1588
+ var dx = t.x - s.x;
1589
+ var dy = t.y - s.y;
1590
+ var dist = Math.sqrt(dx * dx + dy * dy) || 1;
1591
+ var f = (dist - 100) * attraction;
1592
+ var fx = (dx / dist) * f;
1593
+ var fy = (dy / dist) * f;
1594
+ if (graphSim.dragNode !== s) { s.vx += fx; s.vy += fy; }
1595
+ if (graphSim.dragNode !== t) { t.vx -= fx; t.vy -= fy; }
1596
+ });
1597
+
1598
+ nodes.forEach(function(n) {
1599
+ if (graphSim.dragNode === n) return;
1600
+ n.x += n.vx;
1601
+ n.y += n.vy;
1602
+ });
1603
+
1604
+ renderGraph();
1605
+ graphSim.raf = requestAnimationFrame(runSimulation);
1606
+ }
1607
+
1608
+ async function rebuildGraph() {
1609
+ var sb = document.getElementById('graph-sidebar');
1610
+ if (sb) sb.innerHTML = '<h3>Graph</h3><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Rebuilding graph from observations...</p>';
1611
+ await apiPost('graph/build', {});
1612
+ state.graph.loaded = false;
1613
+ loadGraph();
1614
+ }
1615
+
1616
+ function drawNodeShape(ctx, x, y, r, type) {
1617
+ var shape = NODE_SHAPES[type] || 'circle';
1618
+ switch(shape) {
1619
+ case 'rect':
1620
+ ctx.beginPath();
1621
+ ctx.rect(x - r, y - r * 0.75, r * 2, r * 1.5);
1622
+ break;
1623
+ case 'diamond':
1624
+ ctx.beginPath();
1625
+ ctx.moveTo(x, y - r);
1626
+ ctx.lineTo(x + r, y);
1627
+ ctx.lineTo(x, y + r);
1628
+ ctx.lineTo(x - r, y);
1629
+ ctx.closePath();
1630
+ break;
1631
+ case 'hexagon':
1632
+ ctx.beginPath();
1633
+ for (var i = 0; i < 6; i++) {
1634
+ var angle = (Math.PI / 3) * i - Math.PI / 2;
1635
+ var hx = x + r * Math.cos(angle);
1636
+ var hy = y + r * Math.sin(angle);
1637
+ if (i === 0) ctx.moveTo(hx, hy); else ctx.lineTo(hx, hy);
1638
+ }
1639
+ ctx.closePath();
1640
+ break;
1641
+ default:
1642
+ ctx.beginPath();
1643
+ ctx.arc(x, y, r, 0, Math.PI * 2);
1644
+ break;
1645
+ }
1646
+ }
1647
+
1648
+ function renderGraph() {
1649
+ var ctx = graphSim.ctx;
1650
+ var canvas = graphSim.canvas;
1651
+ if (!ctx || !canvas) return;
1652
+ var w = canvas.width / window.devicePixelRatio;
1653
+ var h = canvas.height / window.devicePixelRatio;
1654
+
1655
+ ctx.clearRect(0, 0, w, h);
1656
+
1657
+ // --- Canvas grid background ---
1658
+ var gridSize = 24;
1659
+ ctx.save();
1660
+ ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.04)' : 'rgba(0,0,0,0.04)';
1661
+ ctx.lineWidth = 0.5;
1662
+ for (var gx = 0; gx < w; gx += gridSize) {
1663
+ ctx.beginPath(); ctx.moveTo(gx, 0); ctx.lineTo(gx, h); ctx.stroke();
1664
+ }
1665
+ for (var gy = 0; gy < h; gy += gridSize) {
1666
+ ctx.beginPath(); ctx.moveTo(0, gy); ctx.lineTo(w, gy); ctx.stroke();
1667
+ }
1668
+ ctx.restore();
1669
+
1670
+ ctx.save();
1671
+ ctx.translate(graphSim.panX, graphSim.panY);
1672
+ ctx.scale(graphSim.zoom, graphSim.zoom);
1673
+
1674
+ var nodeMap = {};
1675
+ graphSim.nodes.forEach(function(n) { nodeMap[n.id] = n; });
1676
+
1677
+ var searchActive = graphSearchTerm.length > 0;
1678
+ var totalVisible = graphSim.nodes.filter(function(n) { return state.graph.filters[n.type]; }).length;
1679
+ var isDense = totalVisible > 40;
1680
+ var labelZoomThreshold = isDense ? 1.5 : 0.5;
1681
+ var edgeLabelZoomThreshold = isDense ? 2.5 : 1.2;
1682
+ var selectedId = state.graph.selectedNode ? state.graph.selectedNode.id : null;
1683
+
1684
+ // --- Hover node detection for focus effect ---
1685
+ var hoverNodeId = null;
1686
+ if (!graphSim.dragNode && graphSim.canvas) {
1687
+ var rect = graphSim.canvas.getBoundingClientRect();
1688
+ var hx = (graphSim.mouseX - rect.left - graphSim.panX) / graphSim.zoom;
1689
+ var hy = (graphSim.mouseY - rect.top - graphSim.panY) / graphSim.zoom;
1690
+ for (var hi = graphSim.nodes.length - 1; hi >= 0; hi--) {
1691
+ var hn = graphSim.nodes[hi];
1692
+ if (!state.graph.filters[hn.type]) continue;
1693
+ var hdx = hn.x - hx, hdy = hn.y - hy;
1694
+ if (hdx * hdx + hdy * hdy < hn.r * hn.r + 25) { hoverNodeId = hn.id; break; }
1695
+ }
1696
+ }
1697
+ var focusNodeId = selectedId || hoverNodeId;
1698
+
1699
+ // --- Draw edges ---
1700
+ graphSim.edges.forEach(function(e) {
1701
+ var s = nodeMap[e.sourceNodeId];
1702
+ var t = nodeMap[e.targetNodeId];
1703
+ if (!s || !t) return;
1704
+ if (!state.graph.filters[s.type] || !state.graph.filters[t.type]) return;
1705
+
1706
+ var edgeDimmed = searchActive && !(s.name.toLowerCase().includes(graphSearchTerm) || t.name.toLowerCase().includes(graphSearchTerm));
1707
+ var isConnectedToFocus = focusNodeId && (e.sourceNodeId === focusNodeId || e.targetNodeId === focusNodeId);
1708
+ var isFocusActive = focusNodeId !== null;
1709
+ var weight = typeof e.weight === 'number' ? e.weight : 0.5;
1710
+ var lineWidth = isConnectedToFocus ? 2 + weight * 2 : 1 + weight * 1.5;
1711
+
1712
+ var dx = t.x - s.x;
1713
+ var dy = t.y - s.y;
1714
+ var len = Math.sqrt(dx * dx + dy * dy) || 1;
1715
+ var curveOffset = isDense ? 12 : 18;
1716
+ var offsetX = -dy / len * curveOffset;
1717
+ var offsetY = dx / len * curveOffset;
1718
+ var cpx = (s.x + t.x) / 2 + offsetX;
1719
+ var cpy = (s.y + t.y) / 2 + offsetY;
1720
+
1721
+ // Colored edges based on source node type
1722
+ var edgeColor = NODE_COLORS[s.type] || '#666666';
1723
+ var edgeAlpha;
1724
+ if (edgeDimmed) {
1725
+ edgeAlpha = 0.06;
1726
+ } else if (isFocusActive && isConnectedToFocus) {
1727
+ edgeAlpha = 0.65;
1728
+ } else if (isFocusActive && !isConnectedToFocus) {
1729
+ edgeAlpha = 0.06;
1730
+ } else {
1731
+ edgeAlpha = isDense ? 0.15 : 0.25;
1732
+ }
1733
+
1734
+ ctx.beginPath();
1735
+ ctx.moveTo(s.x, s.y);
1736
+ ctx.quadraticCurveTo(cpx, cpy, t.x, t.y);
1737
+ // Parse hex color to rgba
1738
+ var r = parseInt(edgeColor.slice(1,3), 16);
1739
+ var g = parseInt(edgeColor.slice(3,5), 16);
1740
+ var b = parseInt(edgeColor.slice(5,7), 16);
1741
+ ctx.strokeStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + edgeAlpha + ')';
1742
+ ctx.lineWidth = lineWidth;
1743
+ ctx.stroke();
1744
+
1745
+ if (!isDense || isConnectedToFocus) {
1746
+ var arrowAngle = Math.atan2(t.y - cpy, t.x - cpx);
1747
+ var arrowLen = 5 + lineWidth;
1748
+ ctx.beginPath();
1749
+ ctx.moveTo(t.x - t.r * Math.cos(arrowAngle), t.y - t.r * Math.sin(arrowAngle));
1750
+ ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle - 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle - 0.3));
1751
+ ctx.lineTo(t.x - (t.r + arrowLen) * Math.cos(arrowAngle + 0.3), t.y - (t.r + arrowLen) * Math.sin(arrowAngle + 0.3));
1752
+ ctx.closePath();
1753
+ ctx.fillStyle = 'rgba(' + r + ',' + g + ',' + b + ',' + (edgeDimmed ? 0.06 : isConnectedToFocus ? 0.6 : 0.2) + ')';
1754
+ ctx.fill();
1755
+ }
1756
+
1757
+ var showEdgeLabel = e.type && !edgeDimmed && (isConnectedToFocus ? graphSim.zoom > 0.6 : graphSim.zoom > edgeLabelZoomThreshold);
1758
+ if (showEdgeLabel) {
1759
+ var zoomInv = 1 / graphSim.zoom;
1760
+ ctx.save();
1761
+ ctx.fillStyle = isDarkMode() ? (isConnectedToFocus ? 'rgba(238,238,238,0.9)' : 'rgba(180,180,180,0.7)') : (isConnectedToFocus ? 'rgba(17,17,17,0.85)' : 'rgba(80,80,80,0.7)');
1762
+ ctx.font = (isConnectedToFocus ? '600 ' : '500 ') + (11 * zoomInv).toFixed(1) + 'px Inter, sans-serif';
1763
+ ctx.textAlign = 'center';
1764
+ ctx.fillText(e.type, cpx, cpy - (4 * zoomInv));
1765
+ ctx.restore();
1766
+ }
1767
+ });
1768
+
1769
+ // --- Draw nodes ---
1770
+ graphSim.nodes.forEach(function(n) {
1771
+ if (!state.graph.filters[n.type]) return;
1772
+ var color = NODE_COLORS[n.type] || '#666666';
1773
+ var isSelected = selectedId === n.id;
1774
+ var isHovered = hoverNodeId === n.id;
1775
+ var matchesSearch = !searchActive || n.name.toLowerCase().includes(graphSearchTerm);
1776
+ var isFocusFaded = focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) {
1777
+ return (ed.sourceNodeId === focusNodeId && ed.targetNodeId === n.id) ||
1778
+ (ed.targetNodeId === focusNodeId && ed.sourceNodeId === n.id);
1779
+ });
1780
+
1781
+ var nodeAlpha = !matchesSearch ? 0.12 : (isFocusFaded ? 0.2 : 1);
1782
+
1783
+ ctx.save();
1784
+ ctx.globalAlpha = nodeAlpha;
1785
+
1786
+ // Glow effect
1787
+ if (matchesSearch && !isFocusFaded && (isSelected || isHovered || !searchActive)) {
1788
+ ctx.shadowColor = color;
1789
+ ctx.shadowBlur = isSelected ? 20 : isHovered ? 16 : (isDense ? 4 : 8);
1790
+ }
1791
+
1792
+ // Gradient fill
1793
+ drawNodeShape(ctx, n.x, n.y, n.r, n.type);
1794
+ var grad = ctx.createRadialGradient(n.x - n.r * 0.3, n.y - n.r * 0.3, 0, n.x, n.y, n.r * 1.2);
1795
+ var cr = parseInt(color.slice(1,3), 16);
1796
+ var cg = parseInt(color.slice(3,5), 16);
1797
+ var cb = parseInt(color.slice(5,7), 16);
1798
+ grad.addColorStop(0, 'rgba(' + Math.min(255, cr + 60) + ',' + Math.min(255, cg + 60) + ',' + Math.min(255, cb + 60) + ',0.95)');
1799
+ grad.addColorStop(1, color);
1800
+ ctx.fillStyle = grad;
1801
+ ctx.fill();
1802
+ ctx.restore();
1803
+
1804
+ // Selected ring
1805
+ if (isSelected) {
1806
+ ctx.save();
1807
+ drawNodeShape(ctx, n.x, n.y, n.r + 3, n.type);
1808
+ ctx.strokeStyle = color;
1809
+ ctx.lineWidth = 3;
1810
+ ctx.shadowColor = color;
1811
+ ctx.shadowBlur = 12;
1812
+ ctx.stroke();
1813
+ ctx.restore();
1814
+ } else if (isHovered) {
1815
+ drawNodeShape(ctx, n.x, n.y, n.r + 2, n.type);
1816
+ ctx.strokeStyle = color;
1817
+ ctx.lineWidth = 2;
1818
+ ctx.stroke();
1819
+ } else if (searchActive && matchesSearch) {
1820
+ drawNodeShape(ctx, n.x, n.y, n.r, n.type);
1821
+ ctx.strokeStyle = '#CC0000';
1822
+ ctx.lineWidth = 2;
1823
+ ctx.stroke();
1824
+ }
1825
+
1826
+ var showLabel = matchesSearch && !isFocusFaded && (
1827
+ isSelected || isHovered ||
1828
+ (searchActive && matchesSearch) ||
1829
+ (!isDense && graphSim.zoom > labelZoomThreshold) ||
1830
+ (isDense && graphSim.zoom > labelZoomThreshold && n.r > 10)
1831
+ );
1832
+ if (showLabel) {
1833
+ var zoomInv = 1 / graphSim.zoom;
1834
+ ctx.save();
1835
+ ctx.font = (isSelected || isHovered ? '600 ' : '500 ') + (13 * zoomInv).toFixed(1) + 'px Inter, sans-serif';
1836
+ ctx.textAlign = 'center';
1837
+
1838
+ var label = truncate(n.name, 18);
1839
+ var textW = ctx.measureText(label).width;
1840
+ var labelW = textW + (16 * zoomInv);
1841
+ var labelH = 20 * zoomInv;
1842
+ var labelY = n.y + n.r + (8 * zoomInv); // Top of the background pill
1843
+
1844
+ ctx.fillStyle = isDarkMode() ? 'rgba(30,30,35,0.92)' : 'rgba(255,255,255,0.92)';
1845
+ ctx.beginPath();
1846
+ ctx.roundRect ? ctx.roundRect(n.x - labelW / 2, labelY, labelW, labelH, 4 * zoomInv) : ctx.rect(n.x - labelW / 2, labelY, labelW, labelH);
1847
+ ctx.fill();
1848
+
1849
+ ctx.strokeStyle = isDarkMode() ? 'rgba(255,255,255,0.08)' : 'rgba(0,0,0,0.08)';
1850
+ ctx.lineWidth = 1 * zoomInv;
1851
+ ctx.stroke();
1852
+
1853
+ ctx.fillStyle = isDarkMode() ? (isSelected || isHovered ? '#eeeeee' : '#bbbbbb') : (isSelected || isHovered ? '#111111' : '#444444');
1854
+ // Vertically center text in the pill box
1855
+ ctx.fillText(label, n.x, labelY + (14 * zoomInv));
1856
+ ctx.restore();
1857
+ }
1858
+ });
1859
+
1860
+ ctx.restore();
1861
+
1862
+ if (graphSim.nodes.length === 0) {
1863
+ ctx.fillStyle = '#999999';
1864
+ ctx.font = '14px Lora, Georgia, serif';
1865
+ ctx.textAlign = 'center';
1866
+ ctx.fillText('No graph data yet.', w / 2, h / 2 - 16);
1867
+ ctx.font = '12px Inter, sans-serif';
1868
+ ctx.fillText('Set GRAPH_EXTRACTION_ENABLED=true to enable knowledge graph extraction.', w / 2, h / 2 + 8);
1869
+ }
1870
+ }
1871
+
1872
+ async function loadMemories() {
1873
+ var el = document.getElementById('view-memories');
1874
+ el.innerHTML = '<div class="loading">Loading memories...</div>';
1875
+ var result = await apiGet('memories?latest=true');
1876
+ state.memories.items = (result && result.memories) || [];
1877
+ state.memories.loaded = true;
1878
+ renderMemories();
1879
+ }
1880
+
1881
+ function renderMemories() {
1882
+ var el = document.getElementById('view-memories');
1883
+ var items = state.memories.items;
1884
+ var search = state.memories.search.toLowerCase();
1885
+ var typeFilter = state.memories.typeFilter;
1886
+
1887
+ var filtered = items.filter(function(m) {
1888
+ if (typeFilter && m.type !== typeFilter) return false;
1889
+ if (search && !(m.title || '').toLowerCase().includes(search) && !(m.content || '').toLowerCase().includes(search)) return false;
1890
+ return true;
1891
+ });
1892
+
1893
+ var types = {};
1894
+ items.forEach(function(m) { types[m.type] = true; });
1895
+ var typeOptions = Object.keys(types).sort();
1896
+
1897
+ var html = '<div class="toolbar">';
1898
+ html += '<input type="text" id="mem-search" placeholder="Search memories..." value="' + esc(state.memories.search) + '">';
1899
+ html += '<select id="mem-type-filter"><option value="">All types</option>';
1900
+ typeOptions.forEach(function(t) {
1901
+ html += '<option value="' + esc(t) + '"' + (typeFilter === t ? ' selected' : '') + '>' + esc(t) + '</option>';
1902
+ });
1903
+ html += '</select></div>';
1904
+
1905
+ if (filtered.length === 0) {
1906
+ html += '<div class="empty-state"><div class="empty-icon">&#128218;</div><p>No memories found</p></div>';
1907
+ } else {
1908
+ html += '<table><tr><th>Title</th><th>Type</th><th>Strength</th><th>Version</th><th>Updated</th><th>Actions</th></tr>';
1909
+ filtered.forEach(function(m) {
1910
+ var badgeClass = TYPE_BADGES[m.type] || 'badge-muted';
1911
+ var strength = Math.round((m.strength || 0) * 100);
1912
+ var barColor = strength > 70 ? 'var(--green)' : strength > 40 ? 'var(--yellow)' : 'var(--red)';
1913
+ html += '<tr>';
1914
+ var preview = (m.content || '').split('\n').slice(0, 2).join(' ').trim();
1915
+ var previewHtml = esc(truncate(preview, 150));
1916
+ if (search && search.length > 2) {
1917
+ var re = new RegExp('(' + search.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') + ')', 'gi');
1918
+ previewHtml = previewHtml.replace(re, '<mark>$1</mark>');
1919
+ }
1920
+ html += '<td><span style="color:var(--ink);font-weight:600;">' + esc(truncate(m.title, 50)) + '</span>';
1921
+ html += '<div style="font-size:12px;color:var(--ink-muted);margin-top:3px;line-height:1.4;max-height:34px;overflow:hidden;">' + previewHtml + '</div>';
1922
+ if (m.concepts && m.concepts.length > 0) {
1923
+ html += '<div style="margin-top:3px;display:flex;gap:4px;flex-wrap:wrap;">';
1924
+ m.concepts.slice(0, 4).forEach(function(c) { html += '<span class="tag">' + esc(c) + '</span>'; });
1925
+ html += '</div>';
1926
+ }
1927
+ html += '</td>';
1928
+ html += '<td><span class="badge ' + badgeClass + '">' + esc(m.type) + '</span></td>';
1929
+ html += '<td><div class="strength-bar"><div class="fill" style="width:' + strength + '%;background:' + barColor + '"></div></div> <span style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + strength + '%</span></td>';
1930
+ html += '<td style="color:var(--ink-muted);font-family:var(--font-mono);font-size:12px;">v' + (m.version || 1) + '</td>';
1931
+ html += '<td style="font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(formatTime(m.updatedAt)) + '</td>';
1932
+ html += '<td><button class="btn btn-danger" style="font-size:9px;padding:2px 8px;" onclick="deleteMemory(\'' + esc(m.id) + '\',\'' + esc((m.title || '').replace(/'/g, '')) + '\')">Delete</button></td>';
1933
+ html += '</tr>';
1934
+ });
1935
+ html += '</table>';
1936
+ }
1937
+
1938
+ el.innerHTML = html;
1939
+
1940
+ var searchInput = document.getElementById('mem-search');
1941
+ if (searchInput) {
1942
+ searchInput.addEventListener('input', debounce(function() {
1943
+ state.memories.search = this.value;
1944
+ renderMemories();
1945
+ }, 200));
1946
+ }
1947
+ var typeSelect = document.getElementById('mem-type-filter');
1948
+ if (typeSelect) {
1949
+ typeSelect.addEventListener('change', function() {
1950
+ state.memories.typeFilter = this.value;
1951
+ renderMemories();
1952
+ });
1953
+ }
1954
+ }
1955
+
1956
+ function deleteMemory(id, title) {
1957
+ var modal = document.getElementById('modal');
1958
+ var overlay = document.getElementById('modal-overlay');
1959
+ modal.innerHTML = '<h3>Delete Memory</h3><p>Are you sure you want to delete "' + esc(title) + '"? This action cannot be undone.</p><div class="modal-actions"><button class="btn" onclick="closeModal()">Cancel</button><button class="btn btn-danger" onclick="confirmDeleteMemory(\'' + esc(id) + '\')">Delete</button></div>';
1960
+ overlay.classList.add('open');
1961
+ }
1962
+
1963
+ async function confirmDeleteMemory(id) {
1964
+ closeModal();
1965
+ await apiDelete('governance/memories', { memoryIds: [id], reason: 'Deleted via viewer' });
1966
+ state.memories.loaded = false;
1967
+ loadMemories();
1968
+ }
1969
+
1970
+ function closeModal() {
1971
+ document.getElementById('modal-overlay').classList.remove('open');
1972
+ }
1973
+
1974
+ async function loadTimeline() {
1975
+ var el = document.getElementById('view-timeline');
1976
+ el.innerHTML = '<div class="loading">Loading timeline...</div>';
1977
+ var sessResult = await apiGet('sessions');
1978
+ var sessions = (sessResult && sessResult.sessions) || [];
1979
+ state.timeline.loaded = true;
1980
+
1981
+ if (sessions.length > 0 && !state.timeline.sessionId) {
1982
+ var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
1983
+ state.timeline.sessionId = sorted[0].id;
1984
+ }
1985
+
1986
+ renderTimelineToolbar(sessions);
1987
+ if (state.timeline.sessionId) await loadObservations();
1988
+ }
1989
+
1990
+ function renderTimelineToolbar(sessions) {
1991
+ var el = document.getElementById('view-timeline');
1992
+ var html = '<div class="toolbar">';
1993
+ html += '<select id="tl-session"><option value="">Select session</option>';
1994
+ sessions.sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); }).forEach(function(s) {
1995
+ var label = (s.project ? s.project.split('/').pop() : s.id.slice(0,8)) + ' (' + s.id.slice(0,8) + ')';
1996
+ html += '<option value="' + esc(s.id) + '"' + (state.timeline.sessionId === s.id ? ' selected' : '') + '>' + esc(label) + '</option>';
1997
+ });
1998
+ html += '</select>';
1999
+ html += '<select id="tl-importance"><option value="0">All importance</option>';
2000
+ for (var i = 1; i <= 9; i++) {
2001
+ html += '<option value="' + i + '"' + (state.timeline.minImportance === i ? ' selected' : '') + '>&ge; ' + i + '</option>';
2002
+ }
2003
+ html += '</select></div>';
2004
+ html += '<div id="tl-content"></div>';
2005
+ el.innerHTML = html;
2006
+
2007
+ document.getElementById('tl-session').addEventListener('change', function() {
2008
+ state.timeline.sessionId = this.value;
2009
+ state.timeline.page = 0;
2010
+ loadObservations();
2011
+ });
2012
+ document.getElementById('tl-importance').addEventListener('change', function() {
2013
+ state.timeline.minImportance = parseInt(this.value);
2014
+ renderObservations();
2015
+ });
2016
+ }
2017
+
2018
+ async function loadObservations() {
2019
+ var content = document.getElementById('tl-content');
2020
+ if (!content) return;
2021
+ if (!state.timeline.sessionId) {
2022
+ content.innerHTML = '<div class="empty-state"><div class="empty-icon">&#128337;</div><p>Select a session to view observations</p></div>';
2023
+ return;
2024
+ }
2025
+ content.innerHTML = '<div class="loading">Loading observations...</div>';
2026
+ var result = await apiGet('observations?sessionId=' + encodeURIComponent(state.timeline.sessionId));
2027
+ state.timeline.observations = (result && result.observations) || [];
2028
+ renderObservations();
2029
+ }
2030
+
2031
+ var tlTypeFilter = '';
2032
+
2033
+ function renderObservations() {
2034
+ var content = document.getElementById('tl-content');
2035
+ if (!content) return;
2036
+ var obs = state.timeline.observations;
2037
+ var minImp = state.timeline.minImportance;
2038
+ var filtered = minImp > 0 ? obs.filter(function(o) { return (o.importance || 0) >= minImp; }) : obs;
2039
+
2040
+ var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' };
2041
+
2042
+ var typeCounts = {};
2043
+ filtered.forEach(function(o) {
2044
+ var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other');
2045
+ typeCounts[t] = (typeCounts[t] || 0) + 1;
2046
+ });
2047
+ var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; });
2048
+
2049
+ if (tlTypeFilter) {
2050
+ filtered = filtered.filter(function(o) {
2051
+ var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other');
2052
+ return t === tlTypeFilter;
2053
+ });
2054
+ }
2055
+
2056
+ var pageSize = state.timeline.pageSize;
2057
+ var page = state.timeline.page;
2058
+ var start = page * pageSize;
2059
+ var paged = filtered.slice(start, start + pageSize);
2060
+ var totalPages = Math.ceil(filtered.length / pageSize);
2061
+
2062
+ var html = '<div class="type-chips">';
2063
+ html += '<span class="type-chip' + (!tlTypeFilter ? ' active' : '') + '" onclick="setTlTypeFilter(\'\')">All (' + obs.length + ')</span>';
2064
+ typeList.forEach(function(t) {
2065
+ var color = OBS_TYPE_COLORS[t] || '#666666';
2066
+ html += '<span class="type-chip' + (tlTypeFilter === t ? ' active' : '') + '" onclick="setTlTypeFilter(\'' + esc(t) + '\')" style="' + (tlTypeFilter === t ? 'background:' + color + ';border-color:' + color + ';' : 'border-color:' + color + ';color:' + color + ';') + '">' + esc(t.replace(/_/g, ' ')) + ' (' + typeCounts[t] + ')</span>';
2067
+ });
2068
+ html += '</div>';
2069
+
2070
+ if (paged.length === 0) {
2071
+ html += '<div class="empty-state"><div class="empty-icon">&#128337;</div><p>No observations' + (obs.length > 0 ? ' match the filter (' + obs.length + ' total)' : ' for this session') + '</p></div>';
2072
+ content.innerHTML = html;
2073
+ return;
2074
+ }
2075
+
2076
+ html += '<div style="font-size:11px;color:var(--ink-faint);margin-bottom:16px;font-family:var(--font-mono);text-transform:uppercase;letter-spacing:0.06em;">' + filtered.length + ' observations shown</div>';
2077
+
2078
+ html += '<div class="timeline-container">';
2079
+
2080
+ var lastDateGroup = '';
2081
+ paged.forEach(function(o, idx) {
2082
+ var isCompressed = !!o.narrative || !!o.type;
2083
+ var isRaw = !isCompressed;
2084
+ var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other';
2085
+ var impVal = typeof o.importance === 'number' ? o.importance : 5;
2086
+ var impClass = impVal >= 7 ? 'high' : impVal >= 4 ? 'med' : 'low';
2087
+ var title = o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation');
2088
+ var typeColor = OBS_TYPE_COLORS[type] || '#666666';
2089
+ var icon = OBS_TYPE_ICONS[type] || '&#128196;';
2090
+
2091
+ var dateGroup = '';
2092
+ try {
2093
+ var d = new Date(o.timestamp);
2094
+ dateGroup = d.toLocaleDateString() + ' ' + d.getHours() + ':00';
2095
+ } catch(e) { dateGroup = ''; }
2096
+
2097
+ if (dateGroup && dateGroup !== lastDateGroup) {
2098
+ html += '<div class="timeline-date-marker"><span>' + esc(dateGroup) + '</span></div>';
2099
+ lastDateGroup = dateGroup;
2100
+ }
2101
+
2102
+ var side = idx % 2 === 0 ? 'tl-left' : 'tl-right';
2103
+
2104
+ html += '<div class="timeline-item ' + side + '">';
2105
+ html += '<div class="timeline-dot" style="background:' + typeColor + ';"></div>';
2106
+ html += '<div class="timeline-connector"></div>';
2107
+
2108
+ html += '<div class="obs-card imp-' + impClass + '" style="border-left-color:' + typeColor + ';text-align:left;">';
2109
+ html += '<div class="obs-head">';
2110
+ html += '<div style="display:flex;align-items:center;gap:6px;">';
2111
+ html += '<span class="obs-type-icon">' + icon + '</span>';
2112
+ html += '<span class="obs-title">' + esc(title) + '</span>';
2113
+ if (isRaw) html += '<span class="badge badge-muted" style="font-size:8px;margin-left:4px;">raw</span>';
2114
+ html += '</div>';
2115
+ html += '<div style="display:flex;align-items:center;gap:8px;">';
2116
+ if (isCompressed) html += '<span class="obs-importance imp-' + impVal + '" title="Importance: ' + impVal + '/10">' + impVal + '</span>';
2117
+ html += '<span class="obs-time">' + esc(shortTime(o.timestamp)) + '</span>';
2118
+ html += '</div></div>';
2119
+
2120
+ if (o.subtitle) html += '<div class="obs-subtitle">' + esc(o.subtitle) + '</div>';
2121
+
2122
+ html += '<div style="margin-top:4px;">';
2123
+ html += '<span class="badge" style="border-color:' + typeColor + ';color:' + typeColor + ';margin-right:4px;">' + esc(type.replace(/_/g, ' ')) + '</span>';
2124
+ if (o.hookType) html += '<span class="badge badge-muted" style="margin-right:4px;">' + esc(o.hookType) + '</span>';
2125
+ html += '</div>';
2126
+
2127
+ if (isRaw && o.toolInput) {
2128
+ var inputStr = typeof o.toolInput === 'string' ? o.toolInput : JSON.stringify(o.toolInput);
2129
+ html += '<div style="margin-top:6px;"><span style="font-size:10px;color:var(--ink-muted);font-weight:600;font-family:var(--font-ui);text-transform:uppercase;letter-spacing:0.08em;">Input:</span>';
2130
+ html += '<pre style="font-size:11px;color:var(--ink-muted);background:var(--bg-alt);padding:8px 10px;border:1px solid var(--border-light);margin-top:3px;overflow-x:auto;max-height:80px;font-family:var(--font-mono);">' + esc(truncate(inputStr, 300)) + '</pre></div>';
2131
+ }
2132
+ if (isRaw && o.toolOutput) {
2133
+ var outputStr = typeof o.toolOutput === 'string' ? o.toolOutput : JSON.stringify(o.toolOutput);
2134
+ html += '<div style="margin-top:4px;"><span style="font-size:10px;color:var(--ink-muted);font-weight:600;font-family:var(--font-ui);text-transform:uppercase;letter-spacing:0.08em;">Output:</span>';
2135
+ html += '<div class="obs-narrative" style="margin-top:3px;">' + esc(truncate(outputStr, 300)) + '</div></div>';
2136
+ }
2137
+ if (o.narrative) html += '<div class="obs-narrative" style="margin-top:8px;">' + esc(o.narrative) + '</div>';
2138
+ if (o.facts && o.facts.length > 0) {
2139
+ html += '<ul class="obs-facts">';
2140
+ o.facts.forEach(function(f) { html += '<li>' + esc(f) + '</li>'; });
2141
+ html += '</ul>';
2142
+ }
2143
+
2144
+ var hasTags = (o.concepts && o.concepts.length) || (o.files && o.files.length);
2145
+ if (hasTags) {
2146
+ html += '<div class="tag-list">';
2147
+ (o.concepts || []).forEach(function(c) { html += '<span class="tag">' + esc(c) + '</span>'; });
2148
+ (o.files || []).forEach(function(f) {
2149
+ var short = f.split('/').pop();
2150
+ html += '<span class="tag file-tag" title="' + esc(f) + '">' + esc(short) + '</span>';
2151
+ });
2152
+ html += '</div>';
2153
+ }
2154
+ if (isRaw && o.toolInput) {
2155
+ var files = [];
2156
+ var ti = o.toolInput;
2157
+ if (typeof ti === 'object' && ti !== null) {
2158
+ if (ti.file_path) files.push(ti.file_path);
2159
+ if (ti.path) files.push(ti.path);
2160
+ }
2161
+ if (files.length > 0) {
2162
+ html += '<div class="tag-list">';
2163
+ files.forEach(function(f) {
2164
+ var short = String(f).split('/').pop();
2165
+ html += '<span class="tag file-tag" title="' + esc(f) + '">' + esc(short) + '</span>';
2166
+ });
2167
+ html += '</div>';
2168
+ }
2169
+ }
2170
+ html += '</div>';
2171
+ html += '</div>';
2172
+ });
2173
+
2174
+ html += '</div>';
2175
+
2176
+ if (totalPages > 1) {
2177
+ html += '<div class="pagination">';
2178
+ if (page > 0) html += '<button class="btn" onclick="tlPage(' + (page - 1) + ')">Prev</button>';
2179
+ html += '<span style="color:var(--ink-faint);font-size:12px;padding:6px;font-family:var(--font-mono);">Page ' + (page + 1) + ' of ' + totalPages + ' (' + filtered.length + ' total)</span>';
2180
+ if (page < totalPages - 1) html += '<button class="btn" onclick="tlPage(' + (page + 1) + ')">Next</button>';
2181
+ html += '</div>';
2182
+ }
2183
+
2184
+ content.innerHTML = html;
2185
+ }
2186
+
2187
+ function setTlTypeFilter(type) {
2188
+ tlTypeFilter = type;
2189
+ state.timeline.page = 0;
2190
+ renderObservations();
2191
+ }
2192
+
2193
+ function tlPage(p) {
2194
+ state.timeline.page = p;
2195
+ renderObservations();
2196
+ }
2197
+
2198
+ async function loadActivity() {
2199
+ var el = document.getElementById('view-activity');
2200
+ el.innerHTML = '<div class="loading">Loading activity...</div>';
2201
+ var results = await Promise.all([
2202
+ apiGet('sessions'),
2203
+ apiGet('audit?limit=200')
2204
+ ]);
2205
+ var sessions = (results[0] && results[0].sessions) || [];
2206
+ var auditEntries = (results[1] && results[1].entries) || [];
2207
+
2208
+ var allObs = [];
2209
+ var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
2210
+ var recentSessions = sorted.slice(0, 5);
2211
+
2212
+ var obsResults = await Promise.all(recentSessions.map(function(s) {
2213
+ return apiGet('observations?sessionId=' + encodeURIComponent(s.id));
2214
+ }));
2215
+ obsResults.forEach(function(r) {
2216
+ if (r && r.observations) allObs = allObs.concat(r.observations);
2217
+ });
2218
+
2219
+ state.activity.sessions = sessions;
2220
+ state.activity.observations = allObs;
2221
+ state.activity.audit = auditEntries;
2222
+ state.activity.loaded = true;
2223
+ renderActivity();
2224
+ }
2225
+
2226
+ function renderActivity() {
2227
+ var el = document.getElementById('view-activity');
2228
+ var obs = state.activity.observations;
2229
+ var sessions = state.activity.sessions;
2230
+
2231
+ var TOOL_TYPE_MAP = { Read: 'file_read', Write: 'file_write', Edit: 'file_edit', Bash: 'command_run', Grep: 'search', Glob: 'search', WebFetch: 'web_fetch', WebSearch: 'web_fetch', AskUserQuestion: 'conversation', Task: 'subagent' };
2232
+
2233
+ var html = '';
2234
+
2235
+ html += '<div class="card"><div class="card-title">Activity Heatmap (Past Year)</div>';
2236
+ var dayCounts = {};
2237
+ obs.forEach(function(o) {
2238
+ try {
2239
+ var d = new Date(o.timestamp);
2240
+ var key = d.toISOString().slice(0, 10);
2241
+ dayCounts[key] = (dayCounts[key] || 0) + 1;
2242
+ } catch(e) {}
2243
+ });
2244
+ sessions.forEach(function(s) {
2245
+ try {
2246
+ var d = new Date(s.startedAt);
2247
+ var key = d.toISOString().slice(0, 10);
2248
+ dayCounts[key] = (dayCounts[key] || 0) + 1;
2249
+ } catch(e) {}
2250
+ });
2251
+
2252
+ var maxCount = 0;
2253
+ Object.keys(dayCounts).forEach(function(k) { if (dayCounts[k] > maxCount) maxCount = dayCounts[k]; });
2254
+
2255
+ var today = new Date();
2256
+ var dayLabels = ['Mon', '', 'Wed', '', 'Fri', '', ''];
2257
+ html += '<div class="heatmap-labels">';
2258
+ dayLabels.forEach(function(l) { html += '<span style="width:10px;text-align:center;">' + l + '</span>'; });
2259
+ html += '</div>';
2260
+ html += '<div class="heatmap-wrap"><div class="heatmap-grid">';
2261
+ for (var w = 51; w >= 0; w--) {
2262
+ for (var d = 0; d < 7; d++) {
2263
+ var cellDate = new Date(today);
2264
+ cellDate.setDate(cellDate.getDate() - (w * 7 + (6 - d)));
2265
+ var key = cellDate.toISOString().slice(0, 10);
2266
+ var count = dayCounts[key] || 0;
2267
+ var level = count === 0 ? '' : count <= (maxCount * 0.25) ? 'level-1' : count <= (maxCount * 0.5) ? 'level-2' : count <= (maxCount * 0.75) ? 'level-3' : 'level-4';
2268
+ var title = key + ': ' + count + ' event' + (count !== 1 ? 's' : '');
2269
+ html += '<div class="heatmap-cell ' + level + '" title="' + esc(title) + '"></div>';
2270
+ }
2271
+ }
2272
+ html += '</div></div>';
2273
+ html += '<div style="display:flex;align-items:center;gap:4px;margin-top:8px;font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);justify-content:flex-end;">Less ';
2274
+ html += '<div class="heatmap-cell" style="display:inline-block;"></div>';
2275
+ html += '<div class="heatmap-cell level-1" style="display:inline-block;"></div>';
2276
+ html += '<div class="heatmap-cell level-2" style="display:inline-block;"></div>';
2277
+ html += '<div class="heatmap-cell level-3" style="display:inline-block;"></div>';
2278
+ html += '<div class="heatmap-cell level-4" style="display:inline-block;"></div>';
2279
+ html += ' More</div>';
2280
+ html += '</div>';
2281
+
2282
+ var typeCounts = {};
2283
+ obs.forEach(function(o) {
2284
+ var t = o.type || TOOL_TYPE_MAP[o.toolName] || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'other');
2285
+ typeCounts[t] = (typeCounts[t] || 0) + 1;
2286
+ });
2287
+ var typeList = Object.keys(typeCounts).sort(function(a, b) { return typeCounts[b] - typeCounts[a]; });
2288
+ var totalObs = obs.length || 1;
2289
+
2290
+ html += '<div class="two-col" style="margin-top:16px;">';
2291
+
2292
+ html += '<div class="card"><div class="card-title">Type Breakdown</div>';
2293
+ if (typeList.length === 0) {
2294
+ html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No observations yet</div>';
2295
+ } else {
2296
+ html += '<div class="bar-chart">';
2297
+ typeList.slice(0, 12).forEach(function(t) {
2298
+ var pct = Math.round((typeCounts[t] / totalObs) * 100);
2299
+ var color = OBS_TYPE_COLORS[t] || '#666666';
2300
+ html += '<div class="bar-row"><span class="bar-label">' + esc(t.replace(/_/g, ' ')) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:' + color + ';"></div></div><span class="bar-value">' + typeCounts[t] + '</span></div>';
2301
+ });
2302
+ html += '</div>';
2303
+ }
2304
+ html += '</div>';
2305
+
2306
+ html += '<div class="card"><div class="card-title">Activity Feed</div>';
2307
+ var sortedObs = obs.slice().sort(function(a, b) { return (b.timestamp || '').localeCompare(a.timestamp || ''); });
2308
+ if (sortedObs.length === 0) {
2309
+ html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No recent activity</div>';
2310
+ } else {
2311
+ sortedObs.slice(0, 20).forEach(function(o) {
2312
+ var type = o.type || TOOL_TYPE_MAP[o.toolName] || 'other';
2313
+ var typeColor = OBS_TYPE_COLORS[type] || '#666666';
2314
+ var icon = OBS_TYPE_ICONS[type] || '&#128196;';
2315
+ var title = o.title || o.toolName || (o.hookType ? o.hookType.replace(/_/g, ' ') : 'Observation');
2316
+
2317
+ html += '<div class="activity-feed-item">';
2318
+ html += '<div class="activity-feed-icon" style="color:' + typeColor + ';border-color:' + typeColor + ';">' + icon + '</div>';
2319
+ html += '<div class="activity-feed-body">';
2320
+ html += '<div class="activity-feed-title">' + esc(truncate(title, 60)) + '</div>';
2321
+ if (o.narrative) html += '<div style="font-size:12px;color:var(--ink-muted);margin-top:2px;">' + esc(truncate(o.narrative, 100)) + '</div>';
2322
+ html += '<div class="activity-feed-meta">' + esc(type.replace(/_/g, ' '));
2323
+ if (o.files && o.files.length) html += ' &middot; <span class="tag file-tag" style="font-size:9px;padding:0 4px;">' + esc(o.files[0].split('/').pop()) + '</span>';
2324
+ html += ' &middot; ' + esc(shortTime(o.timestamp)) + '</div>';
2325
+ html += '</div></div>';
2326
+ });
2327
+ }
2328
+ html += '</div>';
2329
+
2330
+ html += '</div>';
2331
+
2332
+ el.innerHTML = html;
2333
+ }
2334
+
2335
+ async function loadSessions() {
2336
+ var el = document.getElementById('view-sessions');
2337
+ el.innerHTML = '<div class="loading">Loading sessions...</div>';
2338
+ var result = await apiGet('sessions');
2339
+ state.sessions.items = (result && result.sessions) || [];
2340
+ state.sessions.loaded = true;
2341
+ renderSessions();
2342
+ }
2343
+
2344
+ function renderSessions() {
2345
+ var el = document.getElementById('view-sessions');
2346
+ var items = state.sessions.items.slice().sort(function(a, b) {
2347
+ return (b.startedAt || '').localeCompare(a.startedAt || '');
2348
+ });
2349
+
2350
+ var html = '<div class="session-list">';
2351
+ if (items.length === 0) {
2352
+ html += '<div class="empty-state"><div class="empty-icon">&#128466;</div><p>No sessions</p></div>';
2353
+ } else {
2354
+ items.forEach(function(s) {
2355
+ var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
2356
+ var selected = state.sessions.selectedId === s.id;
2357
+ html += '<div class="session-item' + (selected ? ' selected' : '') + '" onclick="selectSession(\'' + esc(s.id) + '\')">';
2358
+ html += '<div class="session-top"><span class="session-project">' + esc(s.project ? s.project.split('/').pop() : 'Unknown') + '</span>';
2359
+ html += '<span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></div>';
2360
+ html += '<div class="session-meta">' + esc(s.id.slice(0, 12)) + ' &middot; ' + esc(formatTime(s.startedAt));
2361
+ html += ' &middot; ' + (s.observationCount || 0) + ' obs';
2362
+ if (s.model) html += ' &middot; ' + esc(s.model);
2363
+ html += '</div></div>';
2364
+ });
2365
+ }
2366
+ html += '</div>';
2367
+ html += '<div id="session-detail"></div>';
2368
+ el.innerHTML = html;
2369
+
2370
+ if (state.sessions.selectedId) renderSessionDetail();
2371
+ }
2372
+
2373
+ function selectSession(id) {
2374
+ state.sessions.selectedId = state.sessions.selectedId === id ? null : id;
2375
+ renderSessions();
2376
+ }
2377
+
2378
+ function renderSessionDetail() {
2379
+ var panel = document.getElementById('session-detail');
2380
+ if (!panel) return;
2381
+ var s = state.sessions.items.find(function(x) { return x.id === state.sessions.selectedId; });
2382
+ if (!s) { panel.innerHTML = ''; return; }
2383
+
2384
+ var html = '<div class="detail-panel"><h3>Session Details</h3>';
2385
+ html += '<div class="detail-row"><div class="dl">Session ID</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(s.id) + '</div></div>';
2386
+ html += '<div class="detail-row"><div class="dl">Project</div><div class="dv">' + esc(s.project) + '</div></div>';
2387
+ html += '<div class="detail-row"><div class="dl">Working Dir</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(s.cwd) + '</div></div>';
2388
+ html += '<div class="detail-row"><div class="dl">Status</div><div class="dv">' + esc(s.status) + '</div></div>';
2389
+ html += '<div class="detail-row"><div class="dl">Started</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(s.startedAt)) + '</div></div>';
2390
+ if (s.endedAt) html += '<div class="detail-row"><div class="dl">Ended</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(s.endedAt)) + '</div></div>';
2391
+ html += '<div class="detail-row"><div class="dl">Observations</div><div class="dv" style="font-family:var(--font-mono);">' + (s.observationCount || 0) + '</div></div>';
2392
+ if (s.model) html += '<div class="detail-row"><div class="dl">Model</div><div class="dv">' + esc(s.model) + '</div></div>';
2393
+ if (s.tags && s.tags.length) html += '<div class="detail-row"><div class="dl">Tags</div><div class="dv">' + s.tags.map(function(t) { return '<span class="badge badge-muted" style="margin-right:4px;">' + esc(t) + '</span>'; }).join('') + '</div></div>';
2394
+
2395
+ html += '<div style="margin-top:16px;display:flex;gap:8px;">';
2396
+ if (s.status === 'active') {
2397
+ html += '<button class="btn btn-danger" onclick="endSession(\'' + esc(s.id) + '\')">End Session</button>';
2398
+ }
2399
+ html += '<button class="btn btn-primary" onclick="summarizeSession(\'' + esc(s.id) + '\')">Summarize</button>';
2400
+ html += '</div></div>';
2401
+ panel.innerHTML = html;
2402
+ }
2403
+
2404
+ async function endSession(id) {
2405
+ await apiPost('session/end', { sessionId: id });
2406
+ state.sessions.loaded = false;
2407
+ loadSessions();
2408
+ }
2409
+
2410
+ async function summarizeSession(id) {
2411
+ var btn = event.target;
2412
+ btn.textContent = 'Summarizing...';
2413
+ btn.disabled = true;
2414
+ await apiPost('summarize', { sessionId: id });
2415
+ btn.textContent = 'Done';
2416
+ setTimeout(function() { btn.textContent = 'Summarize'; btn.disabled = false; }, 2000);
2417
+ }
2418
+
2419
+ async function loadLessons() {
2420
+ var el = document.getElementById('view-lessons');
2421
+ el.innerHTML = '<div class="loading">Loading lessons...</div>';
2422
+ var result = await apiGet('lessons');
2423
+ state.lessons.items = (result && result.lessons) || [];
2424
+ state.lessons.loaded = true;
2425
+ renderLessons();
2426
+ }
2427
+
2428
+ function renderLessons() {
2429
+ var el = document.getElementById('view-lessons');
2430
+ var items = state.lessons.items;
2431
+ var search = state.lessons.search.toLowerCase();
2432
+
2433
+ if (search) {
2434
+ items = items.filter(function(l) {
2435
+ return (l.content + ' ' + l.context + ' ' + (l.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0;
2436
+ });
2437
+ }
2438
+
2439
+ var html = '<div style="display:flex;gap:8px;margin-bottom:12px;">';
2440
+ html += '<input class="search-input" type="text" placeholder="Search lessons..." value="' + esc(state.lessons.search) + '" oninput="state.lessons.search=this.value;renderLessons()" style="flex:1" />';
2441
+ html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' lessons</span>';
2442
+ html += '</div>';
2443
+
2444
+ if (items.length === 0) {
2445
+ html += '<div class="empty-state"><div class="empty-icon">&#128161;</div><p>No lessons yet</p><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Lessons are extracted from crystals or saved manually via memory_lesson_save.</p></div>';
2446
+ } else {
2447
+ html += '<table><thead><tr><th>Lesson</th><th>Confidence</th><th>Reinforcements</th><th>Source</th><th>Project</th><th>Updated</th></tr></thead><tbody>';
2448
+ items.forEach(function(l) {
2449
+ var confPct = Math.round(l.confidence * 100);
2450
+ var confColor = confPct >= 70 ? 'var(--green)' : confPct >= 40 ? 'var(--yellow)' : 'var(--red)';
2451
+ html += '<tr>';
2452
+ html += '<td style="max-width:400px;">' + esc(truncate(l.content, 120)) + (l.context ? '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">' + esc(truncate(l.context, 80)) + '</div>' : '') + '</td>';
2453
+ html += '<td><div class="gauge" style="min-width:80px;"><div class="gauge-bar"><div class="gauge-fill" style="width:' + confPct + '%;background:' + confColor + '"></div></div><span class="gauge-value" style="font-size:11px;">' + confPct + '%</span></div></td>';
2454
+ html += '<td style="text-align:center;">' + (l.reinforcements || 0) + '</td>';
2455
+ html += '<td><span class="badge badge-' + (l.source === 'crystal' ? 'purple' : l.source === 'consolidation' ? 'yellow' : 'blue') + '">' + esc(l.source) + '</span></td>';
2456
+ html += '<td style="font-size:12px;color:var(--ink-muted);">' + esc(l.project || '-') + '</td>';
2457
+ html += '<td style="font-size:12px;color:var(--ink-muted);">' + shortTime(l.updatedAt) + '</td>';
2458
+ html += '</tr>';
2459
+ });
2460
+ html += '</tbody></table>';
2461
+ }
2462
+
2463
+ el.innerHTML = html;
2464
+ }
2465
+
2466
+ async function loadActions() {
2467
+ var el = document.getElementById('view-actions');
2468
+ el.innerHTML = '<div class="loading">Loading actions...</div>';
2469
+ var results = await Promise.all([apiGet('actions'), apiGet('frontier')]);
2470
+ state.actions.items = (results[0] && results[0].actions) || [];
2471
+ state.actions.frontier = (results[1] && results[1].actions) || [];
2472
+ state.actions.loaded = true;
2473
+ renderActions();
2474
+ }
2475
+
2476
+ function renderActions() {
2477
+ var el = document.getElementById('view-actions');
2478
+ var items = state.actions.items;
2479
+ var search = state.actions.search.toLowerCase();
2480
+ var statusFilter = state.actions.statusFilter;
2481
+ var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; }));
2482
+
2483
+ if (search) {
2484
+ items = items.filter(function(a) {
2485
+ return (a.title + ' ' + (a.description || '') + ' ' + (a.tags || []).join(' ')).toLowerCase().indexOf(search) >= 0;
2486
+ });
2487
+ }
2488
+ if (statusFilter) {
2489
+ items = items.filter(function(a) { return a.status === statusFilter; });
2490
+ }
2491
+
2492
+ var html = '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">';
2493
+ html += '<input class="search-input" type="text" placeholder="Search actions..." value="' + esc(state.actions.search) + '" oninput="state.actions.search=this.value;renderActions()" style="flex:1;min-width:200px" />';
2494
+ html += '<select style="padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--ink);" onchange="state.actions.statusFilter=this.value;renderActions()">';
2495
+ html += '<option value="">All statuses</option>';
2496
+ ['pending','active','done','blocked','cancelled'].forEach(function(s) {
2497
+ html += '<option value="' + s + '"' + (statusFilter === s ? ' selected' : '') + '>' + s + '</option>';
2498
+ });
2499
+ html += '</select>';
2500
+ html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' actions</span>';
2501
+ html += '</div>';
2502
+
2503
+ if (items.length === 0) {
2504
+ html += '<div class="empty-state"><div class="empty-icon">&#9745;</div><p>No actions yet</p><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Create actions via memory_action_create MCP tool or POST /agentmemory/actions</p></div>';
2505
+ } else {
2506
+ html += '<table><thead><tr><th>Title</th><th>Status</th><th>Priority</th><th>Tags</th><th>Frontier</th><th>Updated</th></tr></thead><tbody>';
2507
+ items = items.slice().sort(function(a, b) { return (b.priority || 0) - (a.priority || 0); });
2508
+ items.forEach(function(a) {
2509
+ var statusClass = a.status === 'done' ? 'badge-green' : a.status === 'active' ? 'badge-blue' : a.status === 'blocked' ? 'badge-red' : a.status === 'cancelled' ? 'badge-red' : 'badge-yellow';
2510
+ var isFrontier = frontierIds.has(a.id);
2511
+ html += '<tr' + (isFrontier ? ' style="background:rgba(45,106,79,0.08);"' : '') + '>';
2512
+ html += '<td style="max-width:350px;"><strong>' + esc(a.title) + '</strong>';
2513
+ if (a.description) html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">' + esc(truncate(a.description, 80)) + '</div>';
2514
+ html += '</td>';
2515
+ html += '<td><span class="badge ' + statusClass + '">' + esc(a.status) + '</span></td>';
2516
+ html += '<td style="text-align:center;font-weight:600;">' + (a.priority || '-') + '</td>';
2517
+ html += '<td style="font-size:11px;color:var(--ink-muted);">' + (a.tags || []).map(esc).join(', ') + '</td>';
2518
+ html += '<td style="text-align:center;">' + (isFrontier ? '&#9889;' : '') + '</td>';
2519
+ html += '<td style="font-size:12px;color:var(--ink-muted);">' + shortTime(a.updatedAt) + '</td>';
2520
+ html += '</tr>';
2521
+ });
2522
+ html += '</tbody></table>';
2523
+ }
2524
+
2525
+ el.innerHTML = html;
2526
+ }
2527
+
2528
+ async function loadCrystals() {
2529
+ var el = document.getElementById('view-crystals');
2530
+ if (state.dashboard.loaded && state.dashboard.crystals.length) {
2531
+ state.crystals.items = state.dashboard.crystals;
2532
+ state.crystals.loaded = true;
2533
+ renderCrystals();
2534
+ return;
2535
+ }
2536
+ el.innerHTML = '<div class="loading">Loading crystals...</div>';
2537
+ var result = await apiGet('crystals');
2538
+ state.crystals.items = (result && result.crystals) || [];
2539
+ state.crystals.loaded = true;
2540
+ renderCrystals();
2541
+ }
2542
+
2543
+ function renderCrystals() {
2544
+ var el = document.getElementById('view-crystals');
2545
+ var items = state.crystals.items;
2546
+ var search = state.crystals.search.toLowerCase();
2547
+
2548
+ if (search) {
2549
+ items = items.filter(function(c) {
2550
+ return ((c.narrative || '') + ' ' + (c.keyOutcomes || []).join(' ') + ' ' + (c.lessons || []).join(' ')).toLowerCase().indexOf(search) >= 0;
2551
+ });
2552
+ }
2553
+
2554
+ var html = '<div style="display:flex;gap:8px;margin-bottom:12px;">';
2555
+ html += '<input class="search-input" type="text" placeholder="Search crystals..." value="' + esc(state.crystals.search) + '" oninput="state.crystals.search=this.value;renderCrystals()" style="flex:1" />';
2556
+ html += '<span style="font-size:12px;color:var(--ink-faint);align-self:center;">' + items.length + ' crystals</span>';
2557
+ html += '</div>';
2558
+
2559
+ if (items.length === 0) {
2560
+ html += '<div class="empty-state"><div class="empty-icon">&#128142;</div><p>No crystals yet</p><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Crystals are created by compressing completed action chains via memory_crystallize.</p></div>';
2561
+ } else {
2562
+ items.forEach(function(c) {
2563
+ html += '<div class="card" style="margin-bottom:12px;">';
2564
+ html += '<div class="card-title" style="display:flex;justify-content:space-between;">';
2565
+ html += '<span>' + esc(truncate(c.narrative, 100)) + '</span>';
2566
+ html += '<span style="font-size:11px;color:var(--ink-faint);">' + formatTime(c.createdAt) + '</span>';
2567
+ html += '</div>';
2568
+
2569
+ if (c.keyOutcomes && c.keyOutcomes.length > 0) {
2570
+ html += '<div style="margin:8px 0;"><strong style="font-size:11px;color:var(--ink-muted);">KEY OUTCOMES</strong>';
2571
+ c.keyOutcomes.forEach(function(o) {
2572
+ html += '<div style="font-size:12px;padding:2px 0;color:var(--ink);">&#8226; ' + esc(o) + '</div>';
2573
+ });
2574
+ html += '</div>';
2575
+ }
2576
+
2577
+ if (c.lessons && c.lessons.length > 0) {
2578
+ html += '<div style="margin:8px 0;"><strong style="font-size:11px;color:var(--ink-muted);">LESSONS</strong>';
2579
+ c.lessons.forEach(function(l) {
2580
+ html += '<div style="font-size:12px;padding:2px 0;color:var(--ink);">&#128161; ' + esc(l) + '</div>';
2581
+ });
2582
+ html += '</div>';
2583
+ }
2584
+
2585
+ if (c.filesAffected && c.filesAffected.length > 0) {
2586
+ html += '<div style="margin:8px 0;font-size:11px;color:var(--ink-muted);">Files: <span style="font-family:var(--font-mono);">' + c.filesAffected.map(esc).join(', ') + '</span></div>';
2587
+ }
2588
+
2589
+ if (c.sourceActionIds && c.sourceActionIds.length > 0) {
2590
+ html += '<div style="font-size:11px;color:var(--ink-faint);">Source actions: ' + c.sourceActionIds.map(esc).join(', ') + '</div>';
2591
+ }
2592
+
2593
+ if (c.project) {
2594
+ html += '<div style="font-size:11px;color:var(--ink-faint);margin-top:4px;">Project: ' + esc(c.project) + '</div>';
2595
+ }
2596
+
2597
+ html += '</div>';
2598
+ });
2599
+ }
2600
+
2601
+ el.innerHTML = html;
2602
+ }
2603
+
2604
+ async function loadAudit() {
2605
+ var el = document.getElementById('view-audit');
2606
+ el.innerHTML = '<div class="loading">Loading audit log...</div>';
2607
+ var result = await apiGet('audit?limit=100');
2608
+ state.audit.entries = (result && result.entries) || [];
2609
+ state.audit.loaded = true;
2610
+ renderAudit();
2611
+ }
2612
+
2613
+ function renderAudit() {
2614
+ var el = document.getElementById('view-audit');
2615
+ var entries = state.audit.entries;
2616
+ var opFilter = state.audit.opFilter;
2617
+
2618
+ var ops = {};
2619
+ entries.forEach(function(e) { ops[e.operation] = true; });
2620
+ var opList = Object.keys(ops).sort();
2621
+
2622
+ var filtered = opFilter ? entries.filter(function(e) { return e.operation === opFilter; }) : entries;
2623
+
2624
+ var html = '<div class="toolbar">';
2625
+ html += '<select id="audit-op-filter"><option value="">All operations</option>';
2626
+ opList.forEach(function(op) {
2627
+ html += '<option value="' + esc(op) + '"' + (opFilter === op ? ' selected' : '') + '>' + esc(op) + '</option>';
2628
+ });
2629
+ html += '</select></div>';
2630
+
2631
+ html += '<div class="card">';
2632
+ if (filtered.length === 0) {
2633
+ html += '<div class="empty-state"><div class="empty-icon">&#128220;</div><p>No audit entries yet</p><p style="font-size:12px;color:var(--ink-faint);font-style:italic;">Audit entries are created by governance operations (delete, evolve, consolidate).</p></div>';
2634
+ } else {
2635
+ filtered.forEach(function(a, idx) {
2636
+ var badgeClass = OP_BADGES[a.operation] || 'badge-muted';
2637
+ html += '<div class="audit-entry">';
2638
+ html += '<div class="audit-head">';
2639
+ html += '<span class="badge ' + badgeClass + '">' + esc(a.operation) + '</span>';
2640
+ html += '<span style="font-size:12px;color:var(--ink-muted);font-family:var(--font-mono);">' + esc(a.functionId || '') + '</span>';
2641
+ html += '<span style="font-size:10px;color:var(--ink-faint);margin-left:auto;font-family:var(--font-mono);">' + esc(formatTime(a.timestamp)) + '</span>';
2642
+ html += '<button class="btn" style="font-size:9px;padding:1px 6px;margin-left:8px;" onclick="toggleAuditDetail(' + idx + ')">&#9660;</button>';
2643
+ html += '</div>';
2644
+ if (a.targetIds && a.targetIds.length) {
2645
+ html += '<div style="font-size:10px;color:var(--ink-faint);font-family:var(--font-mono);">' + a.targetIds.length + ' target(s): ' + esc(a.targetIds.slice(0, 3).join(', ')) + (a.targetIds.length > 3 ? '...' : '') + '</div>';
2646
+ }
2647
+ html += '<div class="audit-detail" id="audit-detail-' + idx + '"><pre>' + esc(JSON.stringify(a.details || {}, null, 2)) + '</pre></div>';
2648
+ html += '</div>';
2649
+ });
2650
+ }
2651
+ html += '</div>';
2652
+
2653
+ el.innerHTML = html;
2654
+
2655
+ document.getElementById('audit-op-filter').addEventListener('change', function() {
2656
+ state.audit.opFilter = this.value;
2657
+ renderAudit();
2658
+ });
2659
+ }
2660
+
2661
+ function toggleAuditDetail(idx) {
2662
+ var el = document.getElementById('audit-detail-' + idx);
2663
+ if (el) el.classList.toggle('open');
2664
+ }
2665
+
2666
+ async function loadProfile() {
2667
+ var el = document.getElementById('view-profile');
2668
+ el.innerHTML = '<div class="loading">Loading profile...</div>';
2669
+ var sessResult = await apiGet('sessions');
2670
+ var sessions = (sessResult && sessResult.sessions) || [];
2671
+
2672
+ var projects = {};
2673
+ sessions.forEach(function(s) { if (s.project) projects[s.project] = true; });
2674
+ state.profile.projects = Object.keys(projects).sort();
2675
+ state.profile.loaded = true;
2676
+
2677
+ if (state.profile.projects.length > 0 && !state.profile.selectedProject) {
2678
+ state.profile.selectedProject = state.profile.projects[0];
2679
+ }
2680
+
2681
+ renderProfileToolbar();
2682
+ if (state.profile.selectedProject) await loadProfileData();
2683
+ }
2684
+
2685
+ function renderProfileToolbar() {
2686
+ var el = document.getElementById('view-profile');
2687
+ var html = '<div class="toolbar">';
2688
+ html += '<select id="profile-project">';
2689
+ if (state.profile.projects.length === 0) {
2690
+ html += '<option value="">No projects</option>';
2691
+ } else {
2692
+ state.profile.projects.forEach(function(p) {
2693
+ html += '<option value="' + esc(p) + '"' + (state.profile.selectedProject === p ? ' selected' : '') + '>' + esc(p) + '</option>';
2694
+ });
2695
+ }
2696
+ html += '</select></div>';
2697
+ html += '<div id="profile-content"></div>';
2698
+ el.innerHTML = html;
2699
+
2700
+ document.getElementById('profile-project').addEventListener('change', function() {
2701
+ state.profile.selectedProject = this.value;
2702
+ loadProfileData();
2703
+ });
2704
+ }
2705
+
2706
+ async function loadProfileData() {
2707
+ var content = document.getElementById('profile-content');
2708
+ if (!content || !state.profile.selectedProject) return;
2709
+ content.innerHTML = '<div class="loading">Loading profile data...</div>';
2710
+ var result = await apiGet('profile?project=' + encodeURIComponent(state.profile.selectedProject));
2711
+ state.profile.data = (result && result.profile) ? result.profile : result;
2712
+ renderProfile();
2713
+ }
2714
+
2715
+ function renderProfile() {
2716
+ var content = document.getElementById('profile-content');
2717
+ if (!content) return;
2718
+ var p = state.profile.data;
2719
+
2720
+ if (!p) {
2721
+ content.innerHTML = '<div class="empty-state"><div class="empty-icon">&#128203;</div><p>No profile data for this project</p></div>';
2722
+ return;
2723
+ }
2724
+
2725
+ var html = '<div class="two-col">';
2726
+
2727
+ html += '<div class="card"><div class="card-title">Top Concepts</div>';
2728
+ var concepts = p.topConcepts || [];
2729
+ if (concepts.length === 0) {
2730
+ html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No concepts yet</div>';
2731
+ } else {
2732
+ var maxC = Math.max.apply(null, concepts.map(function(c) { return c.frequency; })) || 1;
2733
+ html += '<div class="bar-chart">';
2734
+ concepts.slice(0, 10).forEach(function(c) {
2735
+ var pct = Math.round((c.frequency / maxC) * 100);
2736
+ html += '<div class="bar-row"><span class="bar-label">' + esc(c.concept) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--yellow);"></div></div><span class="bar-value">' + c.frequency + '</span></div>';
2737
+ });
2738
+ html += '</div>';
2739
+ }
2740
+ html += '</div>';
2741
+
2742
+ html += '<div class="card"><div class="card-title">Top Files</div>';
2743
+ var files = p.topFiles || [];
2744
+ if (files.length === 0) {
2745
+ html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No files yet</div>';
2746
+ } else {
2747
+ var maxF = Math.max.apply(null, files.map(function(f) { return f.frequency; })) || 1;
2748
+ html += '<div class="bar-chart">';
2749
+ files.slice(0, 10).forEach(function(f) {
2750
+ var pct = Math.round((f.frequency / maxF) * 100);
2751
+ html += '<div class="bar-row"><span class="bar-label">' + esc(f.file.split('/').pop()) + '</span><div class="bar-track"><div class="bar-fill" style="width:' + pct + '%;background:var(--green);"></div></div><span class="bar-value">' + f.frequency + '</span></div>';
2752
+ });
2753
+ html += '</div>';
2754
+ }
2755
+ html += '</div>';
2756
+
2757
+ html += '</div>';
2758
+
2759
+ html += '<div class="card" style="margin-top:16px;"><div class="card-title">Conventions</div>';
2760
+ var conventions = p.conventions || [];
2761
+ if (conventions.length === 0) {
2762
+ html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No conventions detected yet</div>';
2763
+ } else {
2764
+ html += '<ul style="padding-left:16px;">';
2765
+ conventions.forEach(function(c) { html += '<li style="font-size:13px;color:var(--ink-muted);margin-bottom:4px;">' + esc(c) + '</li>'; });
2766
+ html += '</ul>';
2767
+ }
2768
+ html += '</div>';
2769
+
2770
+ if (p.summary) {
2771
+ html += '<div class="card" style="margin-top:16px;"><div class="card-title">Project Summary</div>';
2772
+ html += '<p style="font-size:13px;color:var(--ink-muted);line-height:1.7;">' + esc(p.summary) + '</p></div>';
2773
+ }
2774
+
2775
+ var stats = '<div class="card" style="margin-top:16px;"><div class="card-title">Project Stats</div>';
2776
+ stats += '<div class="detail-row"><div class="dl">Sessions</div><div class="dv" style="font-family:var(--font-mono);">' + (p.sessionCount || 0) + '</div></div>';
2777
+ stats += '<div class="detail-row"><div class="dl">Total Obs</div><div class="dv" style="font-family:var(--font-mono);">' + (p.totalObservations || 0) + '</div></div>';
2778
+ stats += '<div class="detail-row"><div class="dl">Updated</div><div class="dv" style="font-family:var(--font-mono);font-size:12px;">' + esc(formatTime(p.updatedAt)) + '</div></div>';
2779
+ stats += '</div>';
2780
+
2781
+ content.innerHTML = html + stats;
2782
+ }
2783
+
2784
+ var wsReconnectTimer = null;
2785
+ var wsRetries = 0;
2786
+ var WS_MAX_RETRIES = 10;
2787
+
2788
+ function connectWs() {
2789
+ if (wsRetries >= WS_MAX_RETRIES) return;
2790
+ try {
2791
+ state.ws = new WebSocket(WS_URL);
2792
+ state.ws.onopen = function() {
2793
+ wsRetries = 0;
2794
+ state.ws.send(JSON.stringify({
2795
+ type: 'join',
2796
+ data: {
2797
+ subscriptionId: 'viewer-' + Date.now(),
2798
+ streamName: 'mem-live',
2799
+ groupId: 'viewer'
2800
+ }
2801
+ }));
2802
+ document.getElementById('ws-status').textContent = 'live';
2803
+ document.getElementById('ws-status').className = 'ws-status connected';
2804
+ };
2805
+ state.ws.onmessage = function(e) {
2806
+ try {
2807
+ var msg = JSON.parse(e.data);
2808
+ if (msg.type === 'stream' && msg.event) {
2809
+ handleStreamEvent(msg);
2810
+ }
2811
+ } catch {}
2812
+ };
2813
+ state.ws.onclose = function() {
2814
+ document.getElementById('ws-status').textContent = 'reconnecting...';
2815
+ document.getElementById('ws-status').className = 'ws-status disconnected';
2816
+ wsRetries++;
2817
+ if (wsRetries < WS_MAX_RETRIES) {
2818
+ wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000));
2819
+ } else {
2820
+ document.getElementById('ws-status').textContent = 'disconnected';
2821
+ }
2822
+ };
2823
+ state.ws.onerror = function() { state.ws.close(); };
2824
+ } catch {
2825
+ wsRetries++;
2826
+ if (wsRetries < WS_MAX_RETRIES) {
2827
+ wsReconnectTimer = setTimeout(connectWs, 2000 + Math.min(wsRetries * 1000, 8000));
2828
+ }
2829
+ }
2830
+ }
2831
+
2832
+ function handleStreamEvent(msg) {
2833
+ var evt = msg.event;
2834
+ if ((evt.type === 'create' || evt.type === 'update') && evt.data) {
2835
+ var payload = evt.data;
2836
+ var observation = payload.observation || payload;
2837
+ if (observation) {
2838
+ routeWsMessage({ observation: observation });
2839
+ }
2840
+ } else if (evt.type === 'sync') {
2841
+ var items = Array.isArray(evt.data) ? evt.data : [];
2842
+ items.forEach(function(item) {
2843
+ var payload = item.data || item;
2844
+ var observation = payload.observation || payload;
2845
+ if (observation) {
2846
+ routeWsMessage({ observation: observation });
2847
+ }
2848
+ });
2849
+ }
2850
+ }
2851
+
2852
+ function routeWsMessage(msg) {
2853
+ if (state.activeTab === 'timeline' && msg.observation) {
2854
+ if (!state.timeline.sessionId || msg.observation.sessionId === state.timeline.sessionId) {
2855
+ var existing = state.timeline.observations.findIndex(function(o) { return o.id === msg.observation.id; });
2856
+ if (existing >= 0) {
2857
+ state.timeline.observations[existing] = msg.observation;
2858
+ } else {
2859
+ state.timeline.observations.unshift(msg.observation);
2860
+ }
2861
+ renderObservations();
2862
+ }
2863
+ }
2864
+ if (state.activeTab === 'dashboard') {
2865
+ state.dashboard.loaded = false;
2866
+ loadDashboard();
2867
+ }
2868
+ if (state.activeTab === 'activity' && msg.observation) {
2869
+ state.activity.observations.unshift(msg.observation);
2870
+ renderActivity();
2871
+ }
2872
+ }
2873
+
2874
+ document.getElementById('tab-bar').addEventListener('click', function(e) {
2875
+ if (e.target.tagName === 'BUTTON' && e.target.dataset.tab) {
2876
+ switchTab(e.target.dataset.tab);
2877
+ }
2878
+ });
2879
+ document.getElementById('modal-overlay').addEventListener('click', function(e) {
2880
+ if (e.target === this) closeModal();
2881
+ });
2882
+
2883
+ loadTab('dashboard');
2884
+ connectWs();
2885
+ startDashboardAutoRefresh();
2886
+ </script>
2887
+ </body>
2888
+ </html>