claude_memory 0.9.1 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. checksums.yaml +4 -4
  2. data/.claude/memory.sqlite3 +0 -0
  3. data/.claude/skills/dashboard/SKILL.md +42 -0
  4. data/.claude-plugin/marketplace.json +1 -1
  5. data/.claude-plugin/plugin.json +1 -1
  6. data/CHANGELOG.md +86 -0
  7. data/CLAUDE.md +21 -5
  8. data/README.md +32 -2
  9. data/db/migrations/015_add_activity_events.rb +26 -0
  10. data/db/migrations/016_add_moment_feedback.rb +22 -0
  11. data/db/migrations/017_add_last_recalled_at.rb +15 -0
  12. data/docs/1_0_punchlist.md +190 -0
  13. data/docs/EXAMPLES.md +41 -2
  14. data/docs/GETTING_STARTED.md +31 -4
  15. data/docs/architecture.md +22 -7
  16. data/docs/audit-queries.md +131 -0
  17. data/docs/dashboard.md +172 -0
  18. data/docs/improvements.md +465 -9
  19. data/docs/influence/cq.md +187 -0
  20. data/docs/plugin.md +13 -6
  21. data/docs/quality_review.md +489 -172
  22. data/docs/reflection_memory_as_accumulating_judgment.md +67 -0
  23. data/lib/claude_memory/activity_log.rb +86 -0
  24. data/lib/claude_memory/commands/census_command.rb +210 -0
  25. data/lib/claude_memory/commands/completion_command.rb +3 -0
  26. data/lib/claude_memory/commands/dashboard_command.rb +54 -0
  27. data/lib/claude_memory/commands/dedupe_conflicts_command.rb +55 -0
  28. data/lib/claude_memory/commands/digest_command.rb +181 -0
  29. data/lib/claude_memory/commands/hook_command.rb +34 -0
  30. data/lib/claude_memory/commands/reclassify_references_command.rb +56 -0
  31. data/lib/claude_memory/commands/registry.rb +6 -1
  32. data/lib/claude_memory/commands/skills/distill-transcripts.md +13 -1
  33. data/lib/claude_memory/commands/stats_command.rb +38 -1
  34. data/lib/claude_memory/commands/sweep_command.rb +2 -0
  35. data/lib/claude_memory/configuration.rb +16 -0
  36. data/lib/claude_memory/core/relative_time.rb +9 -0
  37. data/lib/claude_memory/dashboard/api.rb +610 -0
  38. data/lib/claude_memory/dashboard/conflicts.rb +279 -0
  39. data/lib/claude_memory/dashboard/efficacy.rb +127 -0
  40. data/lib/claude_memory/dashboard/fact_presenter.rb +109 -0
  41. data/lib/claude_memory/dashboard/health.rb +175 -0
  42. data/lib/claude_memory/dashboard/index.html +2707 -0
  43. data/lib/claude_memory/dashboard/knowledge.rb +136 -0
  44. data/lib/claude_memory/dashboard/moments.rb +244 -0
  45. data/lib/claude_memory/dashboard/reuse.rb +97 -0
  46. data/lib/claude_memory/dashboard/scoped_fact_resolver.rb +95 -0
  47. data/lib/claude_memory/dashboard/server.rb +211 -0
  48. data/lib/claude_memory/dashboard/timeline.rb +68 -0
  49. data/lib/claude_memory/dashboard/trust.rb +285 -0
  50. data/lib/claude_memory/distill/reference_material_detector.rb +78 -0
  51. data/lib/claude_memory/hook/auto_memory_mirror.rb +112 -0
  52. data/lib/claude_memory/hook/context_injector.rb +97 -3
  53. data/lib/claude_memory/hook/handler.rb +50 -3
  54. data/lib/claude_memory/mcp/handlers/management_handlers.rb +8 -0
  55. data/lib/claude_memory/mcp/query_guide.rb +11 -0
  56. data/lib/claude_memory/mcp/text_summary.rb +29 -0
  57. data/lib/claude_memory/mcp/tool_definitions.rb +13 -0
  58. data/lib/claude_memory/mcp/tools.rb +148 -0
  59. data/lib/claude_memory/publish.rb +13 -21
  60. data/lib/claude_memory/recall/stale_detector.rb +67 -0
  61. data/lib/claude_memory/resolve/predicate_policy.rb +2 -0
  62. data/lib/claude_memory/resolve/resolver.rb +41 -11
  63. data/lib/claude_memory/store/llm_cache.rb +68 -0
  64. data/lib/claude_memory/store/metrics_aggregator.rb +96 -0
  65. data/lib/claude_memory/store/schema_manager.rb +1 -1
  66. data/lib/claude_memory/store/sqlite_store.rb +47 -143
  67. data/lib/claude_memory/store/store_manager.rb +29 -0
  68. data/lib/claude_memory/sweep/maintenance.rb +216 -0
  69. data/lib/claude_memory/sweep/recall_timestamp_refresher.rb +83 -0
  70. data/lib/claude_memory/sweep/sweeper.rb +2 -0
  71. data/lib/claude_memory/version.rb +1 -1
  72. data/lib/claude_memory.rb +22 -0
  73. metadata +49 -1
