@sesamespace/hivemind 0.8.13 → 0.11.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.
@@ -12,23 +12,43 @@
12
12
  }
13
13
  * { margin: 0; padding: 0; box-sizing: border-box; }
14
14
  body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; background: var(--bg); color: var(--text); display: flex; height: 100vh; overflow: hidden; }
15
-
15
+
16
16
  /* Sidebar */
17
17
  .sidebar { width: 200px; background: var(--bg2); border-right: 1px solid var(--border); padding: 16px 0; flex-shrink: 0; display: flex; flex-direction: column; }
18
- .sidebar h1 { font-size: 16px; padding: 0 16px 16px; border-bottom: 1px solid var(--border); color: var(--accent); }
19
- .sidebar nav { padding: 8px 0; }
18
+ .sidebar-header { padding: 0 16px 12px; border-bottom: 1px solid var(--border); }
19
+ .sidebar-header h1 { font-size: 16px; color: var(--accent); margin-bottom: 6px; }
20
+ .health-indicator { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--text2); }
21
+ .health-dot { width: 8px; height: 8px; border-radius: 50%; background: var(--text2); flex-shrink: 0; }
22
+ .health-dot.ok { background: var(--green); }
23
+ .health-dot.degraded { background: var(--yellow); }
24
+ .health-dot.offline { background: var(--red); }
25
+ .sidebar nav { padding: 8px 0; flex: 1; }
20
26
  .sidebar a { display: block; padding: 8px 16px; color: var(--text2); text-decoration: none; font-size: 14px; cursor: pointer; }
21
27
  .sidebar a:hover, .sidebar a.active { color: var(--text); background: var(--bg3); }
22
-
28
+
23
29
  /* Main */
24
30
  .main { flex: 1; overflow-y: auto; padding: 24px; }
