@agentmemory/agentmemory 0.9.28 → 0.9.29

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 (125) hide show
  1. package/.env.example +15 -6
  2. package/AGENTS.md +6 -6
  3. package/README.md +220 -152
  4. package/dist/.env.example +15 -6
  5. package/dist/cli.d.mts.map +1 -1
  6. package/dist/cli.mjs +233 -35
  7. package/dist/cli.mjs.map +1 -1
  8. package/dist/{connect-JnsJXc68.mjs → connect-Qaeq15TQ.mjs} +599 -41
  9. package/dist/connect-Qaeq15TQ.mjs.map +1 -0
  10. package/dist/docker-compose.yml +2 -2
  11. package/dist/hooks/antigravity-bridge.d.mts +8 -0
  12. package/dist/hooks/antigravity-bridge.d.mts.map +1 -0
  13. package/dist/hooks/antigravity-bridge.mjs +114 -0
  14. package/dist/hooks/antigravity-bridge.mjs.map +1 -0
  15. package/dist/hooks/notification.mjs +19 -4
  16. package/dist/hooks/notification.mjs.map +1 -1
  17. package/dist/hooks/post-commit.mjs +13 -1
  18. package/dist/hooks/post-commit.mjs.map +1 -1
  19. package/dist/hooks/post-tool-failure.mjs +14 -3
  20. package/dist/hooks/post-tool-failure.mjs.map +1 -1
  21. package/dist/hooks/post-tool-use.mjs +14 -3
  22. package/dist/hooks/post-tool-use.mjs.map +1 -1
  23. package/dist/hooks/pre-compact.mjs +12 -2
  24. package/dist/hooks/pre-compact.mjs.map +1 -1
  25. package/dist/hooks/pre-tool-use.mjs +1 -1
  26. package/dist/hooks/pre-tool-use.mjs.map +1 -1
  27. package/dist/hooks/prompt-submit.mjs +14 -3
  28. package/dist/hooks/prompt-submit.mjs.map +1 -1
  29. package/dist/hooks/session-end.mjs +82 -18
  30. package/dist/hooks/session-end.mjs.map +1 -1
  31. package/dist/hooks/session-start.mjs +22 -4
  32. package/dist/hooks/session-start.mjs.map +1 -1
  33. package/dist/hooks/stop.mjs +1 -7
  34. package/dist/hooks/stop.mjs.map +1 -1
  35. package/dist/hooks/subagent-start.mjs +14 -3
  36. package/dist/hooks/subagent-start.mjs.map +1 -1
  37. package/dist/hooks/subagent-stop.mjs +14 -3
  38. package/dist/hooks/subagent-stop.mjs.map +1 -1
  39. package/dist/hooks/task-completed.mjs +14 -3
  40. package/dist/hooks/task-completed.mjs.map +1 -1
  41. package/dist/{image-refs-C2YU7BIm.mjs → image-refs-CJgZmb9X.mjs} +2 -2
  42. package/dist/{image-refs-C2YU7BIm.mjs.map → image-refs-CJgZmb9X.mjs.map} +1 -1
  43. package/dist/index.mjs +1557 -755
  44. package/dist/index.mjs.map +1 -1
  45. package/dist/schema-BFZhNTF7.mjs +197 -0
  46. package/dist/schema-BFZhNTF7.mjs.map +1 -0
  47. package/dist/{src-CzgoepGU.mjs → src-G7yt8gGm.mjs} +1531 -883
  48. package/dist/src-G7yt8gGm.mjs.map +1 -0
  49. package/dist/{standalone-YsZFFQBM.mjs → standalone-BqNJDJ04.mjs} +26 -15
  50. package/dist/standalone-BqNJDJ04.mjs.map +1 -0
  51. package/dist/standalone.d.mts.map +1 -1
  52. package/dist/standalone.mjs +50 -14
  53. package/dist/standalone.mjs.map +1 -1
  54. package/dist/{tools-registry-D3NEoe5m.mjs → tools-registry-BV_2bu7z.mjs} +293 -3
  55. package/dist/tools-registry-BV_2bu7z.mjs.map +1 -0
  56. package/dist/viewer/favicon.svg +35 -1
  57. package/dist/viewer/index.html +473 -83
  58. package/docker-compose.yml +2 -2
  59. package/integrations/pi/README.md +77 -0
  60. package/integrations/pi/index.ts +403 -0
  61. package/integrations/pi/package.json +32 -0
  62. package/integrations/pi/security.ts +35 -0
  63. package/package.json +3 -4
  64. package/plugin/.claude-plugin/plugin.json +2 -2
  65. package/plugin/.codex-plugin/plugin.json +2 -2
  66. package/plugin/.devin-plugin/plugin.json +32 -0
  67. package/plugin/cursor/hooks.json +41 -0
  68. package/plugin/cursor/mcp.json +12 -0
  69. package/plugin/hooks/hooks.antigravity.json +43 -0
  70. package/plugin/hooks/hooks.devin.json +67 -0
  71. package/plugin/hooks/hooks.droid.json +55 -0
  72. package/plugin/opencode/README.md +1 -1
  73. package/plugin/opencode/agentmemory-capture.ts +73 -13
  74. package/plugin/opencode/plugin.json +1 -1
  75. package/plugin/plugin.json +2 -2
  76. package/plugin/scripts/antigravity-bridge.d.mts +8 -0
  77. package/plugin/scripts/antigravity-bridge.d.mts.map +1 -0
  78. package/plugin/scripts/antigravity-bridge.mjs +114 -0
  79. package/plugin/scripts/antigravity-bridge.mjs.map +1 -0
  80. package/plugin/scripts/notification.mjs +19 -4
  81. package/plugin/scripts/notification.mjs.map +1 -1
  82. package/plugin/scripts/post-commit.mjs +13 -1
  83. package/plugin/scripts/post-commit.mjs.map +1 -1
  84. package/plugin/scripts/post-tool-failure.mjs +14 -3
  85. package/plugin/scripts/post-tool-failure.mjs.map +1 -1
  86. package/plugin/scripts/post-tool-use.mjs +14 -3
  87. package/plugin/scripts/post-tool-use.mjs.map +1 -1
  88. package/plugin/scripts/pre-compact.mjs +12 -2
  89. package/plugin/scripts/pre-compact.mjs.map +1 -1
  90. package/plugin/scripts/pre-tool-use.mjs +1 -1
  91. package/plugin/scripts/pre-tool-use.mjs.map +1 -1
  92. package/plugin/scripts/prompt-submit.mjs +14 -3
  93. package/plugin/scripts/prompt-submit.mjs.map +1 -1
  94. package/plugin/scripts/session-end.mjs +82 -18
  95. package/plugin/scripts/session-end.mjs.map +1 -1
  96. package/plugin/scripts/session-start.mjs +22 -4
  97. package/plugin/scripts/session-start.mjs.map +1 -1
  98. package/plugin/scripts/stop.mjs +1 -7
  99. package/plugin/scripts/stop.mjs.map +1 -1
  100. package/plugin/scripts/subagent-start.mjs +14 -3
  101. package/plugin/scripts/subagent-start.mjs.map +1 -1
  102. package/plugin/scripts/subagent-stop.mjs +14 -3
  103. package/plugin/scripts/subagent-stop.mjs.map +1 -1
  104. package/plugin/scripts/task-completed.mjs +14 -3
  105. package/plugin/scripts/task-completed.mjs.map +1 -1
  106. package/plugin/skills/agentmemory-agents/REFERENCE.md +6 -3
  107. package/plugin/skills/agentmemory-config/REFERENCE.md +4 -1
  108. package/plugin/skills/agentmemory-mcp-tools/REFERENCE.md +3 -2
  109. package/plugin/skills/agentmemory-rest-api/REFERENCE.md +14 -1
  110. package/plugin/skills/forget/SKILL.md +4 -1
  111. package/plugin/skills/lesson/SKILL.md +64 -0
  112. package/plugin/skills/memory-discipline/SKILL.md +57 -0
  113. package/plugin/skills/recall/SKILL.md +4 -1
  114. package/plugin/skills/remember/SKILL.md +6 -1
  115. package/dist/config-ChC9EtgV.mjs +0 -259
  116. package/dist/config-ChC9EtgV.mjs.map +0 -1
  117. package/dist/connect-JnsJXc68.mjs.map +0 -1
  118. package/dist/schema-CNiCWzV9.mjs +0 -78
  119. package/dist/schema-CNiCWzV9.mjs.map +0 -1
  120. package/dist/src-CzgoepGU.mjs.map +0 -1
  121. package/dist/standalone-YsZFFQBM.mjs.map +0 -1
  122. package/dist/tools-registry-D3NEoe5m.mjs.map +0 -1
  123. package/plugin/scripts/diagnostics.d.mts +0 -17
  124. package/plugin/scripts/diagnostics.d.mts.map +0 -1
  125. package/plugin/scripts/diagnostics.mjs.map +0 -1
@@ -50,22 +50,21 @@
50
50
  --font-mono: 'JetBrains Mono', 'SF Mono', 'Fira Code', monospace;
51
51
  }
