@grimoire-cc/cli 0.1.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.
@@ -0,0 +1,770 @@
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Skill Router — Log Viewer</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0d1117;
10
+ --surface: #161b22;
11
+ --border: #30363d;
12
+ --text: #e6edf3;
13
+ --text-muted: #8b949e;
14
+ --accent: #58a6ff;
15
+ --green: #3fb950;
16
+ --red: #f85149;
17
+ --orange: #d29922;
18
+ --purple: #bc8cff;
19
+ --cyan: #39d2c0;
20
+ }
21
+ * { box-sizing: border-box; margin: 0; padding: 0; }
22
+ body {
23
+ font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
24
+ background: var(--bg);
25
+ color: var(--text);
26
+ line-height: 1.5;
27
+ padding: 24px;
28
+ }
29
+ h1 { font-size: 20px; font-weight: 600; margin-bottom: 16px; }
30
+ h2 { font-size: 14px; font-weight: 600; color: var(--text-muted); text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
31
+
32
+ /* Drop zone */
33
+ .drop-zone {
34
+ border: 2px dashed var(--border);
35
+ border-radius: 12px;
36
+ padding: 48px;
37
+ text-align: center;
38
+ cursor: pointer;
39
+ transition: border-color 0.2s, background 0.2s;
40
+ margin-bottom: 24px;
41
+ }
42
+ .drop-zone:hover, .drop-zone.drag-over {
43
+ border-color: var(--accent);
44
+ background: rgba(88, 166, 255, 0.05);
45
+ }
46
+ .drop-zone p { color: var(--text-muted); font-size: 14px; }
47
+ .drop-zone .big { font-size: 16px; color: var(--text); margin-bottom: 4px; }
48
+ .drop-zone input { display: none; }
49
+
50
+ /* Dashboard */
51
+ .dashboard { display: none; }
52
+ .dashboard.visible { display: block; }
53
+ .stats-grid {
54
+ display: grid;
55
+ grid-template-columns: repeat(auto-fit, minmax(160px, 1fr));
56
+ gap: 12px;
57
+ margin-bottom: 24px;
58
+ }
59
+ .stat-card {
60
+ background: var(--surface);
61
+ border: 1px solid var(--border);
62
+ border-radius: 8px;
63
+ padding: 16px;
64
+ }
65
+ .stat-card .value { font-size: 28px; font-weight: 700; }
66
+ .stat-card .label { font-size: 12px; color: var(--text-muted); margin-top: 2px; }
67
+ .stat-card.activated .value { color: var(--green); }
68
+ .stat-card.no-match .value { color: var(--text-muted); }
69
+
70
+ /* Filters */
71
+ .filters {
72
+ display: flex;
73
+ gap: 8px;
74
+ flex-wrap: wrap;
75
+ margin-bottom: 16px;
76
+ align-items: center;
77
+ }
78
+ .filters label { font-size: 12px; color: var(--text-muted); margin-right: 4px; }
79
+ select, input[type="text"] {
80
+ background: var(--surface);
81
+ border: 1px solid var(--border);
82
+ border-radius: 6px;
83
+ color: var(--text);
84
+ padding: 6px 10px;
85
+ font-size: 13px;
86
+ outline: none;
87
+ }
88
+ select:focus, input[type="text"]:focus { border-color: var(--accent); }
89
+ input[type="text"] { width: 220px; }
90
+ .filter-group { display: flex; align-items: center; gap: 4px; }
91
+ .btn {
92
+ background: var(--surface);
93
+ border: 1px solid var(--border);
94
+ border-radius: 6px;
95
+ color: var(--text-muted);
96
+ padding: 6px 12px;
97
+ font-size: 12px;
98
+ cursor: pointer;
99
+ transition: all 0.15s;
100
+ }
101
+ .btn:hover { border-color: var(--accent); color: var(--text); }
102
+ .btn.active { background: rgba(88, 166, 255, 0.15); border-color: var(--accent); color: var(--accent); }
103
+
104
+ /* Top skills */
105
+ .top-skills {
106
+ background: var(--surface);
107
+ border: 1px solid var(--border);
108
+ border-radius: 8px;
109
+ padding: 16px;
110
+ margin-bottom: 24px;
111
+ }
112
+ .skill-card {
113
+ padding: 6px 0;
114
+ }
115
+ .skill-card .skill-header {
116
+ display: flex;
117
+ align-items: center;
118
+ gap: 8px;
119
+ font-size: 13px;
120
+ }
121
+ .skill-card .skill-header .name { font-weight: 600; white-space: nowrap; }
122
+ .skill-card .skill-header .desc { font-size: 12px; color: var(--text-muted); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
123
+ .skill-card .count { color: var(--text-muted); font-size: 12px; white-space: nowrap; margin-left: auto; }
124
+ .skill-card .bar-bg {
125
+ width: 100%;
126
+ height: 2px;
127
+ background: var(--border);
128
+ border-radius: 1px;
129
+ overflow: hidden;
130
+ margin-top: 4px;
131
+ }
132
+ .skill-card .bar-fill {
133
+ height: 100%;
134
+ background: var(--accent);
135
+ border-radius: 1px;
136
+ transition: width 0.3s;
137
+ }
138
+ .skill-card .triggers {
139
+ display: none;
140
+ flex-wrap: wrap;
141
+ gap: 4px;
142
+ margin-top: 4px;
143
+ }
144
+ .skill-card:hover .triggers { display: flex; }
145
+
146
+ /* Table */
147
+ .table-wrap {
148
+ overflow-x: auto;
149
+ border: 1px solid var(--border);
150
+ border-radius: 8px;
151
+ }
152
+ table { width: 100%; border-collapse: collapse; font-size: 13px; }
153
+ thead th {
154
+ background: var(--surface);
155
+ padding: 10px 12px;
156
+ text-align: left;
157
+ font-weight: 600;
158
+ font-size: 12px;
159
+ color: var(--text-muted);
160
+ text-transform: uppercase;
161
+ letter-spacing: 0.3px;
162
+ border-bottom: 1px solid var(--border);
163
+ position: sticky;
164
+ top: 0;
165
+ cursor: pointer;
166
+ user-select: none;
167
+ white-space: nowrap;
168
+ }
169
+ thead th:hover { color: var(--text); }
170
+ thead th .sort-arrow { margin-left: 4px; font-size: 10px; }
171
+ tbody tr { border-bottom: 1px solid var(--border); transition: background 0.1s; }
172
+ tbody tr:hover { background: rgba(88, 166, 255, 0.04); }
173
+ tbody tr:last-child { border-bottom: none; }
174
+ td { padding: 8px 12px; vertical-align: top; }
175
+
176
+ .badge {
177
+ display: inline-block;
178
+ padding: 2px 8px;
179
+ border-radius: 12px;
180
+ font-size: 11px;
181
+ font-weight: 600;
182
+ }
183
+ .badge.activated { background: rgba(63, 185, 80, 0.15); color: var(--green); }
184
+ .badge.no-match { background: rgba(139, 148, 158, 0.15); color: var(--text-muted); }
185
+ .badge.hook-prompt { background: rgba(188, 140, 255, 0.15); color: var(--purple); }
186
+ .badge.hook-tool { background: rgba(57, 210, 192, 0.15); color: var(--cyan); }
187
+
188
+ .prompt-text {
189
+ max-width: 400px;
190
+ overflow: hidden;
191
+ text-overflow: ellipsis;
192
+ white-space: nowrap;
193
+ cursor: pointer;
194
+ }
195
+ .prompt-text:hover { white-space: normal; word-break: break-word; }
196
+
197
+ .signal-tag {
198
+ display: inline-block;
199
+ padding: 1px 6px;
200
+ border-radius: 4px;
201
+ font-size: 11px;
202
+ margin: 1px 2px;
203
+ background: rgba(88, 166, 255, 0.1);
204
+ color: var(--accent);
205
+ }
206
+ .signal-tag.keyword { background: rgba(63, 185, 80, 0.1); color: var(--green); }
207
+ .signal-tag.pattern { background: rgba(210, 153, 34, 0.1); color: var(--orange); }
208
+ .signal-tag.extension { background: rgba(188, 140, 255, 0.1); color: var(--purple); }
209
+ .signal-tag.path { background: rgba(57, 210, 192, 0.1); color: var(--cyan); }
210
+
211
+ .skills-cell { min-width: 200px; }
212
+ .skill-match {
213
+ margin-bottom: 4px;
214
+ padding: 4px 0;
215
+ }
216
+ .skill-match .skill-name { font-weight: 600; font-size: 12px; }
217
+ .skill-match .skill-score { color: var(--orange); font-size: 11px; margin-left: 4px; }
218
+
219
+ .session-id {
220
+ font-family: 'SF Mono', SFMono-Regular, Consolas, monospace;
221
+ font-size: 11px;
222
+ color: var(--text-muted);
223
+ max-width: 100px;
224
+ overflow: hidden;
225
+ text-overflow: ellipsis;
226
+ white-space: nowrap;
227
+ }
228
+
229
+ .time-ms { color: var(--text-muted); font-size: 12px; }
230
+ .time-ms.slow { color: var(--orange); }
231
+
232
+ .empty-state {
233
+ text-align: center;
234
+ padding: 40px;
235
+ color: var(--text-muted);
236
+ }
237
+
238
+ .count-badge {
239
+ font-size: 12px;
240
+ color: var(--text-muted);
241
+ margin-left: 8px;
242
+ font-weight: 400;
243
+ }
244
+
245
+ /* Sections layout */
246
+ .section-row {
247
+ display: grid;
248
+ grid-template-columns: 1fr 1fr;
249
+ gap: 16px;
250
+ margin-bottom: 24px;
251
+ }
252
+ @media (max-width: 800px) { .section-row { grid-template-columns: 1fr; } }
253
+
254
+ /* Session timeline */
255
+ .session-list {
256
+ background: var(--surface);
257
+ border: 1px solid var(--border);
258
+ border-radius: 8px;
259
+ padding: 16px;
260
+ max-height: 240px;
261
+ overflow-y: auto;
262
+ }
263
+ .session-item {
264
+ display: flex;
265
+ justify-content: space-between;
266
+ align-items: center;
267
+ padding: 4px 0;
268
+ font-size: 13px;
269
+ cursor: pointer;
270
+ transition: color 0.1s;
271
+ }
272
+ .session-item:hover { color: var(--accent); }
273
+ .session-item .id { font-family: monospace; font-size: 11px; }
274
+ .session-item .meta { color: var(--text-muted); font-size: 11px; }
275
+
276
+ /* Live badge */
277
+ .live-badge {
278
+ display: none;
279
+ align-items: center;
280
+ gap: 6px;
281
+ font-size: 12px;
282
+ font-weight: 600;
283
+ color: var(--green);
284
+ margin-left: 12px;
285
+ vertical-align: middle;
286
+ }
287
+ .live-badge.visible { display: inline-flex; }
288
+ .live-badge::before {
289
+ content: '';
290
+ width: 8px;
291
+ height: 8px;
292
+ background: var(--green);
293
+ border-radius: 50%;
294
+ animation: pulse 2s ease-in-out infinite;
295
+ }
296
+ @keyframes pulse {
297
+ 0%, 100% { opacity: 1; }
298
+ 50% { opacity: 0.4; }
299
+ }
300
+ </style>
301
+ </head>
302
+ <body>
303
+
304
+ <h1>Skill Router — Log Viewer <span class="live-badge" id="liveBadge">LIVE</span></h1>
305
+
306
+ <div class="drop-zone" id="dropZone">
307
+ <p class="big">Drop <code>skill-router.log</code> here</p>
308
+ <p>or click to browse</p>
309
+ <input type="file" id="fileInput" accept=".log,.json,.ndjson">
310
+ </div>
311
+
312
+ <div class="dashboard" id="dashboard">
313
+ <!-- Stats -->
314
+ <div class="stats-grid" id="statsGrid"></div>
315
+
316
+ <!-- Top skills + Sessions -->
317
+ <div class="section-row">
318
+ <div>
319
+ <h2>Skills</h2>
320
+ <div class="top-skills" id="skillsPanel"></div>
321
+ </div>
322
+ <div>
323
+ <h2>Sessions</h2>
324
+ <div class="session-list" id="sessionList"></div>
325
+ </div>
326
+ </div>
327
+
328
+ <!-- Filters -->
329
+ <div class="filters" id="filters">
330
+ <div class="filter-group">
331
+ <label>Outcome:</label>
332
+ <select id="filterOutcome">
333
+ <option value="">All</option>
334
+ <option value="activated">Activated</option>
335
+ <option value="no_match">No Match</option>
336
+ </select>
337
+ </div>
338
+ <div class="filter-group">
339
+ <label>Hook:</label>
340
+ <select id="filterHook">
341
+ <option value="">All</option>
342
+ <option value="prompt">UserPromptSubmit</option>
343
+ <option value="tool">PreToolUse</option>
344
+ </select>
345
+ </div>
346
+ <div class="filter-group">
347
+ <label>Session:</label>
348
+ <select id="filterSession">
349
+ <option value="">All</option>
350
+ </select>
351
+ </div>
352
+ <div class="filter-group">
353
+ <label>Search:</label>
354
+ <input type="text" id="filterSearch" placeholder="Filter by prompt or skill...">
355
+ </div>
356
+ <button class="btn" id="btnReset">Reset</button>
357
+ </div>
358
+
359
+ <!-- Table -->
360
+ <div class="table-wrap">
361
+ <table>
362
+ <thead>
363
+ <tr>
364
+ <th data-sort="index"># <span class="sort-arrow"></span></th>
365
+ <th data-sort="timestamp">Time <span class="sort-arrow"></span></th>
366
+ <th data-sort="hook">Hook <span class="sort-arrow"></span></th>
367
+ <th data-sort="prompt">Prompt <span class="sort-arrow"></span></th>
368
+ <th data-sort="outcome">Outcome <span class="sort-arrow"></span></th>
369
+ <th data-sort="skills">Skills Matched <span class="sort-arrow"></span></th>
370
+ <th data-sort="signals">Signals <span class="sort-arrow"></span></th>
371
+ <th data-sort="score">Score <span class="sort-arrow"></span></th>
372
+ <th data-sort="threshold">Thr <span class="sort-arrow"></span></th>
373
+ <th data-sort="time_ms">ms <span class="sort-arrow"></span></th>
374
+ </tr>
375
+ </thead>
376
+ <tbody id="tableBody"></tbody>
377
+ </table>
378
+ </div>
379
+ </div>
380
+
381
+ <script>
382
+ let entries = [];
383
+ let sortCol = 'timestamp';
384
+ let sortAsc = false;
385
+
386
+ // --- Append new log entries without clearing existing ones ---
387
+ function appendLog(text) {
388
+ const lines = text.split('\n');
389
+ let buffer = '';
390
+ let depth = 0;
391
+ let added = 0;
392
+
393
+ for (const line of lines) {
394
+ const trimmed = line.trim();
395
+ if (!trimmed) continue;
396
+
397
+ buffer += (buffer ? '\n' : '') + line;
398
+ for (const ch of trimmed) {
399
+ if (ch === '{') depth++;
400
+ else if (ch === '}') depth--;
401
+ }
402
+
403
+ if (depth === 0 && buffer.trim()) {
404
+ try {
405
+ const obj = JSON.parse(buffer);
406
+ obj._index = entries.length;
407
+ entries.push(obj);
408
+ added++;
409
+ } catch { /* skip malformed */ }
410
+ buffer = '';
411
+ }
412
+ }
413
+
414
+ if (added === 0) return;
415
+
416
+ dropZone.style.display = 'none';
417
+ document.getElementById('dashboard').classList.add('visible');
418
+ buildDashboard();
419
+ populateFilters();
420
+ renderTable();
421
+ }
422
+
423
+ // --- Load skills from manifest ---
424
+ let manifestSkills = [];
425
+
426
+ async function loadManifestSkills() {
427
+ try {
428
+ const res = await fetch('/api/manifest');
429
+ if (!res.ok) return;
430
+ const manifest = await res.json();
431
+ manifestSkills = manifest.skills || [];
432
+ renderSkillsPanel();
433
+ } catch { /* manifest not available in file: mode */ }
434
+ }
435
+
436
+ function renderSkillsPanel(filteredEntries) {
437
+ const panel = document.getElementById('skillsPanel');
438
+ if (!manifestSkills.length) {
439
+ panel.innerHTML = '<div class="empty-state">No skills configured</div>';
440
+ return;
441
+ }
442
+
443
+ // Count activations per skill from filtered entries
444
+ const src = filteredEntries || entries;
445
+ const skillCounts = {};
446
+ for (const e of src) {
447
+ for (const s of (e.skills_matched || [])) {
448
+ skillCounts[s.name] = (skillCounts[s.name] || 0) + 1;
449
+ }
450
+ }
451
+
452
+ const max = Math.max(1, ...manifestSkills.map(s => skillCounts[s.name] || 0));
453
+
454
+ const sorted = [...manifestSkills].sort((a, b) => (skillCounts[b.name] || 0) - (skillCounts[a.name] || 0));
455
+
456
+ panel.innerHTML = sorted.map(s => {
457
+ const triggers = s.triggers || {};
458
+ const count = skillCounts[s.name] || 0;
459
+ const pct = (count / max * 100).toFixed(0);
460
+ const tags = [
461
+ ...(triggers.keywords || []).map(k => `<span class="signal-tag keyword">${esc(k)}</span>`),
462
+ ...(triggers.file_extensions || []).map(e => `<span class="signal-tag extension">${esc(e)}</span>`),
463
+ ...(triggers.patterns || []).map(p => `<span class="signal-tag pattern">${esc(p)}</span>`),
464
+ ...(triggers.file_paths || []).map(f => `<span class="signal-tag path">${esc(f)}</span>`),
465
+ ];
466
+ return `<div class="skill-card">
467
+ <div class="skill-header">
468
+ <span class="name">${esc(s.name)}</span>
469
+ <span class="desc">${esc(s.description || '')}</span>
470
+ <span class="count">${count}</span>
471
+ </div>
472
+ <div class="bar-bg"><div class="bar-fill" style="width:${pct}%"></div></div>
473
+ <div class="triggers">${tags.join('')}</div>
474
+ </div>`;
475
+ }).join('');
476
+ }
477
+
478
+ // --- Auto-fetch from API when served via HTTP ---
479
+ (function autoFetch() {
480
+ if (location.protocol === 'file:') return;
481
+
482
+ loadManifestSkills();
483
+
484
+ const es = new EventSource('/api/logs/stream');
485
+
486
+ es.onmessage = (e) => {
487
+ if (e.data.trim()) appendLog(e.data);
488
+ document.getElementById('liveBadge').classList.add('visible');
489
+ };
490
+
491
+ es.addEventListener('reset', () => {
492
+ entries = [];
493
+ });
494
+
495
+ es.onerror = async () => {
496
+ es.close();
497
+ document.getElementById('liveBadge').classList.remove('visible');
498
+ // Fall back to one-shot fetch
499
+ try {
500
+ const res = await fetch('/api/logs');
501
+ if (!res.ok) return;
502
+ const text = await res.text();
503
+ if (text.trim()) parseLog(text);
504
+ } catch { /* fall back to drag-and-drop */ }
505
+ };
506
+ })();
507
+
508
+ // --- File loading ---
509
+ const dropZone = document.getElementById('dropZone');
510
+ const fileInput = document.getElementById('fileInput');
511
+
512
+ dropZone.addEventListener('click', () => fileInput.click());
513
+ dropZone.addEventListener('dragover', e => { e.preventDefault(); dropZone.classList.add('drag-over'); });
514
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
515
+ dropZone.addEventListener('drop', e => {
516
+ e.preventDefault();
517
+ dropZone.classList.remove('drag-over');
518
+ if (e.dataTransfer.files.length) loadFile(e.dataTransfer.files[0]);
519
+ });
520
+ fileInput.addEventListener('change', e => { if (e.target.files.length) loadFile(e.target.files[0]); });
521
+
522
+ function loadFile(file) {
523
+ const reader = new FileReader();
524
+ reader.onload = e => {
525
+ const text = e.target.result;
526
+ parseLog(text);
527
+ };
528
+ reader.readAsText(file);
529
+ }
530
+
531
+ function parseLog(text) {
532
+ entries = [];
533
+ // Handle both pretty-printed and single-line NDJSON
534
+ // Strategy: try to extract JSON objects
535
+ const lines = text.split('\n');
536
+ let buffer = '';
537
+ let depth = 0;
538
+
539
+ for (const line of lines) {
540
+ const trimmed = line.trim();
541
+ if (!trimmed) continue;
542
+
543
+ buffer += (buffer ? '\n' : '') + line;
544
+ for (const ch of trimmed) {
545
+ if (ch === '{') depth++;
546
+ else if (ch === '}') depth--;
547
+ }
548
+
549
+ if (depth === 0 && buffer.trim()) {
550
+ try {
551
+ const obj = JSON.parse(buffer);
552
+ obj._index = entries.length;
553
+ entries.push(obj);
554
+ } catch { /* skip malformed */ }
555
+ buffer = '';
556
+ }
557
+ }
558
+
559
+ if (entries.length === 0) {
560
+ alert('No valid log entries found in this file.');
561
+ return;
562
+ }
563
+
564
+ dropZone.style.display = 'none';
565
+ document.getElementById('dashboard').classList.add('visible');
566
+ buildDashboard();
567
+ populateFilters();
568
+ renderTable();
569
+ }
570
+
571
+ // --- Dashboard ---
572
+ function buildDashboard() {
573
+ const total = entries.length;
574
+ const activated = entries.filter(e => e.outcome === 'activated').length;
575
+ const noMatch = entries.filter(e => e.outcome === 'no_match').length;
576
+ const promptHooks = entries.filter(e => !e.hook_event).length;
577
+ const toolHooks = entries.filter(e => e.hook_event === 'PreToolUse').length;
578
+ const avgMs = (entries.reduce((s, e) => s + (e.execution_time_ms || 0), 0) / total).toFixed(1);
579
+ const sessions = new Set(entries.map(e => e.session_id)).size;
580
+
581
+ document.getElementById('statsGrid').innerHTML = `
582
+ <div class="stat-card"><div class="value">${total}</div><div class="label">Total Events</div></div>
583
+ <div class="stat-card activated"><div class="value">${activated}</div><div class="label">Activated (${(activated/total*100).toFixed(0)}%)</div></div>
584
+ <div class="stat-card no-match"><div class="value">${noMatch}</div><div class="label">No Match</div></div>
585
+ <div class="stat-card"><div class="value">${promptHooks}</div><div class="label">Prompt Hooks</div></div>
586
+ <div class="stat-card"><div class="value">${toolHooks}</div><div class="label">Tool Hooks</div></div>
587
+ <div class="stat-card"><div class="value">${avgMs}ms</div><div class="label">Avg Exec Time</div></div>
588
+ <div class="stat-card"><div class="value">${sessions}</div><div class="label">Sessions</div></div>
589
+ `;
590
+
591
+ // Sessions
592
+ const sessionMap = {};
593
+ for (const e of entries) {
594
+ if (!sessionMap[e.session_id]) sessionMap[e.session_id] = { count: 0, activated: 0, first: e.timestamp };
595
+ sessionMap[e.session_id].count++;
596
+ if (e.outcome === 'activated') sessionMap[e.session_id].activated++;
597
+ }
598
+ const sessionEntries = Object.entries(sessionMap).sort((a, b) => b[1].count - a[1].count);
599
+ document.getElementById('sessionList').innerHTML = sessionEntries.map(([id, info]) => `
600
+ <div class="session-item" data-session="${id}" onclick="filterBySession('${id}')">
601
+ <span class="id" title="${id}">${id.length > 16 ? id.slice(0, 8) + '...' : id}</span>
602
+ <span class="meta">${info.count} events, ${info.activated} activated</span>
603
+ </div>`).join('');
604
+
605
+ // Skills panel is updated by renderTable() with filtered entries
606
+ }
607
+
608
+ function filterBySession(id) {
609
+ document.getElementById('filterSession').value = id;
610
+ renderTable();
611
+ }
612
+
613
+ // --- Filters ---
614
+ function populateFilters() {
615
+ const sessionSelect = document.getElementById('filterSession');
616
+ const sessions = [...new Set(entries.map(e => e.session_id || 'unknown'))];
617
+ sessionSelect.innerHTML = '<option value="">All</option>' +
618
+ sessions.map(s => `<option value="${s}">${String(s).length > 20 ? String(s).slice(0, 8) + '...' + String(s).slice(-4) : s}</option>`).join('');
619
+ }
620
+
621
+ document.getElementById('filterOutcome').addEventListener('change', renderTable);
622
+ document.getElementById('filterHook').addEventListener('change', renderTable);
623
+ document.getElementById('filterSession').addEventListener('change', renderTable);
624
+ document.getElementById('filterSearch').addEventListener('input', renderTable);
625
+ document.getElementById('btnReset').addEventListener('click', () => {
626
+ document.getElementById('filterOutcome').value = '';
627
+ document.getElementById('filterHook').value = '';
628
+ document.getElementById('filterSession').value = '';
629
+ document.getElementById('filterSearch').value = '';
630
+ renderTable();
631
+ });
632
+
633
+ // --- Sorting ---
634
+ document.querySelectorAll('thead th[data-sort]').forEach(th => {
635
+ th.addEventListener('click', () => {
636
+ const col = th.dataset.sort;
637
+ if (sortCol === col) sortAsc = !sortAsc;
638
+ else { sortCol = col; sortAsc = true; }
639
+ renderTable();
640
+ });
641
+ });
642
+
643
+ function getSortValue(entry, col) {
644
+ switch (col) {
645
+ case 'index': return entry._index;
646
+ case 'timestamp': return entry.timestamp;
647
+ case 'hook': return entry.hook_event || '';
648
+ case 'prompt': return entry.prompt_raw || '';
649
+ case 'outcome': return entry.outcome;
650
+ case 'skills': return (entry.skills_matched || []).length;
651
+ case 'signals': return (entry.signals_extracted?.words_count || 0);
652
+ case 'score': return Math.max(0, ...(entry.skills_matched || []).map(s => s.score));
653
+ case 'threshold': return entry.threshold || 0;
654
+ case 'time_ms': return entry.execution_time_ms || 0;
655
+ case 'session': return entry.session_id || '';
656
+ default: return 0;
657
+ }
658
+ }
659
+
660
+ // --- Render ---
661
+ function renderTable() {
662
+ const outcome = document.getElementById('filterOutcome').value;
663
+ const hook = document.getElementById('filterHook').value;
664
+ const session = document.getElementById('filterSession').value;
665
+ const search = document.getElementById('filterSearch').value.toLowerCase();
666
+
667
+ let filtered = entries.filter(e => {
668
+ if (outcome && e.outcome !== outcome) return false;
669
+ if (hook === 'prompt' && e.hook_event) return false;
670
+ if (hook === 'tool' && !e.hook_event) return false;
671
+ if (session && e.session_id !== session) return false;
672
+ if (search) {
673
+ const haystack = [
674
+ e.prompt_raw,
675
+ ...(e.skills_matched || []).map(s => s.name),
676
+ ...(e.skills_matched || []).flatMap(s => (s.matched_signals || []).map(sig => sig.value)),
677
+ e.tool_name || '',
678
+ ].join(' ').toLowerCase();
679
+ if (!haystack.includes(search)) return false;
680
+ }
681
+ return true;
682
+ });
683
+
684
+ // Update skills panel with filtered data
685
+ renderSkillsPanel(filtered);
686
+
687
+ // Sort
688
+ filtered.sort((a, b) => {
689
+ const va = getSortValue(a, sortCol);
690
+ const vb = getSortValue(b, sortCol);
691
+ const cmp = va < vb ? -1 : va > vb ? 1 : 0;
692
+ return sortAsc ? cmp : -cmp;
693
+ });
694
+
695
+ // Update sort arrows
696
+ document.querySelectorAll('thead th[data-sort]').forEach(th => {
697
+ const arrow = th.querySelector('.sort-arrow');
698
+ if (th.dataset.sort === sortCol) arrow.textContent = sortAsc ? '▲' : '▼';
699
+ else arrow.textContent = '';
700
+ });
701
+
702
+ const tbody = document.getElementById('tableBody');
703
+ if (!filtered.length) {
704
+ tbody.innerHTML = '<tr><td colspan="10" class="empty-state">No entries match filters</td></tr>';
705
+ return;
706
+ }
707
+
708
+ tbody.innerHTML = filtered.map(e => {
709
+ const hookType = e.hook_event
710
+ ? `<span class="badge hook-tool">${e.tool_name || 'PreToolUse'}</span>`
711
+ : `<span class="badge hook-prompt">Prompt</span>`;
712
+
713
+ const outcomeBadge = `<span class="badge ${e.outcome === 'activated' ? 'activated' : 'no-match'}">${e.outcome}</span>`;
714
+
715
+ const prompt = e.hook_event
716
+ ? (e.prompt_raw || '').replace(/^\[PreToolUse:\w+\]\s*/, '')
717
+ : (e.prompt_raw || '');
718
+
719
+ const skillsHtml = (e.skills_matched || []).length
720
+ ? (e.skills_matched || []).map(s => `
721
+ <div class="skill-match">
722
+ <span class="skill-name">${esc(s.name)}</span>
723
+ <span class="skill-score">${s.score}</span>
724
+ </div>`).join('')
725
+ : '<span style="color:var(--text-muted);font-size:12px">—</span>';
726
+
727
+ const signalsHtml = (e.skills_matched || []).flatMap(s =>
728
+ (s.matched_signals || []).map(sig =>
729
+ `<span class="signal-tag ${sig.type}">${sig.type}:${esc(sig.value)}</span>`)
730
+ ).join('') || '<span style="color:var(--text-muted);font-size:12px">—</span>';
731
+
732
+ const topScore = (e.skills_matched || []).length
733
+ ? Math.max(...(e.skills_matched || []).map(s => s.score))
734
+ : '';
735
+
736
+ const ts = formatTime(e.timestamp);
737
+ const ms = e.execution_time_ms || 0;
738
+ const msClass = ms > 3 ? 'slow' : '';
739
+
740
+ return `<tr>
741
+ <td style="color:var(--text-muted);font-size:12px">${e._index + 1}</td>
742
+ <td style="white-space:nowrap;font-size:12px" title="${e.timestamp}">${ts}</td>
743
+ <td>${hookType}</td>
744
+ <td><div class="prompt-text" title="${esc(e.prompt_raw)}">${esc(prompt)}</div></td>
745
+ <td>${outcomeBadge}</td>
746
+ <td class="skills-cell">${skillsHtml}</td>
747
+ <td>${signalsHtml}</td>
748
+ <td style="font-weight:600;color:${topScore ? 'var(--orange)' : 'var(--text-muted)'}">${topScore || '—'}</td>
749
+ <td style="color:var(--text-muted)">${e.threshold}</td>
750
+ <td><span class="time-ms ${msClass}">${ms}</span></td>
751
+ </tr>`;
752
+ }).join('');
753
+ }
754
+
755
+ function formatTime(ts) {
756
+ try {
757
+ const d = new Date(ts);
758
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }) + ' ' +
759
+ d.toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit', hour12: false });
760
+ } catch { return ts; }
761
+ }
762
+
763
+ function esc(str) {
764
+ const d = document.createElement('div');
765
+ d.textContent = str || '';
766
+ return d.innerHTML;
767
+ }
768
+ </script>
769
+ </body>
770
+ </html>