@@ -0,0 +1,2707 @@
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">
6
+ <title>ClaudeMemory</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --surface: #161b22;
11
+ --surface2: #21262d;
12
+ --surface3: #2d333b;
13
+ --border: #30363d;
14
+ --text: #e6edf3;
15
+ --text-dim: #8b949e;
16
+ --text-faint: #6e7681;
17
+ --accent: #58a6ff;
18
+ --accent-dim: rgba(88,166,255,0.15);
19
+ --green: #3fb950;
20
+ --green-dim: rgba(63,185,80,0.12);
21
+ --yellow: #d29922;
22
+ --yellow-dim: rgba(210,153,34,0.12);
23
+ --red: #f85149;
24
+ --red-dim: rgba(248,81,73,0.12);
25
+ --purple: #bc8cff;
26
+ --purple-dim: rgba(188,140,255,0.12);
27
+ --radius: 8px;
28
+ --mono: ui-monospace, SFMono-Regular, Menlo, monospace;
29
+ }
30
+ * { margin: 0; padding: 0; box-sizing: border-box; }
31
+ body {
32
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
33
+ background: var(--bg);
34
+ color: var(--text);
35
+ line-height: 1.5;
36
+ min-height: 100vh;
37
+ -webkit-font-smoothing: antialiased;
38
+ }
39
+ button, input, select, textarea { font-family: inherit; color: inherit; }
40
+
41
+ /* ---------- Top strip ---------- */
42
+ header {
43
+ background: var(--surface);
44
+ border-bottom: 1px solid var(--border);
45
+ padding: 14px 24px;
46
+ display: flex;
47
+ align-items: center;
48
+ justify-content: space-between;
49
+ gap: 16px;
50
+ flex-wrap: wrap;
51
+ }
52
+ .brand { display: flex; align-items: baseline; gap: 10px; }
53
+ .brand h1 { font-size: 18px; font-weight: 600; letter-spacing: -0.01em; }
54
+ .brand h1 span { color: var(--accent); }
55
+ .brand .tagline { font-size: 12px; color: var(--text-faint); }
56
+
57
+ .top-actions { display: flex; align-items: center; gap: 12px; }
58
+ .health-strip { display: flex; gap: 8px; flex-wrap: wrap; }
59
+ .health-pill {
60
+ display: inline-flex;
61
+ align-items: center;
62
+ gap: 6px;
63
+ padding: 4px 10px;
64
+ border-radius: 999px;
65
+ background: var(--surface2);
66
+ border: 1px solid var(--border);
67
+ font-size: 12px;
68
+ color: var(--text-dim);
69
+ cursor: default;
70
+ }
71
+ .health-pill.actionable { cursor: pointer; }
72
+ .health-pill.actionable:hover { border-color: var(--accent); color: var(--text); }
73
+ .dot { width: 8px; height: 8px; border-radius: 50%; display: inline-block; }
74
+ .dot.healthy { background: var(--green); }
75
+ .dot.warning { background: var(--yellow); }
76
+ .dot.error { background: var(--red); }
77
+
78
+ .icon-btn {
79
+ background: transparent;
80
+ border: 1px solid var(--border);
81
+ color: var(--text-dim);
82
+ padding: 6px 12px;
83
+ border-radius: var(--radius);
84
+ cursor: pointer;
85
+ font-size: 12px;
86
+ transition: all 0.15s;
87
+ }
88
+ .icon-btn:hover { border-color: var(--accent); color: var(--text); }
89
+ .icon-btn.primary { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
90
+
91
+ /* ---------- Layout ---------- */
92
+ .layout {
93
+ display: grid;
94
+ grid-template-columns: 320px 1fr;
95
+ gap: 24px;
96
+ max-width: 1280px;
97
+ margin: 0 auto;
98
+ padding: 24px;
99
+ align-items: start;
100
+ }
101
+ @media (max-width: 880px) {
102
+ .layout { grid-template-columns: 1fr; }
103
+ }
104
+
105
+ /* ---------- Trust sidebar ---------- */
106
+ aside {
107
+ display: flex;
108
+ flex-direction: column;
109
+ gap: 16px;
110
+ position: sticky;
111
+ top: 24px;
112
+ }
113
+ @media (max-width: 880px) {
114
+ aside { position: static; }
115
+ }
116
+ .panel {
117
+ background: var(--surface);
118
+ border: 1px solid var(--border);
119
+ border-radius: var(--radius);
120
+ padding: 16px;
121
+ }
122
+ .panel-label {
123
+ font-size: 11px;
124
+ text-transform: uppercase;
125
+ letter-spacing: 0.08em;
126
+ color: var(--text-faint);
127
+ margin-bottom: 8px;
128
+ display: flex;
129
+ align-items: center;
130
+ gap: 6px;
131
+ }
132
+ .panel-hint {
133
+ font-size: 10px;
134
+ color: var(--text-faint);
135
+ cursor: help;
136
+ border: 1px solid var(--border);
137
+ border-radius: 50%;
138
+ width: 14px;
139
+ height: 14px;
140
+ display: inline-flex;
141
+ align-items: center;
142
+ justify-content: center;
143
+ text-transform: none;
144
+ letter-spacing: 0;
145
+ }
146
+ .moments-headline {
147
+ display: flex;
148
+ align-items: baseline;
149
+ gap: 10px;
150
+ }
151
+ .moments-headline .n {
152
+ font-size: 40px;
153
+ font-weight: 600;
154
+ letter-spacing: -0.02em;
155
+ line-height: 1;
156
+ }
157
+ .moments-headline .delta {
158
+ font-size: 13px;
159
+ font-weight: 500;
160
+ }
161
+ .delta.up { color: var(--green); }
162
+ .delta.down { color: var(--red); }
163
+ .delta.flat { color: var(--text-dim); }
164
+ .moments-sub { font-size: 12px; color: var(--text-dim); margin-top: 6px; }
165
+
166
+ /* Knowledge-base sidebar panel */
167
+ .kb-totals {
168
+ display: flex;
169
+ gap: 6px;
170
+ align-items: baseline;
171
+ margin-bottom: 12px;
172
+ padding-bottom: 10px;
173
+ border-bottom: 1px solid var(--surface2);
174
+ }
175
+ .kb-totals .big {
176
+ font-size: 24px;
177
+ font-weight: 600;
178
+ letter-spacing: -0.02em;
179
+ line-height: 1;
180
+ color: var(--text);
181
+ }
182
+ .kb-totals .split {
183
+ font-size: 11px;
184
+ color: var(--text-faint);
185
+ }
186
+ .kb-rows { display: flex; flex-direction: column; gap: 4px; }
187
+ .kb-row {
188
+ display: flex;
189
+ justify-content: space-between;
190
+ align-items: baseline;
191
+ padding: 6px 8px;
192
+ border-radius: 4px;
193
+ cursor: pointer;
194
+ font-size: 13px;
195
+ color: var(--text);
196
+ transition: background 0.15s;
197
+ }
198
+ .kb-row:hover { background: var(--surface2); }
199
+ .kb-row .kb-label { display: flex; flex-direction: column; }
200
+ .kb-row .kb-desc { font-size: 10px; color: var(--text-faint); margin-top: 1px; }
201
+ .kb-row .kb-count {
202
+ font-variant-numeric: tabular-nums;
203
+ font-weight: 600;
204
+ color: var(--accent);
205
+ font-size: 14px;
206
+ }
207
+ .kb-row.empty { cursor: default; opacity: 0.55; }
208
+ .kb-row.empty:hover { background: transparent; }
209
+ .kb-row.empty .kb-count { color: var(--text-faint); }
210
+
211
+ /* Reuse sidebar panel */
212
+ .reuse-list {
213
+ list-style: none;
214
+ display: flex;
215
+ flex-direction: column;
216
+ gap: 8px;
217
+ }
218
+ .reuse-list li {
219
+ font-size: 12px;
220
+ line-height: 1.4;
221
+ color: var(--text);
222
+ cursor: pointer;
223
+ padding: 6px 8px;
224
+ border-radius: 4px;
225
+ transition: background 0.15s;
226
+ }
227
+ .reuse-list li:hover { background: var(--surface2); }
228
+ .reuse-list .reuse-head {
229
+ display: flex;
230
+ justify-content: space-between;
231
+ align-items: baseline;
232
+ gap: 8px;
233
+ margin-bottom: 2px;
234
+ }
235
+ .reuse-list .reuse-head .pred {
236
+ font-family: var(--mono);
237
+ font-size: 10px;
238
+ padding: 1px 6px;
239
+ background: var(--purple-dim);
240
+ color: var(--purple);
241
+ border-radius: 3px;
242
+ }
243
+ .reuse-list .reuse-count {
244
+ font-variant-numeric: tabular-nums;
245
+ color: var(--green);
246
+ font-size: 11px;
247
+ font-weight: 600;
248
+ white-space: nowrap;
249
+ }
250
+ .reuse-list .reuse-obj {
251
+ color: var(--text);
252
+ overflow: hidden;
253
+ text-overflow: ellipsis;
254
+ display: -webkit-box;
255
+ -webkit-line-clamp: 2;
256
+ -webkit-box-orient: vertical;
257
+ }
258
+ .reuse-empty {
259
+ font-size: 12px;
260
+ color: var(--text-dim);
261
+ line-height: 1.45;
262
+ }
263
+ .utilization {
264
+ margin-top: 12px;
265
+ padding-top: 10px;
266
+ border-top: 1px solid var(--surface2);
267
+ }
268
+ .utilization-stat {
269
+ display: flex;
270
+ align-items: baseline;
271
+ gap: 8px;
272
+ }
273
+ .utilization-n {
274
+ font-size: 20px;
275
+ font-weight: 600;
276
+ letter-spacing: -0.02em;
277
+ line-height: 1;
278
+ }
279
+ .utilization-n.good { color: var(--green); }
280
+ .utilization-n.ok { color: var(--yellow); }
281
+ .utilization-n.low { color: var(--red); }
282
+ .utilization-label {
283
+ font-size: 11px;
284
+ color: var(--text-faint);
285
+ text-transform: uppercase;
286
+ letter-spacing: 0.06em;
287
+ }
288
+ .utilization-breakdown {
289
+ font-size: 12px;
290
+ color: var(--text-dim);
291
+ margin-top: 4px;
292
+ line-height: 1.4;
293
+ }
294
+
295
+ .review-rows { display: flex; flex-direction: column; gap: 6px; }
296
+ .review-row {
297
+ display: flex;
298
+ justify-content: space-between;
299
+ align-items: center;
300
+ padding: 6px 8px;
301
+ border-radius: 6px;
302
+ font-size: 13px;
303
+ color: var(--text-dim);
304
+ }
305
+ .review-row.alert { background: var(--yellow-dim); color: var(--text); cursor: pointer; }
306
+ .review-row.alert:hover { background: rgba(210,153,34,0.2); }
307
+ .review-row .count { font-weight: 600; color: var(--text); }
308
+
309
+ /* ---------- Main feed ---------- */
310
+ main { min-width: 0; }
311
+
312
+ .feed-header {
313
+ display: flex;
314
+ align-items: center;
315
+ justify-content: space-between;
316
+ margin-bottom: 16px;
317
+ gap: 12px;
318
+ flex-wrap: wrap;
319
+ }
320
+ .feed-title {
321
+ font-size: 14px;
322
+ font-weight: 500;
323
+ color: var(--text);
324
+ }
325
+ .feed-title .count { color: var(--text-dim); font-weight: 400; margin-left: 6px; }
326
+
327
+ .feed-filters { display: flex; gap: 6px; flex-wrap: wrap; }
328
+ .chip {
329
+ padding: 4px 10px;
330
+ border-radius: 999px;
331
+ border: 1px solid var(--border);
332
+ background: transparent;
333
+ color: var(--text-dim);
334
+ cursor: pointer;
335
+ font-size: 12px;
336
+ transition: all 0.15s;
337
+ }
338
+ .chip:hover { color: var(--text); border-color: var(--text-dim); }
339
+ .chip.active { background: var(--accent-dim); border-color: var(--accent); color: var(--accent); }
340
+
341
+ .feed { display: flex; flex-direction: column; gap: 10px; }
342
+ .feed-empty {
343
+ background: var(--surface);
344
+ border: 1px dashed var(--border);
345
+ border-radius: var(--radius);
346
+ padding: 40px 24px;
347
+ text-align: center;
348
+ color: var(--text-dim);
349
+ font-size: 14px;
350
+ }
351
+ .feed-empty h3 {
352
+ color: var(--text);
353
+ font-size: 16px;
354
+ font-weight: 500;
355
+ margin-bottom: 8px;
356
+ }
357
+ .feed-empty code {
358
+ background: var(--bg);
359
+ padding: 2px 8px;
360
+ border-radius: 4px;
361
+ font-family: var(--mono);
362
+ font-size: 13px;
363
+ color: var(--accent);
364
+ }
365
+
366
+ .moment {
367
+ background: var(--surface);
368
+ border: 1px solid var(--border);
369
+ border-radius: var(--radius);
370
+ padding: 14px 16px;
371
+ display: flex;
372
+ gap: 14px;
373
+ animation: slideIn 0.25s ease;
374
+ }
375
+ @keyframes slideIn {
376
+ from { opacity: 0; transform: translateY(-6px); }
377
+ to { opacity: 1; transform: translateY(0); }
378
+ }
379
+ .moment:hover { border-color: var(--surface3); }
380
+
381
+ .moment-icon {
382
+ flex-shrink: 0;
383
+ width: 32px;
384
+ height: 32px;
385
+ border-radius: 50%;
386
+ display: flex;
387
+ align-items: center;
388
+ justify-content: center;
389
+ font-size: 14px;
390
+ font-weight: 600;
391
+ }
392
+ .moment-icon.context { background: var(--purple-dim); color: var(--purple); }
393
+ .moment-icon.recall_hit { background: var(--accent-dim); color: var(--accent); }
394
+ .moment-icon.recall_empty { background: var(--yellow-dim); color: var(--yellow); }
395
+ .moment-icon.extraction { background: var(--green-dim); color: var(--green); }
396
+ .moment-icon.ingest { background: var(--surface3); color: var(--text-dim); }
397
+ .moment-icon.sweep { background: var(--surface3); color: var(--text-faint); }
398
+ .moment-icon.conflict { background: var(--red-dim); color: var(--red); }
399
+
400
+ .moment-body { flex: 1; min-width: 0; }
401
+ .moment-headline {
402
+ font-size: 14px;
403
+ line-height: 1.45;
404
+ color: var(--text);
405
+ }
406
+ .moment-headline strong { color: var(--text); font-weight: 600; }
407
+ .moment-meta {
408
+ font-size: 11px;
409
+ color: var(--text-faint);
410
+ margin-top: 2px;
411
+ display: flex;
412
+ gap: 8px;
413
+ align-items: center;
414
+ }
415
+ .moment-meta .dotsep::before { content: "·"; margin: 0 4px; color: var(--text-faint); }
416
+
417
+ .moment-feedback {
418
+ margin-top: 8px;
419
+ display: flex;
420
+ gap: 4px;
421
+ align-items: center;
422
+ }
423
+ .moment-feedback button {
424
+ background: none;
425
+ border: 1px solid transparent;
426
+ color: var(--text-faint);
427
+ padding: 2px 6px;
428
+ font-size: 13px;
429
+ line-height: 1;
430
+ border-radius: 4px;
431
+ cursor: pointer;
432
+ transition: color 0.15s, border-color 0.15s;
433
+ }
434
+ .moment-feedback button:hover { color: var(--text); border-color: var(--surface3); }
435
+ .moment-feedback button.active-up { color: var(--green); border-color: var(--green-dim); background: var(--green-dim); }
436
+ .moment-feedback button.active-down { color: var(--red); border-color: var(--red-dim); background: var(--red-dim); }
437
+
438
+ .moment-detail {
439
+ margin-top: 10px;
440
+ padding: 10px 12px;
441
+ background: var(--bg);
442
+ border-radius: 6px;
443
+ font-size: 12px;
444
+ color: var(--text-dim);
445
+ }
446
+ .moment-facts {
447
+ list-style: none;
448
+ display: flex;
449
+ flex-direction: column;
450
+ gap: 4px;
451
+ margin-top: 8px;
452
+ }
453
+ .moment-facts li {
454
+ font-size: 12px;
455
+ display: flex;
456
+ align-items: baseline;
457
+ gap: 6px;
458
+ }
459
+ .moment-facts .pred {
460
+ font-family: var(--mono);
461
+ font-size: 10px;
462
+ padding: 1px 6px;
463
+ background: var(--purple-dim);
464
+ color: var(--purple);
465
+ border-radius: 3px;
466
+ flex-shrink: 0;
467
+ }
468
+ .moment-facts .obj {
469
+ color: var(--text);
470
+ overflow: hidden;
471
+ text-overflow: ellipsis;
472
+ white-space: nowrap;
473
+ }
474
+ .moment-expand {
475
+ background: none;
476
+ border: none;
477
+ color: var(--text-dim);
478
+ font-size: 11px;
479
+ cursor: pointer;
480
+ padding: 0;
481
+ margin-top: 6px;
482
+ }
483
+ .moment-expand:hover { color: var(--accent); }
484
+
485
+ .moment-preview {
486
+ margin-top: 8px;
487
+ padding: 8px 10px;
488
+ background: var(--bg);
489
+ border-left: 2px solid var(--surface3);
490
+ border-radius: 0 4px 4px 0;
491
+ font-family: var(--mono);
492
+ font-size: 11px;
493
+ color: var(--text-dim);
494
+ max-height: 120px;
495
+ overflow: auto;
496
+ white-space: pre-wrap;
497
+ word-break: break-word;
498
+ }
499
+
500
+ .moment-query {
501
+ font-family: var(--mono);
502
+ font-size: 12px;
503
+ color: var(--text);
504
+ background: var(--bg);
505
+ padding: 2px 8px;
506
+ border-radius: 3px;
507
+ }
508
+
509
+ /* ---------- Modals & Advanced drawer ---------- */
510
+ .modal-backdrop {
511
+ position: fixed; inset: 0;
512
+ background: rgba(0,0,0,0.7);
513
+ display: none;
514
+ align-items: flex-start;
515
+ justify-content: center;
516
+ padding: 48px 16px;
517
+ z-index: 100;
518
+ overflow-y: auto;
519
+ }
520
+ .modal-backdrop.open { display: flex; }
521
+ .modal {
522
+ background: var(--surface);
523
+ border: 1px solid var(--border);
524
+ border-radius: var(--radius);
525
+ width: 100%;
526
+ max-width: 720px;
527
+ padding: 20px 24px 24px;
528
+ box-shadow: 0 12px 48px rgba(0,0,0,0.6);
529
+ }
530
+ .modal-wide { max-width: 960px; }
531
+ .modal-header {
532
+ display: flex;
533
+ align-items: center;
534
+ justify-content: space-between;
535
+ margin-bottom: 16px;
536
+ }
537
+ .modal-header h2 { font-size: 16px; font-weight: 600; }
538
+ .modal-close {
539
+ background: transparent;
540
+ border: none;
541
+ color: var(--text-dim);
542
+ font-size: 20px;
543
+ line-height: 1;
544
+ cursor: pointer;
545
+ padding: 4px 8px;
546
+ }
547
+ .modal-close:hover { color: var(--text); }
548
+ .modal-section { margin-top: 16px; }
549
+ .modal-section:first-of-type { margin-top: 0; }
550
+ .modal-section h3 {
551
+ font-size: 11px;
552
+ text-transform: uppercase;
553
+ letter-spacing: 0.08em;
554
+ color: var(--text-faint);
555
+ margin-bottom: 8px;
556
+ }
557
+ .modal-issue { border-top: 1px solid var(--surface2); padding: 16px 0; }
558
+ .modal-issue:first-of-type { border-top: none; padding-top: 0; }
559
+ .modal-issue-title {
560
+ display: flex; align-items: center; gap: 8px;
561
+ font-weight: 500; margin-bottom: 4px;
562
+ }
563
+ .modal-issue-msg { color: var(--text-dim); font-size: 13px; margin-bottom: 8px; }
564
+ .modal-issue-fix {
565
+ background: var(--surface2);
566
+ border-left: 3px solid var(--accent);
567
+ padding: 10px 12px;
568
+ border-radius: 0 4px 4px 0;
569
+ font-size: 13px;
570
+ line-height: 1.6;
571
+ }
572
+ .modal-issue-fix code {
573
+ background: var(--bg);
574
+ padding: 1px 6px;
575
+ border-radius: 3px;
576
+ font-size: 12px;
577
+ font-family: var(--mono);
578
+ }
579
+
580
+ .modal-kv {
581
+ display: grid;
582
+ grid-template-columns: 140px 1fr;
583
+ gap: 4px 12px;
584
+ font-size: 13px;
585
+ }
586
+ .modal-kv dt { color: var(--text-dim); }
587
+ .modal-kv dd { word-break: break-word; font-family: var(--mono); font-size: 12px; }
588
+
589
+ pre.modal-json, pre.modal-text {
590
+ background: var(--bg);
591
+ border: 1px solid var(--border);
592
+ border-radius: 4px;
593
+ padding: 10px 12px;
594
+ margin-top: 4px;
595
+ max-height: 320px;
596
+ overflow: auto;
597
+ font-size: 12px;
598
+ font-family: var(--mono);
599
+ white-space: pre-wrap;
600
+ word-break: break-word;
601
+ }
602
+
603
+ /* Conflict pair — inline in moment detail & in advanced drawer */
604
+ .conflict-pair {
605
+ display: grid;
606
+ grid-template-columns: 1fr 1fr;
607
+ gap: 12px;
608
+ margin-top: 8px;
609
+ }
610
+ .conflict-side {
611
+ background: var(--surface2);
612
+ border: 1px solid var(--border);
613
+ border-radius: 6px;
614
+ padding: 12px;
615
+ }
616
+ .conflict-side h4 {
617
+ font-size: 10px;
618
+ text-transform: uppercase;
619
+ letter-spacing: 0.08em;
620
+ color: var(--text-faint);
621
+ margin-bottom: 6px;
622
+ }
623
+ .conflict-fact-object {
624
+ background: var(--bg);
625
+ border-radius: 4px;
626
+ padding: 8px 10px;
627
+ margin: 6px 0 10px;
628
+ font-family: var(--mono);
629
+ font-size: 12px;
630
+ white-space: pre-wrap;
631
+ word-break: break-word;
632
+ max-height: 160px;
633
+ overflow: auto;
634
+ }
635
+ .conflict-prov-list {
636
+ list-style: none;
637
+ font-size: 12px;
638
+ color: var(--text-dim);
639
+ }
640
+ .conflict-prov-list li {
641
+ padding: 4px 0;
642
+ border-top: 1px dashed var(--border);
643
+ }
644
+ .conflict-prov-list li:first-child { border-top: none; }
645
+ .conflict-prov-list .quote {
646
+ color: var(--text);
647
+ font-family: var(--mono);
648
+ font-size: 11px;
649
+ display: block;
650
+ padding-top: 2px;
651
+ }
652
+ .conflict-actions {
653
+ margin-top: 10px;
654
+ display: flex;
655
+ gap: 6px;
656
+ align-items: flex-start;
657
+ }
658
+ .btn-reject {
659
+ background: transparent;
660
+ border: 1px solid var(--red);
661
+ color: var(--red);
662
+ padding: 5px 10px;
663
+ border-radius: 4px;
664
+ cursor: pointer;
665
+ font-size: 11px;
666
+ font-weight: 500;
667
+ }
668
+ .btn-reject:hover { background: var(--red-dim); }
669
+ .btn-reject:disabled { opacity: 0.5; cursor: not-allowed; }
670
+ .btn-promote {
671
+ border-color: var(--accent);
672
+ color: var(--accent);
673
+ }
674
+ .btn-promote:hover { background: var(--accent-dim); }
675
+ .conflict-reason {
676
+ flex: 1;
677
+ background: var(--bg);
678
+ border: 1px solid var(--border);
679
+ color: var(--text);
680
+ font-size: 11px;
681
+ padding: 4px 6px;
682
+ border-radius: 4px;
683
+ min-height: 24px;
684
+ resize: vertical;
685
+ }
686
+
687
+ .toast {
688
+ position: fixed;
689
+ bottom: 24px;
690
+ right: 24px;
691
+ background: var(--surface);
692
+ border: 1px solid var(--accent);
693
+ padding: 10px 16px;
694
+ border-radius: var(--radius);
695
+ font-size: 13px;
696
+ color: var(--text);
697
+ opacity: 0;
698
+ transform: translateY(16px);
699
+ transition: opacity 0.2s, transform 0.2s;
700
+ pointer-events: none;
701
+ z-index: 200;
702
+ max-width: 420px;
703
+ }
704
+ .toast.visible { opacity: 1; transform: translateY(0); }
705
+ .toast.error { border-color: var(--red); }
706
+
707
+ /* ---------- Advanced drawer ---------- */
708
+ .drawer {
709
+ position: fixed;
710
+ top: 0; right: 0; bottom: 0;
711
+ width: min(960px, 100%);
712
+ background: var(--bg);
713
+ border-left: 1px solid var(--border);
714
+ z-index: 90;
715
+ transform: translateX(100%);
716
+ transition: transform 0.25s ease;
717
+ overflow-y: auto;
718
+ box-shadow: -8px 0 32px rgba(0,0,0,0.4);
719
+ }
720
+ .drawer.open { transform: translateX(0); }
721
+ .drawer-scrim {
722
+ position: fixed; inset: 0;
723
+ background: rgba(0,0,0,0.4);
724
+ z-index: 89;
725
+ display: none;
726
+ }
727
+ .drawer-scrim.open { display: block; }
728
+ .drawer-header {
729
+ position: sticky;
730
+ top: 0;
731
+ background: var(--bg);
732
+ border-bottom: 1px solid var(--border);
733
+ padding: 14px 20px;
734
+ display: flex;
735
+ align-items: center;
736
+ justify-content: space-between;
737
+ z-index: 1;
738
+ }
739
+ .drawer-header h2 { font-size: 15px; font-weight: 600; }
740
+ .drawer-tabs {
741
+ display: flex;
742
+ gap: 2px;
743
+ border-bottom: 1px solid var(--border);
744
+ padding: 0 20px;
745
+ background: var(--surface);
746
+ }
747
+ .drawer-tab {
748
+ padding: 10px 14px;
749
+ font-size: 13px;
750
+ color: var(--text-dim);
751
+ cursor: pointer;
752
+ border-bottom: 2px solid transparent;
753
+ }
754
+ .drawer-tab:hover { color: var(--text); }
755
+ .drawer-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
756
+ .drawer-panel { display: none; padding: 20px; }
757
+ .drawer-panel.active { display: block; }
758
+
759
+ /* Generic tables inside drawer */
760
+ table { width: 100%; border-collapse: collapse; font-size: 12px; }
761
+ th {
762
+ text-align: left;
763
+ padding: 8px 10px;
764
+ border-bottom: 1px solid var(--border);
765
+ color: var(--text-faint);
766
+ font-weight: 500;
767
+ font-size: 11px;
768
+ text-transform: uppercase;
769
+ letter-spacing: 0.06em;
770
+ }
771
+ td {
772
+ padding: 8px 10px;
773
+ border-bottom: 1px solid var(--surface2);
774
+ vertical-align: top;
775
+ }
776
+ tr.row-clickable { cursor: pointer; }
777
+ tr.row-clickable:hover td { background: var(--surface2); }
778
+
779
+ .badge {
780
+ display: inline-block;
781
+ padding: 2px 8px;
782
+ border-radius: 10px;
783
+ font-size: 10px;
784
+ font-weight: 500;
785
+ font-family: var(--mono);
786
+ }
787
+ .badge-success { background: var(--green-dim); color: var(--green); }
788
+ .badge-warning { background: var(--yellow-dim); color: var(--yellow); }
789
+ .badge-error { background: var(--red-dim); color: var(--red); }
790
+ .badge-info { background: var(--accent-dim); color: var(--accent); }
791
+ .badge-purple { background: var(--purple-dim); color: var(--purple); }
792
+
793
+ .bar-chart { margin-top: 8px; }
794
+ .bar-row { display: flex; align-items: center; margin-bottom: 4px; font-size: 12px; }
795
+ .bar-label { width: 140px; color: var(--text-dim); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
796
+ .bar-track { flex: 1; height: 14px; background: var(--surface2); border-radius: 3px; margin: 0 6px; overflow: hidden; }
797
+ .bar-fill { height: 100%; background: var(--accent); border-radius: 3px; min-width: 2px; transition: width 0.4s ease; }
798
+ .bar-value { width: 36px; text-align: right; color: var(--text-dim); font-size: 11px; }
799
+
800
+ .search-box {
801
+ background: var(--surface2);
802
+ border: 1px solid var(--border);
803
+ border-radius: var(--radius);
804
+ padding: 8px 12px;
805
+ color: var(--text);
806
+ font-size: 13px;
807
+ outline: none;
808
+ }
809
+ .search-box:focus { border-color: var(--accent); }
810
+
811
+ .adv-card {
812
+ background: var(--surface);
813
+ border: 1px solid var(--border);
814
+ border-radius: var(--radius);
815
+ padding: 14px 16px;
816
+ margin-bottom: 16px;
817
+ }
818
+ .adv-card h3 {
819
+ font-size: 11px;
820
+ text-transform: uppercase;
821
+ letter-spacing: 0.08em;
822
+ color: var(--text-faint);
823
+ margin-bottom: 10px;
824
+ }
825
+ .adv-grid {
826
+ display: grid;
827
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
828
+ gap: 10px;
829
+ }
830
+ .adv-metric {
831
+ background: var(--surface2);
832
+ border: 1px solid var(--border);
833
+ border-radius: 6px;
834
+ padding: 10px 12px;
835
+ }
836
+ .adv-metric .label {
837
+ font-size: 10px;
838
+ text-transform: uppercase;
839
+ letter-spacing: 0.06em;
840
+ color: var(--text-faint);
841
+ }
842
+ .adv-metric .value {
843
+ font-size: 22px;
844
+ font-weight: 600;
845
+ margin-top: 2px;
846
+ }
847
+
848
+ .filter-row {
849
+ display: flex;
850
+ gap: 6px;
851
+ align-items: center;
852
+ margin-bottom: 12px;
853
+ flex-wrap: wrap;
854
+ }
855
+ .filter-row .search-box { flex: 1; min-width: 220px; }
856
+
857
+ .json-key { color: var(--accent); }
858
+ .json-string { color: var(--green); }
859
+ .json-number { color: var(--yellow); }
860
+ .json-bool { color: var(--purple); }
861
+ .json-null { color: var(--text-faint); font-style: italic; }
862
+ .json-error { color: var(--red); font-size: 11px; display: block; margin-bottom: 6px; }
863
+
864
+ /* ---------- Rich moment modals ---------- */
865
+ .recall-header {
866
+ display: flex;
867
+ gap: 24px;
868
+ padding: 12px 16px;
869
+ background: var(--surface2);
870
+ border-radius: 6px;
871
+ margin-bottom: 14px;
872
+ }
873
+ .recall-stat { display: flex; flex-direction: column; gap: 2px; }
874
+ .recall-stat-n { font-size: 20px; font-weight: 600; color: var(--text); letter-spacing: -0.01em; }
875
+ .recall-stat-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); }
876
+ .recall-query {
877
+ margin-bottom: 14px;
878
+ }
879
+ .recall-query .label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); margin-bottom: 4px; }
880
+ .recall-query .query-text {
881
+ font-family: var(--mono);
882
+ font-size: 13px;
883
+ background: var(--bg);
884
+ padding: 8px 12px;
885
+ border-radius: 4px;
886
+ border: 1px solid var(--border);
887
+ word-break: break-word;
888
+ }
889
+ .trigger-card {
890
+ background: var(--surface2);
891
+ border-left: 3px solid var(--accent);
892
+ padding: 10px 12px;
893
+ border-radius: 0 6px 6px 0;
894
+ }
895
+ .trigger-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em; color: var(--text-faint); margin-bottom: 4px; }
896
+ .trigger-prompt { font-size: 13px; line-height: 1.5; color: var(--text); }
897
+ .trigger-session { font-size: 11px; color: var(--text-faint); margin-top: 6px; font-family: var(--mono); }
898
+
899
+ .facts-table { width: 100%; }
900
+ .facts-table .object-cell { max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--text); }
901
+ .facts-table tr.row-clickable td { padding: 10px; }
902
+
903
+ .source-meta {
904
+ display: flex;
905
+ gap: 12px;
906
+ font-size: 11px;
907
+ color: var(--text-dim);
908
+ margin-bottom: 8px;
909
+ flex-wrap: wrap;
910
+ }
911
+ .source-meta code { background: var(--bg); padding: 1px 5px; border-radius: 3px; font-family: var(--mono); }
912
+ .source-body {
913
+ background: var(--bg);
914
+ border: 1px solid var(--border);
915
+ border-radius: 4px;
916
+ padding: 12px 14px;
917
+ max-height: 380px;
918
+ overflow: auto;
919
+ }
920
+ .prose {
921
+ font-size: 13px;
922
+ line-height: 1.55;
923
+ color: var(--text);
924
+ white-space: pre-wrap;
925
+ word-break: break-word;
926
+ }
927
+ .context-preview {
928
+ font-family: var(--mono);
929
+ font-size: 12px;
930
+ }
931
+ .turn {
932
+ margin-bottom: 10px;
933
+ padding: 8px 10px;
934
+ border-left: 2px solid var(--border);
935
+ background: var(--surface);
936
+ border-radius: 0 4px 4px 0;
937
+ }
938
+ .turn:last-child { margin-bottom: 0; }
939
+ .turn-user { border-left-color: var(--accent); }
940
+ .turn-assistant { border-left-color: var(--purple); }
941
+ .turn-role {
942
+ font-size: 10px;
943
+ text-transform: uppercase;
944
+ letter-spacing: 0.08em;
945
+ color: var(--text-faint);
946
+ margin-bottom: 4px;
947
+ }
948
+ .turn-user .turn-role { color: var(--accent); }
949
+ .turn-assistant .turn-role { color: var(--purple); }
950
+ .turn-text {
951
+ font-size: 12px;
952
+ line-height: 1.5;
953
+ color: var(--text);
954
+ white-space: pre-wrap;
955
+ word-break: break-word;
956
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
957
+ }
958
+
959
+ .modal-collapsible {
960
+ margin-top: 16px;
961
+ font-size: 12px;
962
+ color: var(--text-dim);
963
+ }
964
+ .modal-collapsible summary {
965
+ cursor: pointer;
966
+ padding: 6px 0;
967
+ user-select: none;
968
+ }
969
+ .modal-collapsible summary:hover { color: var(--text); }
970
+ .modal-collapsible[open] summary { color: var(--text); }
971
+
972
+ /* ---------- Knowledge drawer tab ---------- */
973
+ .knowledge-body { display: flex; flex-direction: column; gap: 24px; }
974
+ .knowledge-section { }
975
+ .knowledge-section-header {
976
+ display: flex;
977
+ align-items: baseline;
978
+ gap: 12px;
979
+ margin-bottom: 12px;
980
+ padding-bottom: 8px;
981
+ border-bottom: 1px solid var(--border);
982
+ }
983
+ .knowledge-section-header h3 {
984
+ font-size: 16px;
985
+ font-weight: 600;
986
+ color: var(--text);
987
+ letter-spacing: -0.01em;
988
+ }
989
+ .knowledge-section-header .count {
990
+ font-size: 13px;
991
+ color: var(--text-dim);
992
+ font-variant-numeric: tabular-nums;
993
+ }
994
+ .knowledge-section-header .desc {
995
+ font-size: 12px;
996
+ color: var(--text-faint);
997
+ margin-left: auto;
998
+ }
999
+ .knowledge-empty {
1000
+ font-size: 12px;
1001
+ color: var(--text-dim);
1002
+ padding: 20px;
1003
+ text-align: center;
1004
+ border: 1px dashed var(--border);
1005
+ border-radius: var(--radius);
1006
+ }
1007
+ .knowledge-cards { display: flex; flex-direction: column; gap: 8px; }
1008
+ .knowledge-card {
1009
+ background: var(--surface);
1010
+ border: 1px solid var(--border);
1011
+ border-radius: var(--radius);
1012
+ padding: 12px 14px;
1013
+ cursor: pointer;
1014
+ transition: border-color 0.15s;
1015
+ }
1016
+ .knowledge-card:hover { border-color: var(--accent); }
1017
+ .knowledge-card .kc-object {
1018
+ font-size: 13px;
1019
+ color: var(--text);
1020
+ line-height: 1.5;
1021
+ }
1022
+ .knowledge-card .kc-meta {
1023
+ display: flex;
1024
+ gap: 10px;
1025
+ align-items: center;
1026
+ margin-top: 6px;
1027
+ font-size: 11px;
1028
+ color: var(--text-faint);
1029
+ flex-wrap: wrap;
1030
+ }
1031
+ .knowledge-card .kc-meta .pred {
1032
+ font-family: var(--mono);
1033
+ font-size: 10px;
1034
+ padding: 1px 6px;
1035
+ background: var(--purple-dim);
1036
+ color: var(--purple);
1037
+ border-radius: 3px;
1038
+ }
1039
+ .knowledge-card .kc-meta .src {
1040
+ font-family: var(--mono);
1041
+ font-size: 10px;
1042
+ padding: 1px 6px;
1043
+ border-radius: 3px;
1044
+ }
1045
+ .knowledge-card .kc-meta .src.project { background: var(--accent-dim); color: var(--accent); }
1046
+ .knowledge-card .kc-meta .src.global { background: var(--purple-dim); color: var(--purple); }
1047
+ .knowledge-card.highlighted {
1048
+ border-color: var(--accent);
1049
+ box-shadow: 0 0 0 3px var(--accent-dim);
1050
+ }
1051
+
1052
+ /* ---------- Recall error card ---------- */
1053
+ .recall-error-card {
1054
+ background: var(--red-dim);
1055
+ border: 1px solid var(--red);
1056
+ border-radius: 6px;
1057
+ padding: 14px 16px;
1058
+ margin-top: 12px;
1059
+ }
1060
+ .recall-error-card h4 {
1061
+ color: var(--red);
1062
+ font-size: 13px;
1063
+ margin-bottom: 6px;
1064
+ font-weight: 600;
1065
+ }
1066
+ .recall-error-card p {
1067
+ color: var(--text);
1068
+ font-size: 12px;
1069
+ line-height: 1.5;
1070
+ margin-bottom: 10px;
1071
+ }
1072
+ .recall-error-card .cmd-row {
1073
+ display: flex;
1074
+ gap: 8px;
1075
+ align-items: center;
1076
+ background: var(--bg);
1077
+ border-radius: 4px;
1078
+ padding: 8px 12px;
1079
+ font-family: var(--mono);
1080
+ font-size: 12px;
1081
+ }
1082
+ .recall-error-card .cmd { flex: 1; color: var(--text); word-break: break-all; }
1083
+ .recall-error-card .copy-btn {
1084
+ background: var(--surface);
1085
+ border: 1px solid var(--border);
1086
+ color: var(--text-dim);
1087
+ padding: 4px 10px;
1088
+ border-radius: 3px;
1089
+ cursor: pointer;
1090
+ font-size: 11px;
1091
+ }
1092
+ .recall-error-card .copy-btn:hover { border-color: var(--accent); color: var(--accent); }
1093
+ .recall-error-card .retry-hint {
1094
+ font-size: 11px;
1095
+ color: var(--text-faint);
1096
+ margin-top: 8px;
1097
+ }
1098
+ </style>
1099
+ </head>
1100
+ <body>
1101
+
1102
+ <header>
1103
+ <div class="brand">
1104
+ <h1><span>Claude</span>Memory</h1>
1105
+ <span class="tagline" id="version-tag"></span>
1106
+ </div>
1107
+ <div class="top-actions">
1108
+ <div class="health-strip" id="health-strip"></div>
1109
+ <button class="icon-btn" onclick="loadAll()" title="Refresh">↻</button>
1110
+ <button class="icon-btn" onclick="openDrawer('overview')">Advanced ▸</button>
1111
+ </div>
1112
+ </header>
1113
+
1114
+ <div class="layout">
1115
+ <aside>
1116
+ <div class="panel" id="panel-knowledge">
1117
+ <div class="panel-label">Your knowledge base
1118
+ <span class="panel-hint" title="Facts Claude has learned from your sessions, grouped by category. Click to browse each.">ⓘ</span>
1119
+ </div>
1120
+ <div class="kb-totals" id="kb-totals"></div>
1121
+ <div class="kb-rows" id="kb-rows"></div>
1122
+ </div>
1123
+
1124
+ <div class="panel" id="panel-reuse">
1125
+ <div class="panel-label">Most used this week
1126
+ <span class="panel-hint" title="Facts Claude actually recalled or injected into sessions in the last 7 days. This is the ROI — memories you taught that are actively shaping responses.">ⓘ</span>
1127
+ </div>
1128
+ <ul class="reuse-list" id="reuse-list"></ul>
1129
+ <div class="reuse-empty" id="reuse-empty" style="display:none;">
1130
+ No memory recalls in the last 7 days. When Claude calls memory.recall / memory.conventions / memory.decisions, the top facts returned will appear here.
1131
+ </div>
1132
+ <div class="utilization" id="utilization" style="display:none;">
1133
+ <div class="utilization-stat">
1134
+ <span class="utilization-n" id="utilization-pct">—</span>
1135
+ <span class="utilization-label">utilization (30d)</span>
1136
+ </div>
1137
+ <div class="utilization-breakdown" id="utilization-breakdown"></div>
1138
+ </div>
1139
+ </div>
1140
+
1141
+ <div class="panel" id="panel-moments">
1142
+ <div class="panel-label">Activity this week</div>
1143
+ <div class="moments-headline">
1144
+ <span class="n" id="moments-count">—</span>
1145
+ <span class="delta flat" id="moments-delta"></span>
1146
+ </div>
1147
+ <div class="moments-sub" id="moments-sub"></div>
1148
+ </div>
1149
+
1150
+ <div class="panel" id="panel-review">
1151
+ <div class="panel-label">Needs review</div>
1152
+ <div class="review-rows" id="review-rows"></div>
1153
+ </div>
1154
+ </aside>
1155
+
1156
+ <main>
1157
+ <div class="feed-header">
1158
+ <div class="feed-title">Recent moments <span class="count" id="feed-count"></span></div>
1159
+ <div class="feed-filters" id="feed-filters">
1160
+ <button class="chip active" data-kinds="context_injection,recall_hit,recall_empty,extraction">Value moments</button>
1161
+ <button class="chip" data-kinds="context_injection">Context</button>
1162
+ <button class="chip" data-kinds="recall_hit,recall_empty">Recalls</button>
1163
+ <button class="chip" data-kinds="extraction">Extractions</button>
1164
+ <button class="chip" data-kinds="ingest,ingest_skipped,sweep">Plumbing</button>
1165
+ <button class="chip" data-kinds="">All</button>
1166
+ </div>
1167
+ </div>
1168
+ <div class="feed" id="feed"></div>
1169
+ <div class="feed-empty" id="feed-empty" style="display:none;">
1170
+ <h3>No value moments yet in this filter</h3>
1171
+ <p>A "value moment" is when memory shapes a Claude session — an injection at session start, a recall during a conversation, or an extraction that captures a new fact.</p>
1172
+ <p style="margin-top: 10px;">Check <button class="icon-btn" style="display: inline-block;" onclick="switchFeedToAll()">All</button> to see plumbing events, or open Claude Code and start a conversation to produce new moments.</p>
1173
+ </div>
1174
+ </main>
1175
+ </div>
1176
+
1177
+ <!-- ---------- Modals ---------- -->
1178
+ <div class="modal-backdrop" id="health-modal" role="dialog" aria-modal="true">
1179
+ <div class="modal" onclick="event.stopPropagation()">
1180
+ <div class="modal-header">
1181
+ <h2>How to fix</h2>
1182
+ <button class="modal-close" aria-label="Close" onclick="closeModal('health-modal')">&times;</button>
1183
+ </div>
1184
+ <div id="health-modal-body"></div>
1185
+ </div>
1186
+ </div>
1187
+
1188
+ <div class="modal-backdrop" id="moment-modal" role="dialog" aria-modal="true">
1189
+ <div class="modal modal-wide" onclick="event.stopPropagation()">
1190
+ <div class="modal-header">
1191
+ <h2 id="moment-modal-title">Moment details</h2>
1192
+ <button class="modal-close" aria-label="Close" onclick="closeModal('moment-modal')">&times;</button>
1193
+ </div>
1194
+ <div id="moment-modal-body"></div>
1195
+ </div>
1196
+ </div>
1197
+
1198
+ <div class="modal-backdrop" id="conflict-modal" role="dialog" aria-modal="true">
1199
+ <div class="modal modal-wide" onclick="event.stopPropagation()">
1200
+ <div class="modal-header">
1201
+ <h2>Conflict</h2>
1202
+ <button class="modal-close" aria-label="Close" onclick="closeModal('conflict-modal')">&times;</button>
1203
+ </div>
1204
+ <div id="conflict-modal-body"></div>
1205
+ </div>
1206
+ </div>
1207
+
1208
+ <div class="modal-backdrop" id="fact-modal" role="dialog" aria-modal="true">
1209
+ <div class="modal modal-wide" onclick="event.stopPropagation()">
1210
+ <div class="modal-header">
1211
+ <h2>Fact detail</h2>
1212
+ <button class="modal-close" aria-label="Close" onclick="closeModal('fact-modal')">&times;</button>
1213
+ </div>
1214
+ <div id="fact-modal-body"></div>
1215
+ </div>
1216
+ </div>
1217
+
1218
+ <!-- ---------- Advanced drawer ---------- -->
1219
+ <div class="drawer-scrim" id="drawer-scrim" onclick="closeDrawer()"></div>
1220
+ <div class="drawer" id="drawer">
1221
+ <div class="drawer-header">
1222
+ <h2>Advanced</h2>
1223
+ <button class="modal-close" onclick="closeDrawer()">&times;</button>
1224
+ </div>
1225
+ <div class="drawer-tabs">
1226
+ <div class="drawer-tab active" data-adv="knowledge">Knowledge</div>
1227
+ <div class="drawer-tab" data-adv="overview">Overview</div>
1228
+ <div class="drawer-tab" data-adv="facts">Facts</div>
1229
+ <div class="drawer-tab" data-adv="explore">Explore</div>
1230
+ <div class="drawer-tab" data-adv="efficacy">Efficacy</div>
1231
+ <div class="drawer-tab" data-adv="conflicts">Conflicts</div>
1232
+ <div class="drawer-tab" data-adv="activity">Raw log</div>
1233
+ </div>
1234
+
1235
+ <div class="drawer-panel active" id="adv-knowledge">
1236
+ <div class="filter-row">
1237
+ <button class="chip active" data-knowledge-scope="all">All scopes</button>
1238
+ <button class="chip" data-knowledge-scope="project">Project only</button>
1239
+ <button class="chip" data-knowledge-scope="global">Global only</button>
1240
+ <span style="color: var(--text-faint); font-size: 11px; margin-left: auto;" id="knowledge-totals"></span>
1241
+ </div>
1242
+ <div class="knowledge-body" id="knowledge-body"></div>
1243
+ </div>
1244
+
1245
+ <div class="drawer-panel" id="adv-overview">
1246
+ <div id="overview-rows"></div>
1247
+ <div class="adv-card">
1248
+ <h3>Top predicates</h3>
1249
+ <div class="bar-chart" id="predicate-chart"></div>
1250
+ </div>
1251
+ <div class="adv-card">
1252
+ <h3>Entity types</h3>
1253
+ <div class="bar-chart" id="entity-chart"></div>
1254
+ </div>
1255
+ </div>
1256
+
1257
+ <div class="drawer-panel" id="adv-facts">
1258
+ <div class="filter-row">
1259
+ <input class="search-box" id="fact-search" placeholder="Search by predicate or value…">
1260
+ <button class="chip active" data-fact-scope="all">All</button>
1261
+ <button class="chip" data-fact-scope="project">Project</button>
1262
+ <button class="chip" data-fact-scope="global">Global</button>
1263
+ <button class="chip active" data-status="active">Active</button>
1264
+ <button class="chip" data-status="superseded">Superseded</button>
1265
+ <button class="chip" id="stale-chip" data-stale="true" title="Active facts never returned by a recall in the last 30 days">Stale (30d)</button>
1266
+ </div>
1267
+ <div class="adv-card" style="padding: 0;">
1268
+ <table>
1269
+ <thead>
1270
+ <tr><th>ID</th><th>Subject</th><th>Predicate</th><th>Object</th><th>Conf</th><th>Source</th><th>Created</th></tr>
1271
+ </thead>
1272
+ <tbody id="facts-tbody"></tbody>
1273
+ </table>
1274
+ </div>
1275
+ </div>
1276
+
1277
+ <div class="drawer-panel" id="adv-explore">
1278
+ <div class="adv-card">
1279
+ <h3>Run a recall</h3>
1280
+ <p style="color: var(--text-dim); font-size: 12px; margin-bottom: 10px;">
1281
+ Fires the same pipeline Claude uses via <code>memory.recall</code>. Useful for verifying that memory has what you'd expect.
1282
+ </p>
1283
+ <div class="filter-row">
1284
+ <input class="search-box" id="explore-query" placeholder="e.g., dashboard architecture">
1285
+ <select class="search-box" id="explore-scope" style="flex: 0 0 130px;">
1286
+ <option value="all">All scopes</option>
1287
+ <option value="project">Project only</option>
1288
+ <option value="global">Global only</option>
1289
+ </select>
1290
+ <select class="search-box" id="explore-limit" style="flex: 0 0 110px;">
1291
+ <option value="5">5 results</option>
1292
+ <option value="10" selected>10 results</option>
1293
+ <option value="20">20 results</option>
1294
+ <option value="50">50 results</option>
1295
+ </select>
1296
+ <button class="icon-btn primary" id="explore-btn">Recall</button>
1297
+ </div>
1298
+ <div id="explore-meta" style="font-size: 12px; color: var(--text-dim);"></div>
1299
+ </div>
1300
+ <div class="adv-card" style="padding: 0;">
1301
+ <table>
1302
+ <thead>
1303
+ <tr><th>#</th><th>Subject</th><th>Predicate</th><th>Object</th><th>Scope</th><th>Receipts</th><th>Score</th></tr>
1304
+ </thead>
1305
+ <tbody id="explore-tbody"></tbody>
1306
+ </table>
1307
+ </div>
1308
+ </div>
1309
+
1310
+ <div class="drawer-panel" id="adv-efficacy">
1311
+ <div class="filter-row">
1312
+ <button class="chip" data-timeframe="session">This session</button>
1313
+ <button class="chip active" data-timeframe="7d">Last 7 days</button>
1314
+ <button class="chip" data-timeframe="all">All time</button>
1315
+ </div>
1316
+ <div class="adv-grid" id="efficacy-cards"></div>
1317
+ <div class="adv-card" style="margin-top: 16px;">
1318
+ <h3>Tool mix</h3>
1319
+ <div class="bar-chart" id="efficacy-tool-mix"></div>
1320
+ </div>
1321
+ <div class="adv-card">
1322
+ <h3>Memory gaps (zero-result queries)</h3>
1323
+ <div id="efficacy-gaps"></div>
1324
+ </div>
1325
+ <div class="adv-card" style="padding: 0;">
1326
+ <table>
1327
+ <thead>
1328
+ <tr><th>When</th><th>Tool</th><th>Query</th><th>Results</th><th>Latency</th></tr>
1329
+ </thead>
1330
+ <tbody id="efficacy-trace-tbody"></tbody>
1331
+ </table>
1332
+ </div>
1333
+ </div>
1334
+
1335
+ <div class="drawer-panel" id="adv-conflicts">
1336
+ <div class="filter-row">
1337
+ <button class="chip active" data-conflict-scope="project">Project <span data-count-scope="project">—</span></button>
1338
+ <button class="chip" data-conflict-scope="global">Global <span data-count-scope="global">—</span></button>
1339
+ <button class="chip" data-conflict-scope="all">All</button>
1340
+ <button class="chip active" data-conflict-status="open">Open</button>
1341
+ <button class="chip" data-conflict-status="resolved">Resolved</button>
1342
+ </div>
1343
+ <div class="adv-card" style="padding: 0;">
1344
+ <table>
1345
+ <thead>
1346
+ <tr><th>Detected</th><th>Fact A</th><th></th><th>Fact B</th><th>Status</th></tr>
1347
+ </thead>
1348
+ <tbody id="conflicts-tbody"></tbody>
1349
+ </table>
1350
+ </div>
1351
+ </div>
1352
+
1353
+ <div class="drawer-panel" id="adv-activity">
1354
+ <div class="adv-card" style="padding: 0;">
1355
+ <table>
1356
+ <thead>
1357
+ <tr><th>Time</th><th>Event</th><th>Status</th><th>Duration</th><th>Details</th></tr>
1358
+ </thead>
1359
+ <tbody id="activity-tbody"></tbody>
1360
+ </table>
1361
+ </div>
1362
+ </div>
1363
+ </div>
1364
+
1365
+ <div id="toast" class="toast"></div>
1366
+
1367
+ <script>
1368
+ // ==================== State ====================
1369
+ let lastHealth = null;
1370
+ let currentKinds = 'context_injection,recall_hit,recall_empty,extraction';
1371
+ let currentTimeframe = '7d';
1372
+ let currentSessionId = null;
1373
+ let currentFactId = null;
1374
+ let currentFactScope = null;
1375
+ let currentConflictId = null;
1376
+ let currentConflictScope = null;
1377
+
1378
+ // ==================== API helper ====================
1379
+ async function api(path, params = {}) {
1380
+ const qs = new URLSearchParams(params).toString();
1381
+ const url = qs ? `/api/${path}?${qs}` : `/api/${path}`;
1382
+ const res = await fetch(url);
1383
+ return res.json();
1384
+ }
1385
+
1386
+ // ==================== Load cycle ====================
1387
+ async function loadAll() {
1388
+ await Promise.all([loadHealth(), loadTrust(), loadMoments(), loadKnowledgePanel(), loadReusePanel()]);
1389
+ }
1390
+
1391
+ async function loadKnowledgePanel() {
1392
+ const data = await api('knowledge', {scope: 'all', limit: 1});
1393
+ const totalsEl = document.getElementById('kb-totals');
1394
+ const rowsEl = document.getElementById('kb-rows');
1395
+
1396
+ const projN = (data.totals && data.totals.project) || 0;
1397
+ const globN = (data.totals && data.totals.global) || 0;
1398
+ totalsEl.innerHTML = `
1399
+ <span class="big">${(projN + globN).toLocaleString()}</span>
1400
+ <span class="split">active facts <span style="color: var(--text-dim);">(${projN} project · ${globN} global)</span></span>
1401
+ `;
1402
+
1403
+ const sections = data.sections || [];
1404
+ rowsEl.innerHTML = sections.map(s => {
1405
+ const cls = s.count === 0 ? 'empty' : '';
1406
+ const onclick = s.count === 0 ? '' : `onclick="openKnowledge('${s.key}')"`;
1407
+ return `<div class="kb-row ${cls}" ${onclick} title="${esc(s.description)}">
1408
+ <div class="kb-label">
1409
+ <span>${esc(s.label)}</span>
1410
+ <span class="kb-desc">${esc(s.description)}</span>
1411
+ </div>
1412
+ <span class="kb-count">${s.count}</span>
1413
+ </div>`;
1414
+ }).join('');
1415
+ }
1416
+
1417
+ async function loadReusePanel() {
1418
+ const data = await api('reuse', {limit: 5});
1419
+ const list = document.getElementById('reuse-list');
1420
+ const empty = document.getElementById('reuse-empty');
1421
+ const facts = data.facts || [];
1422
+ if (!facts.length) {
1423
+ list.innerHTML = '';
1424
+ empty.style.display = 'block';
1425
+ return;
1426
+ }
1427
+ empty.style.display = 'none';
1428
+ list.innerHTML = facts.map(f => `
1429
+ <li onclick="openFactModal(${f.id}, '${esc(f.source)}')" title="Last recalled ${esc(f.last_recalled_ago || '')}">
1430
+ <div class="reuse-head">
1431
+ <span class="pred">${esc(f.predicate)}</span>
1432
+ <span class="reuse-count">${f.recall_count}×</span>
1433
+ </div>
1434
+ <div class="reuse-obj" title="${esc(f.object || '')}">${esc(f.object || '')}</div>
1435
+ </li>
1436
+ `).join('');
1437
+ }
1438
+
1439
+ // Open the Advanced drawer on the Knowledge tab, pre-filtered to the given
1440
+ // section key. Called from the sidebar "Your knowledge base" rows.
1441
+ function openKnowledge(sectionKey) {
1442
+ openDrawer('knowledge');
1443
+ setTimeout(() => applyKnowledgeSection(sectionKey), 50);
1444
+ }
1445
+
1446
+ async function loadHealth() {
1447
+ const data = await api('health');
1448
+ lastHealth = data;
1449
+ document.getElementById('version-tag').textContent = 'v' + (data.version || '?');
1450
+
1451
+ const strip = document.getElementById('health-strip');
1452
+ strip.innerHTML = (data.checks || []).map(c => {
1453
+ const actionable = c.status !== 'healthy' && c.fix;
1454
+ const click = actionable ? `onclick="openHealthModal('${c.name}')"` : '';
1455
+ return `<span class="health-pill ${actionable ? 'actionable' : ''}" ${click}>
1456
+ <span class="dot ${c.status}"></span>${esc(c.name.replace('_', ' '))}
1457
+ </span>`;
1458
+ }).join('');
1459
+ }
1460
+
1461
+ function openHealthModal(focus) {
1462
+ if (!lastHealth) return;
1463
+ const issues = (lastHealth.checks || []).filter(c => c.status !== 'healthy');
1464
+ const body = document.getElementById('health-modal-body');
1465
+ if (!issues.length) {
1466
+ body.innerHTML = '<p style="color: var(--text-dim);">Everything looks healthy.</p>';
1467
+ } else {
1468
+ const ordered = focus
1469
+ ? [...issues.filter(c => c.name === focus), ...issues.filter(c => c.name !== focus)]
1470
+ : issues;
1471
+ body.innerHTML = ordered.map(c => `
1472
+ <div class="modal-issue">
1473
+ <div class="modal-issue-title">
1474
+ <span class="dot ${c.status}"></span>
1475
+ <span>${esc(c.name.replace('_', ' '))}</span>
1476
+ <span class="badge badge-${c.status === 'error' ? 'error' : 'warning'}">${esc(c.status)}</span>
1477
+ </div>
1478
+ <div class="modal-issue-msg">${esc(c.message)}</div>
1479
+ ${c.fix ? `<div class="modal-issue-fix">${renderFix(c.fix)}</div>` : ''}
1480
+ </div>
1481
+ `).join('');
1482
+ }
1483
+ openModal('health-modal');
1484
+ }
1485
+ function renderFix(t) {
1486
+ return esc(t).replace(/`([^`]+)`/g, (_, code) => `<code>${code}</code>`);
1487
+ }
1488
+
1489
+ async function loadTrust() {
1490
+ const data = await api('trust');
1491
+
1492
+ // Utilization (ROI diagnostic) — extracted vs used over 30d window.
1493
+ // Cheap to render; shows even when reuse list is empty so a user can
1494
+ // see "50 extracted, 0 used" with the right color cue.
1495
+ const util = data.utilization || {};
1496
+ const utilBox = document.getElementById('utilization');
1497
+ const utilPct = document.getElementById('utilization-pct');
1498
+ const utilBreak = document.getElementById('utilization-breakdown');
1499
+ if ((util.extracted || 0) > 0 || (util.used || 0) > 0) {
1500
+ utilBox.style.display = 'block';
1501
+ const pct = util.ratio_pct ?? 0;
1502
+ utilPct.textContent = pct + '%';
1503
+ utilPct.classList.remove('good', 'ok', 'low');
1504
+ utilPct.classList.add(pct >= 40 ? 'good' : pct >= 15 ? 'ok' : 'low');
1505
+ let breakdown =
1506
+ `${util.used_from_extracted || 0} of ${util.extracted || 0} facts extracted this month used · ${util.used || 0} total fact-uses across recalls and injections`;
1507
+ const fb = data.feedback || {};
1508
+ if ((fb.up || 0) + (fb.down || 0) > 0) {
1509
+ breakdown += ` · 👍 ${fb.up || 0} / 👎 ${fb.down || 0}` +
1510
+ (fb.ratio_pct != null ? ` (${fb.ratio_pct}% positive)` : '');
1511
+ }
1512
+ utilBreak.textContent = breakdown;
1513
+ } else {
1514
+ utilBox.style.display = 'none';
1515
+ }
1516
+
1517
+ // Activity this week
1518
+ const moments = data.weekly_moments || {this_week: 0, last_week: 0, delta: 0, by_kind: {}};
1519
+ document.getElementById('moments-count').textContent = moments.this_week;
1520
+ const deltaEl = document.getElementById('moments-delta');
1521
+ const d = moments.delta;
1522
+ deltaEl.classList.remove('up', 'down', 'flat');
1523
+ if (d > 0) { deltaEl.classList.add('up'); deltaEl.textContent = `+${d} vs last week`; }
1524
+ else if (d < 0) { deltaEl.classList.add('down'); deltaEl.textContent = `${d} vs last week`; }
1525
+ else { deltaEl.classList.add('flat'); deltaEl.textContent = moments.this_week === 0 ? '—' : 'same as last week'; }
1526
+
1527
+ const bk = moments.by_kind || {};
1528
+ const sub = [];
1529
+ if (bk.recall) sub.push(`${bk.recall} recall${bk.recall === 1 ? '' : 's'}`);
1530
+ if (bk.store_extraction) sub.push(`${bk.store_extraction} extraction${bk.store_extraction === 1 ? '' : 's'}`);
1531
+ if (bk.hook_context) sub.push(`${bk.hook_context} injection${bk.hook_context === 1 ? '' : 's'}`);
1532
+ document.getElementById('moments-sub').textContent = sub.length ? sub.join(' · ') : 'No value-producing events yet.';
1533
+
1534
+ // Needs review
1535
+ const nr = data.needs_review || {};
1536
+ const rows = [];
1537
+ const conflicts = (nr.open_conflicts && nr.open_conflicts.total) || 0;
1538
+ if (conflicts > 0) {
1539
+ rows.push(`<div class="review-row alert" onclick="openDrawer('conflicts')">
1540
+ <span>Open conflicts</span><span class="count">${conflicts}</span>
1541
+ </div>`);
1542
+ } else {
1543
+ rows.push(`<div class="review-row"><span>Open conflicts</span><span class="count">0</span></div>`);
1544
+ }
1545
+ const stale = nr.stale_facts || 0;
1546
+ if (stale > 0) {
1547
+ rows.push(`<div class="review-row alert" onclick="openDrawer('facts'); setTimeout(applyStaleFilter, 50);">
1548
+ <span>Facts never recalled (30d)</span><span class="count">${stale}</span>
1549
+ </div>`);
1550
+ }
1551
+ const empty = nr.empty_recalls || 0;
1552
+ if (empty > 0) {
1553
+ rows.push(`<div class="review-row alert" onclick="openDrawer('efficacy')">
1554
+ <span>Zero-result queries (7d)</span><span class="count">${empty}</span>
1555
+ </div>`);
1556
+ }
1557
+ if (conflicts === 0 && stale === 0 && empty === 0) {
1558
+ rows.push(`<div class="review-row" style="color: var(--green);">✓ Nothing urgent</div>`);
1559
+ }
1560
+ document.getElementById('review-rows').innerHTML = rows.join('');
1561
+ }
1562
+
1563
+ // ==================== Feed ====================
1564
+ async function loadMoments() {
1565
+ const params = {limit: 50};
1566
+ if (currentKinds) params.kinds = currentKinds;
1567
+ const data = await api('moments', params);
1568
+ const moments = data.moments || [];
1569
+
1570
+ // Seed current session from newest moment with a session_id
1571
+ if (!currentSessionId) {
1572
+ const recent = moments.find(m => m.session_id);
1573
+ if (recent) currentSessionId = recent.session_id;
1574
+ }
1575
+
1576
+ const feed = document.getElementById('feed');
1577
+ const empty = document.getElementById('feed-empty');
1578
+ document.getElementById('feed-count').textContent = moments.length ? `(${moments.length})` : '';
1579
+
1580
+ if (!moments.length) {
1581
+ feed.innerHTML = '';
1582
+ empty.style.display = 'block';
1583
+ return;
1584
+ }
1585
+ empty.style.display = 'none';
1586
+ feed.innerHTML = moments.map(renderMoment).join('');
1587
+ }
1588
+
1589
+ function renderMoment(m) {
1590
+ const map = {
1591
+ context_injection: {icon: 'C', cls: 'context', render: renderContextMoment},
1592
+ context_skipped: {icon: 'C', cls: 'sweep', render: renderContextSkippedMoment},
1593
+ recall_hit: {icon: 'R', cls: 'recall_hit', render: renderRecallMoment},
1594
+ recall_empty: {icon: '?', cls: 'recall_empty', render: renderRecallMoment},
1595
+ extraction: {icon: '+', cls: 'extraction', render: renderExtractionMoment},
1596
+ ingest: {icon: '↓', cls: 'ingest', render: renderIngestMoment},
1597
+ ingest_skipped: {icon: '—', cls: 'sweep', render: renderIngestSkippedMoment},
1598
+ sweep: {icon: '~', cls: 'sweep', render: renderSweepMoment}
1599
+ };
1600
+ const def = map[m.kind] || {icon: '•', cls: 'sweep', render: renderGenericMoment};
1601
+ return `<div class="moment" onclick="openMomentModal(${m.id})">
1602
+ <div class="moment-icon ${def.cls}">${def.icon}</div>
1603
+ <div class="moment-body">${def.render(m)}${renderMomentFeedback(m)}</div>
1604
+ </div>`;
1605
+ }
1606
+
1607
+ // Thumbs up/down on each moment (improvements.md #43). Stops click propagation
1608
+ // so rating doesn't open the modal. Clicking the same verdict twice clears it.
1609
+ function renderMomentFeedback(m) {
1610
+ const verdict = m.feedback && m.feedback.verdict;
1611
+ const upClass = verdict === 'up' ? 'active-up' : '';
1612
+ const downClass = verdict === 'down' ? 'active-down' : '';
1613
+ return `<div class="moment-feedback" onclick="event.stopPropagation()">
1614
+ <button class="${upClass}" title="Helpful" onclick="rateMoment(${m.id}, 'up', event)">👍</button>
1615
+ <button class="${downClass}" title="Not helpful" onclick="rateMoment(${m.id}, 'down', event)">👎</button>
1616
+ </div>`;
1617
+ }
1618
+
1619
+ async function rateMoment(id, verdict, ev) {
1620
+ ev.stopPropagation();
1621
+ const btn = ev.currentTarget;
1622
+ const wasActive = btn.classList.contains(verdict === 'up' ? 'active-up' : 'active-down');
1623
+ try {
1624
+ if (wasActive) {
1625
+ await fetch('/api/moments/' + id + '/feedback', {method: 'DELETE'});
1626
+ } else {
1627
+ await fetch('/api/moments/' + id + '/feedback', {
1628
+ method: 'POST',
1629
+ headers: {'Content-Type': 'application/json'},
1630
+ body: JSON.stringify({verdict})
1631
+ });
1632
+ }
1633
+ loadMoments();
1634
+ } catch (e) {
1635
+ showToast('Feedback failed: ' + e.message);
1636
+ }
1637
+ }
1638
+
1639
+ function renderContextMoment(m) {
1640
+ const n = m.fact_count || (m.top_facts || []).length;
1641
+ const headline = n > 0
1642
+ ? `Memory showed Claude <strong>${n} fact${n === 1 ? '' : 's'}</strong> at session start`
1643
+ : `Memory attempted session-start injection`;
1644
+ const facts = (m.top_facts || []).slice(0, 5).map(f => `
1645
+ <li><span class="pred">${esc(f.predicate)}</span><span class="obj" title="${esc(f.object || '')}">${esc(f.object || '')}</span></li>
1646
+ `).join('');
1647
+ return `
1648
+ <div class="moment-headline">${headline}</div>
1649
+ <div class="moment-meta">
1650
+ <span>${esc(m.occurred_ago)}</span>
1651
+ ${m.context_length ? `<span class="dotsep">${m.context_length.toLocaleString()} chars injected</span>` : ''}
1652
+ ${m.session_id ? `<span class="dotsep">session ${esc(m.session_id.slice(0,8))}</span>` : ''}
1653
+ </div>
1654
+ ${facts ? `<ul class="moment-facts">${facts}</ul>` : ''}
1655
+ `;
1656
+ }
1657
+
1658
+ function renderContextSkippedMoment(m) {
1659
+ return `
1660
+ <div class="moment-headline">Session-start injection skipped <span style="color: var(--text-dim); font-size: 12px;">(no facts to inject)</span></div>
1661
+ <div class="moment-meta"><span>${esc(m.occurred_ago)}</span></div>
1662
+ `;
1663
+ }
1664
+
1665
+ function renderRecallMoment(m) {
1666
+ const n = m.result_count || 0;
1667
+ const tool = (m.tool || 'memory.recall').replace('memory.', '');
1668
+ const q = m.query || '(shortcut)';
1669
+ const verb = n === 0 ? 'found nothing for' : `recalled <strong>${n} fact${n === 1 ? '' : 's'}</strong> for`;
1670
+ const facts = (m.top_facts || []).slice(0, 3).map(f => `
1671
+ <li><span class="pred">${esc(f.predicate)}</span><span class="obj" title="${esc(f.object || '')}">${esc(f.object || '')}</span></li>
1672
+ `).join('');
1673
+ return `
1674
+ <div class="moment-headline">
1675
+ Claude ${verb} <span class="moment-query">${esc(q)}</span>${queryQualityBadge(q)}
1676
+ </div>
1677
+ <div class="moment-meta">
1678
+ <span>${esc(m.occurred_ago)}</span>
1679
+ <span class="dotsep"><span class="badge badge-info">${esc(tool)}</span></span>
1680
+ ${m.duration_ms != null ? `<span class="dotsep">${m.duration_ms}ms</span>` : ''}
1681
+ </div>
1682
+ ${facts ? `<ul class="moment-facts">${facts}</ul>` : ''}
1683
+ `;
1684
+ }
1685
+
1686
+ function renderExtractionMoment(m) {
1687
+ const nf = m.facts_created || 0;
1688
+ const ne = m.entities_created || 0;
1689
+ const parts = [];
1690
+ if (nf) parts.push(`<strong>${nf} fact${nf === 1 ? '' : 's'}</strong>`);
1691
+ if (ne) parts.push(`<strong>${ne} entit${ne === 1 ? 'y' : 'ies'}</strong>`);
1692
+ const desc = parts.length ? parts.join(' and ') : 'nothing';
1693
+ const facts = (m.extracted_facts || []).slice(0, 3).map(f => `
1694
+ <li><span class="pred">${esc(f.predicate)}</span><span class="obj" title="${esc(f.object || '')}">${esc(f.object || '')}</span></li>
1695
+ `).join('');
1696
+ return `
1697
+ <div class="moment-headline">Claude learned ${desc}</div>
1698
+ <div class="moment-meta">
1699
+ <span>${esc(m.occurred_ago)}</span>
1700
+ ${m.session_id ? `<span class="dotsep">session ${esc(m.session_id.slice(0,8))}</span>` : ''}
1701
+ </div>
1702
+ ${facts ? `<ul class="moment-facts">${facts}</ul>` : ''}
1703
+ `;
1704
+ }
1705
+
1706
+ function renderIngestMoment(m) {
1707
+ const nFacts = (m.extracted_facts || []).length;
1708
+ const bytes = m.bytes_read ? (m.bytes_read / 1024).toFixed(1) + ' KB' : '';
1709
+ const factBit = nFacts > 0 ? ` → ${nFacts} fact${nFacts === 1 ? '' : 's'} linked` : '';
1710
+ return `
1711
+ <div class="moment-headline">Ingested transcript ${bytes ? `(${bytes})` : ''}${factBit}</div>
1712
+ <div class="moment-meta">
1713
+ <span>${esc(m.occurred_ago)}</span>
1714
+ ${m.duration_ms != null ? `<span class="dotsep">${m.duration_ms}ms</span>` : ''}
1715
+ ${m.session_id ? `<span class="dotsep">session ${esc(m.session_id.slice(0,8))}</span>` : ''}
1716
+ </div>
1717
+ `;
1718
+ }
1719
+
1720
+ function renderIngestSkippedMoment(m) {
1721
+ return `
1722
+ <div class="moment-headline" style="color: var(--text-dim);">
1723
+ Ingest skipped <span style="font-size: 12px;">(${esc(m.reason || 'no new content')})</span>
1724
+ </div>
1725
+ <div class="moment-meta"><span>${esc(m.occurred_ago)}</span></div>
1726
+ `;
1727
+ }
1728
+
1729
+ function renderSweepMoment(m) {
1730
+ return `
1731
+ <div class="moment-headline" style="color: var(--text-dim);">
1732
+ Maintenance sweep ${m.elapsed_seconds ? `(${m.elapsed_seconds.toFixed(2)}s)` : ''}
1733
+ </div>
1734
+ <div class="moment-meta"><span>${esc(m.occurred_ago)}</span></div>
1735
+ `;
1736
+ }
1737
+
1738
+ function renderGenericMoment(m) {
1739
+ return `
1740
+ <div class="moment-headline">${esc(m.event_type)}</div>
1741
+ <div class="moment-meta"><span>${esc(m.occurred_ago)}</span></div>
1742
+ `;
1743
+ }
1744
+
1745
+ async function openMomentModal(id) {
1746
+ const body = document.getElementById('moment-modal-body');
1747
+ const title = document.getElementById('moment-modal-title');
1748
+ body.innerHTML = '<p style="color: var(--text-dim);">Loading…</p>';
1749
+ title.textContent = 'Moment details';
1750
+ openModal('moment-modal');
1751
+ try {
1752
+ const data = await api('activity/' + id);
1753
+ if (data.error) { body.innerHTML = `<p style="color: var(--text-dim);">${esc(data.error)}</p>`; return; }
1754
+ const rendered = renderMomentDetail(data);
1755
+ title.textContent = rendered.title;
1756
+ body.innerHTML = rendered.html;
1757
+ } catch (e) {
1758
+ body.innerHTML = `<p style="color: var(--text-dim);">Failed: ${esc(e.message)}</p>`;
1759
+ }
1760
+ }
1761
+
1762
+ // Dispatcher: picks the right renderer by event type. Each renderer returns
1763
+ // { title, html } so the modal heading can reflect the narrative (e.g.
1764
+ // "Recall" vs "Memory details") rather than a generic "Moment details".
1765
+ function renderMomentDetail(data) {
1766
+ const e = data.event || {};
1767
+ switch (e.event_type) {
1768
+ case 'recall': return renderRecallDetail(data);
1769
+ case 'store_extraction': return renderExtractionDetail(data);
1770
+ case 'hook_context': return renderContextDetail(data);
1771
+ case 'hook_ingest': return renderIngestDetail(data);
1772
+ default: return renderGenericDetail(data);
1773
+ }
1774
+ }
1775
+
1776
+ function renderRecallDetail(data) {
1777
+ const e = data.event || {};
1778
+ const d = e.details || {};
1779
+ const facts = data.linked_facts || [];
1780
+ const trigger = data.trigger;
1781
+ const tool = (d.tool || 'memory.recall').replace('memory.', '');
1782
+ const query = d.query || '(shortcut)';
1783
+ const n = d.result_count || facts.length || 0;
1784
+ const title = `Recalled ${n} fact${n === 1 ? '' : 's'} for "${truncate(query, 60)}"`;
1785
+
1786
+ const triggerBlock = renderTriggerBlock(trigger);
1787
+ const header = `
1788
+ <div class="recall-header">
1789
+ <div class="recall-stat"><div class="recall-stat-n">${n}</div><div class="recall-stat-label">facts returned</div></div>
1790
+ <div class="recall-stat"><div class="recall-stat-n">${e.duration_ms != null ? e.duration_ms + 'ms' : '—'}</div><div class="recall-stat-label">latency</div></div>
1791
+ <div class="recall-stat"><div class="recall-stat-n"><span class="badge badge-info">${esc(tool)}</span></div><div class="recall-stat-label">via</div></div>
1792
+ </div>
1793
+ <div class="recall-query">
1794
+ <div class="label">Query</div>
1795
+ <div class="query-text">${esc(query)}${queryQualityBadge(query)}</div>
1796
+ </div>
1797
+ `;
1798
+
1799
+ const factsBlock = facts.length
1800
+ ? `<div class="modal-section">
1801
+ <h3>What Claude got back (${facts.length})</h3>
1802
+ <table class="facts-table">
1803
+ <thead><tr><th>Subject</th><th>Predicate</th><th>Object</th><th>Scope</th></tr></thead>
1804
+ <tbody>
1805
+ ${facts.map(f => `<tr class="row-clickable" onclick="openFactModal(${f.id}, '${esc(f.scope)}')">
1806
+ <td><strong>${esc(f.subject || '—')}</strong></td>
1807
+ <td><span class="badge badge-purple">${esc(f.predicate || '')}</span></td>
1808
+ <td class="object-cell" title="${esc(f.object || '')}">${esc(f.object || '')}</td>
1809
+ <td><span class="badge badge-info">${esc(f.scope || '')}</span></td>
1810
+ </tr>`).join('')}
1811
+ </tbody>
1812
+ </table>
1813
+ </div>`
1814
+ : '<div class="modal-section"><p style="color: var(--text-dim);">No facts were returned by this recall.</p></div>';
1815
+
1816
+ const meta = `
1817
+ <details class="modal-collapsible">
1818
+ <summary>Raw event data</summary>
1819
+ <dl class="modal-kv">
1820
+ <dt>id</dt><dd>${e.id}</dd>
1821
+ <dt>occurred_at</dt><dd>${esc(e.occurred_at)} (${esc(e.occurred_ago || '')})</dd>
1822
+ <dt>session_id</dt><dd>${esc(e.session_id || '—')}</dd>
1823
+ </dl>
1824
+ <pre class="modal-json">${formatJson(d)}</pre>
1825
+ </details>`;
1826
+
1827
+ return {
1828
+ title,
1829
+ html: header + triggerBlock + factsBlock + meta
1830
+ };
1831
+ }
1832
+
1833
+ function renderTriggerBlock(trigger) {
1834
+ if (!trigger) return '';
1835
+ const prompt = trigger.user_prompt;
1836
+ const ago = trigger.occurred_ago || '';
1837
+ if (prompt) {
1838
+ const snippet = truncate(prompt, 280);
1839
+ return `
1840
+ <div class="modal-section">
1841
+ <h3>What triggered this</h3>
1842
+ <div class="trigger-card">
1843
+ <div class="trigger-label">User prompt · ${esc(ago)}</div>
1844
+ <div class="trigger-prompt">${esc(snippet)}${prompt.length > snippet.length ? '…' : ''}</div>
1845
+ ${trigger.session_id ? `<div class="trigger-session">session ${esc(trigger.session_id.slice(0,8))}</div>` : ''}
1846
+ </div>
1847
+ </div>`;
1848
+ }
1849
+ return `
1850
+ <div class="modal-section">
1851
+ <h3>What triggered this</h3>
1852
+ <div class="trigger-card">
1853
+ <div class="trigger-label">${esc(trigger.event_type)} · ${esc(ago)}</div>
1854
+ <div style="color: var(--text-dim); font-size: 12px;">(No human-readable prompt in the preceding ingest.)</div>
1855
+ </div>
1856
+ </div>`;
1857
+ }
1858
+
1859
+ function queryQualityBadge(q) {
1860
+ if (!q || typeof q !== 'string') return '';
1861
+ const trimmed = q.trim();
1862
+ if (!trimmed) return '';
1863
+ const words = trimmed.split(/\s+/);
1864
+ if (words.length === 1 && trimmed.length < 12) {
1865
+ return ' <span class="badge badge-warning" title="Very short query — results may be broad or incidental">narrow</span>';
1866
+ }
1867
+ if (words.length <= 2 && words.every(w => w.length < 12)) {
1868
+ return ' <span class="badge badge-warning" title="Generic terms — matches tend to be broad and incidental">generic</span>';
1869
+ }
1870
+ return '';
1871
+ }
1872
+
1873
+ function renderExtractionDetail(data) {
1874
+ const e = data.event || {};
1875
+ const d = e.details || {};
1876
+ const ci = data.content_item;
1877
+ const facts = data.linked_facts || [];
1878
+ const nFacts = d.facts_created || facts.length;
1879
+ const nEnts = d.entities_created || 0;
1880
+ const title = `Extracted ${nFacts} fact${nFacts === 1 ? '' : 's'}`;
1881
+
1882
+ const factsBlock = facts.length
1883
+ ? `<div class="modal-section">
1884
+ <h3>What Claude learned</h3>
1885
+ <table class="facts-table">
1886
+ <thead><tr><th>Subject</th><th>Predicate</th><th>Object</th><th>Scope</th></tr></thead>
1887
+ <tbody>
1888
+ ${facts.map(f => `<tr class="row-clickable" onclick="openFactModal(${f.id}, '${esc(f.scope)}')">
1889
+ <td><strong>${esc(f.subject || '—')}</strong></td>
1890
+ <td><span class="badge badge-purple">${esc(f.predicate || '')}</span></td>
1891
+ <td class="object-cell" title="${esc(f.object || '')}">${esc(f.object || '')}</td>
1892
+ <td><span class="badge badge-info">${esc(f.scope || '')}</span></td>
1893
+ </tr>`).join('')}
1894
+ </tbody>
1895
+ </table>
1896
+ </div>`
1897
+ : '<div class="modal-section"><p style="color: var(--text-dim);">No facts could be linked.</p></div>';
1898
+
1899
+ const sourceBlock = ci ? renderSourceContent(ci) : '';
1900
+ const summary = `
1901
+ <div class="recall-header">
1902
+ <div class="recall-stat"><div class="recall-stat-n">${nFacts}</div><div class="recall-stat-label">facts</div></div>
1903
+ <div class="recall-stat"><div class="recall-stat-n">${nEnts}</div><div class="recall-stat-label">entities</div></div>
1904
+ <div class="recall-stat"><div class="recall-stat-n">${e.duration_ms != null ? e.duration_ms + 'ms' : '—'}</div><div class="recall-stat-label">latency</div></div>
1905
+ </div>`;
1906
+
1907
+ const meta = `
1908
+ <details class="modal-collapsible">
1909
+ <summary>Raw event data</summary>
1910
+ <dl class="modal-kv">
1911
+ <dt>id</dt><dd>${e.id}</dd>
1912
+ <dt>occurred_at</dt><dd>${esc(e.occurred_at)} (${esc(e.occurred_ago || '')})</dd>
1913
+ <dt>session_id</dt><dd>${esc(e.session_id || '—')}</dd>
1914
+ </dl>
1915
+ <pre class="modal-json">${formatJson(d)}</pre>
1916
+ </details>`;
1917
+
1918
+ return {title, html: summary + factsBlock + sourceBlock + meta};
1919
+ }
1920
+
1921
+ // Smart source preview. Tries to extract readable prose:
1922
+ // - If the content is already prose (e.g. distilled concept summaries from
1923
+ // the store_extraction MCP tool), render with wrapping, not monospace.
1924
+ // - If it's Claude Code transcript JSONL, pull out the user/assistant text
1925
+ // turn-by-turn so the reader sees the conversation, not the envelope.
1926
+ function renderSourceContent(ci) {
1927
+ const raw = ci.raw_text_preview || '';
1928
+ const isTranscript = ci.source === 'claude_code' || /^\{"parentUuid"|^\{"type"/.test(raw);
1929
+ const label = isTranscript ? 'From this conversation' : 'From this content';
1930
+
1931
+ const body = isTranscript ? renderTranscript(raw) : renderProse(raw);
1932
+
1933
+ return `
1934
+ <div class="modal-section">
1935
+ <h3>${label}</h3>
1936
+ <div class="source-meta">
1937
+ <span>source <code>${esc(ci.source || '—')}</code></span>
1938
+ ${ci.session_id ? `<span>session ${esc(ci.session_id.slice(0,8))}</span>` : ''}
1939
+ <span>${(ci.byte_len || 0).toLocaleString()} bytes</span>
1940
+ ${ci.truncated ? '<span class="badge badge-warning">preview truncated</span>' : ''}
1941
+ </div>
1942
+ <div class="source-body">${body}</div>
1943
+ </div>`;
1944
+ }
1945
+
1946
+ function renderProse(raw) {
1947
+ if (!raw) return '<p style="color: var(--text-dim);">(empty)</p>';
1948
+ return `<div class="prose">${esc(raw)}</div>`;
1949
+ }
1950
+
1951
+ function renderTranscript(raw) {
1952
+ const lines = raw.split('\n');
1953
+ const turns = [];
1954
+ for (const line of lines) {
1955
+ const t = line.trim();
1956
+ if (!t) continue;
1957
+ let obj;
1958
+ try { obj = JSON.parse(t); } catch (_) { continue; }
1959
+ if (!obj || !obj.message) continue;
1960
+ const role = obj.message.role;
1961
+ const content = obj.message.content;
1962
+ const text = extractTurnText(content);
1963
+ if (!text) continue;
1964
+ turns.push({role, text});
1965
+ if (turns.length >= 6) break; // keep the modal tight
1966
+ }
1967
+ if (!turns.length) {
1968
+ // Fallback to raw JSON if we couldn't find readable turns.
1969
+ return `<pre class="modal-json">${formatJson(raw, {truncated: false})}</pre>`;
1970
+ }
1971
+ return turns.map(t => `
1972
+ <div class="turn turn-${esc(t.role)}">
1973
+ <div class="turn-role">${esc(t.role)}</div>
1974
+ <div class="turn-text">${esc(truncate(t.text, 400))}${t.text.length > 400 ? '…' : ''}</div>
1975
+ </div>
1976
+ `).join('');
1977
+ }
1978
+
1979
+ function extractTurnText(content) {
1980
+ if (typeof content === 'string') return content;
1981
+ if (!Array.isArray(content)) return null;
1982
+ for (const c of content) {
1983
+ if (!c || typeof c !== 'object') continue;
1984
+ if (c.type === 'text' && c.text) return c.text;
1985
+ if (c.type === 'tool_result' && typeof c.content === 'string') return c.content;
1986
+ if (c.type === 'tool_use' && c.name) return `[tool_use: ${c.name}]`;
1987
+ }
1988
+ return null;
1989
+ }
1990
+
1991
+ function renderContextDetail(data) {
1992
+ const e = data.event || {};
1993
+ const d = e.details || {};
1994
+ const facts = data.linked_facts || [];
1995
+ const n = d.fact_count || facts.length;
1996
+ const title = `Showed Claude ${n} fact${n === 1 ? '' : 's'} at session start`;
1997
+
1998
+ const preview = d.preview
1999
+ ? `<div class="modal-section">
2000
+ <h3>What was injected</h3>
2001
+ <div class="prose context-preview">${esc(d.preview)}${d.truncated ? '\n…' : ''}</div>
2002
+ </div>` : '';
2003
+
2004
+ const factsBlock = facts.length
2005
+ ? `<div class="modal-section">
2006
+ <h3>Facts injected</h3>
2007
+ <table class="facts-table">
2008
+ <thead><tr><th>Subject</th><th>Predicate</th><th>Object</th></tr></thead>
2009
+ <tbody>
2010
+ ${facts.map(f => `<tr class="row-clickable" onclick="openFactModal(${f.id}, '${esc(f.scope)}')">
2011
+ <td><strong>${esc(f.subject || '—')}</strong></td>
2012
+ <td><span class="badge badge-purple">${esc(f.predicate || '')}</span></td>
2013
+ <td class="object-cell" title="${esc(f.object || '')}">${esc(f.object || '')}</td>
2014
+ </tr>`).join('')}
2015
+ </tbody>
2016
+ </table>
2017
+ </div>`
2018
+ : '';
2019
+
2020
+ const meta = `
2021
+ <details class="modal-collapsible">
2022
+ <summary>Raw event data</summary>
2023
+ <pre class="modal-json">${formatJson(d)}</pre>
2024
+ </details>`;
2025
+
2026
+ return {title, html: preview + factsBlock + meta};
2027
+ }
2028
+
2029
+ function renderIngestDetail(data) {
2030
+ const e = data.event || {};
2031
+ const d = e.details || {};
2032
+ const ci = data.content_item;
2033
+ const facts = data.linked_facts || [];
2034
+ const title = `Ingested ${d.bytes_read ? (d.bytes_read/1024).toFixed(1) + ' KB' : 'transcript'}`;
2035
+
2036
+ const sourceBlock = ci ? renderSourceContent(ci) : '';
2037
+ const factsBlock = facts.length
2038
+ ? `<div class="modal-section">
2039
+ <h3>Facts linked to this content (${facts.length})</h3>
2040
+ <table class="facts-table">
2041
+ <thead><tr><th>Subject</th><th>Predicate</th><th>Object</th></tr></thead>
2042
+ <tbody>
2043
+ ${facts.map(f => `<tr class="row-clickable" onclick="openFactModal(${f.id}, '${esc(f.scope)}')">
2044
+ <td>${esc(f.subject || '—')}</td>
2045
+ <td><span class="badge badge-purple">${esc(f.predicate || '')}</span></td>
2046
+ <td class="object-cell" title="${esc(f.object || '')}">${esc(f.object || '')}</td>
2047
+ </tr>`).join('')}
2048
+ </tbody>
2049
+ </table>
2050
+ </div>`
2051
+ : '';
2052
+
2053
+ return {title, html: sourceBlock + factsBlock};
2054
+ }
2055
+
2056
+ function renderGenericDetail(data) {
2057
+ const e = data.event || {};
2058
+ const d = e.details || {};
2059
+ return {
2060
+ title: e.event_type || 'Event',
2061
+ html: `
2062
+ <div class="modal-section">
2063
+ <dl class="modal-kv">
2064
+ <dt>type</dt><dd><span class="badge badge-info">${esc(e.event_type)}</span></dd>
2065
+ <dt>status</dt><dd><span class="badge badge-${statusBadge(e.status)}">${esc(e.status)}</span></dd>
2066
+ <dt>occurred_at</dt><dd>${esc(e.occurred_at)} (${esc(e.occurred_ago || '')})</dd>
2067
+ <dt>duration</dt><dd>${e.duration_ms != null ? e.duration_ms + 'ms' : '—'}</dd>
2068
+ <dt>session_id</dt><dd>${esc(e.session_id || '—')}</dd>
2069
+ </dl>
2070
+ <pre class="modal-json">${formatJson(d)}</pre>
2071
+ </div>
2072
+ `
2073
+ };
2074
+ }
2075
+
2076
+ function truncate(s, n) { s = String(s || ''); return s.length > n ? s.slice(0, n) : s; }
2077
+
2078
+ function switchFeedToAll() {
2079
+ const allChip = document.querySelector('.chip[data-kinds=""]');
2080
+ if (allChip) allChip.click();
2081
+ }
2082
+
2083
+ // Feed filter chips
2084
+ document.querySelectorAll('.chip[data-kinds]').forEach(btn => {
2085
+ btn.addEventListener('click', () => {
2086
+ document.querySelectorAll('.chip[data-kinds]').forEach(b => b.classList.remove('active'));
2087
+ btn.classList.add('active');
2088
+ currentKinds = btn.dataset.kinds;
2089
+ loadMoments();
2090
+ });
2091
+ });
2092
+
2093
+ // ==================== Modals / drawer ====================
2094
+ function openModal(id) { document.getElementById(id).classList.add('open'); }
2095
+ function closeModal(id) { document.getElementById(id).classList.remove('open'); }
2096
+ document.querySelectorAll('.modal-backdrop').forEach(bd => bd.addEventListener('click', e => {
2097
+ if (e.target === bd) bd.classList.remove('open');
2098
+ }));
2099
+ document.addEventListener('keydown', e => {
2100
+ if (e.key === 'Escape') {
2101
+ document.querySelectorAll('.modal-backdrop.open').forEach(m => m.classList.remove('open'));
2102
+ closeDrawer();
2103
+ }
2104
+ });
2105
+
2106
+ function openDrawer(tab) {
2107
+ document.getElementById('drawer').classList.add('open');
2108
+ document.getElementById('drawer-scrim').classList.add('open');
2109
+ if (tab) switchAdvTab(tab);
2110
+ loadAdvanced();
2111
+ }
2112
+ function closeDrawer() {
2113
+ document.getElementById('drawer').classList.remove('open');
2114
+ document.getElementById('drawer-scrim').classList.remove('open');
2115
+ }
2116
+ document.querySelectorAll('.drawer-tab').forEach(t => t.addEventListener('click', () => switchAdvTab(t.dataset.adv)));
2117
+ function switchAdvTab(name) {
2118
+ document.querySelectorAll('.drawer-tab').forEach(t => t.classList.toggle('active', t.dataset.adv === name));
2119
+ document.querySelectorAll('.drawer-panel').forEach(p => p.classList.toggle('active', p.id === 'adv-' + name));
2120
+ }
2121
+
2122
+ // ==================== Advanced drawer loaders ====================
2123
+ async function loadAdvanced() {
2124
+ await Promise.all([loadKnowledge(), loadOverview(), loadFacts(), loadEfficacy(), loadConflicts(), loadActivityLog()]);
2125
+ }
2126
+
2127
+ let knowledgeScope = 'all';
2128
+ let pendingKnowledgeSection = null;
2129
+
2130
+ async function loadKnowledge() {
2131
+ const data = await api('knowledge', {scope: knowledgeScope, limit: 100});
2132
+ const body = document.getElementById('knowledge-body');
2133
+ const totals = document.getElementById('knowledge-totals');
2134
+
2135
+ const projN = (data.totals && data.totals.project) || 0;
2136
+ const globN = (data.totals && data.totals.global) || 0;
2137
+ totals.textContent = `${(projN + globN).toLocaleString()} active · ${projN} project · ${globN} global`;
2138
+
2139
+ const sections = data.sections || [];
2140
+ if (!sections.length) {
2141
+ body.innerHTML = '<div class="knowledge-empty">No knowledge yet. Facts will appear here as Claude extracts decisions, conventions, and architecture from your sessions.</div>';
2142
+ return;
2143
+ }
2144
+
2145
+ body.innerHTML = sections.map(s => `
2146
+ <section class="knowledge-section" id="kb-section-${esc(s.key)}">
2147
+ <header class="knowledge-section-header">
2148
+ <h3>${esc(s.label)}</h3>
2149
+ <span class="count">${s.count}</span>
2150
+ <span class="desc">${esc(s.description)}</span>
2151
+ </header>
2152
+ ${s.count === 0
2153
+ ? '<div class="knowledge-empty">Nothing in this category yet.</div>'
2154
+ : `<div class="knowledge-cards">${s.facts.map(kbCard).join('')}</div>`}
2155
+ </section>
2156
+ `).join('');
2157
+
2158
+ // Pending deep-link from sidebar — scroll to & highlight the requested
2159
+ // section now that it's rendered. Cleared after first application so
2160
+ // subsequent refreshes don't keep scrolling.
2161
+ if (pendingKnowledgeSection) {
2162
+ const el = document.getElementById('kb-section-' + pendingKnowledgeSection);
2163
+ if (el) {
2164
+ el.scrollIntoView({behavior: 'smooth', block: 'start'});
2165
+ const header = el.querySelector('.knowledge-section-header h3');
2166
+ if (header) {
2167
+ header.style.transition = 'color 0.3s';
2168
+ header.style.color = 'var(--accent)';
2169
+ setTimeout(() => { header.style.color = ''; }, 1500);
2170
+ }
2171
+ }
2172
+ pendingKnowledgeSection = null;
2173
+ }
2174
+ }
2175
+
2176
+ function kbCard(f) {
2177
+ return `<div class="knowledge-card" onclick="openFactModal(${f.id}, '${esc(f.source)}')">
2178
+ <div class="kc-object">${esc(f.object || '')}</div>
2179
+ <div class="kc-meta">
2180
+ <span class="pred">${esc(f.predicate)}</span>
2181
+ <span class="src ${esc(f.source)}">${esc(f.source)}</span>
2182
+ ${f.confidence != null ? `<span>${(f.confidence * 100).toFixed(0)}% confident</span>` : ''}
2183
+ ${f.subject ? `<span>subject: <strong>${esc(f.subject)}</strong></span>` : ''}
2184
+ ${f.created_ago ? `<span>added ${esc(f.created_ago)}</span>` : ''}
2185
+ </div>
2186
+ </div>`;
2187
+ }
2188
+
2189
+ function applyKnowledgeSection(sectionKey) {
2190
+ pendingKnowledgeSection = sectionKey;
2191
+ loadKnowledge();
2192
+ }
2193
+
2194
+ document.querySelectorAll('.chip[data-knowledge-scope]').forEach(btn => {
2195
+ btn.addEventListener('click', () => {
2196
+ document.querySelectorAll('.chip[data-knowledge-scope]').forEach(b => b.classList.remove('active'));
2197
+ btn.classList.add('active');
2198
+ knowledgeScope = btn.dataset.knowledgeScope;
2199
+ loadKnowledge();
2200
+ });
2201
+ });
2202
+
2203
+ async function loadOverview() {
2204
+ const data = await api('stats');
2205
+ const dbs = data.databases || {};
2206
+ const host = document.getElementById('overview-rows');
2207
+ const scopes = [
2208
+ {key: 'project', label: 'Project', db: dbs.project || {}},
2209
+ {key: 'global', label: 'Global', db: dbs.global || {}}
2210
+ ];
2211
+ let preds = [], ents = [];
2212
+ host.innerHTML = scopes.map(({label, db}) => {
2213
+ if (!db.exists) {
2214
+ return `<div class="adv-card"><h3>${label}</h3><div style="color: var(--text-dim); font-size: 12px;">Not initialized</div></div>`;
2215
+ }
2216
+ preds = preds.concat(db.top_predicates || []);
2217
+ ents = ents.concat(db.entity_types || []);
2218
+ return `<div class="adv-card">
2219
+ <h3>${label} memory</h3>
2220
+ <div class="adv-grid">
2221
+ ${advMetric('Active facts', db.facts_active, `${db.facts_total} total`)}
2222
+ ${advMetric('Entities', db.entities_total, '')}
2223
+ ${advMetric('Content items', db.content_items, 'ingested')}
2224
+ ${advMetric('Open conflicts', db.open_conflicts, db.open_conflicts ? 'need resolution' : 'none')}
2225
+ </div>
2226
+ </div>`;
2227
+ }).join('');
2228
+ renderBarChart('predicate-chart', mergeAndSort(preds, 'predicate'));
2229
+ renderBarChart('entity-chart', mergeAndSort(ents, 'type'));
2230
+ }
2231
+
2232
+ function advMetric(label, value, sub) {
2233
+ return `<div class="adv-metric">
2234
+ <div class="label">${label}</div>
2235
+ <div class="value">${typeof value === 'number' ? value.toLocaleString() : (value ?? '—')}</div>
2236
+ ${sub ? `<div class="label" style="text-transform: none; letter-spacing: 0; color: var(--text-faint); margin-top: 2px;">${sub}</div>` : ''}
2237
+ </div>`;
2238
+ }
2239
+ function mergeAndSort(items, key) {
2240
+ const m = {};
2241
+ items.forEach(i => { m[i[key]] = (m[i[key]] || 0) + i.count; });
2242
+ return Object.entries(m).sort((a,b) => b[1]-a[1]).slice(0,10).map(([label,count]) => ({label,count}));
2243
+ }
2244
+
2245
+ async function loadFacts() {
2246
+ const scope = document.querySelector('.chip[data-fact-scope].active')?.dataset.factScope || 'all';
2247
+ const status = document.querySelector('.chip[data-status].active')?.dataset.status || 'active';
2248
+ const q = document.getElementById('fact-search').value;
2249
+ const stale = document.getElementById('stale-chip')?.classList.contains('active');
2250
+ const params = {scope, status, q};
2251
+ if (stale) params.stale = 'true';
2252
+ const data = await api('facts', params);
2253
+ const tbody = document.getElementById('facts-tbody');
2254
+ if (!(data.facts || []).length) { tbody.innerHTML = '<tr><td colspan="7" style="color: var(--text-dim);">No facts match.</td></tr>'; return; }
2255
+ tbody.innerHTML = data.facts.map(f => `
2256
+ <tr class="row-clickable" onclick="openFactModal(${f.id}, '${esc(f.source)}')">
2257
+ <td><code>${f.docid || f.id}</code></td>
2258
+ <td>${esc(f.subject)}</td>
2259
+ <td><span class="badge badge-purple">${esc(f.predicate)}</span></td>
2260
+ <td style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${esc(f.object)}">${esc(f.object)}</td>
2261
+ <td>${f.confidence ? (f.confidence * 100).toFixed(0) + '%' : '-'}</td>
2262
+ <td><span class="badge ${f.source === 'global' ? 'badge-purple' : 'badge-info'}">${esc(f.source)}</span></td>
2263
+ <td style="font-size: 11px; color: var(--text-dim);" title="${f.created_at}">${f.created_ago || '-'}</td>
2264
+ </tr>
2265
+ `).join('');
2266
+ }
2267
+
2268
+ document.querySelectorAll('.chip[data-fact-scope], .chip[data-status]').forEach(btn => {
2269
+ btn.addEventListener('click', () => {
2270
+ const grp = btn.dataset.factScope ? 'data-fact-scope' : 'data-status';
2271
+ document.querySelectorAll(`.chip[${grp}]`).forEach(b => b.classList.remove('active'));
2272
+ btn.classList.add('active');
2273
+ loadFacts();
2274
+ });
2275
+ });
2276
+ // Stale chip is a toggle (not part of scope/status group — it composes with them)
2277
+ document.getElementById('stale-chip').addEventListener('click', (e) => {
2278
+ e.currentTarget.classList.toggle('active');
2279
+ loadFacts();
2280
+ });
2281
+
2282
+ // Opens the Facts drawer with the stale filter pre-activated. Called from
2283
+ // the sidebar "Facts never recalled" review row.
2284
+ function applyStaleFilter() {
2285
+ const chip = document.getElementById('stale-chip');
2286
+ if (chip && !chip.classList.contains('active')) {
2287
+ chip.classList.add('active');
2288
+ loadFacts();
2289
+ }
2290
+ }
2291
+ let factSearchTimer;
2292
+ document.getElementById('fact-search').addEventListener('input', () => {
2293
+ clearTimeout(factSearchTimer);
2294
+ factSearchTimer = setTimeout(loadFacts, 250);
2295
+ });
2296
+
2297
+ async function loadEfficacy() {
2298
+ const params = {};
2299
+ if (currentTimeframe === '7d') params.since = new Date(Date.now() - 7*86400*1000).toISOString();
2300
+ else if (currentTimeframe === 'session' && currentSessionId) params.session_id = currentSessionId;
2301
+ const data = await api('efficacy', params);
2302
+
2303
+ const cards = document.getElementById('efficacy-cards');
2304
+ cards.innerHTML = [
2305
+ advMetric('Hit rate', data.recall_events < 10 ? `${data.successful_recalls}/${data.recall_events}` : data.hit_rate + '%', data.recall_events < 10 ? 'n<10 — raw ratio' : `of ${data.recall_events} recalls`),
2306
+ advMetric('Recalls', data.recall_events, currentTimeframe === '7d' ? 'last 7 days' : currentTimeframe),
2307
+ advMetric('Median results', data.median_results_per_query, 'per recall'),
2308
+ advMetric('Median latency', (data.median_latency_ms || 0) + 'ms', 'p50')
2309
+ ].join('');
2310
+
2311
+ renderBarChart('efficacy-tool-mix', (data.tool_mix || []).map(t => ({label: (t.tool || '—').replace('memory.', ''), count: t.count})));
2312
+
2313
+ const gaps = document.getElementById('efficacy-gaps');
2314
+ if (!(data.memory_gaps || []).length) {
2315
+ gaps.innerHTML = '<div style="color: var(--text-dim); font-size: 12px;">No zero-result queries.</div>';
2316
+ } else {
2317
+ gaps.innerHTML = data.memory_gaps.map(g => `
2318
+ <div style="padding: 4px 0; font-size: 12px; border-bottom: 1px solid var(--surface2);">
2319
+ <span style="color: var(--text-faint);">${g.occurred_ago}</span>
2320
+ <span class="badge badge-info" style="margin: 0 6px;">${(g.tool || '').replace('memory.', '')}</span>
2321
+ <code>${esc(g.query || '')}</code>
2322
+ </div>
2323
+ `).join('');
2324
+ }
2325
+
2326
+ const tbody = document.getElementById('efficacy-trace-tbody');
2327
+ if (!(data.recall_trace || []).length) {
2328
+ tbody.innerHTML = '<tr><td colspan="5" style="color: var(--text-dim);">No recalls.</td></tr>';
2329
+ } else {
2330
+ tbody.innerHTML = data.recall_trace.map(r => `
2331
+ <tr class="row-clickable" onclick="openMomentModal(${r.id})">
2332
+ <td title="${r.occurred_at}">${r.occurred_ago}</td>
2333
+ <td><span class="badge badge-info">${(r.tool || '').replace('memory.', '')}</span></td>
2334
+ <td style="max-width: 280px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${esc(r.query || '')}">${esc(r.query || '—')}</td>
2335
+ <td>${r.result_count}</td>
2336
+ <td>${r.duration_ms != null ? r.duration_ms + 'ms' : '-'}</td>
2337
+ </tr>
2338
+ `).join('');
2339
+ }
2340
+ }
2341
+ document.querySelectorAll('.chip[data-timeframe]').forEach(btn => {
2342
+ btn.addEventListener('click', () => {
2343
+ document.querySelectorAll('.chip[data-timeframe]').forEach(b => b.classList.remove('active'));
2344
+ btn.classList.add('active');
2345
+ currentTimeframe = btn.dataset.timeframe;
2346
+ loadEfficacy();
2347
+ });
2348
+ });
2349
+
2350
+ async function loadConflicts() {
2351
+ const scope = document.querySelector('.chip[data-conflict-scope].active')?.dataset.conflictScope || 'project';
2352
+ const status = document.querySelector('.chip[data-conflict-status].active')?.dataset.conflictStatus || 'open';
2353
+ const data = await api('conflicts', {scope, status});
2354
+ if (data.counts) {
2355
+ ['project','global'].forEach(s => {
2356
+ const el = document.querySelector(`[data-count-scope="${s}"]`);
2357
+ if (el) { const c = data.counts[s] || {}; el.textContent = '(' + (status === 'all' ? (c.total||0) : (c[status]||0)) + ')'; }
2358
+ });
2359
+ }
2360
+ const tbody = document.getElementById('conflicts-tbody');
2361
+ if (!(data.conflicts || []).length) { tbody.innerHTML = '<tr><td colspan="5" style="color: var(--text-dim);">No conflicts.</td></tr>'; return; }
2362
+ tbody.innerHTML = data.conflicts.map(c => {
2363
+ const a = c.fact_a_preview || {}, b = c.fact_b_preview || {};
2364
+ const size = c.group_size || 1;
2365
+ const sizeBadge = size > 1 ? ` <span class="badge badge-info" title="Group: ${size} duplicate detections resolve together">×${size}</span>` : '';
2366
+ return `<tr class="row-clickable" onclick="openConflictModal(${c.id}, '${esc(c.source)}')">
2367
+ <td title="${c.detected_at}">${c.detected_ago}</td>
2368
+ <td style="max-width: 240px;">
2369
+ <span class="badge badge-purple">${esc(a.predicate || '?')}</span>
2370
+ <div style="font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${esc(a.object || '')}">${esc(a.object || '—')}</div>
2371
+ </td>
2372
+ <td style="color: var(--text-faint); font-size: 11px;">vs</td>
2373
+ <td style="max-width: 240px;">
2374
+ <span class="badge badge-purple">${esc(b.predicate || '?')}</span>
2375
+ <div style="font-size: 11px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${esc(b.object || '')}">${esc(b.object || '—')}</div>
2376
+ </td>
2377
+ <td><span class="badge badge-${c.status === 'open' ? 'warning' : 'success'}">${esc(c.status)}</span>${sizeBadge}</td>
2378
+ </tr>`;
2379
+ }).join('');
2380
+ }
2381
+ document.querySelectorAll('.chip[data-conflict-scope], .chip[data-conflict-status]').forEach(btn => {
2382
+ btn.addEventListener('click', () => {
2383
+ const grp = btn.dataset.conflictScope ? 'data-conflict-scope' : 'data-conflict-status';
2384
+ document.querySelectorAll(`.chip[${grp}]`).forEach(b => b.classList.remove('active'));
2385
+ btn.classList.add('active');
2386
+ loadConflicts();
2387
+ });
2388
+ });
2389
+
2390
+ async function loadActivityLog() {
2391
+ const data = await api('activity', {limit: 100});
2392
+ const tbody = document.getElementById('activity-tbody');
2393
+ if (!(data.events || []).length) { tbody.innerHTML = '<tr><td colspan="5" style="color: var(--text-dim);">No events.</td></tr>'; return; }
2394
+ tbody.innerHTML = data.events.map(e => `
2395
+ <tr class="row-clickable" onclick="openMomentModal(${e.id})">
2396
+ <td title="${e.occurred_at}">${e.occurred_ago || e.occurred_at}</td>
2397
+ <td><span class="badge badge-info">${esc(e.event_type)}</span></td>
2398
+ <td><span class="badge badge-${statusBadge(e.status)}">${esc(e.status)}</span></td>
2399
+ <td>${e.duration_ms != null ? e.duration_ms + 'ms' : '-'}</td>
2400
+ <td style="font-size: 11px; color: var(--text-dim);">${esc(JSON.stringify(e.details || {}).slice(0, 120))}</td>
2401
+ </tr>
2402
+ `).join('');
2403
+ }
2404
+
2405
+ // ==================== Conflict modal (inline) ====================
2406
+ async function openConflictModal(id, scope) {
2407
+ currentConflictId = id; currentConflictScope = scope;
2408
+ const body = document.getElementById('conflict-modal-body');
2409
+ body.innerHTML = '<p style="color: var(--text-dim);">Loading…</p>';
2410
+ openModal('conflict-modal');
2411
+ try {
2412
+ const data = await api('conflicts/' + id, {scope});
2413
+ if (data.error) { body.innerHTML = `<p style="color: var(--text-dim);">${esc(data.error)}</p>`; return; }
2414
+ body.innerHTML = renderConflictDetail(data);
2415
+ } catch (e) {
2416
+ body.innerHTML = `<p style="color: var(--text-dim);">Failed: ${esc(e.message)}</p>`;
2417
+ }
2418
+ }
2419
+ function renderConflictDetail(data) {
2420
+ const c = data.conflict;
2421
+ const canReject = c.status === 'open';
2422
+ const keeper = (data.fact_a && data.fact_a.status === 'active') ? data.fact_a
2423
+ : (data.fact_b && data.fact_b.status === 'active') ? data.fact_b : null;
2424
+ const bulk = (canReject && keeper) ? `
2425
+ <div style="margin-bottom: 12px; padding: 10px 12px; background: var(--surface2); border-left: 3px solid var(--accent); border-radius: 4px; font-size: 12px;">
2426
+ Fact #${keeper.id} may have other open conflicts against it.
2427
+ <button class="btn-reject" style="margin-left: 8px;" onclick="rejectAllSimilar(${keeper.id})">Reject all similar</button>
2428
+ </div>` : '';
2429
+ return `
2430
+ <div style="font-size: 12px; color: var(--text-dim); padding: 8px 12px; background: var(--surface2); border-radius: 4px; margin-bottom: 12px;">
2431
+ Conflict #${c.id} — detected ${esc(c.detected_ago || '')} — <span class="badge badge-${c.status === 'open' ? 'warning' : 'success'}">${esc(c.status)}</span>
2432
+ </div>
2433
+ ${bulk}
2434
+ <div class="conflict-pair">
2435
+ ${renderConflictSide('A', data.fact_a, canReject)}
2436
+ ${renderConflictSide('B', data.fact_b, canReject)}
2437
+ </div>
2438
+ `;
2439
+ }
2440
+ function renderConflictSide(label, fact, canReject) {
2441
+ if (!fact) return `<div class="conflict-side"><h4>Fact ${label}</h4><p style="color: var(--text-dim);">missing</p></div>`;
2442
+ const sideKey = label.toLowerCase();
2443
+ const provList = (fact.provenance || []).length
2444
+ ? `<ul class="conflict-prov-list">${fact.provenance.map(p => `
2445
+ <li>
2446
+ <div>${esc(p.strength || 'stated')} — session ${p.session_id ? esc(p.session_id.slice(0,8)) : '(unknown)'}${p.occurred_at ? ' on ' + esc(p.occurred_at.slice(0,10)) : ''}</div>
2447
+ ${p.quote ? `<span class="quote">${esc(p.quote)}</span>` : ''}
2448
+ </li>`).join('')}</ul>`
2449
+ : '<p style="color: var(--text-dim); font-size: 11px;">No provenance.</p>';
2450
+ return `
2451
+ <div class="conflict-side">
2452
+ <h4>Fact ${label} (#${fact.id})</h4>
2453
+ <div><strong>${esc(fact.subject)}</strong> <span class="badge badge-purple">${esc(fact.predicate)}</span></div>
2454
+ <div class="conflict-fact-object">${esc(fact.object)}</div>
2455
+ <div style="font-size: 11px; color: var(--text-dim); margin-bottom: 8px;">
2456
+ ${fact.confidence ? (fact.confidence * 100).toFixed(0) + '% · ' : ''}
2457
+ <span class="badge badge-${fact.status === 'active' ? 'success' : 'warning'}">${esc(fact.status)}</span>
2458
+ </div>
2459
+ ${provList}
2460
+ ${canReject ? `
2461
+ <div class="conflict-actions">
2462
+ <textarea class="conflict-reason" id="conflict-reason-${sideKey}" placeholder="Reason (optional)" rows="1"></textarea>
2463
+ <button class="btn-reject" onclick="rejectConflictFact('${sideKey}', this)">Reject ${label}</button>
2464
+ </div>` : ''}
2465
+ </div>`;
2466
+ }
2467
+ async function rejectAllSimilar(keeper) {
2468
+ if (!confirm(`Reject every fact currently in open conflict with fact #${keeper}?`)) return;
2469
+ try {
2470
+ const res = await fetch('/api/conflicts/reject_similar', {
2471
+ method: 'POST',
2472
+ headers: {'Content-Type': 'application/json'},
2473
+ body: JSON.stringify({keeper_fact_id: keeper, reason: 'bulk reject via dashboard', scope: currentConflictScope || 'project'})
2474
+ });
2475
+ const data = await res.json();
2476
+ if (data.error) return showToast('Error: ' + data.error, true);
2477
+ showToast(`Kept #${data.keeper_fact_id} · rejected ${(data.rejected_fact_ids || []).length} fact(s)`);
2478
+ closeModal('conflict-modal');
2479
+ loadConflicts(); loadTrust();
2480
+ } catch (e) { showToast('Failed: ' + e.message, true); }
2481
+ }
2482
+ async function rejectConflictFact(side, btn) {
2483
+ if (currentConflictId == null) return;
2484
+ const reason = document.getElementById('conflict-reason-' + side)?.value?.trim();
2485
+ btn.disabled = true; btn.textContent = 'Rejecting…';
2486
+ try {
2487
+ const res = await fetch('/api/conflicts/' + currentConflictId + '/reject', {
2488
+ method: 'POST',
2489
+ headers: {'Content-Type': 'application/json'},
2490
+ body: JSON.stringify({side, reason, scope: currentConflictScope || 'project'})
2491
+ });
2492
+ const data = await res.json();
2493
+ if (data.error) { showToast('Error: ' + data.error, true); btn.disabled = false; btn.textContent = 'Reject ' + side.toUpperCase(); return; }
2494
+ showToast(`Fact #${data.rejected_fact_id} rejected — ${data.conflicts_resolved} conflict(s) resolved`);
2495
+ closeModal('conflict-modal');
2496
+ loadConflicts(); loadTrust();
2497
+ } catch (e) { showToast('Failed: ' + e.message, true); btn.disabled = false; btn.textContent = 'Reject ' + side.toUpperCase(); }
2498
+ }
2499
+
2500
+ // ==================== Fact modal ====================
2501
+ async function openFactModal(id, scope) {
2502
+ currentFactId = id; currentFactScope = scope;
2503
+ const body = document.getElementById('fact-modal-body');
2504
+ body.innerHTML = '<p style="color: var(--text-dim);">Loading…</p>';
2505
+ openModal('fact-modal');
2506
+ try {
2507
+ const data = await api('facts/' + id, {scope});
2508
+ if (data.error) { body.innerHTML = `<p style="color: var(--text-dim);">${esc(data.error)}</p>`; return; }
2509
+ body.innerHTML = renderFactDetail(data);
2510
+ } catch (e) {
2511
+ body.innerHTML = `<p style="color: var(--text-dim);">Failed: ${esc(e.message)}</p>`;
2512
+ }
2513
+ }
2514
+ function renderFactDetail(f) {
2515
+ const provList = (f.provenance || []).length
2516
+ ? `<ul class="conflict-prov-list">${f.provenance.map(p => `
2517
+ <li>
2518
+ <div>${esc(p.strength || 'stated')} — session ${p.session_id ? esc(p.session_id.slice(0,8)) : '(unknown)'}${p.occurred_at ? ' on ' + esc(p.occurred_at.slice(0,10)) : ''}</div>
2519
+ ${p.quote ? `<span class="quote">${esc(p.quote)}</span>` : ''}
2520
+ </li>`).join('')}</ul>`
2521
+ : '<p style="color: var(--text-dim); font-size: 11px;">No provenance.</p>';
2522
+ const canReject = f.status === 'active' || f.status === 'disputed';
2523
+ const canPromote = f.source === 'project' && f.status === 'active';
2524
+ return `
2525
+ <div class="conflict-side" style="background: transparent; border: none; padding: 0;">
2526
+ <h4>Fact #${f.id} · <code>${esc(f.docid || '')}</code></h4>
2527
+ <div><strong>${esc(f.subject)}</strong> <span class="badge badge-purple">${esc(f.predicate)}</span></div>
2528
+ <div class="conflict-fact-object">${esc(f.object)}</div>
2529
+ <dl class="modal-kv">
2530
+ <dt>status</dt><dd><span class="badge badge-${f.status === 'active' ? 'success' : 'warning'}">${esc(f.status)}</span></dd>
2531
+ <dt>scope</dt><dd><span class="badge badge-info">${esc(f.scope)}</span> (${esc(f.source)})</dd>
2532
+ <dt>confidence</dt><dd>${f.confidence ? (f.confidence * 100).toFixed(0) + '%' : '-'}</dd>
2533
+ <dt>created</dt><dd>${esc(f.created_at || '')} (${esc(f.created_ago || '')})</dd>
2534
+ </dl>
2535
+ <div class="modal-section"><h3>Provenance</h3>${provList}</div>
2536
+ ${canReject ? `
2537
+ <div class="conflict-actions" style="margin-top: 16px;">
2538
+ <textarea class="conflict-reason" id="fact-reason" placeholder="Reason (optional)" rows="1"></textarea>
2539
+ <button class="btn-reject" onclick="rejectFactFromModal(this)">Reject</button>
2540
+ ${canPromote ? '<button class="btn-reject btn-promote" onclick="promoteFactFromModal(this)">Promote to global</button>' : ''}
2541
+ </div>` : ''}
2542
+ </div>
2543
+ `;
2544
+ }
2545
+ async function rejectFactFromModal(btn) {
2546
+ if (currentFactId == null) return;
2547
+ const reason = document.getElementById('fact-reason')?.value?.trim();
2548
+ btn.disabled = true; btn.textContent = 'Rejecting…';
2549
+ try {
2550
+ const res = await fetch('/api/facts/' + currentFactId + '/reject', {
2551
+ method: 'POST', headers: {'Content-Type': 'application/json'},
2552
+ body: JSON.stringify({reason, scope: currentFactScope || 'project'})
2553
+ });
2554
+ const data = await res.json();
2555
+ if (data.error) { showToast('Error: ' + data.error, true); btn.disabled = false; btn.textContent = 'Reject'; return; }
2556
+ showToast(`Fact #${data.fact_id} rejected`);
2557
+ closeModal('fact-modal'); loadFacts(); loadTrust();
2558
+ } catch (e) { showToast('Failed: ' + e.message, true); btn.disabled = false; btn.textContent = 'Reject'; }
2559
+ }
2560
+ async function promoteFactFromModal(btn) {
2561
+ if (currentFactId == null) return;
2562
+ if (!confirm(`Promote fact #${currentFactId} to global memory?`)) return;
2563
+ btn.disabled = true; btn.textContent = 'Promoting…';
2564
+ try {
2565
+ const res = await fetch('/api/facts/' + currentFactId + '/promote', {
2566
+ method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
2567
+ });
2568
+ const data = await res.json();
2569
+ if (data.error) { showToast('Error: ' + data.error, true); btn.disabled = false; btn.textContent = 'Promote to global'; return; }
2570
+ showToast(`Fact #${data.project_fact_id} promoted → global #${data.global_fact_id}`);
2571
+ closeModal('fact-modal'); loadFacts(); loadTrust();
2572
+ } catch (e) { showToast('Failed: ' + e.message, true); btn.disabled = false; btn.textContent = 'Promote to global'; }
2573
+ }
2574
+
2575
+ // ==================== Explore ====================
2576
+ async function runExplore() {
2577
+ const query = document.getElementById('explore-query').value.trim();
2578
+ const scope = document.getElementById('explore-scope').value;
2579
+ const limit = document.getElementById('explore-limit').value;
2580
+ const meta = document.getElementById('explore-meta');
2581
+ const tbody = document.getElementById('explore-tbody');
2582
+ if (!query) { meta.textContent = 'Enter a query.'; return; }
2583
+ meta.textContent = 'Querying…';
2584
+ tbody.innerHTML = '';
2585
+ try {
2586
+ const data = await api('recall', {query, scope, limit});
2587
+ if (data.error) {
2588
+ meta.innerHTML = renderRecallError(data, scope);
2589
+ return;
2590
+ }
2591
+ meta.textContent = `${data.count} result(s) in ${data.duration_ms}ms · scope=${data.scope}`;
2592
+ if (!(data.facts || []).length) { tbody.innerHTML = '<tr><td colspan="7" style="color: var(--text-dim);">No facts matched.</td></tr>'; return; }
2593
+ tbody.innerHTML = data.facts.map((f, i) => `
2594
+ <tr class="row-clickable" onclick="openFactModal(${f.id}, '${esc(f.source || f.scope)}')">
2595
+ <td style="color: var(--text-faint);">${i+1}</td>
2596
+ <td>${esc(f.subject || '')}</td>
2597
+ <td><span class="badge badge-purple">${esc(f.predicate || '')}</span></td>
2598
+ <td style="max-width: 320px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;" title="${esc(f.object || '')}">${esc(f.object || '')}</td>
2599
+ <td><span class="badge badge-info">${esc(f.scope || '')}</span></td>
2600
+ <td style="font-size: 11px; color: var(--text-dim);">${f.receipts_count != null ? f.receipts_count : '—'}</td>
2601
+ <td style="font-size: 11px; color: var(--text-dim);">${f.score ? f.score.toFixed(3) : '—'}</td>
2602
+ </tr>
2603
+ `).join('');
2604
+ } catch (e) { meta.innerHTML = `<span class="json-error">Failed: ${esc(e.message)}</span>`; }
2605
+ }
2606
+ document.getElementById('explore-btn').addEventListener('click', runExplore);
2607
+ document.getElementById('explore-query').addEventListener('keydown', e => { if (e.key === 'Enter') runExplore(); });
2608
+
2609
+ function renderRecallError(data, scope) {
2610
+ const msg = data.error || 'Recall failed';
2611
+ // FTS5 'disk image is malformed' on ORDER BY rank is almost always the
2612
+ // auxiliary-index artifact that claude-memory compact fixes. Detect and
2613
+ // turn the error into an actionable card rather than a raw error line.
2614
+ const isFts5Corruption = /disk image is malformed/i.test(msg);
2615
+ if (isFts5Corruption) {
2616
+ const targetScope = (scope === 'all' || !scope) ? 'project' : scope;
2617
+ const cmd = `claude-memory compact --scope ${targetScope}`;
2618
+ return `
2619
+ <div class="recall-error-card">
2620
+ <h4>Search index is out of sync</h4>
2621
+ <p>The FTS5 auxiliary index for the <strong>${esc(targetScope)}</strong> database fell out of sync — usually a harmless leftover from a prior <code>sqlite3 .recover</code> or an interrupted write. The database itself is fine; just the search index needs rebuilding.</p>
2622
+ <div class="cmd-row">
2623
+ <span class="cmd">${esc(cmd)}</span>
2624
+ <button class="copy-btn" onclick="copyCmd(this, '${esc(cmd)}')">Copy</button>
2625
+ </div>
2626
+ <div class="retry-hint">Run that in a terminal, then click <strong>Recall</strong> again.</div>
2627
+ </div>
2628
+ `;
2629
+ }
2630
+ const hint = data.hint ? `<div style="margin-top: 6px; font-size: 12px; color: var(--text-dim);">${esc(data.hint)}</div>` : '';
2631
+ return `<span class="json-error">${esc(msg)}</span>${hint}`;
2632
+ }
2633
+
2634
+ function copyCmd(btn, cmd) {
2635
+ navigator.clipboard.writeText(cmd).then(() => {
2636
+ const prev = btn.textContent;
2637
+ btn.textContent = 'Copied ✓';
2638
+ setTimeout(() => { btn.textContent = prev; }, 1500);
2639
+ }).catch(() => showToast('Copy failed — select and copy manually', true));
2640
+ }
2641
+
2642
+ // ==================== Utilities ====================
2643
+ function renderBarChart(id, data) {
2644
+ const el = document.getElementById(id);
2645
+ if (!data.length) { el.innerHTML = '<div style="color: var(--text-dim); font-size: 12px;">No data</div>'; return; }
2646
+ const max = Math.max(...data.map(d => d.count), 1);
2647
+ el.innerHTML = data.map(d => `
2648
+ <div class="bar-row">
2649
+ <span class="bar-label" title="${esc(d.label)}">${esc(d.label)}</span>
2650
+ <div class="bar-track"><div class="bar-fill" style="width: ${(d.count/max*100).toFixed(1)}%"></div></div>
2651
+ <span class="bar-value">${d.count}</span>
2652
+ </div>`).join('');
2653
+ }
2654
+ function statusBadge(s) { return s === 'success' ? 'success' : s === 'error' ? 'error' : s === 'skipped' ? 'warning' : 'info'; }
2655
+ function esc(s) { if (s == null) return ''; const d = document.createElement('div'); d.textContent = String(s); return d.innerHTML; }
2656
+ function showToast(msg, isError) {
2657
+ const t = document.getElementById('toast');
2658
+ t.textContent = msg;
2659
+ t.classList.toggle('error', !!isError);
2660
+ t.classList.add('visible');
2661
+ clearTimeout(showToast._timer);
2662
+ showToast._timer = setTimeout(() => t.classList.remove('visible'), 4000);
2663
+ }
2664
+
2665
+ // JSON pretty-printer (transcript-aware — JSONL-safe).
2666
+ function formatJson(input, {fallbackLabel = null, truncated = false} = {}) {
2667
+ if (input == null || input === '') return '<span class="json-null">(empty)</span>';
2668
+ if (typeof input !== 'string') return colorizeJson(JSON.stringify(input, null, 2));
2669
+ try { return colorizeJson(JSON.stringify(JSON.parse(input), null, 2)); } catch (_) {}
2670
+ const lines = input.split(/\r?\n/);
2671
+ const parseable = [];
2672
+ const pieces = [];
2673
+ let lastLineFailed = false;
2674
+ lines.forEach((line, i) => {
2675
+ if (line.trim() === '') { pieces.push(''); return; }
2676
+ try { pieces.push(colorizeJson(JSON.stringify(JSON.parse(line), null, 2))); parseable.push(i); }
2677
+ catch (_) { pieces.push(colorizeJson(line)); if (i === lines.length - 1) lastLineFailed = true; }
2678
+ });
2679
+ if (lines.filter(l => l.trim()).length > 1 && parseable.length > 0) {
2680
+ const hint = (truncated || lastLineFailed) ? '<span class="json-error">Last record appears truncated</span>' : '';
2681
+ return hint + pieces.join('\n');
2682
+ }
2683
+ const hint = truncated
2684
+ ? '<span class="json-error">Content truncated</span>'
2685
+ : fallbackLabel ? `<span class="json-error">${esc(fallbackLabel)}</span>`
2686
+ : '<span class="json-error">Non-JSON content — tokenized</span>';
2687
+ return hint + colorizeJson(input);
2688
+ }
2689
+ function colorizeJson(text) {
2690
+ const escaped = text.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
2691
+ return escaped.replace(
2692
+ /("(?:\\u[a-zA-Z0-9]{4}|\\[^u]|[^\\"])*"(?:\s*:)?|\b(?:true|false|null)\b|-?\d+(?:\.\d*)?(?:[eE][+\-]?\d+)?)/g,
2693
+ m => {
2694
+ let cls = 'json-number';
2695
+ if (/^"/.test(m)) cls = /:$/.test(m) ? 'json-key' : 'json-string';
2696
+ else if (/true|false/.test(m)) cls = 'json-bool';
2697
+ else if (/null/.test(m)) cls = 'json-null';
2698
+ return `<span class="${cls}">${m}</span>`;
2699
+ });
2700
+ }
2701
+
2702
+ // ==================== Boot ====================
2703
+ loadAll();
2704
+ setInterval(loadAll, 30000);
2705
+ </script>
2706
+ </body>
2707
+ </html>