52
52
  html[data-theme="dark"] {
53
- --bg: #1a1a1e;
54
- --bg-alt: #232328;
55
- --bg-subtle: #1f1f24;
56
- --bg-inset: #2a2a30;
57
- --border: #444;
58
- --border-light: #3a3a42;
59
- --border-heavy: #ccc;
60
- --ink: #eee;
61
- --ink-secondary: #ccc;
62
- --ink-muted: #999;
63
- --ink-faint: #777;
53
+ --bg: #121316;
54
+ --bg-alt: #1a1c20;
55
+ --bg-subtle: #17181b;
56
+ --bg-inset: #222428;
57
+ --border: #33363b;
58
+ --border-light: #26282c;
59
+ --border-heavy: #c9cbd1;
60
+ --ink: #eef0f3;
61
+ --ink-secondary: #c6c9ce;
62
+ --ink-muted: #94979d;
63
+ --ink-faint: #6d7076;
64
+ --accent: #f2555a;
65
+ --accent-light: #ff7a70;
64
66
  --cream: #2a2520;
65
67
  }
66
- html[data-theme="dark"] body {
67
- background-image: radial-gradient(circle, #3a3a42 0.5px, transparent 0.5px);
68
- }
69
68
  html[data-theme="dark"] .graph-tooltip {
70
69
  background: rgba(30,30,35,0.92);
71
70
  border-color: rgba(255,255,255,0.1);
@@ -80,6 +79,16 @@
80
79
  color: var(--bg);
81
80
  }
82
81
  * { margin: 0; padding: 0; box-sizing: border-box; }
82
+ #bg-dither {
83
+ position: fixed;
84
+ inset: 0;
85
+ width: 100%;
86
+ height: 100%;
87
+ z-index: 0;
88
+ pointer-events: none;
89
+ opacity: 0.5;
90
+ }
91
+ .app-header, .tab-bar, .view, .flags-banner, footer, .app-footer { position: relative; z-index: 1; }
83
92
  body {
84
93
  font-family: var(--font-body);
85
94
  background: var(--bg);
@@ -89,8 +98,6 @@
89
98
  height: 100vh;
90
99
  display: flex;
91
100
  flex-direction: column;
92
- background-image: radial-gradient(circle, #D4D4CF 0.5px, transparent 0.5px);
93
- background-size: 16px 16px;
94
101
  }
95
102
  ::-webkit-scrollbar { width: 6px; }
96
103
  ::-webkit-scrollbar-track { background: var(--bg); }
@@ -139,6 +146,11 @@
139
146
  align-items: center;
140
147
  gap: 12px;
141
148
  }
149
+ @media (max-width: 720px) {
150
+ .app-header { flex-wrap: wrap; row-gap: 6px; padding: 10px 16px; }
151
+ .app-header .dateline { display: none; }
152
+ .view { overflow-x: auto; }
153
+ }
142
154
  .ws-status {
143
155
  font-size: 10px;
144
156
  padding: 3px 10px;
@@ -158,7 +170,11 @@
158
170
  display: inline-block;
159
171
  }
160
172
  .ws-status.connected { border-color: var(--green); color: var(--green); }
161
- .ws-status.connected::before { background: var(--green); }
173
+ .ws-status.connected::before { background: var(--green); animation: live-pulse 2.4s ease-in-out infinite; }
174
+ @keyframes live-pulse {
175
+ 0%, 100% { opacity: 1; }
176
+ 50% { opacity: 0.35; }
177
+ }
162
178
  .ws-status.disconnected { border-color: var(--ink-faint); color: var(--ink-faint); }
163
179
  .ws-status.disconnected::before { background: var(--ink-faint); }
164
180
 
@@ -195,7 +211,15 @@
195
211
  }
196
212
 
197
213
  .view { display: none; flex: 1 1 auto; min-height: 0; overflow-y: auto; padding: 24px; }
198
- .view.active { display: block; }
214
+ .view.active { display: block; animation: view-in 160ms ease-out; }
215
+ @keyframes view-in {
216
+ from { opacity: 0; transform: translateY(4px); }
217
+ to { opacity: 1; transform: translateY(0); }
218
+ }
219
+ @media (prefers-reduced-motion: reduce) {
220
+ .view.active { animation: none; }
221
+ .ws-status.connected::before { animation: none; }
222
+ }
199
223
 
200
224
  .stats-grid {
201
225
  display: grid;
@@ -211,6 +235,14 @@
211
235
  border-bottom: 1px solid var(--border-light);
212
236
  }
213
237
  .stat-card:last-child { border-right: none; }
238
+ .stat-card[data-action] {
239
+ cursor: pointer;
240
+ transition: background 0.15s ease-out;
241
+ }
242
+ .stat-card[data-action]:hover { background: var(--bg-alt); }
243
+ .stat-card[data-action]:hover .label { color: var(--accent); }
244
+ .stat-card[data-action]:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
245
+ .stat-card[data-action]:active { background: var(--bg-inset); }
214
246
  .stat-card .label {
215
247
  font-size: 9px;
216
248
  color: var(--ink-muted);
@@ -294,6 +326,7 @@
294
326
  border-collapse: collapse;
295
327
  font-size: 13px;
296
328
  font-family: var(--font-body);
329
+ font-variant-numeric: tabular-nums;
297
330
  }
298
331
  th {
299
332
  text-align: left;
@@ -333,7 +366,7 @@
333
366
  align-items: center;
334
367
  flex-wrap: wrap;
335
368
  }
336
- .toolbar input, .toolbar select {
369
+ .toolbar input, .toolbar select, .search-input {
337
370
  background: var(--bg);
338
371
  border: 1px solid var(--border);
339
372
  color: var(--ink);
@@ -342,13 +375,13 @@
342
375
  outline: none;
343
376
  font-family: var(--font-ui);
344
377
  }
345
- .toolbar input:focus, .toolbar select:focus {
378
+ .toolbar input:focus, .toolbar select:focus, .search-input:focus {
346
379
  border-color: var(--ink);
347
380
  box-shadow: 2px 2px 0px 0px var(--border);
348
381
  }
349
382
  .toolbar input { flex: 1; min-width: 200px; }
350
383
 
351
- .btn {
384
+ .btn, .toolbar button {
352
385
  background: var(--bg);
353
386
  border: 1px solid var(--border);
354
387
  color: var(--ink);
@@ -361,8 +394,8 @@
361
394
  text-transform: uppercase;
362
395
  letter-spacing: 0.06em;
363
396
  }
364
- .btn:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); }
365
- .btn:active { box-shadow: none; transform: translate(0, 0); }
397
+ .btn:hover, .toolbar button:hover { box-shadow: 3px 3px 0px 0px var(--border); transform: translate(-1px, -1px); }
398
+ .btn:active, .toolbar button:active { box-shadow: none; transform: translate(0, 0); }
366
399
  .btn-danger { border-color: var(--accent); color: var(--accent); }
367
400
  .btn-danger:hover { background: var(--accent); color: white; box-shadow: 3px 3px 0px 0px var(--border); }
368
401
  .btn-primary { background: var(--ink); color: var(--bg); border-color: var(--ink); }
@@ -370,7 +403,8 @@
370
403
 
371
404
  .graph-container {
372
405
  display: flex;
373
- height: calc(100vh - 130px);
406
+ height: calc(100vh - 178px);
407
+ min-height: 460px;
374
408
  margin: -24px;
375
409
  border-top: 1px solid var(--border-light);
376
410
  }
@@ -523,18 +557,36 @@
523
557
  }
524
558
  .tag.file-tag { border-color: var(--green); color: var(--green); }
525
559
 
560
+ /* Two-pane sessions: list left, detail pinned right on wide screens.
561
+ The detail panel previously rendered below the full list — selecting
562
+ a session on any real corpus put the response off-screen. */
563
+ .sessions-layout {
564
+ display: grid;
565
+ grid-template-columns: minmax(300px, 400px) minmax(0, 1fr);
566
+ gap: 20px;
567
+ align-items: start;
568
+ }
569
+ .sessions-layout #session-detail { position: sticky; top: 0; min-width: 0; }
570
+ .sessions-layout #session-detail .detail-panel { margin-top: 0; }
571
+ @media (max-width: 1100px) {
572
+ .sessions-layout { grid-template-columns: 1fr; }
573
+ .sessions-layout #session-detail { position: static; }
574
+ }
526
575
  .session-list { display: flex; flex-direction: column; gap: 0; }
527
576
  .session-item {
528
577
  background: var(--bg);
529
578
  border: 1px solid var(--border-light);
530
579
  border-bottom: none;
580
+ border-left: 3px solid transparent;
531
581
  padding: 14px 20px;
532
582
  cursor: pointer;
533
- transition: background 0.1s;
583
+ transition: background 0.15s ease-out, border-color 0.15s ease-out;
534
584
  }
535
585
  .session-item:last-child { border-bottom: 1px solid var(--border-light); }