25
-
31
+
32
+ /* Toast notifications */
33
+ .toast-container { position: fixed; top: 16px; right: 16px; z-index: 9999; display: flex; flex-direction: column; gap: 8px; }
34
+ .toast { padding: 10px 16px; border-radius: 6px; font-size: 13px; color: #fff; animation: toastIn 0.3s ease; max-width: 400px; cursor: pointer; }
35
+ .toast.info { background: var(--accent2); }
36
+ .toast.success { background: #238636; }
37
+ .toast.warning { background: #9e6a03; }
38
+ .toast.error { background: #da3633; }
39
+ @keyframes toastIn { from { opacity: 0; transform: translateX(20px); } to { opacity: 1; transform: none; } }
40
+
26
41
  /* Filters */
27
- .filters { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; }
42
+ .filters { display: flex; gap: 12px; margin-bottom: 16px; align-items: center; flex-wrap: wrap; }
28
43
  .filters input, .filters select { background: var(--bg2); border: 1px solid var(--border); color: var(--text); padding: 6px 10px; border-radius: 6px; font-size: 13px; }
29
44
  .filters input:focus, .filters select:focus { outline: none; border-color: var(--accent); }
30
45
  .filters button { background: var(--accent2); color: #fff; border: none; padding: 6px 14px; border-radius: 6px; cursor: pointer; font-size: 13px; }
31
-
46
+ .filters button:hover { background: var(--accent); }
47
+
48
+ /* Auto-refresh toggle */
49
+ .auto-refresh-btn { background: var(--bg3) !important; border: 1px solid var(--border) !important; color: var(--text2) !important; }
50
+ .auto-refresh-btn.active { border-color: var(--green) !important; color: var(--green) !important; }
51
+
32
52
  /* Request list */
33
53
  .req-row { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; margin-bottom: 8px; cursor: pointer; transition: border-color 0.15s; }
34
54
  .req-row:hover { border-color: var(--accent); }
@@ -39,14 +59,14 @@
39
59
  .req-summary .model { color: var(--text2); }
40
60
  .req-summary .latency { color: var(--yellow); text-align: right; }
41
61
  .req-summary .tokens { color: var(--text2); text-align: right; }
42
-
62
+
43
63
  /* Detail */
44
64
  .req-detail { padding: 0 14px 14px; border-top: 1px solid var(--border); display: none; }
45
65
  .req-detail.open { display: block; }
46
66
  .section { margin-top: 12px; }
47
67
  .section-header { font-size: 12px; font-weight: 600; color: var(--accent); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 6px; cursor: pointer; user-select: none; }
48
- .section-header::before { content: ' '; }
49
- .section-header.open::before { content: ' '; }
68
+ .section-header::before { content: '\25B8 '; }
69
+ .section-header.open::before { content: '\25BE '; }
50
70
  .section-body { display: none; }
51
71
  .section-body.open { display: block; }
52
72
  .section-body pre { background: var(--bg); border: 1px solid var(--border); border-radius: 6px; padding: 10px; font-size: 12px; line-height: 1.5; white-space: pre-wrap; word-break: break-word; max-height: 400px; overflow-y: auto; }
@@ -54,78 +74,187 @@
54
74
  .episode .meta { color: var(--text2); font-size: 11px; margin-bottom: 2px; }
55
75
  .episode .score { color: var(--yellow); }
56
76
  .l3-entry { background: var(--bg); border-left: 3px solid var(--purple); padding: 6px 10px; margin-bottom: 4px; font-size: 12px; }
57
-
58
- /* Token bar */
59
- .token-bar { display: flex; height: 20px; border-radius: 4px; overflow: hidden; margin-top: 4px; font-size: 10px; }
60
- .token-bar div { display: flex; align-items: center; justify-content: center; color: #fff; min-width: 30px; }
77
+
78
+ /* Token cards + bar */
79
+ .token-cards { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; margin-bottom: 8px; }
80
+ .token-card { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 6px 8px; text-align: center; }
81
+ .token-card .label { font-size: 10px; color: var(--text2); text-transform: uppercase; }
82
+ .token-card .value { font-size: 16px; font-weight: 600; margin-top: 2px; }
83
+ .token-card .pct { font-size: 10px; color: var(--text2); }
84
+ .token-bar { display: flex; height: 20px; border-radius: 4px; overflow: hidden; font-size: 10px; }
85
+ .token-bar div { display: flex; align-items: center; justify-content: center; color: #fff; min-width: 30px; position: relative; }
86
+ .token-bar div[title] { cursor: help; }
61
87
  .tb-sys { background: #58a6ff; }
62
88
  .tb-hist { background: #bc8cff; }
63
89
  .tb-user { background: #3fb950; }
64
-
90
+
65
91
  /* Config */
66
92
  .config-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px; }
67
93
  .config-item { background: var(--bg); border: 1px solid var(--border); border-radius: 4px; padding: 8px; text-align: center; }
68
94
  .config-item .label { font-size: 10px; color: var(--text2); text-transform: uppercase; }
69
95
  .config-item .value { font-size: 14px; font-weight: 600; margin-top: 2px; }
70
-
96
+
97
+ /* Status cards grid */
98
+ .status-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: 12px; margin-bottom: 24px; }
99
+ .status-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 16px; }
100
+ .status-card .label { font-size: 11px; color: var(--text2); text-transform: uppercase; letter-spacing: 0.5px; }
101
+ .status-card .value { font-size: 24px; font-weight: 700; margin-top: 4px; }
102
+ .status-card .sub { font-size: 11px; color: var(--text2); margin-top: 2px; }
103
+
71
104
  /* Memory browser */
72
105
  .ctx-card { background: var(--bg2); border: 1px solid var(--border); border-radius: 8px; padding: 14px; margin-bottom: 8px; }
73
106
  .ctx-card h3 { font-size: 14px; color: var(--accent); margin-bottom: 4px; }
74
107
  .ctx-card .meta { font-size: 12px; color: var(--text2); }
108
+ .ctx-card .actions { display: flex; gap: 6px; margin-top: 8px; }
75
109
  .btn-sm { background: var(--bg3); border: 1px solid var(--border); color: var(--text2); padding: 4px 8px; border-radius: 4px; cursor: pointer; font-size: 11px; }
76
110
  .btn-sm:hover { border-color: var(--accent); color: var(--text); }
77
111
  .btn-danger { border-color: var(--red); color: var(--red); }
78
112
  .btn-danger:hover { background: var(--red); color: #fff; }
79
-
113
+ .btn-primary { background: var(--accent2); border-color: var(--accent2); color: #fff; }
114
+ .btn-primary:hover { background: var(--accent); }
115
+
80
116
  /* Pagination */
81
117
  .pagination { display: flex; gap: 8px; margin-top: 16px; justify-content: center; align-items: center; font-size: 13px; color: var(--text2); }
82
118
  .pagination button { background: var(--bg2); border: 1px solid var(--border); color: var(--text); padding: 4px 12px; border-radius: 4px; cursor: pointer; }
83
119
  .pagination button:disabled { opacity: 0.4; cursor: default; }
84
-
120
+
85
121
  .hidden { display: none; }
86
122
  .view { display: none; }
87
123
  .view.active { display: block; }
88
-
124
+
89
125
  /* Memory browser sub-nav */
90
126
  .mem-tabs { display: flex; gap: 8px; margin-bottom: 16px; }
91
127
  .mem-tab { padding: 6px 14px; border-radius: 6px; background: var(--bg2); border: 1px solid var(--border); color: var(--text2); cursor: pointer; font-size: 13px; }
92
128
  .mem-tab.active { border-color: var(--accent); color: var(--accent); }
129
+
130
+ /* Sub-tabs for promotion view */
131
+ .sub-tabs { display: flex; gap: 8px; margin-bottom: 16px; }
132
+ .sub-tab { padding: 6px 14px; border-radius: 6px; background: var(--bg2); border: 1px solid var(--border); color: var(--text2); cursor: pointer; font-size: 13px; }
133
+ .sub-tab.active { border-color: var(--accent); color: var(--accent); }
134
+
135
+ /* Progress bar */
136
+ .progress-bar { background: var(--bg3); border-radius: 4px; height: 16px; overflow: hidden; position: relative; }
137
+ .progress-fill { height: 100%; border-radius: 4px; transition: width 0.3s; display: flex; align-items: center; justify-content: center; font-size: 10px; color: #fff; }
138
+ .progress-fill.green { background: var(--green); }
139
+ .progress-fill.yellow { background: var(--yellow); }
140
+ .progress-fill.purple { background: var(--purple); }
141
+
142
+ /* Search results */
143
+ .search-result { background: var(--bg2); border: 1px solid var(--border); border-radius: 6px; padding: 10px 14px; margin-bottom: 8px; }
144
+ .search-result .meta { font-size: 11px; color: var(--text2); margin-bottom: 4px; }
145
+ .search-result .content { font-size: 13px; }
146
+ .search-group-header { font-size: 14px; font-weight: 600; color: var(--purple); margin: 16px 0 8px; padding-bottom: 4px; border-bottom: 1px solid var(--border); }
147
+
148
+ /* L3 edit textarea */
149
+ .l3-edit-area { width: 100%; min-height: 80px; background: var(--bg); border: 1px solid var(--accent); color: var(--text); padding: 8px; border-radius: 4px; font-family: inherit; font-size: 12px; resize: vertical; }
150
+
151
+ /* Decay curve SVG */
152
+ .decay-curve { margin-top: 8px; }
153
+
154
+ /* Table */
155
+ .data-table { width: 100%; border-collapse: collapse; font-size: 13px; }
156
+ .data-table th { text-align: left; padding: 8px; border-bottom: 2px solid var(--border); color: var(--text2); font-size: 11px; text-transform: uppercase; }
157
+ .data-table td { padding: 8px; border-bottom: 1px solid var(--border); }
158
+ .data-table tr:hover td { background: var(--bg3); }
159
+
160
+ /* L1 message */
161
+ .l1-msg { padding: 8px 12px; margin-bottom: 4px; border-radius: 6px; font-size: 13px; }
162
+ .l1-msg.user { background: var(--bg3); border-left: 3px solid var(--green); }
163
+ .l1-msg.assistant { background: var(--bg2); border-left: 3px solid var(--accent); }
164
+ .l1-msg .role { font-size: 11px; font-weight: 600; color: var(--text2); margin-bottom: 2px; text-transform: uppercase; }
93
165
  </style>
94
166
  </head>
95
167
  <body>
96
168
  <div class="sidebar">
97
- <h1>🧠 Hivemind</h1>
169
+ <div class="sidebar-header">
170
+ <h1>Hivemind</h1>
171
+ <div class="health-indicator">
172
+ <span class="health-dot" id="health-dot"></span>
173
+ <span id="health-text">Checking...</span>
174
+ </div>
175
+ </div>
98
176
  <nav>
99
- <a data-view="requests" class="active">Requests</a>
177
+ <a data-view="health" class="active">Health</a>
178
+ <a data-view="requests">Requests</a>
100
179
  <a data-view="memory">Memory</a>
180
+ <a data-view="search">Search</a>
181
+ <a data-view="promotion">Promotion</a>
101
182
  <a data-view="contexts">Contexts</a>
102
183
  </nav>
103
184
  </div>
104
185
  <div class="main">
186
+
187
+ <!-- Toast Container -->
188
+ <div class="toast-container" id="toast-container"></div>
189
+
190
+ <!-- Health View -->
191
+ <div id="v-health" class="view active">
192
+ <h2 style="margin-bottom:16px">System Status</h2>
193
+ <div class="status-grid" id="health-cards"></div>
194
+ <h3 style="margin:24px 0 12px">Context Summary</h3>
195
+ <table class="data-table" id="ctx-summary-table">
196
+ <thead><tr><th>Context</th><th>Episodes</th><th>L3 Entries</th><th>Created</th></tr></thead>
197
+ <tbody id="ctx-summary-body"></tbody>
198
+ </table>
199
+ <h3 style="margin:24px 0 12px">Scoring Configuration</h3>
200
+ <div id="scoring-config-section"></div>
201
+ </div>
202
+
105
203
  <!-- Requests View -->
106
- <div id="v-requests" class="view active">
204
+ <div id="v-requests" class="view">
107
205
  <div class="filters">
108
- <input type="text" id="f-context" placeholder="Filter context" />
109
- <input type="text" id="f-sender" placeholder="Filter sender" />
206
+ <input type="text" id="f-context" placeholder="Filter context..." />
207
+ <input type="text" id="f-sender" placeholder="Filter sender..." />
110
208
  <button onclick="loadRequests()">Filter</button>
209
+ <button class="auto-refresh-btn" id="ar-requests" onclick="toggleAutoRefresh('requests')">Auto-refresh</button>
111
210
  </div>
112
211
  <div id="req-list"></div>
113
212
  <div class="pagination" id="req-pagination"></div>
114
213
  </div>
115
-
214
+
116
215
  <!-- Memory View -->
117
216
  <div id="v-memory" class="view">
118
217
  <div class="mem-tabs">
119
218
  <div class="mem-tab active" data-mtab="l3">L3 Knowledge</div>
120
219
  <div class="mem-tab" data-mtab="l2">L2 Episodes</div>
220
+ <div class="mem-tab" data-mtab="l1">L1 History</div>
121
221
  </div>
122
- <div id="mem-ctx-select" style="margin-bottom:12px">
123
- <select id="mem-context"><option value="">Select context…</option></select>
222
+ <div id="mem-ctx-select" style="margin-bottom:12px;display:flex;gap:12px;align-items:center">
223
+ <select id="mem-context"><option value="">Select context...</option></select>
224
+ <button class="auto-refresh-btn" id="ar-memory" onclick="toggleAutoRefresh('memory')">Auto-refresh</button>
124
225
  </div>
125
226
  <div id="mem-l3" class="view active"></div>
126
227
  <div id="mem-l2" class="view"></div>
228
+ <div id="mem-l1" class="view"></div>
229
+ </div>
230
+
231
+ <!-- Search View -->
232
+ <div id="v-search" class="view">
233
+ <h2 style="margin-bottom:16px">Cross-Context Search</h2>
234
+ <div class="filters">
235
+ <input type="text" id="search-query" placeholder="Search query..." style="flex:1;min-width:200px" />
236
+ <select id="search-scope">
237
+ <option value="all">All Contexts</option>
238
+ </select>
239
+ <input type="number" id="search-limit" value="20" min="1" max="100" style="width:60px" />
240
+ <button onclick="runSearch()">Search</button>
241
+ </div>
242
+ <div id="search-results"></div>
243
+ </div>
244
+
245
+ <!-- Promotion View -->
246
+ <div id="v-promotion" class="view">
247
+ <h2 style="margin-bottom:16px">Promotion & Scoring</h2>
248
+ <div class="sub-tabs">
249
+ <div class="sub-tab active" data-ptab="thresholds">Thresholds</div>
250
+ <div class="sub-tab" data-ptab="candidates">Candidates</div>
251
+ <div class="sub-tab" data-ptab="scoring">Scoring</div>
252
+ </div>
253
+ <div id="promo-thresholds" class="view active"></div>
254
+ <div id="promo-candidates" class="view"></div>
255
+ <div id="promo-scoring" class="view"></div>
127
256
  </div>
128
-
257
+
129
258
  <!-- Contexts View -->
130
259
  <div id="v-contexts" class="view">
131
260
  <div id="ctx-list"></div>
@@ -136,19 +265,104 @@
136
265
  const API = '';
137
266
  let reqOffset = 0;
138
267
  const REQ_LIMIT = 30;
268
+ let l2Page = 0;
269
+ const L2_PAGE_SIZE = 50;
270
+ let l2AllEpisodes = [];
271
+ let l2SearchMode = false;
272
+ let autoRefreshTimers = {};
273
+ let activeMemTab = 'l3';
274
+
275
+ // ===== Toast Notifications =====
276
+ function showNotification(message, type = 'info') {
277
+ const container = document.getElementById('toast-container');
278
+ const toast = document.createElement('div');
279
+ toast.className = 'toast ' + type;
280
+ toast.textContent = message;
281
+ toast.addEventListener('click', () => toast.remove());
282
+ container.appendChild(toast);
283
+ setTimeout(() => { if (toast.parentNode) toast.remove(); }, 5000);
284
+ }
285
+
286
+ // ===== Health Polling =====
287
+ let daemonStatus = 'offline';
288
+ let daemonVersion = '';
289
+
290
+ async function checkHealth() {
291
+ const dot = document.getElementById('health-dot');
292
+ const text = document.getElementById('health-text');
293
+ try {
294
+ const res = await fetch(API + '/api/health');
295
+ if (res.ok) {
296
+ const data = await res.json();
297
+ daemonStatus = data.status || 'ok';
298
+ daemonVersion = data.version || '';
299
+ dot.className = 'health-dot ok';
300
+ text.textContent = 'v' + daemonVersion;
301
+ } else {
302
+ daemonStatus = 'degraded';
303
+ dot.className = 'health-dot degraded';
304
+ text.textContent = 'Degraded';
305
+ }
306
+ } catch {
307
+ daemonStatus = 'offline';
308
+ dot.className = 'health-dot offline';
309
+ text.textContent = 'Daemon offline';
310
+ }
311
+ }
312
+ checkHealth();
313
+ setInterval(checkHealth, 10000);
314
+
315
+ // ===== Auto-Refresh =====
316
+ function toggleAutoRefresh(view) {
317
+ const btn = document.getElementById('ar-' + view);
318
+ if (autoRefreshTimers[view]) {
319
+ clearInterval(autoRefreshTimers[view]);
320
+ autoRefreshTimers[view] = null;
321
+ btn.classList.remove('active');
322
+ localStorage.removeItem('ar-' + view);
323
+ } else {
324
+ autoRefreshTimers[view] = setInterval(() => refreshView(view), 5000);
325
+ btn.classList.add('active');
326
+ localStorage.setItem('ar-' + view, '1');
327
+ }
328
+ }
139
329
 
140
- // Navigation
330
+ function refreshView(view) {
331
+ if (view === 'requests') loadRequests();
332
+ else if (view === 'memory') {
333
+ const ctx = document.getElementById('mem-context').value;
334
+ if (ctx) {
335
+ if (activeMemTab === 'l3') loadL3(ctx);
336
+ else if (activeMemTab === 'l2') loadL2(ctx);
337
+ else if (activeMemTab === 'l1') loadL1(ctx);
338
+ }
339
+ }
340
+ }
341
+
342
+ // Restore auto-refresh state
343
+ ['requests', 'memory'].forEach(v => {
344
+ if (localStorage.getItem('ar-' + v)) {
345
+ const btn = document.getElementById('ar-' + v);
346
+ if (btn) {
347
+ autoRefreshTimers[v] = setInterval(() => refreshView(v), 5000);
348
+ btn.classList.add('active');
349
+ }
350
+ }
351
+ });
352
+
353
+ // ===== Navigation =====
141
354
  document.querySelectorAll('.sidebar a').forEach(a => {
142
355
  a.addEventListener('click', () => {
143
356
  document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
144
357
  a.classList.add('active');
145
- document.querySelectorAll('.view').forEach(v => {
146
- if (v.closest('.main')) v.classList.remove('active');
147
- });
358
+ document.querySelectorAll('.main > .view').forEach(v => v.classList.remove('active'));
148
359
  const viewId = 'v-' + a.dataset.view;
149
360
  document.getElementById(viewId)?.classList.add('active');
361
+ if (a.dataset.view === 'health') loadHealthView();
150
362
  if (a.dataset.view === 'contexts') loadContexts();
151
363
  if (a.dataset.view === 'memory') loadMemoryContexts();
364
+ if (a.dataset.view === 'search') loadSearchContexts();
365
+ if (a.dataset.view === 'promotion') loadPromotion();
152
366
  });
153
367
  });
154
368
 
@@ -157,85 +371,210 @@ document.querySelectorAll('.mem-tab').forEach(t => {
157
371
  t.addEventListener('click', () => {
158
372
  document.querySelectorAll('.mem-tab').forEach(x => x.classList.remove('active'));
159
373
  t.classList.add('active');
160
- document.getElementById('mem-l3').classList.toggle('active', t.dataset.mtab === 'l3');
161
- document.getElementById('mem-l2').classList.toggle('active', t.dataset.mtab === 'l2');
374
+ activeMemTab = t.dataset.mtab;
375
+ document.getElementById('mem-l3').classList.toggle('active', activeMemTab === 'l3');
376
+ document.getElementById('mem-l2').classList.toggle('active', activeMemTab === 'l2');
377
+ document.getElementById('mem-l1').classList.toggle('active', activeMemTab === 'l1');
378
+ const ctx = document.getElementById('mem-context').value;
379
+ if (ctx) {
380
+ if (activeMemTab === 'l1') loadL1(ctx);
381
+ }
382
+ });
383
+ });
384
+
385
+ // Promotion sub-tabs
386
+ document.querySelectorAll('.sub-tab').forEach(t => {
387
+ t.addEventListener('click', () => {
388
+ document.querySelectorAll('.sub-tab').forEach(x => x.classList.remove('active'));
389
+ t.classList.add('active');
390
+ document.getElementById('promo-thresholds').classList.toggle('active', t.dataset.ptab === 'thresholds');
391
+ document.getElementById('promo-candidates').classList.toggle('active', t.dataset.ptab === 'candidates');
392
+ document.getElementById('promo-scoring').classList.toggle('active', t.dataset.ptab === 'scoring');
393
+ if (t.dataset.ptab === 'candidates') loadCandidates();
394
+ if (t.dataset.ptab === 'scoring') loadScoringTab();
162
395
  });
163
396
  });
164
397
 
165
398
  document.getElementById('mem-context').addEventListener('change', () => {
166
399
  const ctx = document.getElementById('mem-context').value;
167
- if (ctx) { loadL3(ctx); loadL2(ctx); }
400
+ if (ctx) { loadL3(ctx); loadL2(ctx); if (activeMemTab === 'l1') loadL1(ctx); }
168
401
  });
169
402
 
170
- // Requests
403
+ // ===== Health & System Status View =====
404
+ async function loadHealthView() {
405
+ const cards = document.getElementById('health-cards');
406
+ const tbody = document.getElementById('ctx-summary-body');
407
+ const scoringEl = document.getElementById('scoring-config-section');
408
+
409
+ let stats = null;
410
+ let contexts = [];
411
+
412
+ // Fetch stats and contexts independently so partial data still shows
413
+ try {
414
+ const ctxRes = await fetch(API + '/api/contexts');
415
+ if (ctxRes.ok) {
416
+ const ctxData = await ctxRes.json();
417
+ contexts = ctxData.contexts || [];
418
+ }
419
+ } catch {}
420
+
421
+ try {
422
+ const statsRes = await fetch(API + '/api/stats');
423
+ if (statsRes.ok) stats = await statsRes.json();
424
+ } catch {}
425
+
426
+ if (!stats && contexts.length === 0) {
427
+ cards.innerHTML = '<p style="color:var(--red)">Cannot connect to memory daemon</p>';
428
+ showNotification('Health view failed: daemon may be offline', 'error');
429
+ return;
430
+ }
431
+
432
+ try {
433
+ const reqCountRes = await fetch(API + '/api/requests?limit=1');
434
+ var reqCount = reqCountRes.ok ? (await reqCountRes.json()).total || 0 : '?';
435
+ } catch { var reqCount = '?'; }
436
+
437
+ if (stats) {
438
+ const totalEpisodes = Object.values(stats.total_episodes || {}).reduce((a, b) => a + b, 0);
439
+ cards.innerHTML = `
440
+ <div class="status-card"><div class="label">Daemon Status</div><div class="value" style="color:var(${daemonStatus === 'ok' ? '--green' : '--red'})">${daemonStatus === 'ok' ? 'Online' : daemonStatus}</div><div class="sub">v${esc(daemonVersion)}</div></div>
441
+ <div class="status-card"><div class="label">Embedding Model</div><div class="value" style="font-size:14px">${esc(stats.embedding_model)}</div></div>
442
+ <div class="status-card"><div class="label">Total Episodes</div><div class="value">${totalEpisodes}</div><div class="sub">${contexts.length} contexts</div></div>
443
+ <div class="status-card"><div class="label">L3 Entries</div><div class="value">${stats.total_l3_entries}</div></div>
444
+ <div class="status-card"><div class="label">Access Records</div><div class="value">${stats.total_access_records}</div></div>
445
+ <div class="status-card"><div class="label">Request Logs</div><div class="value">${reqCount}</div></div>
446
+ `;
447
+ } else {
448
+ const totalEpisodes = contexts.reduce((a, c) => a + (c.episode_count || 0), 0);
449
+ cards.innerHTML = `
450
+ <div class="status-card"><div class="label">Daemon Status</div><div class="value" style="color:var(${daemonStatus === 'ok' ? '--green' : '--red'})">${daemonStatus === 'ok' ? 'Online' : daemonStatus}</div><div class="sub">v${esc(daemonVersion)}</div></div>
451
+ <div class="status-card"><div class="label">Stats</div><div class="value" style="font-size:14px;color:var(--yellow)">Unavailable</div><div class="sub">/stats endpoint not found — rebuild daemon</div></div>
452
+ <div class="status-card"><div class="label">Total Episodes</div><div class="value">${totalEpisodes}</div><div class="sub">${contexts.length} contexts</div></div>
453
+ <div class="status-card"><div class="label">Request Logs</div><div class="value">${reqCount}</div></div>
454
+ `;
455
+ }
456
+
457
+ // Context summary table — always populate from contexts data
458
+ tbody.innerHTML = '';
459
+ for (const c of contexts) {
460
+ const epCount = stats ? (stats.total_episodes?.[c.name] ?? c.episode_count ?? 0) : (c.episode_count ?? 0);
461
+ const tr = document.createElement('tr');
462
+ tr.innerHTML = `<td style="color:var(--accent)">${esc(c.name)}</td><td>${epCount}</td><td>-</td><td>${c.created_at ? new Date(c.created_at).toLocaleDateString() : '-'}</td>`;
463
+ tbody.appendChild(tr);
464
+ }
465
+
466
+ // Scoring section
467
+ const defaultHL = (stats && stats.default_half_life_hours) || 48;
468
+ if (stats) {
469
+ scoringEl.innerHTML = `
470
+ <p style="color:var(--text2);font-size:13px;margin-bottom:12px">Default half-life: <strong>${defaultHL}h</strong>. Episodes lose ~50% relevance weight every ${defaultHL} hours.</p>
471
+ ${renderDecayCurve(defaultHL)}
472
+ `;
473
+ } else {
474
+ scoringEl.innerHTML = `
475
+ <p style="color:var(--yellow);font-size:13px;margin-bottom:12px">Stats unavailable — showing default half-life: <strong>${defaultHL}h</strong>.</p>
476
+ ${renderDecayCurve(defaultHL)}
477
+ `;
478
+ }
479
+ }
480
+
481
+ function renderDecayCurve(halfLife) {
482
+ const w = 300, h = 100, pad = 30;
483
+ const points = [];
484
+ const maxH = halfLife * 4;
485
+ for (let t = 0; t <= maxH; t += maxH / 50) {
486
+ const weight = Math.exp(-Math.LN2 / halfLife * t);
487
+ const x = pad + (t / maxH) * (w - pad * 2);
488
+ const y = pad + (1 - weight) * (h - pad * 2);
489
+ points.push(`${x},${y}`);
490
+ }
491
+ return `<svg width="${w}" height="${h}" class="decay-curve" style="background:var(--bg);border:1px solid var(--border);border-radius:4px">
492
+ <polyline points="${points.join(' ')}" fill="none" stroke="var(--accent)" stroke-width="2"/>
493
+ <text x="${pad}" y="${pad - 8}" fill="var(--text2)" font-size="10">1.0</text>
494
+ <text x="${pad}" y="${h - 8}" fill="var(--text2)" font-size="10">0.0</text>
495
+ <text x="${w - pad}" y="${h - 8}" fill="var(--text2)" font-size="10">${maxH}h</text>
496
+ <line x1="${pad + (halfLife / maxH) * (w - pad * 2)}" y1="${pad}" x2="${pad + (halfLife / maxH) * (w - pad * 2)}" y2="${h - pad}" stroke="var(--yellow)" stroke-dasharray="4"/>
497
+ <text x="${pad + (halfLife / maxH) * (w - pad * 2) - 10}" y="${h - 8}" fill="var(--yellow)" font-size="9">${halfLife}h</text>
498
+ </svg>`;
499
+ }
500
+
501
+ // ===== Requests =====
171
502
  async function loadRequests() {
172
503
  const ctx = document.getElementById('f-context').value;
173
504
  const sender = document.getElementById('f-sender').value;
174
505
  const params = new URLSearchParams({ limit: REQ_LIMIT, offset: reqOffset });
175
506
  if (ctx) params.set('context', ctx);
176
507
  if (sender) params.set('sender', sender);
177
-
178
- const res = await fetch(`${API}/api/requests?${params}`);
179
- const data = await res.json();
180
-
181
- const list = document.getElementById('req-list');
182
- list.innerHTML = '';
183
-
184
- for (const r of data.requests) {
185
- const row = document.createElement('div');
186
- row.className = 'req-row';
187
- const ts = new Date(r.timestamp).toLocaleString();
188
- const latency = r.response_latency_ms + 'ms';
189
- const tokens = r.token_est_total;
190
- const model = r.response_model.split('/').pop().slice(0, 15);
191
-
192
- row.innerHTML = `
193
- <div class="req-summary">
194
- <span class="ts">${ts}</span>
195
- <span class="sender">${esc(r.sender_handle || 'stdin')}</span>
196
- <span class="ctx">${esc(r.context)}</span>
197
- <span class="model">${esc(model)}</span>
198
- <span class="latency">${latency}</span>
199
- <span class="tokens">~${tokens}t</span>
200
- </div>
201
- <div class="req-detail" id="detail-${r.id}"></div>
508
+
509
+ try {
510
+ const res = await fetch(API + '/api/requests?' + params);
511
+ if (!res.ok) { showNotification('Failed to load requests', 'error'); return; }
512
+ const data = await res.json();
513
+
514
+ const list = document.getElementById('req-list');
515
+ list.innerHTML = '';
516
+
517
+ for (const r of data.requests) {
518
+ const row = document.createElement('div');
519
+ row.className = 'req-row';
520
+ const ts = new Date(r.timestamp).toLocaleString();
521
+ const latency = r.response_latency_ms + 'ms';
522
+ const tokens = r.token_est_total;
523
+ const model = r.response_model.split('/').pop().slice(0, 15);
524
+
525
+ row.innerHTML = `
526
+ <div class="req-summary">
527
+ <span class="ts">${ts}</span>
528
+ <span class="sender">${esc(r.sender_handle || 'stdin')}</span>
529
+ <span class="ctx">${esc(r.context)}</span>
530
+ <span class="model">${esc(model)}</span>
531
+ <span class="latency">${latency}</span>
532
+ <span class="tokens">~${tokens}t</span>
533
+ </div>
534
+ <div class="req-detail" id="detail-${r.id}"></div>
535
+ `;
536
+
537
+ row.querySelector('.req-summary').addEventListener('click', () => {
538
+ const detail = document.getElementById('detail-' + r.id);
539
+ if (detail.classList.contains('open')) {
540
+ detail.classList.remove('open');
541
+ } else {
542
+ renderDetail(detail, r);
543
+ detail.classList.add('open');
544
+ }
545
+ });
546
+ list.appendChild(row);
547
+ }
548
+
549
+ // Pagination
550
+ const pag = document.getElementById('req-pagination');
551
+ const page = Math.floor(reqOffset / REQ_LIMIT) + 1;
552
+ const totalPages = Math.ceil(data.total / REQ_LIMIT);
553
+ pag.innerHTML = `
554
+ <button ${reqOffset === 0 ? 'disabled' : ''} onclick="reqOffset -= ${REQ_LIMIT}; loadRequests()">Prev</button>
555
+ <span>Page ${page} of ${totalPages} (${data.total} total)</span>
556
+ <button ${reqOffset + REQ_LIMIT >= data.total ? 'disabled' : ''} onclick="reqOffset += ${REQ_LIMIT}; loadRequests()">Next</button>
202
557
  `;
203
-
204
- row.querySelector('.req-summary').addEventListener('click', () => {
205
- const detail = document.getElementById('detail-' + r.id);
206
- if (detail.classList.contains('open')) {
207
- detail.classList.remove('open');
208
- } else {
209
- renderDetail(detail, r);
210
- detail.classList.add('open');
211
- }
212
- });
213
- list.appendChild(row);
558
+ } catch (err) {
559
+ showNotification('Failed to load requests: ' + err.message, 'error');
214
560
  }
215
-
216
- // Pagination
217
- const pag = document.getElementById('req-pagination');
218
- const page = Math.floor(reqOffset / REQ_LIMIT) + 1;
219
- const totalPages = Math.ceil(data.total / REQ_LIMIT);
220
- pag.innerHTML = `
221
- <button ${reqOffset === 0 ? 'disabled' : ''} onclick="reqOffset -= ${REQ_LIMIT}; loadRequests()">← Prev</button>
222
- <span>Page ${page} of ${totalPages} (${data.total} total)</span>
223
- <button ${reqOffset + REQ_LIMIT >= data.total ? 'disabled' : ''} onclick="reqOffset += ${REQ_LIMIT}; loadRequests()">Next →</button>
224
- `;
225
561
  }
226
562
 
227
563
  function renderDetail(el, r) {
228
564
  const comps = typeof r.system_prompt_components === 'string' ? JSON.parse(r.system_prompt_components) : r.system_prompt_components;
229
565
  const history = typeof r.conversation_history === 'string' ? JSON.parse(r.conversation_history) : r.conversation_history;
230
566
  const config = typeof r.config_snapshot === 'string' ? JSON.parse(r.config_snapshot) : r.config_snapshot;
231
-
567
+
232
568
  const totalTokens = r.token_est_total || 1;
233
- const sysPct = Math.round((r.token_est_system / totalTokens) * 100);
234
- const histPct = Math.round((r.token_est_history / totalTokens) * 100);
235
- const userPct = 100 - sysPct - histPct;
236
-
569
+ const sysTokens = r.token_est_system || 0;
570
+ const histTokens = r.token_est_history || 0;
571
+ const userTokens = r.token_est_user || 0;
572
+ const sysPct = Math.round((sysTokens / totalTokens) * 100);
573
+ const histPct = Math.round((histTokens / totalTokens) * 100);
574
+ const userPct = Math.max(100 - sysPct - histPct, 0);
575
+
237
576
  let html = '';
238
-
577
+
239
578
  // Config
240
579
  html += `<div class="section"><div class="config-grid">
241
580
  <div class="config-item"><div class="label">Model</div><div class="value">${esc(config?.model || r.response_model)}</div></div>
@@ -243,56 +582,62 @@ function renderDetail(el, r) {
243
582
  <div class="config-item"><div class="label">Top K</div><div class="value">${config?.topK ?? '?'}</div></div>
244
583
  <div class="config-item"><div class="label">Temperature</div><div class="value">${config?.temperature ?? '?'}</div></div>
245
584
  </div></div>`;
246
-
247
- // Token bar
585
+
586
+ // Token cards + bar
248
587
  html += `<div class="section">
249
588
  <div class="section-header open">Token Breakdown (~${totalTokens} total)</div>
250
589
  <div class="section-body open">
590
+ <div class="token-cards">
591
+ <div class="token-card"><div class="label">System</div><div class="value" style="color:var(--accent)">${sysTokens.toLocaleString()}</div><div class="pct">${sysPct}%</div></div>
592
+ <div class="token-card"><div class="label">History</div><div class="value" style="color:var(--purple)">${histTokens.toLocaleString()}</div><div class="pct">${histPct}%</div></div>
593
+ <div class="token-card"><div class="label">User</div><div class="value" style="color:var(--green)">${userTokens.toLocaleString()}</div><div class="pct">${userPct}%</div></div>
594
+ <div class="token-card"><div class="label">Total</div><div class="value">${totalTokens.toLocaleString()}</div><div class="pct">100%</div></div>
595
+ </div>
251
596
  <div class="token-bar">
252
- <div class="tb-sys" style="width:${sysPct}%">sys ${r.token_est_system}</div>
253
- <div class="tb-hist" style="width:${histPct}%">${r.token_est_history > 0 ? 'hist ' + r.token_est_history : ''}</div>
254
- <div class="tb-user" style="width:${Math.max(userPct, 5)}%">user ${r.token_est_user}</div>
597
+ <div class="tb-sys" style="width:${sysPct}%" title="System: ${sysTokens} tokens (${sysPct}%)">sys</div>
598
+ <div class="tb-hist" style="width:${histPct}%" title="History: ${histTokens} tokens (${histPct}%)">${histTokens > 0 ? 'hist' : ''}</div>
599
+ <div class="tb-user" style="width:${Math.max(userPct, 5)}%" title="User: ${userTokens} tokens (${userPct}%)">user</div>
255
600
  </div>
256
601
  </div>
257
602
  </div>`;
258
-
603
+
259
604
  // User message
260
605
  html += section('User Message', `<pre>${esc(r.user_message)}</pre>`, true);
261
-
606
+
262
607
  // Response
263
608
  const skipped = r.response_skipped ? ' <span style="color:var(--yellow)">[SKIPPED]</span>' : '';
264
609
  html += section('Response' + skipped, `<pre>${esc(r.response_content)}</pre>`, true);
265
-
610
+
266
611
  // L3 Knowledge
267
612
  if (comps?.l3Knowledge?.length > 0) {
268
613
  const l3Html = comps.l3Knowledge.map(k => `<div class="l3-entry">${esc(k)}</div>`).join('');
269
614
  html += section(`L3 Knowledge (${comps.l3Knowledge.length})`, l3Html, true);
270
615
  }
271
-
616
+
272
617
  // L2 Episodes
273
618
  if (comps?.l2Episodes?.length > 0) {
274
619
  const epHtml = comps.l2Episodes.map(ep => `
275
620
  <div class="episode">
276
- <div class="meta">${esc(ep.role)} · ${esc(ep.context_name)} · <span class="score">score: ${ep.score?.toFixed(3)}</span> · ${new Date(ep.timestamp).toLocaleString()}</div>
621
+ <div class="meta">${esc(ep.role)} &middot; ${esc(ep.context_name)} &middot; <span class="score">score: ${ep.score?.toFixed(3)}</span> &middot; ${new Date(ep.timestamp).toLocaleString()}</div>
277
622
  ${esc(ep.content)}
278
623
  </div>
279
624
  `).join('');
280
625
  html += section(`L2 Episodes (${comps.l2Episodes.length})`, epHtml, true);
281
626
  }
282
-
627
+
283
628
  // L1 History
284
629
  if (history?.length > 0) {
285
630
  const histHtml = history.map(m => `<div class="episode"><div class="meta">${esc(m.role)}</div>${esc(m.content)}</div>`).join('');
286
631
  html += section(`L1 History (${history.length} turns)`, histHtml, false);
287
632
  }
288
-
289
- // Identity (collapsed by default)
633
+
634
+ // Identity
290
635
  if (comps?.identity) {
291
636
  html += section('Identity Files', `<pre>${esc(comps.identity)}</pre>`, false);
292
637
  }
293
-
638
+
294
639
  el.innerHTML = html;
295
-
640
+
296
641
  // Wire section toggles
297
642
  el.querySelectorAll('.section-header').forEach(h => {
298
643
  h.addEventListener('click', () => {
@@ -309,10 +654,11 @@ function section(title, content, startOpen) {
309
654
  </div>`;
310
655
  }
311
656
 
312
- // Contexts
657
+ // ===== Contexts View =====
313
658
  async function loadContexts() {
314
659
  try {
315
- const res = await fetch(`${API}/api/contexts`);
660
+ const res = await fetch(API + '/api/contexts');
661
+ if (!res.ok) { showNotification('Failed to load contexts', 'error'); return; }
316
662
  const data = await res.json();
317
663
  const contexts = data.contexts || [];
318
664
  const list = document.getElementById('ctx-list');
@@ -323,8 +669,13 @@ async function loadContexts() {
323
669
  list.innerHTML = contexts.map(c => `
324
670
  <div class="ctx-card">
325
671
  <h3>${esc(c.name)}</h3>
326
- <div class="meta">${c.episode_count ?? '?'} episodes · created ${new Date(c.created_at).toLocaleDateString()}</div>
672
+ <div class="meta">${c.episode_count ?? '?'} episodes &middot; created ${c.created_at ? new Date(c.created_at).toLocaleDateString() : '?'}</div>
327
673
  ${c.description ? `<div class="meta">${esc(c.description)}</div>` : ''}
674
+ <div class="actions">
675
+ <button class="btn-sm btn-primary" onclick="drillToMemory('${esc(c.name)}')">Browse Episodes</button>
676
+ <button class="btn-sm" onclick="viewScoring('${esc(c.name)}')">Scoring</button>
677
+ ${c.name !== 'global' ? `<button class="btn-sm btn-danger" onclick="deleteContext('${esc(c.name)}')">Delete</button>` : ''}
678
+ </div>
328
679
  </div>
329
680
  `).join('');
330
681
  } catch (err) {
@@ -332,37 +683,100 @@ async function loadContexts() {
332
683
  }
333
684
  }
334
685
 
335
- // Memory browser
686
+ function drillToMemory(ctx) {
687
+ // Switch to memory view with context pre-selected
688
+ document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
689
+ document.querySelector('[data-view="memory"]').classList.add('active');
690
+ document.querySelectorAll('.main > .view').forEach(v => v.classList.remove('active'));
691
+ document.getElementById('v-memory').classList.add('active');
692
+ loadMemoryContexts().then(() => {
693
+ document.getElementById('mem-context').value = ctx;
694
+ loadL3(ctx);
695
+ loadL2(ctx);
696
+ });
697
+ }
698
+
699
+ function viewScoring(ctx) {
700
+ // Switch to promotion > scoring
701
+ document.querySelectorAll('.sidebar a').forEach(x => x.classList.remove('active'));
702
+ document.querySelector('[data-view="promotion"]').classList.add('active');
703
+ document.querySelectorAll('.main > .view').forEach(v => v.classList.remove('active'));
704
+ document.getElementById('v-promotion').classList.add('active');
705
+ document.querySelectorAll('.sub-tab').forEach(x => x.classList.remove('active'));
706
+ document.querySelector('[data-ptab="scoring"]').classList.add('active');
707
+ document.getElementById('promo-thresholds').classList.remove('active');
708
+ document.getElementById('promo-candidates').classList.remove('active');
709
+ document.getElementById('promo-scoring').classList.add('active');
710
+ loadScoringTab();
711
+ }
712
+
713
+ async function deleteContext(name) {
714
+ if (!confirm(`Delete context "${name}" and all its episodes? This cannot be undone.`)) return;
715
+ try {
716
+ const res = await fetch(API + '/api/contexts/' + encodeURIComponent(name), { method: 'DELETE' });
717
+ if (res.ok || res.status === 204) {
718
+ showNotification('Context "' + name + '" deleted', 'success');
719
+ loadContexts();
720
+ } else {
721
+ showNotification('Failed to delete context', 'error');
722
+ }
723
+ } catch (err) {
724
+ showNotification('Delete failed: ' + err.message, 'error');
725
+ }
726
+ }
727
+
728
+ // ===== Memory Browser =====
336
729
  async function loadMemoryContexts() {
337
730
  try {
338
- const res = await fetch(`${API}/api/contexts`);
731
+ const res = await fetch(API + '/api/contexts');
732
+ if (!res.ok) {
733
+ showNotification('Failed to load memory contexts', 'error');
734
+ return;
735
+ }
339
736
  const data = await res.json();
340
737
  const sel = document.getElementById('mem-context');
341
738
  const current = sel.value;
342
- sel.innerHTML = '<option value="">Select context…</option>';
739
+ sel.innerHTML = '<option value="">Select context...</option>';
343
740
  for (const c of (data.contexts || [])) {
344
741
  sel.innerHTML += `<option value="${esc(c.name)}">${esc(c.name)} (${c.episode_count ?? '?'} eps)</option>`;
345
742
  }
346
743
  if (current) { sel.value = current; loadL3(current); loadL2(current); }
347
- } catch {}
744
+ } catch (err) {
745
+ showNotification('Failed to load contexts: ' + err.message, 'error');
746
+ }
348
747
  }
349
748
 
749
+ // ----- L3 Knowledge -----
350
750
  async function loadL3(ctx) {
351
751
  const el = document.getElementById('mem-l3');
352
752
  try {
353
- const res = await fetch(`${API}/api/contexts/${encodeURIComponent(ctx)}/l3`);
753
+ const res = await fetch(API + '/api/contexts/' + encodeURIComponent(ctx) + '/l3');
754
+ if (!res.ok) { el.innerHTML = '<p style="color:var(--red)">Failed to load L3 data</p>'; showNotification('L3 load failed', 'error'); return; }
354
755
  const data = await res.json();
355
756
  const entries = data.entries || [];
757
+
758
+ let html = `<div style="margin-bottom:12px"><button class="btn-sm btn-primary" onclick="runPromotion('${esc(ctx)}')">Run Promotion</button></div>`;
759
+
356
760
  if (entries.length === 0) {
357
- el.innerHTML = '<p style="color:var(--text2)">No L3 knowledge entries</p>';
761
+ html += '<p style="color:var(--text2)">No L3 knowledge entries</p>';
762
+ el.innerHTML = html;
358
763
  return;
359
764
  }
360
- el.innerHTML = entries.map(e => `
361
- <div class="l3-entry" style="display:flex;justify-content:space-between;align-items:start;margin-bottom:8px">
362
- <div style="flex:1">${esc(e.content)}<br><span style="font-size:11px;color:var(--text2)">access: ${e.access_count} · density: ${e.connection_density?.toFixed(2)}</span></div>
363
- <button class="btn-sm btn-danger" onclick="deleteL3('${esc(e.id)}','${esc(ctx)}')">Delete</button>
765
+ html += entries.map(e => `
766
+ <div class="l3-entry" style="margin-bottom:8px;padding:10px" id="l3-${esc(e.id)}">
767
+ <div style="display:flex;justify-content:space-between;align-items:start">
768
+ <div style="flex:1" class="l3-content">${esc(e.content)}</div>
769
+ <div style="display:flex;gap:4px;margin-left:8px">
770
+ <button class="btn-sm" onclick="editL3('${esc(e.id)}','${esc(ctx)}')">Edit</button>
771
+ <button class="btn-sm btn-danger" onclick="deleteL3('${esc(e.id)}','${esc(ctx)}')">Delete</button>
772
+ </div>
773
+ </div>
774
+ <div style="font-size:11px;color:var(--text2);margin-top:4px">
775
+ source: ${esc(e.source_episode_id?.slice(0,8))} &middot; access: ${e.access_count} &middot; density: ${e.connection_density} &middot; promoted: ${e.promoted_at ? new Date(e.promoted_at).toLocaleString() : '?'}
776
+ </div>
364
777
  </div>
365
778
  `).join('');
779
+ el.innerHTML = html;
366
780
  } catch (err) {
367
781
  el.innerHTML = `<p style="color:var(--red)">Failed: ${esc(err.message)}</p>`;
368
782
  }
@@ -370,41 +784,409 @@ async function loadL3(ctx) {
370
784
 
371
785
  async function deleteL3(id, ctx) {
372
786
  if (!confirm('Delete this L3 entry?')) return;
373
- await fetch(`${API}/api/l3/${encodeURIComponent(id)}`, { method: 'DELETE' });
374
- loadL3(ctx);
787
+ try {
788
+ const res = await fetch(API + '/api/l3/' + encodeURIComponent(id), { method: 'DELETE' });
789
+ if (res.ok || res.status === 204) {
790
+ showNotification('L3 entry deleted', 'success');
791
+ loadL3(ctx);
792
+ } else {
793
+ const data = await res.json().catch(() => ({}));
794
+ showNotification('Delete failed: ' + (data.error || res.statusText), 'error');
795
+ }
796
+ } catch (err) {
797
+ showNotification('Delete failed: ' + err.message, 'error');
798
+ }
799
+ }
800
+
801
+ function editL3(id, ctx) {
802
+ const el = document.getElementById('l3-' + id);
803
+ const contentEl = el.querySelector('.l3-content');
804
+ const currentContent = contentEl.textContent;
805
+ contentEl.innerHTML = `
806
+ <textarea class="l3-edit-area" id="l3-edit-${id}">${esc(currentContent)}</textarea>
807
+ <div style="margin-top:6px;display:flex;gap:4px">
808
+ <button class="btn-sm btn-primary" onclick="saveL3('${esc(id)}','${esc(ctx)}')">Save</button>
809
+ <button class="btn-sm" onclick="loadL3('${esc(ctx)}')">Cancel</button>
810
+ </div>
811
+ `;
812
+ }
813
+
814
+ async function saveL3(id, ctx) {
815
+ const textarea = document.getElementById('l3-edit-' + id);
816
+ const content = textarea.value;
817
+ try {
818
+ const res = await fetch(API + '/api/l3/' + encodeURIComponent(id), {
819
+ method: 'PATCH',
820
+ headers: { 'Content-Type': 'application/json' },
821
+ body: JSON.stringify({ content }),
822
+ });
823
+ if (res.ok || res.status === 204) {
824
+ showNotification('L3 entry updated', 'success');
825
+ loadL3(ctx);
826
+ } else {
827
+ showNotification('Update failed: ' + res.statusText, 'error');
828
+ }
829
+ } catch (err) {
830
+ showNotification('Update failed: ' + err.message, 'error');
831
+ }
832
+ }
833
+
834
+ async function runPromotion(ctx) {
835
+ try {
836
+ const res = await fetch(API + '/api/promotion/run?context=' + encodeURIComponent(ctx), { method: 'POST' });
837
+ if (res.ok) {
838
+ const data = await res.json();
839
+ showNotification(`Promotion complete: ${data.promoted_count} episodes promoted`, 'success');
840
+ loadL3(ctx);
841
+ } else {
842
+ showNotification('Promotion failed: ' + res.statusText, 'error');
843
+ }
844
+ } catch (err) {
845
+ showNotification('Promotion failed: ' + err.message, 'error');
846
+ }
375
847
  }
376
848
 
849
+ // ----- L2 Episodes -----
377
850
  async function loadL2(ctx) {
378
851
  const el = document.getElementById('mem-l2');
852
+ l2SearchMode = false;
853
+ l2Page = 0;
379
854
  try {
380
- const res = await fetch(`${API}/api/contexts/${encodeURIComponent(ctx)}/episodes`);
855
+ const res = await fetch(API + '/api/contexts/' + encodeURIComponent(ctx) + '/episodes');
856
+ if (!res.ok) { el.innerHTML = '<p style="color:var(--red)">Failed to load episodes</p>'; return; }
381
857
  const data = await res.json();
382
- const episodes = Array.isArray(data) ? data : (data.episodes || []);
383
- if (episodes.length === 0) {
384
- el.innerHTML = '<p style="color:var(--text2)">No episodes</p>';
858
+ l2AllEpisodes = Array.isArray(data) ? data : (data.episodes || []);
859
+ renderL2(ctx);
860
+ } catch (err) {
861
+ el.innerHTML = `<p style="color:var(--red)">Failed: ${esc(err.message)}</p>`;
862
+ }
863
+ }
864
+
865
+ function renderL2(ctx) {
866
+ const el = document.getElementById('mem-l2');
867
+ const start = l2Page * L2_PAGE_SIZE;
868
+ const pageEpisodes = l2AllEpisodes.slice(start, start + L2_PAGE_SIZE);
869
+ const totalPages = Math.ceil(l2AllEpisodes.length / L2_PAGE_SIZE);
870
+
871
+ let html = `
872
+ <div class="filters" style="margin-bottom:12px">
873
+ <input type="text" id="l2-search" placeholder="Semantic search..." style="flex:1" />
874
+ <button onclick="searchL2('${esc(ctx)}')">Search</button>
875
+ ${l2SearchMode ? `<button onclick="loadL2('${esc(ctx)}')">Clear Search</button>` : ''}
876
+ </div>
877
+ `;
878
+
879
+ if (l2AllEpisodes.length === 0) {
880
+ html += '<p style="color:var(--text2)">No episodes</p>';
881
+ el.innerHTML = html;
882
+ return;
883
+ }
884
+
885
+ html += pageEpisodes.map(ep => `
886
+ <div class="episode">
887
+ <div class="meta">
888
+ ${esc(ep.role)} &middot; ${new Date(ep.timestamp).toLocaleString()} &middot; ${esc(ep.id?.slice(0,8) || '')}
889
+ ${ep.score !== undefined ? ` &middot; <span class="score">score: ${ep.score.toFixed(4)}</span>` : ''}
890
+ </div>
891
+ ${esc(ep.content)}
892
+ </div>
893
+ `).join('');
894
+
895
+ if (totalPages > 1) {
896
+ html += `<div class="pagination">
897
+ <button ${l2Page === 0 ? 'disabled' : ''} onclick="l2Page--; renderL2('${esc(ctx)}')">Prev</button>
898
+ <span>Page ${l2Page + 1} of ${totalPages} (${l2AllEpisodes.length} total)</span>
899
+ <button ${l2Page >= totalPages - 1 ? 'disabled' : ''} onclick="l2Page++; renderL2('${esc(ctx)}')">Next</button>
900
+ </div>`;
901
+ }
902
+
903
+ el.innerHTML = html;
904
+ }
905
+
906
+ async function searchL2(ctx) {
907
+ const query = document.getElementById('l2-search')?.value;
908
+ if (!query?.trim()) return;
909
+ const el = document.getElementById('mem-l2');
910
+ try {
911
+ const res = await fetch(API + '/api/search?q=' + encodeURIComponent(query) + '&context=' + encodeURIComponent(ctx) + '&limit=50');
912
+ if (!res.ok) { showNotification('Search failed', 'error'); return; }
913
+ const data = await res.json();
914
+ l2AllEpisodes = (data.episodes || []).map(e => ({...e.episode || e, score: e.score}));
915
+ l2SearchMode = true;
916
+ l2Page = 0;
917
+ renderL2(ctx);
918
+ } catch (err) {
919
+ showNotification('Search failed: ' + err.message, 'error');
920
+ }
921
+ }
922
+
923
+ // ----- L1 History -----
924
+ async function loadL1(ctx) {
925
+ const el = document.getElementById('mem-l1');
926
+ try {
927
+ const res = await fetch(API + '/api/l1/' + encodeURIComponent(ctx));
928
+ if (!res.ok) { el.innerHTML = '<p style="color:var(--text2)">L1 data unavailable</p>'; return; }
929
+ const data = await res.json();
930
+ const messages = data.messages || [];
931
+ if (messages.length === 0) {
932
+ el.innerHTML = '<p style="color:var(--text2)">No L1 conversation history for this context</p>';
385
933
  return;
386
934
  }
387
- el.innerHTML = episodes.slice(0, 100).map(ep => `
388
- <div class="episode">
389
- <div class="meta">${esc(ep.role)} · ${new Date(ep.timestamp).toLocaleString()} · layer: ${ep.layer || 'L2'}</div>
390
- ${esc(ep.content)}
935
+ el.innerHTML = `<p style="color:var(--text2);margin-bottom:12px">${messages.length} messages in working memory</p>` +
936
+ messages.map(m => `
937
+ <div class="l1-msg ${esc(m.role)}">
938
+ <div class="role">${esc(m.role)}</div>
939
+ ${esc(m.content)}
940
+ </div>
941
+ `).join('');
942
+ } catch (err) {
943
+ el.innerHTML = `<p style="color:var(--text2)">L1 data unavailable</p>`;
944
+ }
945
+ }
946
+
947
+ // ===== Search View =====
948
+ async function loadSearchContexts() {
949
+ try {
950
+ const res = await fetch(API + '/api/contexts');
951
+ if (!res.ok) return;
952
+ const data = await res.json();
953
+ const sel = document.getElementById('search-scope');
954
+ sel.innerHTML = '<option value="all">All Contexts</option>';
955
+ for (const c of (data.contexts || [])) {
956
+ sel.innerHTML += `<option value="${esc(c.name)}">${esc(c.name)}</option>`;
957
+ }
958
+ } catch {}
959
+ }
960
+
961
+ async function runSearch() {
962
+ const query = document.getElementById('search-query').value.trim();
963
+ if (!query) return;
964
+ const scope = document.getElementById('search-scope').value;
965
+ const limit = document.getElementById('search-limit').value || 20;
966
+ const el = document.getElementById('search-results');
967
+ el.innerHTML = '<p style="color:var(--text2)">Searching...</p>';
968
+
969
+ try {
970
+ let url, res;
971
+ if (scope === 'all') {
972
+ url = API + '/api/search/cross-context?q=' + encodeURIComponent(query) + '&limit=' + limit;
973
+ res = await fetch(url);
974
+ if (!res.ok) { showNotification('Search failed', 'error'); el.innerHTML = ''; return; }
975
+ const data = await res.json();
976
+ const results = data.results || [];
977
+ if (results.length === 0) {
978
+ el.innerHTML = '<p style="color:var(--text2)">No results found</p>';
979
+ return;
980
+ }
981
+ let html = '';
982
+ for (const group of results) {
983
+ html += `<div class="search-group-header">${esc(group.context)} (${group.episodes.length})</div>`;
984
+ for (const ep of group.episodes) {
985
+ html += `<div class="search-result">
986
+ <div class="meta">${esc(ep.episode?.role || ep.role)} &middot; ${esc(group.context)} &middot; <span class="score">score: ${(ep.score || 0).toFixed(4)}</span> &middot; ${ep.episode?.timestamp ? new Date(ep.episode.timestamp).toLocaleString() : ''}</div>
987
+ <div class="content">${esc(ep.episode?.content || ep.content)}</div>
988
+ </div>`;
989
+ }
990
+ }
991
+ el.innerHTML = html;
992
+ } else {
993
+ url = API + '/api/search?q=' + encodeURIComponent(query) + '&context=' + encodeURIComponent(scope) + '&limit=' + limit;
994
+ res = await fetch(url);
995
+ if (!res.ok) { showNotification('Search failed', 'error'); el.innerHTML = ''; return; }
996
+ const data = await res.json();
997
+ const episodes = data.episodes || [];
998
+ if (episodes.length === 0) {
999
+ el.innerHTML = '<p style="color:var(--text2)">No results found</p>';
1000
+ return;
1001
+ }
1002
+ el.innerHTML = episodes.map(ep => `
1003
+ <div class="search-result">
1004
+ <div class="meta">${esc(ep.episode?.role || ep.role)} &middot; ${esc(scope)} &middot; <span class="score">score: ${(ep.score || 0).toFixed(4)}</span> &middot; ${ep.episode?.timestamp ? new Date(ep.episode.timestamp).toLocaleString() : ''}</div>
1005
+ <div class="content">${esc(ep.episode?.content || ep.content)}</div>
1006
+ </div>
1007
+ `).join('');
1008
+ }
1009
+ } catch (err) {
1010
+ showNotification('Search failed: ' + err.message, 'error');
1011
+ el.innerHTML = '';
1012
+ }
1013
+ }
1014
+
1015
+ // ===== Promotion & Scoring View =====
1016
+ async function loadPromotion() {
1017
+ loadThresholds();
1018
+ }
1019
+
1020
+ async function loadThresholds() {
1021
+ const el = document.getElementById('promo-thresholds');
1022
+ let stats = null;
1023
+ let statsUnavailable = false;
1024
+ try {
1025
+ const res = await fetch(API + '/api/stats');
1026
+ if (res.ok) stats = await res.json();
1027
+ else statsUnavailable = true;
1028
+ } catch { statsUnavailable = true; }
1029
+
1030
+ const t = (stats && stats.promotion_thresholds) || { access: 5, cooccurrence: 3 };
1031
+ const warning = statsUnavailable ? '<p style="color:var(--yellow);font-size:12px;margin-bottom:12px;padding:8px;border:1px solid var(--yellow);border-radius:4px">/stats endpoint unavailable — showing default thresholds (access: 5, cooccurrence: 3). Rebuild daemon to enable.</p>' : '';
1032
+
1033
+ el.innerHTML = `
1034
+ ${warning}
1035
+ <div class="status-grid" style="margin-bottom:24px">
1036
+ <div class="status-card">
1037
+ <div class="label">Access Threshold</div>
1038
+ <div class="value">${t.access}</div>
1039
+ <div class="sub">Episode must be accessed at least ${t.access} times</div>
391
1040
  </div>
392
- `).join('');
393
- if (episodes.length > 100) {
394
- el.innerHTML += `<p style="color:var(--text2)">Showing 100 of ${episodes.length}</p>`;
1041
+ <div class="status-card">
1042
+ <div class="label">Co-occurrence Threshold</div>
1043
+ <div class="value">${t.cooccurrence}</div>
1044
+ <div class="sub">Episode must co-occur with others at least ${t.cooccurrence} times</div>
1045
+ </div>
1046
+ </div>
1047
+ <h3 style="margin-bottom:12px">How Promotion Works</h3>
1048
+ <div style="color:var(--text2);font-size:13px;line-height:1.8">
1049
+ <p><strong>L2 &rarr; L3 promotion</strong> happens when an episode meets <em>both</em> thresholds:</p>
1050
+ <ol style="margin:8px 0 8px 20px">
1051
+ <li>The episode has been accessed (retrieved in search results) at least <strong>${t.access} times</strong></li>
1052
+ <li>The episode has co-occurred with other episodes at least <strong>${t.cooccurrence} times total</strong> (connection density)</li>
1053
+ </ol>
1054
+ <p>Once promoted, the episode's content is stored as long-term semantic knowledge (L3) and included in future system prompts for that context.</p>
1055
+ </div>
1056
+ `;
1057
+ }
1058
+
1059
+ async function loadCandidates() {
1060
+ const el = document.getElementById('promo-candidates');
1061
+ el.innerHTML = '<p style="color:var(--text2)">Loading candidates...</p>';
1062
+ try {
1063
+ const res = await fetch(API + '/api/access/top?limit=50');
1064
+ if (!res.ok) {
1065
+ if (res.status === 404) {
1066
+ el.innerHTML = '<p style="color:var(--yellow);padding:12px;border:1px solid var(--yellow);border-radius:4px">/access/top endpoint not found (404). The running daemon binary may be outdated. Rebuild with: <code style="background:var(--bg);padding:2px 6px;border-radius:3px">cd packages/memory && cargo build --release</code> and restart the daemon.</p>';
1067
+ } else {
1068
+ el.innerHTML = `<p style="color:var(--red)">Failed to load candidates (HTTP ${res.status})</p>`;
1069
+ }
1070
+ return;
1071
+ }
1072
+ const data = await res.json();
1073
+ const records = data.records || [];
1074
+
1075
+ if (records.length === 0) {
1076
+ el.innerHTML = '<p style="color:var(--text2)">No access records found. Episodes need to be searched/retrieved to generate access counts.</p>';
1077
+ return;
395
1078
  }
1079
+
1080
+ el.innerHTML = records.map(r => {
1081
+ const accessPct = Math.min((r.access_count / 5) * 100, 100);
1082
+ const densityPct = Math.min((r.connection_density / 3) * 100, 100);
1083
+ const promoted = r.is_promoted;
1084
+ return `
1085
+ <div class="ctx-card">
1086
+ <div style="display:flex;justify-content:space-between;align-items:start">
1087
+ <div style="flex:1">
1088
+ <div style="font-size:12px;color:var(--text2)">${esc(r.context_name || '?')} &middot; ${esc(r.episode_id.slice(0, 12))}${promoted ? ' &middot; <span style="color:var(--green)">PROMOTED</span>' : ''}</div>
1089
+ <div style="font-size:13px;margin-top:4px">${esc(r.content_preview || '(no content)')}</div>
1090
+ </div>
1091
+ </div>
1092
+ <div style="display:grid;grid-template-columns:1fr 1fr;gap:12px;margin-top:10px">
1093
+ <div>
1094
+ <div style="font-size:11px;color:var(--text2);margin-bottom:2px">Access: ${r.access_count}/5</div>
1095
+ <div class="progress-bar"><div class="progress-fill ${accessPct >= 100 ? 'green' : 'yellow'}" style="width:${accessPct}%">${r.access_count}/5</div></div>
1096
+ </div>
1097
+ <div>
1098
+ <div style="font-size:11px;color:var(--text2);margin-bottom:2px">Density: ${r.connection_density}/3</div>
1099
+ <div class="progress-bar"><div class="progress-fill ${densityPct >= 100 ? 'green' : 'purple'}" style="width:${densityPct}%">${r.connection_density}/3</div></div>
1100
+ </div>
1101
+ </div>
1102
+ </div>
1103
+ `;
1104
+ }).join('');
396
1105
  } catch (err) {
397
1106
  el.innerHTML = `<p style="color:var(--red)">Failed: ${esc(err.message)}</p>`;
398
1107
  }
399
1108
  }
400
1109
 
1110
+ async function loadScoringTab() {
1111
+ const el = document.getElementById('promo-scoring');
1112
+ try {
1113
+ const ctxRes = await fetch(API + '/api/contexts');
1114
+ if (!ctxRes.ok) { el.innerHTML = '<p style="color:var(--red)">Failed to load contexts</p>'; return; }
1115
+ const ctxData = await ctxRes.json();
1116
+ const contexts = ctxData.contexts || [];
1117
+
1118
+ // Fetch scoring for each context
1119
+ const scoringData = {};
1120
+ let defaultHL = 48;
1121
+ let scoringAvailable = true;
1122
+ for (const c of contexts) {
1123
+ try {
1124
+ const sRes = await fetch(API + '/api/contexts/' + encodeURIComponent(c.name) + '/scoring');
1125
+ if (sRes.ok) {
1126
+ const sd = await sRes.json();
1127
+ scoringData[c.name] = sd;
1128
+ defaultHL = sd.default_half_life_hours || 48;
1129
+ } else if (sRes.status === 404) {
1130
+ scoringAvailable = false;
1131
+ }
1132
+ } catch {}
1133
+ }
1134
+
1135
+ let html = '';
1136
+ if (!scoringAvailable) {
1137
+ html += '<p style="color:var(--yellow);font-size:12px;margin-bottom:12px;padding:8px;border:1px solid var(--yellow);border-radius:4px">/scoring endpoint not found — the running daemon may be outdated. Showing default values. Rebuild daemon to enable per-context scoring configuration.</p>';
1138
+ }
1139
+ html += `<p style="color:var(--text2);font-size:13px;margin-bottom:16px">Default half-life: <strong>${defaultHL}h</strong>. Settings reset when daemon restarts (in-memory only).</p>`;
1140
+
1141
+ html += '<table class="data-table"><thead><tr><th>Context</th><th>Half-Life (hours)</th><th>Custom?</th><th>Decay Curve</th><th>Actions</th></tr></thead><tbody>';
1142
+ for (const c of contexts) {
1143
+ const sd = scoringData[c.name] || {};
1144
+ const hl = sd.half_life_hours || defaultHL;
1145
+ const isCustom = sd.is_default === false;
1146
+ const sourceLabel = !scoringAvailable ? '<span style="color:var(--text2)">Default (endpoint unavailable)</span>' : (isCustom ? '<span style="color:var(--yellow)">Yes</span>' : 'Default');
1147
+ html += `<tr>
1148
+ <td style="color:var(--accent)">${esc(c.name)}</td>
1149
+ <td><input type="number" value="${hl}" min="1" max="8760" step="1" style="width:80px;background:var(--bg);border:1px solid var(--border);color:var(--text);padding:4px;border-radius:4px" id="hl-${esc(c.name)}" ${!scoringAvailable ? 'disabled' : ''} /></td>
1150
+ <td>${sourceLabel}</td>
1151
+ <td>${renderDecayCurve(hl)}</td>
1152
+ <td><button class="btn-sm" onclick="saveScoring('${esc(c.name)}')" ${!scoringAvailable ? 'disabled' : ''}>Save</button></td>
1153
+ </tr>`;
1154
+ }
1155
+ html += '</tbody></table>';
1156
+ el.innerHTML = html;
1157
+ } catch (err) {
1158
+ el.innerHTML = `<p style="color:var(--red)">Failed: ${esc(err.message)}</p>`;
1159
+ }
1160
+ }
1161
+
1162
+ async function saveScoring(ctx) {
1163
+ const input = document.getElementById('hl-' + ctx);
1164
+ const hours = parseFloat(input.value);
1165
+ if (!hours || hours <= 0) { showNotification('Invalid half-life value', 'warning'); return; }
1166
+ try {
1167
+ const res = await fetch(API + '/api/contexts/' + encodeURIComponent(ctx) + '/scoring', {
1168
+ method: 'POST',
1169
+ headers: { 'Content-Type': 'application/json' },
1170
+ body: JSON.stringify({ half_life_hours: hours }),
1171
+ });
1172
+ if (res.ok || res.status === 204) {
1173
+ showNotification(`Half-life for "${ctx}" set to ${hours}h`, 'success');
1174
+ } else {
1175
+ showNotification('Failed to save scoring', 'error');
1176
+ }
1177
+ } catch (err) {
1178
+ showNotification('Failed: ' + err.message, 'error');
1179
+ }
1180
+ }
1181
+
1182
+ // ===== Utility =====
401
1183
  function esc(s) {
402
1184
  if (!s) return '';
403
1185
  return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
404
1186
  }
405
1187
 
406
- // Auto-load
407
- loadRequests();
1188
+ // ===== Auto-load =====
1189
+ loadHealthView();
408
1190
  </script>
409
1191
  </body>
410
1192
  </html>