536
- .session-item:hover { background: var(--bg-alt); }
537
- .session-item.selected { background: var(--bg-alt); border-left: 3px solid var(--accent); }
586
+ .session-item:hover { background: var(--bg-alt); border-left-color: var(--border-light); }
587
+ .session-item:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; }
588
+ .session-item:active { background: var(--bg-inset); }
589
+ .session-item.selected { background: var(--bg-alt); border-left-color: var(--accent); }
538
590
  .session-item .session-top {
539
591
  display: flex;
540
592
  justify-content: space-between;
@@ -975,6 +1027,7 @@
975
1027
  </style>
976
1028
  </head>
977
1029
  <body>
1030
+ <canvas id="bg-dither" aria-hidden="true"></canvas>
978
1031
  <div class="app-header">
979
1032
  <a class="brand" href="#dashboard" data-tab-link="dashboard" aria-label="Open dashboard">
980
1033
  <h1>agentmemory</h1>
@@ -1112,12 +1165,12 @@
1112
1165
  activeTab: 'dashboard',
1113
1166
  dashboard: { loaded: false, health: null, sessions: [], memories: [], graphStats: null, recentAudit: [], lessons: [], crystals: [] },
1114
1167
  graph: { loaded: false, nodes: [], edges: [], stats: null, filters: {}, selectedNode: null, queryError: null, truncated: false, totalNodes: 0, totalEdges: 0 },
1115
- memories: { loaded: false, items: [], search: '', typeFilter: '' },
1168
+ memories: { loaded: false, items: [], search: '', typeFilter: '', selectedId: null },
1116
1169
  timeline: { loaded: false, observations: [], sessionId: '', minImportance: 0, page: 0, pageSize: 50 },
1117
1170
  sessions: { loaded: false, items: [], selectedId: null },
1118
1171
  audit: { loaded: false, entries: [], opFilter: '' },
1119
1172
  activity: { loaded: false, observations: [], sessions: [], typeFilter: '' },
1120
- lessons: { loaded: false, items: [], search: '' },
1173
+ lessons: { loaded: false, items: [], search: '', selectedId: null },
1121
1174
  actions: { loaded: false, items: [], frontier: [], statusFilter: '', search: '' },
1122
1175
  crystals: { loaded: false, items: [], search: '', lessonMap: {} },
1123
1176
  profile: { loaded: false, projects: [], selectedProject: '', data: null },
@@ -1141,6 +1194,45 @@
1141
1194
  if (!ts) return '';
1142
1195
  try { return new Date(ts).toLocaleTimeString(); } catch { return ts; }
1143
1196
  }
1197
+ // Observation subtitles are often the raw tool input serialized as
1198
+ // JSON ('{"file_path":"src/x.ts"}'). Pull the human-meaningful field
1199
+ // out for display; anything unparseable renders as-is.
1200
+ // Health alerts/notes arrive as compact machine slugs
1201
+ // (memory_heap_tight_93%_rss111mb). Translate the known families
1202
+ // into sentences; unknown slugs render as-is.
1203
+ function humanizeHealthFlag(f) {
1204
+ var m;
1205
+ if ((m = /^memory_heap_tight_(\d+)%_rss(\d+)mb$/.exec(f)))
1206
+ return 'Heap is running tight: ' + m[1] + '% of allocated heap in use (process memory ' + m[2] + ' MB). Informational — Node grows the heap on demand.';
1207
+ if ((m = /^memory_(warn|critical)_(\d+)%_rss(\d+)mb$/.exec(f)))
1208
+ return 'Memory ' + (m[1] === 'critical' ? 'critically high' : 'elevated') + ': ' + m[2] + '% of heap in use, process memory ' + m[3] + ' MB.';
1209
+ if ((m = /^cpu_(warn|critical)_(\d+)%$/.exec(f)))
1210
+ return 'CPU ' + (m[1] === 'critical' ? 'critically high' : 'elevated') + ': ' + m[2] + '%.';
1211
+ if ((m = /^event_loop_lag_(warn|critical)_(\d+)ms$/.exec(f)))
1212
+ return 'Event loop ' + (m[1] === 'critical' ? 'severely delayed' : 'delayed') + ': ' + m[2] + ' ms behind. The worker is busy or blocked.';
1213
+ if (f === 'connection_reconnecting')
1214
+ return 'Engine connection lost — reconnecting.';
1215
+ if ((m = /^connection_(.+)$/.exec(f)))
1216
+ return 'Engine connection state: ' + m[1] + '.';
1217
+ return f;
1218
+ }
1219
+
1220
+ function humanizeSubtitle(s) {
1221
+ if (typeof s !== 'string') return '';
1222
+ var t = s.trim();
1223
+ if (!t.startsWith('{')) return s;
1224
+ try {
1225
+ var o = JSON.parse(t);
1226
+ if (o && typeof o === 'object') {
1227
+ var keys = ['file_path', 'filepath', 'filePath', 'path', 'file', 'command', 'pattern', 'url', 'query', 'prompt'];
1228
+ for (var i = 0; i < keys.length; i++) {
1229
+ if (typeof o[keys[i]] === 'string' && o[keys[i]].length > 0) return o[keys[i]];
1230
+ }
1231
+ }
1232
+ } catch (_) {}
1233
+ return s;
1234
+ }
1235
+
1144
1236
  function truncate(s, n) {
1145
1237
  if (!s) return '';
1146
1238
  return s.length > n ? s.slice(0, n) + '...' : s;
@@ -1150,7 +1242,15 @@
1150
1242
  }
1151
1243
  function shortSessionId(s, n) {
1152
1244
  var id = sessionId(s);
1153
- return id ? id.slice(0, n || 8) : '';
1245
+ if (!id) return '';
1246
+ var max = n || 8;
1247
+ if (id.length <= max) return id;
1248
+ // Session ids share a long common prefix (demo_msuaboq7_...); the
1249
+ // distinguishing part is the tail. Keep head + tail so truncated
1250
+ // ids stay tellable apart in lists and dropdowns.
1251
+ var tail = 6;
1252
+ var head = Math.max(2, max - tail - 1);
1253
+ return id.slice(0, head) + '…' + id.slice(-tail);
1154
1254
  }
1155
1255
  function sessionDisplayName(s) {
1156
1256
  var project = s && s.project ? String(s.project).split('/').pop() : '';
@@ -1244,11 +1344,24 @@
1244
1344
  headers.Authorization = 'Bearer ' + viewerToken;
1245
1345
  }
1246
1346
  var fetchOpts = Object.assign({}, opts || {}, { headers: headers });
1347
+ var readErrorBody = fetchOpts.readErrorBody;
1348
+ delete fetchOpts.readErrorBody;
1247
1349
  var res = await fetch(url, fetchOpts);
1248
1350
  if (!res.ok) {
1249
1351
  if (res.status === 401) showViewerAuthPrompt();
1250
1352
  console.warn('[viewer] API ' + (fetchOpts.method || 'GET') + ' ' + path + ' returned ' + res.status);
1251
- return null;
1353
+ // Non-2xx responses resolve to null so callers can keep treating
1354
+ // null as "request failed" (e.g. loadGraph's disabled/error state).
1355
+ // The health endpoint opts out via readErrorBody: it intentionally
1356
+ // responds 503 with a valid JSON body when status is "critical"
1357
+ // (see #1019), and the dashboard badge needs that body.
1358
+ if (!readErrorBody) return null;
1359
+ try {
1360
+ return await res.json();
1361
+ } catch (parseErr) {
1362
+ console.debug('[viewer] API ' + path + ' non-2xx body was not JSON:', parseErr);
1363
+ return null;
1364
+ }
1252
1365
  }
1253
1366
  hideViewerAuthPrompt();
1254
1367
  return await res.json();
@@ -1322,29 +1435,44 @@
1322
1435
  loadTab(tab);
1323
1436
  }
1324
1437
 
1438
+ // Per-tab freshness stamps. Tabs refetch on entry (the loaded-once
1439
+ // model went stale the moment anything wrote through the API), but a
1440
+ // short window stops rapid tab flipping from re-issuing the full
1441
+ // fan-out (dashboard alone is ~10 requests) on every click.
1442
+ var tabFetchedAt = {};
1443
+ var TAB_FRESH_MS = 5000;
1444
+
1325
1445
  async function loadTab(tab) {
1446
+ var now = Date.now();
1447
+ if (tab !== 'replay' && tabFetchedAt[tab] && now - tabFetchedAt[tab] < TAB_FRESH_MS) {
1448
+ return;
1449
+ }
1450
+ tabFetchedAt[tab] = now;
1326
1451
  switch(tab) {
1327
- case 'dashboard': if (!state.dashboard.loaded) await loadDashboard(); break;
1328
- case 'graph': if (!state.graph.loaded) await loadGraph(); break;
1329
- case 'memories': if (!state.memories.loaded) await loadMemories(); break;
1330
- case 'timeline': if (!state.timeline.loaded) await loadTimeline(); break;
1331
- case 'sessions': if (!state.sessions.loaded) await loadSessions(); break;
1332
- case 'lessons': if (!state.lessons.loaded) await loadLessons(); break;
1333
- case 'actions': if (!state.actions.loaded) await loadActions(); break;
1334
- case 'crystals': if (!state.crystals.loaded) await loadCrystals(); break;
1335
- case 'audit': if (!state.audit.loaded) await loadAudit(); break;
1336
- case 'activity': if (!state.activity.loaded) await loadActivity(); break;
1337
- case 'profile': if (!state.profile.loaded) await loadProfile(); break;
1452
+ case 'dashboard': await loadDashboard(); break;
1453
+ case 'graph': await loadGraph(); break;
1454
+ case 'memories': await loadMemories(); break;
1455
+ case 'timeline': await loadTimeline(); break;
1456
+ case 'sessions': await loadSessions(); break;
1457
+ case 'lessons': await loadLessons(); break;
1458
+ case 'actions': await loadActions(); break;
1459
+ case 'crystals': await loadCrystals(); break;
1460
+ case 'audit': await loadAudit(); break;
1461
+ case 'activity': await loadActivity(); break;
1462
+ case 'profile': await loadProfile(); break;
1463
+ // Replay stays fetch-once: reloading it would reset playback
1464
+ // timer and cursor state mid-session; its toolbar has an explicit
1465
+ // Refresh button instead.
1338
1466
  case 'replay': if (!state.replay.loaded) await loadReplay(); break;
1339
1467
  }
1340
1468
  }
1341
1469
 
1342
1470
  async function loadDashboard() {
1343
1471
  var el = document.getElementById('view-dashboard');
1344
- el.innerHTML = '<div class="loading">Loading dashboard...</div>';
1472
+ if (!state.dashboard.loaded) el.innerHTML = '<div class="loading">Loading dashboard...</div>';
1345
1473
  try {
1346
1474
  var results = await Promise.all([
1347
- apiGet('health'),
1475
+ api('health', { readErrorBody: true }),
1348
1476
  apiGet('sessions'),
1349
1477
  apiGet('memories?latest=true&limit=500'),
1350
1478
  apiGet('graph/stats'),
@@ -1415,13 +1543,13 @@
1415
1543
  '</div>';
1416
1544
  }
1417
1545
  html += '<div class="stats-grid">';
1418
- html += '<div class="stat-card"><div class="label">Sessions</div><div class="value">' + d.sessions.length + '</div><div class="sub">' + activeSessions + ' active</div></div>';
1419
- html += '<div class="stat-card"><div class="label">Memories</div><div class="value">' + d.memories.length + '</div><div class="sub">latest versions</div></div>';
1546
+ html += '<div class="stat-card" data-action="goto-tab" data-tab="sessions" role="link" tabindex="0"><div class="label">Sessions</div><div class="value">' + d.sessions.length + '</div><div class="sub">' + activeSessions + ' active</div></div>';
1547
+ html += '<div class="stat-card" data-action="goto-tab" data-tab="memories" role="link" tabindex="0"><div class="label">Memories</div><div class="value">' + d.memories.length + '</div><div class="sub">latest versions</div></div>';
1420
1548
  var lessonCount = (d.lessons || []).length;
1421
1549
  var crystalCount = (d.crystals || []).length;
1422
- html += '<div class="stat-card"><div class="label">Lessons</div><div class="value">' + lessonCount + '</div><div class="sub">confidence-scored</div></div>';
1423
- html += '<div class="stat-card"><div class="label">Crystals</div><div class="value">' + crystalCount + '</div><div class="sub">action digests</div></div>';
1424
- html += '<div class="stat-card"><div class="label">Graph Nodes</div><div class="value">' + nodeCount + '</div><div class="sub">' + edgeCount + ' edges</div></div>';
1550
+ html += '<div class="stat-card" data-action="goto-tab" data-tab="lessons" role="link" tabindex="0"><div class="label">Lessons</div><div class="value">' + lessonCount + '</div><div class="sub">confidence-scored</div></div>';
1551
+ html += '<div class="stat-card" data-action="goto-tab" data-tab="crystals" role="link" tabindex="0"><div class="label">Crystals</div><div class="value">' + crystalCount + '</div><div class="sub">action digests</div></div>';
1552
+ html += '<div class="stat-card" data-action="goto-tab" data-tab="graph" role="link" tabindex="0"><div class="label">Graph Nodes</div><div class="value">' + nodeCount + '</div><div class="sub">' + edgeCount + ' edges</div></div>';
1425
1553
  html += '<div class="stat-card"><div class="label">Health</div><div class="value"><div class="health-bar"><span class="health-dot ' + dotClass + '"></span> ' + esc(healthStatus) + '</div></div>';
1426
1554
  html += '<div class="sub">' + esc(snap.connectionState || 'unknown') + '</div></div>';
1427
1555
  var totalCalls = fMetrics.reduce(function(a, m) { return a + (m.totalCalls || 0); }, 0);
@@ -1482,7 +1610,8 @@
1482
1610
 
1483
1611
  if (snap.alerts && snap.alerts.length > 0) {
1484
1612
  html += '<div class="card" style="margin-bottom:16px;border-color:var(--accent);border-width:2px;"><div class="card-title" style="color:var(--accent);border-bottom-color:var(--accent);">Alerts (' + snap.alerts.length + ')</div>';
1485
- snap.alerts.forEach(function(al) {
1613
+ snap.alerts.forEach(function(alRaw) {
1614
+ var al = humanizeHealthFlag(alRaw);
1486
1615
  html += '<div style="font-size:12px;color:var(--accent);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(al) + '</div>';
1487
1616
  });
1488
1617
  html += '</div>';
@@ -1491,7 +1620,7 @@
1491
1620
  if (snap.notes && snap.notes.length > 0) {
1492
1621
  html += '<div class="card" style="margin-bottom:16px;"><div class="card-title" style="color:var(--ink-muted);">Notes (' + snap.notes.length + ')</div>';
1493
1622
  snap.notes.forEach(function(n) {
1494
- html += '<div style="font-size:12px;color:var(--ink-muted);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(n) + '</div>';
1623
+ html += '<div style="font-size:12px;color:var(--ink-muted);padding:4px 0;border-bottom:1px solid var(--border-light);font-family:var(--font-ui);">' + esc(humanizeHealthFlag(n)) + '</div>';
1495
1624
  });
1496
1625
  html += '</div>';
1497
1626
  }
@@ -1625,6 +1754,9 @@
1625
1754
  html += '<div class="consolidation-row"><span class="cl">Semantic facts</span><span class="cv">' + semFacts.length + '</span></div>';
1626
1755
  html += '<div class="consolidation-row"><span class="cl">Procedures</span><span class="cv">' + procItems.length + '</span></div>';
1627
1756
  html += '<div class="consolidation-row"><span class="cl">Relations</span><span class="cv">' + relItems.length + '</span></div>';
1757
+ if (semFacts.length === 0 && procItems.length === 0 && relItems.length === 0) {
1758
+ html += '<div style="font-size:12px;color:var(--ink-faint);margin-top:8px;line-height:1.5;">Consolidation distills session observations into durable facts and repeatable procedures. It runs on a schedule when <code>CONSOLIDATION_ENABLED=true</code> and an LLM provider key are set, or on demand via <code>memory_consolidate</code>.</div>';
1759
+ }
1628
1760
  html += '</div>';
1629
1761
 
1630
1762
  if (relItems.length > 0) {
@@ -1682,9 +1814,24 @@
1682
1814
 
1683
1815
  var results = await Promise.all([
1684
1816
  apiPost('graph/query', { limit: GRAPH_INITIAL_LIMIT }),
1685
- apiGet('graph/stats')
1817
+ api('graph/stats', { readErrorBody: true })
1686
1818
  ]);
1687
1819
  var queryResult = results[0];
1820
+ var statsResult = results[1];
1821
+ if (statsResult && statsResult.error && statsResult.flag) {
1822
+ // 503 with a structured body = the feature is off, not broken.
1823
+ // Rendering this as "query failed / Retry" sends users hunting
1824
+ // through server logs for an error that isn't one.
1825
+ state.graph.disabledInfo = statsResult;
1826
+ state.graph.queryError = null;
1827
+ state.graph.nodes = [];
1828
+ state.graph.edges = [];
1829
+ state.graph.stats = {};
1830
+ state.graph.loaded = true;
1831
+ renderGraphSidebar();
1832
+ return;
1833
+ }
1834
+ state.graph.disabledInfo = null;
1688
1835
  if (queryResult === null) {
1689
1836
  // api() returns null only on non-2xx or a transport error; an
1690
1837
  // empty graph would come back as { nodes: [], edges: [] }.
@@ -1751,6 +1898,17 @@
1751
1898
 
1752
1899
  var html = '';
1753
1900
 
1901
+ if (state.graph.disabledInfo) {
1902
+ html += '<div style="margin:8px 0;padding:10px 12px;border:1px solid var(--border);background:var(--bg-alt);font-size:12px;color:var(--ink);line-height:1.5;">';
1903
+ html += '<div style="font-weight:600;margin-bottom:4px;">Knowledge graph is off</div>';
1904
+ html += '<div style="font-size:11px;color:var(--ink-muted);">' + esc(state.graph.disabledInfo.enableHow || 'Set ' + (state.graph.disabledInfo.flag || 'GRAPH_EXTRACTION_ENABLED') + '=true and restart.') + '</div>';
1905
+ if (state.graph.disabledInfo.docsHref) {
1906
+ html += '<a href="' + esc(state.graph.disabledInfo.docsHref) + '" target="_blank" rel="noopener" style="font-size:11px;">docs &rarr;</a>';
1907
+ }
1908
+ html += '</div>';
1909
+ sb.innerHTML = html;
1910
+ return;
1911
+ }
1754
1912
  // #753: error banner stays above the search box so a failed
1755
1913
  // graph/query doesn't read as "0 nodes".
1756
1914
  if (state.graph.queryError) {
@@ -1783,7 +1941,10 @@
1783
1941
  html += '<label class="filter-item"><input type="checkbox" checked data-type="' + esc(type) + '"><span class="filter-dot" style="background:' + color + '"></span>' + esc(type) + '</label>';
1784
1942
  });
1785
1943
 
1786
- html += '<div class="graph-legend"><h3>Legend</h3>';
1944
+ if (state.graph.nodes.length > 0 && state.graph.edges.length === 0) {
1945
+ html += '<div style="margin:10px 0;padding:10px 12px;border:1px solid var(--border-light);background:var(--bg-subtle);font-size:11px;color:var(--ink-muted);line-height:1.5;">Entities extracted, no relations between them yet. Nodes are grouped by kind; edges appear as extraction sees entities acting on each other across more sessions (larger models find them faster).</div>';
1946
+ }
1947
+ html += '<div class="graph-legend" style="display:none"><h3>Legend</h3>';
1787
1948
  var shapeLabels = { rect: '&#9645;', circle: '&#9679;', diamond: '&#9670;', hexagon: '&#11042;' };
1788
1949
  var shownShapes = {};
1789
1950
  Object.keys(NODE_COLORS).forEach(function(type) {
@@ -1843,21 +2004,38 @@
1843
2004
  edgeMap[e.targetNodeId] = (edgeMap[e.targetNodeId] || 0) + 1;
1844
2005
  });
1845
2006
 
2007
+ // With no (or few) edges a pure force layout is a meaningless
2008
+ // scatter. Anchor each type to its own cluster center so the
2009
+ // picture reads as "here is what memory knows, grouped by kind";
2010
+ // once real relations exist the edge springs dominate instead.
2011
+ var types = [];
2012
+ state.graph.nodes.forEach(function(n) {
2013
+ if (types.indexOf(n.type) === -1) types.push(n.type);
2014
+ });
2015
+ var typeCenters = {};
2016
+ types.forEach(function(t, ti) {
2017
+ var a = (2 * Math.PI * ti) / Math.max(types.length, 1) - Math.PI / 2;
2018
+ var cr = types.length > 1 ? Math.min(cw, ch) * 0.28 : 0;
2019
+ typeCenters[t] = { x: Math.cos(a) * cr, y: Math.sin(a) * cr };
2020
+ });
2021
+ graphSim.typeCenters = typeCenters;
2022
+ graphSim.clustered = state.graph.edges.length < state.graph.nodes.length / 2;
2023
+
1846
2024
  graphSim.nodes = state.graph.nodes.map(function(n, i) {
1847
- var angle = (2 * Math.PI * i) / Math.max(state.graph.nodes.length, 1);
1848
- var radius = Math.min(cw, ch) * 0.3;
1849
2025
  var deg = edgeMap[n.id] || 0;
2026
+ var c = typeCenters[n.type] || { x: 0, y: 0 };
1850
2027
  return {
1851
2028
  id: n.id, type: n.type, name: n.name, properties: n.properties,
1852
- x: Math.cos(angle) * radius + (Math.random() - 0.5) * 50,
1853
- y: Math.sin(angle) * radius + (Math.random() - 0.5) * 50,
2029
+ x: c.x + (Math.random() - 0.5) * 150,
2030
+ y: c.y + (Math.random() - 0.5) * 150,
1854
2031
  vx: 0, vy: 0,
1855
- r: Math.max(8, Math.min(22, 8 + deg * 2.5))
2032
+ r: Math.max(11, Math.min(22, 11 + deg * 2.5))
1856
2033
  };
1857
2034
  });
1858
2035
  graphSim.edges = state.graph.edges.slice();
1859
2036
  graphSim.running = true;
1860
2037
  graphSim.dragNode = null;
2038
+ graphSim.autoFitPending = true;
1861
2039
 
1862
2040
  setupGraphInteraction(canvas);
1863
2041
  runSimulation();
@@ -2071,8 +2249,14 @@
2071
2249
  fx += (dx / dist) * force;
2072
2250
  fy += (dy / dist) * force;
2073
2251
  }
2074
- fx -= n.x * centerGravity;
2075
- fy -= n.y * centerGravity;
2252
+ if (graphSim.clustered && graphSim.typeCenters && graphSim.typeCenters[n.type]) {
2253
+ var tc = graphSim.typeCenters[n.type];
2254
+ fx += (tc.x - n.x) * 0.006;
2255
+ fy += (tc.y - n.y) * 0.006;
2256
+ } else {
2257
+ fx -= n.x * centerGravity;
2258
+ fy -= n.y * centerGravity;
2259
+ }
2076
2260
  var nvx = (n.vx + fx) * damping;
2077
2261
  var nvy = (n.vy + fy) * damping;
2078
2262
  // Velocity cap (#563): keep any single node from being launched
@@ -2105,6 +2289,10 @@
2105
2289
  totalKineticEnergy += n.vx * n.vx + n.vy * n.vy;
2106
2290
  });
2107
2291
 
2292
+ if (graphSim.autoFitPending && graphSim.tickCount > 45) {
2293
+ graphSim.autoFitPending = false;
2294
+ fitGraphToView();
2295
+ }
2108
2296
  // park the simulation when the layout is quiet to save CPU.
2109
2297
  // Pick up again when a drag/interaction wakes the loop.
2110
2298
  var rmsVelocity = nodes.length > 0 ? Math.sqrt(totalKineticEnergy / nodes.length) : 0;
@@ -2162,6 +2350,27 @@
2162
2350
  }
2163
2351
  }
2164
2352
 
2353
+ function fitGraphToView() {
2354
+ var canvas = graphSim.canvas;
2355
+ if (!canvas || graphSim.nodes.length === 0) return;
2356
+ var w = canvas.width / window.devicePixelRatio;
2357
+ var h = canvas.height / window.devicePixelRatio;
2358
+ var minX = Infinity, maxX = -Infinity, minY = Infinity, maxY = -Infinity;
2359
+ graphSim.nodes.forEach(function(n) {
2360
+ if (n.x < minX) minX = n.x;
2361
+ if (n.x > maxX) maxX = n.x;
2362
+ if (n.y < minY) minY = n.y;
2363
+ if (n.y > maxY) maxY = n.y;
2364
+ });
2365
+ var pad = 150;
2366
+ var spanX = (maxX - minX) + pad * 2;
2367
+ var spanY = (maxY - minY) + pad * 2;
2368
+ var z = Math.min(w / spanX, h / spanY);
2369
+ graphSim.zoom = Math.max(0.35, Math.min(1.4, z));
2370
+ graphSim.panX = w / 2 - ((minX + maxX) / 2) * graphSim.zoom;
2371
+ graphSim.panY = h / 2 - ((minY + maxY) / 2) * graphSim.zoom;
2372
+ }
2373
+
2165
2374
  function renderGraph() {
2166
2375
  var ctx = graphSim.ctx;
2167
2376
  var canvas = graphSim.canvas;
@@ -2192,6 +2401,15 @@
2192
2401
  graphSim.nodes.forEach(function(n) { nodeMap[n.id] = n; });
2193
2402
 
2194
2403
  var searchActive = graphSearchTerm.length > 0;
2404
+ var drawnLabelRects = [];
2405
+ function labelFits(x, y, w2, h2) {
2406
+ for (var li = 0; li < drawnLabelRects.length; li++) {
2407
+ var r = drawnLabelRects[li];
2408
+ if (x < r.x + r.w && x + w2 > r.x && y < r.y + r.h && y + h2 > r.y) return false;
2409
+ }
2410
+ drawnLabelRects.push({ x: x, y: y, w: w2, h: h2 });
2411
+ return true;
2412
+ }
2195
2413
  var totalVisible = graphSim.nodes.filter(function(n) { return state.graph.filters[n.type]; }).length;
2196
2414
  var isDense = totalVisible > 40;
2197
2415
  var labelZoomThreshold = isDense ? 1.5 : 0.5;
@@ -2290,7 +2508,7 @@
2290
2508
  var isSelected = selectedId === n.id;
2291
2509
  var isHovered = hoverNodeId === n.id;
2292
2510
  var matchesSearch = !searchActive || n.name.toLowerCase().includes(graphSearchTerm);
2293
- var isFocusFaded = focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) {
2511
+ var isFocusFaded = graphSim.edges.length > 0 && focusNodeId && n.id !== focusNodeId && !graphSim.edges.some(function(ed) {
2294
2512
  return (ed.sourceNodeId === focusNodeId && ed.targetNodeId === n.id) ||
2295
2513
  (ed.targetNodeId === focusNodeId && ed.sourceNodeId === n.id);
2296
2514
  });
@@ -2343,6 +2561,7 @@
2343
2561
  var showLabel = matchesSearch && !isFocusFaded && (
2344
2562
  isSelected || isHovered ||
2345
2563
  (searchActive && matchesSearch) ||
2564
+ (totalVisible <= 30 && graphSim.zoom > 0.5) ||
2346
2565
  (!isDense && graphSim.zoom > labelZoomThreshold) ||
2347
2566
  (isDense && graphSim.zoom > labelZoomThreshold && n.r > 10)
2348
2567
  );
@@ -2357,7 +2576,13 @@
2357
2576
  var labelW = textW + (16 * zoomInv);
2358
2577
  var labelH = 20 * zoomInv;
2359
2578
  var labelY = n.y + n.r + (8 * zoomInv); // Top of the background pill
2360
-
2579
+ // Selected/hovered labels always win; others draw only into
2580
+ // free space so zoomed-out views degrade to fewer labels
2581
+ // instead of a pile of overlapping pills.
2582
+ if (!isSelected && !isHovered && !labelFits(n.x - labelW / 2, labelY, labelW, labelH)) {
2583
+ ctx.restore();
2584
+ return;
2585
+ }
2361
2586
  ctx.fillStyle = isDarkMode() ? 'rgba(30,30,35,0.92)' : 'rgba(255,255,255,0.92)';
2362
2587
  ctx.beginPath();
2363
2588
  ctx.roundRect ? ctx.roundRect(n.x - labelW / 2, labelY, labelW, labelH, 4 * zoomInv) : ctx.rect(n.x - labelW / 2, labelY, labelW, labelH);
@@ -2388,7 +2613,7 @@
2388
2613
 
2389
2614
  async function loadMemories() {
2390
2615
  var el = document.getElementById('view-memories');
2391
- el.innerHTML = '<div class="loading">Loading memories...</div>';
2616
+ if (!state.memories.loaded) el.innerHTML = '<div class="loading">Loading memories...</div>';
2392
2617
  // cap at 2000 so the viewer remains responsive on large
2393
2618
  // corpora. Older endpoints returned the full unbounded list which
2394
2619
  // hit the iii invocation timeout and the UI fell through to 0.
@@ -2471,7 +2696,8 @@
2471
2696
  var strength = Math.round(rawStrength <= 1 ? rawStrength * 100 : rawStrength * 10);
2472
2697
  if (strength > 100) strength = 100;
2473
2698
  var barColor = strength > 70 ? 'var(--green)' : strength > 40 ? 'var(--yellow)' : 'var(--red)';
2474
- html += '<tr>';
2699
+ var expanded = state.memories.selectedId === m.id;
2700
+ html += '<tr class="memory-row' + (expanded ? ' selected' : '') + '" data-action="select-memory" data-memory-id="' + esc(m.id) + '" role="button" tabindex="0" aria-expanded="' + (expanded ? 'true' : 'false') + '" style="cursor:pointer;">';
2475
2701
  var preview = (m.content || '').split('\n').slice(0, 2).join(' ').trim();
2476
2702
  var previewHtml = esc(truncate(preview, 150));
2477
2703
  if (search && search.length > 2) {
@@ -2492,6 +2718,24 @@
2492
2718
  html += '<td style="font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);">' + esc(formatTime(m.updatedAt)) + '</td>';
2493
2719
  html += '<td><button class="btn btn-danger" style="font-size:9px;padding:2px 8px;" data-action="delete-memory" data-memory-id="' + esc(m.id) + '" data-memory-title="' + esc(m.title || '') + '">Delete</button></td>';
2494
2720
  html += '</tr>';
2721
+ if (expanded) {
2722
+ html += '<tr class="memory-detail-row"><td colspan="6" style="background:var(--bg-alt);padding:14px 16px;">';
2723
+ html += '<div style="font-size:13px;color:var(--ink);white-space:pre-wrap;line-height:1.5;margin-bottom:10px;">' + esc(m.content || '') + '</div>';
2724
+ html += '<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);margin-bottom:10px;">';
2725
+ html += '<span>id: ' + esc(m.id) + '</span>';
2726
+ if (m.origin && m.origin.channel) {
2727
+ html += '<span>origin: ' + esc(m.origin.channel) + (m.origin.detail ? ' (' + esc(m.origin.detail) + ')' : '') + '</span>';
2728
+ }
2729
+ if (m.project) html += '<span>project: ' + esc(m.project) + '</span>';
2730
+ if (m.createdAt) html += '<span>created: ' + esc(formatTime(m.createdAt)) + '</span>';
2731
+ if (m.supersedes && m.supersedes.length > 0) html += '<span>supersedes: ' + esc(m.supersedes.join(', ')) + '</span>';
2732
+ if (m.files && m.files.length > 0) html += '<span>files: ' + esc(m.files.join(', ')) + '</span>';
2733
+ if (m.sessionIds && m.sessionIds.length > 0) html += '<span>sessions: ' + m.sessionIds.length + '</span>';
2734
+ html += '</div>';
2735
+ html += '<details><summary style="cursor:pointer;font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);">raw record</summary>';
2736
+ html += '<pre style="margin-top:8px;padding:10px;background:var(--bg);border:1px solid var(--border);font-size:11px;line-height:1.5;overflow-x:auto;">' + esc(JSON.stringify(m, null, 2)) + '</pre></details>';
2737
+ html += '</td></tr>';
2738
+ }
2495
2739
  });
2496
2740
  html += '</table>';
2497
2741
  }
@@ -2533,13 +2777,19 @@
2533
2777
 
2534
2778
  async function loadTimeline() {
2535
2779
  var el = document.getElementById('view-timeline');
2536
- el.innerHTML = '<div class="loading">Loading timeline...</div>';
2780
+ if (!state.timeline.loaded) el.innerHTML = '<div class="loading">Loading timeline...</div>';
2537
2781
  var sessResult = await apiGet('sessions');
2538
2782
  var sessions = (sessResult && sessResult.sessions) || [];
2539
2783
  state.timeline.loaded = true;
2540
2784
 
2541
2785
  if (sessions.length > 0 && !state.timeline.sessionId) {
2542
- var sorted = sessions.slice().sort(function(a, b) { return (b.startedAt || '').localeCompare(a.startedAt || ''); });
2786
+ // Default to the session with the most observations — the newest
2787
+ // one is often a sparse just-started session, which made the tab
2788
+ // look empty on first open.
2789
+ var sorted = sessions.slice().sort(function(a, b) {
2790
+ return (b.observationCount || 0) - (a.observationCount || 0) ||
2791
+ (b.startedAt || '').localeCompare(a.startedAt || '');
2792
+ });
2543
2793
  var firstSelectable = sorted.find(function(s) { return sessionId(s); });
2544
2794
  state.timeline.sessionId = firstSelectable ? sessionId(firstSelectable) : '';
2545
2795
  }
@@ -2679,7 +2929,7 @@
2679
2929
  html += '<span class="obs-time">' + esc(shortTime(o.timestamp)) + '</span>';
2680
2930
  html += '</div></div>';
2681
2931
 
2682
- if (o.subtitle) html += '<div class="obs-subtitle">' + esc(o.subtitle) + '</div>';
2932
+ if (o.subtitle) html += '<div class="obs-subtitle">' + esc(humanizeSubtitle(o.subtitle)) + '</div>';
2683
2933
 
2684
2934
  html += '<div style="margin-top:4px;">';
2685
2935
  html += '<span class="badge" style="border-color:' + typeColor + ';color:' + typeColor + ';margin-right:4px;">' + esc(type.replace(/_/g, ' ')) + '</span>';
@@ -2759,7 +3009,7 @@
2759
3009
 
2760
3010
  async function loadActivity() {
2761
3011
  var el = document.getElementById('view-activity');
2762
- el.innerHTML = '<div class="loading">Loading activity...</div>';
3012
+ if (!state.activity.loaded) el.innerHTML = '<div class="loading">Loading activity...</div>';
2763
3013
  var results = await Promise.all([
2764
3014
  apiGet('sessions'),
2765
3015
  apiGet('audit?limit=200')
@@ -2896,7 +3146,7 @@
2896
3146
 
2897
3147
  async function loadSessions() {
2898
3148
  var el = document.getElementById('view-sessions');
2899
- el.innerHTML = '<div class="loading">Loading sessions...</div>';
3149
+ if (!state.sessions.loaded) el.innerHTML = '<div class="loading">Loading sessions...</div>';
2900
3150
  var result = await apiGet('sessions');
2901
3151
  state.sessions.items = (result && result.sessions) || [];
2902
3152
  state.sessions.loaded = true;
@@ -2909,7 +3159,7 @@
2909
3159
  return (b.startedAt || '').localeCompare(a.startedAt || '');
2910
3160
  });
2911
3161
 
2912
- var html = '<div class="session-list">';
3162
+ var html = '<div class="sessions-layout"><div class="session-list">';
2913
3163
  if (items.length === 0) {
2914
3164
  html += '<div class="empty-state"><div class="empty-icon">&#128466;</div><p>No sessions</p></div>';
2915
3165
  } else {
@@ -2917,7 +3167,7 @@
2917
3167
  var statusBadge = s.status === 'active' ? 'badge-green' : s.status === 'completed' ? 'badge-blue' : 'badge-muted';
2918
3168
  var id = sessionId(s);
2919
3169
  var selected = id && state.sessions.selectedId === id;
2920
- html += '<div class="session-item' + (selected ? ' selected' : '') + '"' + (id ? ' data-action="select-session" data-session-id="' + esc(id) + '"' : '') + '>';
3170
+ html += '<div class="session-item' + (selected ? ' selected' : '') + '"' + (id ? ' data-action="select-session" data-session-id="' + esc(id) + '" role="button" tabindex="0" aria-expanded="' + (selected ? 'true' : 'false') + '"' : '') + '>';
2921
3171
  html += '<div class="session-top"><span class="session-project">' + esc(sessionDisplayName(s)) + '</span>';
2922
3172
  html += '<span class="badge ' + statusBadge + '">' + esc(s.status) + '</span></div>';
2923
3173
  var preview = s.firstPrompt || s.summary || '';
@@ -2931,7 +3181,7 @@
2931
3181
  });
2932
3182
  }
2933
3183
  html += '</div>';
2934
- html += '<div id="session-detail"></div>';
3184
+ html += '<div id="session-detail"></div></div>';
2935
3185
  el.innerHTML = html;
2936
3186
 
2937
3187
  if (state.sessions.selectedId) renderSessionDetail();
@@ -2940,6 +3190,18 @@
2940
3190
  function selectSession(id) {
2941
3191
  state.sessions.selectedId = state.sessions.selectedId === id ? null : id;
2942
3192
  renderSessions();
3193
+ // On the stacked layout (narrow screens) the detail renders below
3194
+ // the list; bring it into view. The wide two-pane layout keeps the
3195
+ // panel sticky beside the list, so no scroll is needed there.
3196
+ if (
3197
+ state.sessions.selectedId &&
3198
+ window.matchMedia('(max-width: 1100px)').matches
3199
+ ) {
3200
+ var panel = document.getElementById('session-detail');
3201
+ if (panel && panel.scrollIntoView) {
3202
+ panel.scrollIntoView({ behavior: 'smooth', block: 'start' });
3203
+ }
3204
+ }
2943
3205
  }
2944
3206
 
2945
3207
  async function renderSessionDetail() {
@@ -3063,7 +3325,7 @@
3063
3325
 
3064
3326
  async function loadLessons() {
3065
3327
  var el = document.getElementById('view-lessons');
3066
- el.innerHTML = '<div class="loading">Loading lessons...</div>';
3328
+ if (!state.lessons.loaded) el.innerHTML = '<div class="loading">Loading lessons...</div>';
3067
3329
  var result = await apiGet('lessons');
3068
3330
  state.lessons.items = (result && result.lessons) || [];
3069
3331
  state.lessons.loaded = true;
@@ -3095,16 +3357,17 @@
3095
3357
  html += '<div class="empty-state">' +
3096
3358
  '<div class="empty-icon">&#128161;</div>' +
3097
3359
  '<div class="empty-title">No lessons yet</div>' +
3098
- '<div class="empty-lead">Lessons are confidence-scored pattern observations &mdash; things you corrected once that the agent should never do again. They persist across projects.</div>' +
3360
+ '<div class="empty-lead">Lessons are short imperative rules (always/never/prefer/avoid) learned from past work &mdash; things you corrected once that the agent should never repeat. Confidence grows when they hold and decays when unused.</div>' +
3099
3361
  '<pre class="empty-cmd"># Save a lesson explicitly\nmemory_lesson_save { rule, reason, confidence }\n\n# Or: Replay tab &rarr; Import JSONL auto-extracts lessons\n# from your past Claude Code sessions</pre>' +
3100
3362
  '<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#lessons" target="_blank" rel="noopener">Lesson decay &amp; scoring &rarr;</a></div>' +
3101
3363
  '</div>';
3102
3364
  } else {
3103
- html += '<table><thead><tr><th>Lesson</th><th>Confidence</th><th>Reinforcements</th><th>Source</th><th>Project</th><th>Updated</th></tr></thead><tbody>';
3365
+ html += '<table><thead><tr><th>Lesson</th><th title="How battle-tested the rule is. Grows each time it is reinforced, decays weekly when unused.">Confidence</th><th title="Times the agent re-confirmed this rule.">Uses</th><th>Source</th><th>Project</th><th>Updated</th></tr></thead><tbody>';
3104
3366
  items.forEach(function(l) {
3105
3367
  var confPct = Math.round(l.confidence * 100);
3106
3368
  var confColor = confPct >= 70 ? 'var(--green)' : confPct >= 40 ? 'var(--yellow)' : 'var(--red)';
3107
- html += '<tr>';
3369
+ var expanded = state.lessons.selectedId === l.id;
3370
+ html += '<tr class="lesson-row' + (expanded ? ' selected' : '') + '" data-action="select-lesson" data-lesson-id="' + esc(l.id) + '" role="button" tabindex="0" aria-expanded="' + (expanded ? 'true' : 'false') + '" style="cursor:pointer;">';
3108
3371
  html += '<td style="max-width:400px;">' + esc(truncate(l.content, 120)) + (l.context ? '<div style="font-size:11px;color:var(--ink-faint);margin-top:2px;">' + esc(truncate(l.context, 80)) + '</div>' : '') + '</td>';
3109
3372
  html += '<td><div class="gauge" style="min-width:80px;"><div class="gauge-bar"><div class="gauge-fill" style="width:' + confPct + '%;background:' + confColor + '"></div></div><span class="gauge-value" style="font-size:11px;">' + confPct + '%</span></div></td>';
3110
3373
  html += '<td style="text-align:center;">' + (l.reinforcements || 0) + '</td>';
@@ -3112,6 +3375,21 @@
3112
3375
  html += '<td style="font-size:12px;color:var(--ink-muted);">' + esc(l.project || '-') + '</td>';
3113
3376
  html += '<td style="font-size:12px;color:var(--ink-muted);">' + shortTime(l.updatedAt) + '</td>';
3114
3377
  html += '</tr>';
3378
+ if (expanded) {
3379
+ html += '<tr class="lesson-detail-row"><td colspan="6" style="background:var(--bg-alt);padding:14px 16px;">';
3380
+ html += '<div style="font-size:13px;color:var(--ink);white-space:pre-wrap;line-height:1.5;margin-bottom:8px;">' + esc(l.content) + '</div>';
3381
+ if (l.context) html += '<div style="font-size:12px;color:var(--ink-muted);margin-bottom:8px;"><span style="font-family:var(--font-ui);font-size:10px;text-transform:uppercase;letter-spacing:0.1em;">Why learned</span><br>' + esc(l.context) + '</div>';
3382
+ html += '<div style="display:flex;gap:16px;flex-wrap:wrap;font-size:11px;color:var(--ink-muted);font-family:var(--font-mono);margin-bottom:8px;">';
3383
+ html += '<span>id: ' + esc(l.id) + '</span>';
3384
+ if (l.tags && l.tags.length) html += '<span>tags: ' + esc(l.tags.join(', ')) + '</span>';
3385
+ if (l.createdAt) html += '<span>learned: ' + esc(formatTime(l.createdAt)) + '</span>';
3386
+ if (l.lastReinforcedAt) html += '<span>last confirmed: ' + esc(formatTime(l.lastReinforcedAt)) + '</span>';
3387
+ if (l.sourceIds && l.sourceIds.length) html += '<span>from ' + l.sourceIds.length + ' session(s)</span>';
3388
+ html += '</div>';
3389
+ html += '<details><summary style="cursor:pointer;font-size:11px;color:var(--ink-faint);font-family:var(--font-mono);">raw record</summary>';
3390
+ html += '<pre style="margin-top:8px;padding:10px;background:var(--bg);border:1px solid var(--border);font-size:11px;line-height:1.5;overflow-x:auto;">' + esc(JSON.stringify(l, null, 2)) + '</pre></details>';
3391
+ html += '</td></tr>';
3392
+ }
3115
3393
  });
3116
3394
  html += '</tbody></table>';
3117
3395
  }
@@ -3125,7 +3403,7 @@
3125
3403
 
3126
3404
  async function loadActions() {
3127
3405
  var el = document.getElementById('view-actions');
3128
- el.innerHTML = '<div class="loading">Loading actions...</div>';
3406
+ if (!state.actions.loaded) el.innerHTML = '<div class="loading">Loading actions...</div>';
3129
3407
  var results = await Promise.all([apiGet('actions'), apiGet('frontier')]);
3130
3408
  state.actions.items = (results[0] && results[0].actions) || [];
3131
3409
  state.actions.frontier = (results[1] && (results[1].frontier || results[1].actions)) || [];
@@ -3136,6 +3414,10 @@
3136
3414
  function renderActions() {
3137
3415
  var el = document.getElementById('view-actions');
3138
3416
  var items = state.actions.items;
3417
+ var introCard = '<div class="card" style="margin-bottom:12px;padding:12px;background:var(--bg-subtle);">' +
3418
+ '<div style="font-size:13px;color:var(--ink-muted);line-height:1.5;">' +
3419
+ '<strong>Actions</strong> are follow-ups the agent surfaced during sessions — decisions to revisit, files to inspect, tasks blocked on input. Status flows pending &rarr; active &rarr; done/blocked; the <em>frontier</em> marks what is unblocked and ready to pick up next.' +
3420
+ '</div></div>';
3139
3421
  var search = state.actions.search.toLowerCase();
3140
3422
  var statusFilter = state.actions.statusFilter;
3141
3423
  var frontierIds = new Set((state.actions.frontier || []).map(function(a) { return a.id; }));
@@ -3149,7 +3431,8 @@
3149
3431
  items = items.filter(function(a) { return a.status === statusFilter; });
3150
3432
  }
3151
3433
 
3152
- var html = '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">';
3434
+ var html = introCard;
3435
+ html += '<div style="display:flex;gap:8px;margin-bottom:12px;flex-wrap:wrap;">';
3153
3436
  html += '<input id="actions-search" class="search-input" type="text" placeholder="Search actions..." value="' + esc(state.actions.search) + '" style="flex:1;min-width:200px" />';
3154
3437
  html += '<select id="actions-status-filter" style="padding:4px 8px;font-size:12px;border:1px solid var(--border);border-radius:4px;background:var(--bg);color:var(--ink);">';
3155
3438
  html += '<option value="">All statuses</option>';
@@ -3200,7 +3483,7 @@
3200
3483
 
3201
3484
  async function loadCrystals() {
3202
3485
  var el = document.getElementById('view-crystals');
3203
- el.innerHTML = '<div class="loading">Loading crystals...</div>';
3486
+ if (!state.crystals.loaded) el.innerHTML = '<div class="loading">Loading crystals...</div>';
3204
3487
  var results = await Promise.all([apiGet('crystals'), apiGet('lessons')]);
3205
3488
  state.crystals.items = (results[0] && results[0].crystals) || [];
3206
3489
  var lessonMap = {};
@@ -3251,7 +3534,7 @@
3251
3534
  html += '<div class="empty-state">' +
3252
3535
  '<div class="empty-icon">&#128142;</div>' +
3253
3536
  '<div class="empty-title">No crystals yet</div>' +
3254
- '<div class="empty-lead">Crystals are compressed action digests &mdash; the 3-line summary of what happened in a session. Generated from long conversations to give the next session fast context without re-reading everything.</div>' +
3537
+ '<div class="empty-lead">Crystals are frozen snapshots of completed work &mdash; one session’s narrative, key outcomes, files touched, and lessons surfaced, kept after raw observations are pruned so the next session gets fast context.</div>' +
3255
3538
  '<pre class="empty-cmd"># Auto: import a JSONL transcript\n# Replay tab &rarr; Import JSONL\n\n# Manual: crystallize a specific session\nmemory_crystallize { sessionId }</pre>' +
3256
3539
  '<div><a class="empty-link" href="https://github.com/rohitg00/agentmemory#crystals" target="_blank" rel="noopener">Crystal pipeline &rarr;</a></div>' +
3257
3540
  '</div>';
@@ -3313,7 +3596,7 @@
3313
3596
 
3314
3597
  async function loadAudit() {
3315
3598
  var el = document.getElementById('view-audit');
3316
- el.innerHTML = '<div class="loading">Loading audit log...</div>';
3599
+ if (!state.audit.loaded) el.innerHTML = '<div class="loading">Loading audit log...</div>';
3317
3600
  var result = await apiGet('audit?limit=100');
3318
3601
  state.audit.entries = (result && result.entries) || [];
3319
3602
  state.audit.loaded = true;
@@ -3437,7 +3720,7 @@
3437
3720
  html += '<div class="card"><div class="card-title">Top Concepts</div>';
3438
3721
  var concepts = p.topConcepts || [];
3439
3722
  if (concepts.length === 0) {
3440
- html += '<div style="font-size:13px;color:var(--ink-faint);font-style:italic;">No concepts yet</div>';
3723
+ html += '<div style="font-size:13px;color:var(--ink-faint);line-height:1.5;">No concepts yet. Concepts are tagged when observations are compressed by an LLM (<code>AGENTMEMORY_AUTO_COMPRESS=true</code> + provider key) or attached to saved memories.</div>';
3441
3724
  } else {
3442
3725
  var maxC = Math.max.apply(null, concepts.map(function(c) { return c.frequency; })) || 1;
3443
3726
  html += '<div class="bar-chart">';
@@ -3846,6 +4129,17 @@
3846
4129
  }
3847
4130
  });
3848
4131
  fetchFlags();
4132
+ // Keyboard activation for role="button" cards (session items). Click
4133
+ // delegation alone leaves them unreachable for keyboard and AT users.
4134
+ document.addEventListener('keydown', function(e) {
4135
+ if (e.key !== 'Enter' && e.key !== ' ') return;
4136
+ if (!(e.target instanceof Element)) return;
4137
+ var card = e.target.closest('[data-action][role="button"], [data-action][role="link"]');
4138
+ if (!card) return;
4139
+ e.preventDefault();
4140
+ card.click();
4141
+ });
4142
+
3849
4143
  document.addEventListener('click', function(e) {
3850
4144
  if (!(e.target instanceof Element)) return;
3851
4145
  var target = e.target.closest('[data-action]');
@@ -3926,6 +4220,27 @@
3926
4220
  if (sessionId) selectSession(sessionId);
3927
4221
  return;
3928
4222
  }
4223
+ if (action === 'goto-tab') {
4224
+ var gotoTab = target.getAttribute('data-tab');
4225
+ if (gotoTab) switchTab(gotoTab);
4226
+ return;
4227
+ }
4228
+ if (action === 'select-lesson') {
4229
+ var lsnId = target.getAttribute('data-lesson-id');
4230
+ if (lsnId) {
4231
+ state.lessons.selectedId = state.lessons.selectedId === lsnId ? null : lsnId;
4232
+ renderLessons();
4233
+ }
4234
+ return;
4235
+ }
4236
+ if (action === 'select-memory') {
4237
+ var memId = target.getAttribute('data-memory-id');
4238
+ if (memId) {
4239
+ state.memories.selectedId = state.memories.selectedId === memId ? null : memId;
4240
+ renderMemories();
4241
+ }
4242
+ return;
4243
+ }
3929
4244
  if (action === 'end-session') {
3930
4245
  var endSessionId = target.getAttribute('data-session-id');
3931
4246
  if (endSessionId) endSession(endSessionId);
@@ -4175,8 +4490,83 @@
4175
4490
  });
4176
4491
 
4177
4492
  switchTab(tabFromRoute(), { replaceRoute: true });
4178
- connectWs();
4493
+ // Resolve the stream WebSocket target from the server before the first
4494
+ // connect. The old viewerPort-1 arithmetic breaks whenever the viewer
4495
+ // binds a fallback port (3113 taken → viewer on 3114 → 3113 is another
4496
+ // HTTP server, not the streams endpoint) — every retry then fails and
4497
+ // the viewer silently degrades to 10s polling.
4498
+ (async function initWs() {
4499
+ try {
4500
+ var live = await api('livez', { signal: AbortSignal.timeout(5000) });
4501
+ if (live && typeof live.streamsPort === 'number' && live.streamsPort > 0) {
4502
+ WS_URL = wsProto + '//' + window.location.hostname + ':' + live.streamsPort;
4503
+ WS_DIRECT_URL = WS_URL + '/stream/mem-live/viewer';
4504
+ }
4505
+ } catch (_) {}
4506
+ connectWs();
4507
+ })();
4179
4508
  startDashboardAutoRefresh();
4509
+
4510
+ // Ambient background: an ordered-dither dot field that drifts very
4511
+ // slowly — the print-halftone cousin of the old static dot grid.
4512
+ // Renders at quarter resolution and ~12fps; a single static frame
4513
+ // under prefers-reduced-motion.
4514
+ (function ditherField() {
4515
+ var cv = document.getElementById('bg-dither');
4516
+ if (!cv || !cv.getContext) return;
4517
+ var ctx = cv.getContext('2d');
4518
+ var BAYER = [
4519
+ [0, 8, 2, 10],
4520
+ [12, 4, 14, 6],
4521
+ [3, 11, 1, 9],
4522
+ [15, 7, 13, 5]
4523
+ ];
4524
+ var CELL = 7;
4525
+ var t = 0;
4526
+ var reduced = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
4527
+
4528
+ function dotColor() {
4529
+ return document.documentElement.getAttribute('data-theme') === 'dark'
4530
+ ? 'rgba(150, 154, 162, 0.16)'
4531
+ : 'rgba(90, 90, 84, 0.14)';
4532
+ }
4533
+
4534
+ function draw() {
4535
+ var w = cv.width = Math.ceil(window.innerWidth / 2);
4536
+ var h = cv.height = Math.ceil(window.innerHeight / 2);
4537
+ ctx.clearRect(0, 0, w, h);
4538
+ ctx.fillStyle = dotColor();
4539
+ var cols = Math.ceil(w / CELL) + 1;
4540
+ var rows = Math.ceil(h / CELL) + 1;
4541
+ for (var y = 0; y < rows; y++) {
4542
+ for (var x = 0; x < cols; x++) {
4543
+ // Slow luminance swells drifting diagonally; the Bayer
4544
+ // threshold turns them into stable ordered-dither dots.
4545
+ var v =
4546
+ 0.5 +
4547
+ 0.5 *
4548
+ Math.sin(x * 0.11 + t * 0.013) *
4549
+ Math.cos(y * 0.13 - t * 0.011);
4550
+ if (v * 16 > BAYER[y % 4][x % 4] + 6.5) {
4551
+ ctx.fillRect(x * CELL, y * CELL, 1.4, 1.4);
4552
+ }
4553
+ }
4554
+ }
4555
+ }
4556
+
4557
+ draw();
4558
+ if (!reduced) {
4559
+ setInterval(function () {
4560
+ t++;
4561
+ draw();
4562
+ }, 85);
4563
+ }
4564
+ window.addEventListener('resize', draw);
4565
+ new MutationObserver(draw).observe(document.documentElement, {
4566
+ attributes: true,
4567
+ attributeFilter: ['data-theme']
4568
+ });
4569
+ })();
4180
4570
  </script>
4181
4571
  </body>
4182
4572
  </html>