selftune 0.1.0 → 0.1.2

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,1114 @@
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>selftune — Dashboard</title>
7
+ <script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.7/dist/chart.umd.min.js"></script>
8
+ <style>
9
+ :root {
10
+ --bg: #faf9f5;
11
+ --surface: #ffffff;
12
+ --border: #e8e6dc;
13
+ --text: #141413;
14
+ --text-muted: #b0aea5;
15
+ --text-secondary: #6b6961;
16
+ --accent: #d97757;
17
+ --accent-hover: #c4613f;
18
+ --green: #788c5d;
19
+ --green-bg: #eef2e8;
20
+ --red: #c44;
21
+ --red-bg: #fceaea;
22
+ --blue: #4a7fd4;
23
+ --blue-bg: #e8f0fa;
24
+ --amber: #c49133;
25
+ --amber-bg: #fdf4e3;
26
+ --header-bg: #141413;
27
+ --header-text: #faf9f5;
28
+ --radius: 6px;
29
+ --mono: 'SF Mono', SFMono-Regular, Consolas, 'Liberation Mono', Menlo, monospace;
30
+ }
31
+
32
+ * { box-sizing: border-box; margin: 0; padding: 0; }
33
+
34
+ body {
35
+ font-family: 'Lora', Georgia, serif;
36
+ background: var(--bg);
37
+ color: var(--text);
38
+ height: 100vh;
39
+ display: flex;
40
+ flex-direction: column;
41
+ }
42
+
43
+ /* ---- Header ---- */
44
+ .header {
45
+ background: var(--header-bg);
46
+ color: var(--header-text);
47
+ padding: 1rem 2rem;
48
+ display: flex;
49
+ justify-content: space-between;
50
+ align-items: center;
51
+ flex-shrink: 0;
52
+ }
53
+ .header-left { display: flex; align-items: center; gap: 1rem; }
54
+ .header h1 {
55
+ font-family: 'Poppins', sans-serif;
56
+ font-size: 1.25rem;
57
+ font-weight: 600;
58
+ letter-spacing: -0.01em;
59
+ }
60
+ .header h1 span { color: var(--accent); }
61
+ .header .version {
62
+ font-family: var(--mono);
63
+ font-size: 0.6875rem;
64
+ opacity: 0.5;
65
+ padding: 0.15rem 0.5rem;
66
+ border: 1px solid rgba(255,255,255,0.15);
67
+ border-radius: 9999px;
68
+ }
69
+ .header .status {
70
+ font-size: 0.8rem;
71
+ opacity: 0.7;
72
+ font-family: 'Poppins', sans-serif;
73
+ }
74
+ .header .status .count {
75
+ color: var(--accent);
76
+ font-weight: 600;
77
+ }
78
+
79
+ /* ---- Main ---- */
80
+ .main {
81
+ flex: 1;
82
+ overflow-y: auto;
83
+ padding: 1.5rem 2rem;
84
+ display: flex;
85
+ flex-direction: column;
86
+ gap: 1.25rem;
87
+ }
88
+
89
+ /* ---- Drop zone ---- */
90
+ .drop-zone {
91
+ border: 2px dashed var(--border);
92
+ border-radius: var(--radius);
93
+ padding: 3rem 2rem;
94
+ text-align: center;
95
+ transition: all 0.2s;
96
+ cursor: pointer;
97
+ background: var(--surface);
98
+ }
99
+ .drop-zone:hover, .drop-zone.drag-over {
100
+ border-color: var(--accent);
101
+ background: rgba(217, 119, 87, 0.04);
102
+ }
103
+ .drop-zone h2 {
104
+ font-family: 'Poppins', sans-serif;
105
+ font-size: 1.125rem;
106
+ font-weight: 600;
107
+ margin-bottom: 0.5rem;
108
+ }
109
+ .drop-zone p {
110
+ color: var(--text-muted);
111
+ font-size: 0.875rem;
112
+ margin-bottom: 1rem;
113
+ }
114
+ .drop-zone .file-types {
115
+ display: flex;
116
+ justify-content: center;
117
+ gap: 0.5rem;
118
+ flex-wrap: wrap;
119
+ }
120
+ .file-tag {
121
+ display: inline-block;
122
+ padding: 0.2rem 0.625rem;
123
+ border-radius: 9999px;
124
+ font-family: var(--mono);
125
+ font-size: 0.6875rem;
126
+ font-weight: 500;
127
+ background: var(--bg);
128
+ color: var(--text-secondary);
129
+ border: 1px solid var(--border);
130
+ }
131
+ .file-tag.loaded {
132
+ background: var(--green-bg);
133
+ color: var(--green);
134
+ border-color: var(--green);
135
+ }
136
+ .drop-zone input[type="file"] { display: none; }
137
+
138
+ /* ---- KPI row ---- */
139
+ .kpi-row {
140
+ display: grid;
141
+ grid-template-columns: repeat(auto-fit, minmax(180px, 1fr));
142
+ gap: 1rem;
143
+ }
144
+ .kpi-card {
145
+ background: var(--surface);
146
+ border: 1px solid var(--border);
147
+ border-radius: var(--radius);
148
+ padding: 1.25rem;
149
+ }
150
+ .kpi-label {
151
+ font-family: 'Poppins', sans-serif;
152
+ font-size: 0.6875rem;
153
+ font-weight: 500;
154
+ text-transform: uppercase;
155
+ letter-spacing: 0.05em;
156
+ color: var(--text-muted);
157
+ margin-bottom: 0.5rem;
158
+ }
159
+ .kpi-value {
160
+ font-family: 'Poppins', sans-serif;
161
+ font-size: 2rem;
162
+ font-weight: 700;
163
+ line-height: 1;
164
+ }
165
+ .kpi-sub {
166
+ font-size: 0.75rem;
167
+ color: var(--text-muted);
168
+ margin-top: 0.375rem;
169
+ }
170
+
171
+ /* ---- Section cards ---- */
172
+ .section {
173
+ background: var(--surface);
174
+ border: 1px solid var(--border);
175
+ border-radius: var(--radius);
176
+ }
177
+ .section-header {
178
+ font-family: 'Poppins', sans-serif;
179
+ padding: 0.75rem 1rem;
180
+ font-size: 0.75rem;
181
+ font-weight: 500;
182
+ text-transform: uppercase;
183
+ letter-spacing: 0.05em;
184
+ color: var(--text-muted);
185
+ border-bottom: 1px solid var(--border);
186
+ background: var(--bg);
187
+ border-radius: var(--radius) var(--radius) 0 0;
188
+ display: flex;
189
+ justify-content: space-between;
190
+ align-items: center;
191
+ }
192
+ .section-body { padding: 1rem; }
193
+
194
+ /* ---- Chart containers ---- */
195
+ .chart-container {
196
+ position: relative;
197
+ height: 280px;
198
+ width: 100%;
199
+ }
200
+
201
+ /* ---- Badges ---- */
202
+ .badge {
203
+ display: inline-block;
204
+ padding: 0.125rem 0.5rem;
205
+ border-radius: 9999px;
206
+ font-family: 'Poppins', sans-serif;
207
+ font-size: 0.6875rem;
208
+ font-weight: 600;
209
+ }
210
+ .badge-green { background: var(--green-bg); color: var(--green); }
211
+ .badge-red { background: var(--red-bg); color: var(--red); }
212
+ .badge-blue { background: var(--blue-bg); color: var(--blue); }
213
+ .badge-amber { background: var(--amber-bg); color: var(--amber); }
214
+
215
+ /* ---- Skill Health Grid ---- */
216
+ .skill-health-grid {
217
+ width: 100%;
218
+ }
219
+ .skill-health-row {
220
+ display: grid;
221
+ grid-template-columns: 180px 1fr 50px 80px 100px;
222
+ align-items: center;
223
+ gap: 0.75rem;
224
+ padding: 0.625rem 1rem;
225
+ border-bottom: 1px solid var(--border);
226
+ cursor: pointer;
227
+ transition: background 0.1s;
228
+ }
229
+ .skill-health-row:hover {
230
+ background: var(--bg);
231
+ }
232
+ .skill-health-row.selected {
233
+ background: var(--blue-bg);
234
+ border-left: 3px solid var(--blue);
235
+ }
236
+ .skill-health-header {
237
+ display: grid;
238
+ grid-template-columns: 180px 1fr 50px 80px 100px;
239
+ gap: 0.75rem;
240
+ padding: 0.5rem 1rem;
241
+ font-family: 'Poppins', sans-serif;
242
+ font-size: 0.6875rem;
243
+ font-weight: 500;
244
+ text-transform: uppercase;
245
+ letter-spacing: 0.04em;
246
+ color: var(--text-muted);
247
+ background: var(--bg);
248
+ border-bottom: 1px solid var(--border);
249
+ }
250
+ .skill-name {
251
+ font-family: var(--mono);
252
+ font-size: 0.75rem;
253
+ font-weight: 500;
254
+ overflow: hidden;
255
+ text-overflow: ellipsis;
256
+ white-space: nowrap;
257
+ }
258
+ .pass-rate-bar {
259
+ display: flex;
260
+ align-items: center;
261
+ gap: 0.5rem;
262
+ }
263
+ .pass-rate-track {
264
+ flex: 1;
265
+ height: 18px;
266
+ background: var(--bg);
267
+ border-radius: 3px;
268
+ overflow: hidden;
269
+ }
270
+ .pass-rate-fill {
271
+ height: 100%;
272
+ border-radius: 3px;
273
+ transition: width 0.4s ease;
274
+ }
275
+ .pass-rate-fill.healthy { background: var(--green); }
276
+ .pass-rate-fill.drifting { background: var(--amber); }
277
+ .pass-rate-fill.regressed { background: var(--red); }
278
+ .pass-rate-label {
279
+ font-family: 'Poppins', sans-serif;
280
+ font-size: 0.75rem;
281
+ font-weight: 600;
282
+ min-width: 42px;
283
+ text-align: right;
284
+ }
285
+ .trend-arrow {
286
+ font-size: 1rem;
287
+ text-align: center;
288
+ }
289
+ .trend-up { color: var(--green); }
290
+ .trend-down { color: var(--red); }
291
+ .trend-flat { color: var(--text-muted); }
292
+ .missed-count {
293
+ font-family: 'Poppins', sans-serif;
294
+ font-size: 0.75rem;
295
+ text-align: center;
296
+ }
297
+
298
+ /* ---- Drill-down panel ---- */
299
+ .drill-down-panel {
300
+ display: none;
301
+ background: var(--surface);
302
+ border: 1px solid var(--border);
303
+ border-radius: var(--radius);
304
+ }
305
+ .drill-down-panel.visible {
306
+ display: block;
307
+ }
308
+ .drill-down-header {
309
+ font-family: 'Poppins', sans-serif;
310
+ padding: 0.75rem 1rem;
311
+ font-size: 0.875rem;
312
+ font-weight: 600;
313
+ border-bottom: 1px solid var(--border);
314
+ background: var(--bg);
315
+ border-radius: var(--radius) var(--radius) 0 0;
316
+ display: flex;
317
+ justify-content: space-between;
318
+ align-items: center;
319
+ }
320
+ .drill-down-close {
321
+ font-family: 'Poppins', sans-serif;
322
+ font-size: 0.75rem;
323
+ padding: 0.25rem 0.75rem;
324
+ border: 1px solid var(--border);
325
+ border-radius: var(--radius);
326
+ background: var(--surface);
327
+ color: var(--text-secondary);
328
+ cursor: pointer;
329
+ }
330
+ .drill-down-close:hover { border-color: var(--accent); color: var(--accent); }
331
+ .drill-down-content {
332
+ display: grid;
333
+ grid-template-columns: 1fr 1fr;
334
+ gap: 1rem;
335
+ padding: 1rem;
336
+ }
337
+ @media (max-width: 900px) {
338
+ .drill-down-content { grid-template-columns: 1fr; }
339
+ }
340
+ .drill-down-section { min-height: 200px; }
341
+
342
+ /* ---- Table ---- */
343
+ .data-table {
344
+ width: 100%;
345
+ border-collapse: collapse;
346
+ font-size: 0.8125rem;
347
+ }
348
+ .data-table th, .data-table td {
349
+ padding: 0.625rem 0.75rem;
350
+ text-align: left;
351
+ border-bottom: 1px solid var(--border);
352
+ }
353
+ .data-table th {
354
+ font-family: 'Poppins', sans-serif;
355
+ font-weight: 500;
356
+ font-size: 0.6875rem;
357
+ text-transform: uppercase;
358
+ letter-spacing: 0.04em;
359
+ color: var(--text-muted);
360
+ background: var(--bg);
361
+ position: sticky;
362
+ top: 0;
363
+ }
364
+ .data-table tr:hover { background: var(--bg); }
365
+ .data-table td.mono { font-family: var(--mono); font-size: 0.75rem; }
366
+
367
+ /* ---- Timeline ---- */
368
+ .timeline-item {
369
+ display: flex;
370
+ gap: 1rem;
371
+ padding: 0.75rem 0;
372
+ border-bottom: 1px solid var(--border);
373
+ font-size: 0.8125rem;
374
+ }
375
+ .timeline-item:last-child { border-bottom: none; }
376
+ .timeline-date {
377
+ font-family: var(--mono);
378
+ font-size: 0.6875rem;
379
+ color: var(--text-muted);
380
+ min-width: 140px;
381
+ flex-shrink: 0;
382
+ }
383
+ .timeline-action { font-weight: 500; }
384
+
385
+ /* ---- Empty state ---- */
386
+ .empty-state {
387
+ color: var(--text-muted);
388
+ font-style: italic;
389
+ padding: 2rem;
390
+ text-align: center;
391
+ }
392
+
393
+ /* ---- Table scroll wrapper ---- */
394
+ .table-scroll {
395
+ max-height: 400px;
396
+ overflow-y: auto;
397
+ }
398
+
399
+ /* ---- Export button ---- */
400
+ .export-btn {
401
+ font-family: 'Poppins', sans-serif;
402
+ font-size: 0.75rem;
403
+ font-weight: 500;
404
+ padding: 0.4rem 1rem;
405
+ border: 1px solid var(--border);
406
+ border-radius: var(--radius);
407
+ background: var(--surface);
408
+ color: var(--text-secondary);
409
+ cursor: pointer;
410
+ transition: all 0.15s;
411
+ }
412
+ .export-btn:hover {
413
+ border-color: var(--accent);
414
+ color: var(--accent);
415
+ }
416
+ </style>
417
+ </head>
418
+ <body>
419
+
420
+ <!-- ===== Header ===== -->
421
+ <div class="header">
422
+ <div class="header-left">
423
+ <h1>self<span>tune</span></h1>
424
+ <span class="version">v0.5</span>
425
+ </div>
426
+ <div class="status" id="headerStatus">Drop log files to get started</div>
427
+ </div>
428
+
429
+ <!-- ===== Content ===== -->
430
+ <div class="main" id="mainContent">
431
+
432
+ <!-- Drop zone (shown when no data) -->
433
+ <div class="drop-zone" id="dropZone" role="button" tabindex="0" aria-label="Load log files by clicking or dragging">
434
+ <h2>Load Your Data</h2>
435
+ <p>Drag &amp; drop your JSONL log files here, or click to browse.<br>
436
+ Files are processed locally &mdash; nothing leaves your machine.</p>
437
+ <div class="file-types">
438
+ <span class="file-tag" id="tag-telemetry">session_telemetry_log.jsonl</span>
439
+ <span class="file-tag" id="tag-skill">skill_usage_log.jsonl</span>
440
+ <span class="file-tag" id="tag-query">all_queries_log.jsonl</span>
441
+ <span class="file-tag" id="tag-evolution">evolution_audit_log.jsonl</span>
442
+ </div>
443
+ <input type="file" id="fileInput" multiple accept=".jsonl,.json">
444
+ </div>
445
+
446
+ <!-- ===== SYSTEM HEALTH SUMMARY ===== -->
447
+ <div id="healthSummary" style="display:none;">
448
+ <div class="kpi-row" id="kpiRow">
449
+ <div class="kpi-card">
450
+ <div class="kpi-label">Skills Monitored</div>
451
+ <div class="kpi-value" id="kpi-skills-monitored">0</div>
452
+ <div class="kpi-sub" id="kpi-skills-sub"></div>
453
+ </div>
454
+ <div class="kpi-card">
455
+ <div class="kpi-label">Avg Pass Rate</div>
456
+ <div class="kpi-value" id="kpi-avg-pass-rate">--</div>
457
+ <div class="kpi-sub" id="kpi-pass-rate-sub"></div>
458
+ </div>
459
+ <div class="kpi-card">
460
+ <div class="kpi-label">Regressions</div>
461
+ <div class="kpi-value" id="kpi-regressions">0</div>
462
+ <div class="kpi-sub" id="kpi-regressions-sub"></div>
463
+ </div>
464
+ <div class="kpi-card">
465
+ <div class="kpi-label">Unmatched Queries</div>
466
+ <div class="kpi-value" id="kpi-unmatched">0</div>
467
+ <div class="kpi-sub" id="kpi-unmatched-sub"></div>
468
+ </div>
469
+ <div class="kpi-card">
470
+ <div class="kpi-label">Sessions</div>
471
+ <div class="kpi-value" id="kpi-sessions">0</div>
472
+ <div class="kpi-sub" id="kpi-sessions-sub"></div>
473
+ </div>
474
+ <div class="kpi-card">
475
+ <div class="kpi-label">Pending Proposals</div>
476
+ <div class="kpi-value" id="kpi-pending">0</div>
477
+ <div class="kpi-sub" id="kpi-pending-sub"></div>
478
+ </div>
479
+ </div>
480
+ </div>
481
+
482
+ <!-- ===== SKILL HEALTH GRID ===== -->
483
+ <div class="section" id="skillHealthSection" style="display:none;">
484
+ <div class="section-header">
485
+ <span>Skill Health Grid</span>
486
+ <button class="export-btn" id="exportCsvBtn">Export CSV</button>
487
+ </div>
488
+ <div class="skill-health-header">
489
+ <span>Skill</span>
490
+ <span>Pass Rate</span>
491
+ <span>Trend</span>
492
+ <span>Missed</span>
493
+ <span>Status</span>
494
+ </div>
495
+ <div class="section-body skill-health-grid" id="skillHealthGrid">
496
+ <div class="empty-state">Load data to see skill health</div>
497
+ </div>
498
+ </div>
499
+
500
+ <!-- ===== DRILL-DOWN PANEL ===== -->
501
+ <div class="drill-down-panel" id="drillDownPanel">
502
+ <div class="drill-down-header">
503
+ <span id="drillDownTitle">Skill Details</span>
504
+ <button class="drill-down-close" id="drillDownClose">Close</button>
505
+ </div>
506
+ <div class="drill-down-content">
507
+ <div class="drill-down-section">
508
+ <h4 style="font-family:'Poppins',sans-serif;font-size:0.75rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-muted);margin-bottom:0.75rem;">Pass Rate Over Time</h4>
509
+ <div class="chart-container"><canvas id="chartDrillPassRate"></canvas></div>
510
+ </div>
511
+ <div class="drill-down-section">
512
+ <h4 style="font-family:'Poppins',sans-serif;font-size:0.75rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-muted);margin-bottom:0.75rem;">Missed Queries</h4>
513
+ <div class="table-scroll" style="max-height:260px;">
514
+ <table class="data-table" id="drillMissedTable">
515
+ <thead><tr><th>Timestamp</th><th>Session</th><th>Query</th></tr></thead>
516
+ <tbody></tbody>
517
+ </table>
518
+ </div>
519
+ </div>
520
+ <div class="drill-down-section">
521
+ <h4 style="font-family:'Poppins',sans-serif;font-size:0.75rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-muted);margin-bottom:0.75rem;">Evolution History</h4>
522
+ <div id="drillEvoTimeline" class="table-scroll" style="max-height:260px;">
523
+ <div class="empty-state">No evolution history</div>
524
+ </div>
525
+ </div>
526
+ <div class="drill-down-section">
527
+ <h4 style="font-family:'Poppins',sans-serif;font-size:0.75rem;font-weight:500;text-transform:uppercase;letter-spacing:0.04em;color:var(--text-muted);margin-bottom:0.75rem;">Sessions</h4>
528
+ <div class="table-scroll" style="max-height:260px;">
529
+ <table class="data-table" id="drillSessionsTable">
530
+ <thead><tr><th>Timestamp</th><th>Tools</th><th>Skills</th><th>Errors</th></tr></thead>
531
+ <tbody></tbody>
532
+ </table>
533
+ </div>
534
+ </div>
535
+ </div>
536
+ </div>
537
+
538
+ <!-- ===== UNMATCHED QUERIES ===== -->
539
+ <div class="section" id="unmatchedSection" style="display:none;">
540
+ <div class="section-header">Unmatched Queries</div>
541
+ <div class="section-body">
542
+ <div class="table-scroll">
543
+ <table class="data-table" id="unmatchedTable">
544
+ <thead><tr><th>Timestamp</th><th>Session</th><th>Query</th></tr></thead>
545
+ <tbody></tbody>
546
+ </table>
547
+ </div>
548
+ </div>
549
+ </div>
550
+
551
+ <!-- ===== PENDING PROPOSALS ===== -->
552
+ <div class="section" id="pendingSection" style="display:none;">
553
+ <div class="section-header">Pending Proposals</div>
554
+ <div class="section-body" id="pendingProposals">
555
+ <div class="empty-state">No pending proposals</div>
556
+ </div>
557
+ </div>
558
+
559
+ </div>
560
+
561
+ <script>
562
+ // ========================================================================
563
+ // State
564
+ // ========================================================================
565
+ const state = {
566
+ telemetry: [], // SessionTelemetryRecord[]
567
+ skills: [], // SkillUsageRecord[]
568
+ queries: [], // QueryLogRecord[]
569
+ evolution: [], // EvolutionAuditEntry[]
570
+ computed: null, // Pre-computed monitoring data (from CLI)
571
+ };
572
+
573
+ const charts = {};
574
+ let selectedSkill = null;
575
+
576
+ // ========================================================================
577
+ // File identification
578
+ // ========================================================================
579
+ function identifyFile(name, firstLine) {
580
+ if (name.includes('session_telemetry')) return 'telemetry';
581
+ if (name.includes('skill_usage')) return 'skills';
582
+ if (name.includes('all_queries')) return 'queries';
583
+ if (name.includes('evolution_audit')) return 'evolution';
584
+ if (firstLine) {
585
+ try {
586
+ const obj = JSON.parse(firstLine);
587
+ if ('total_tool_calls' in obj || 'transcript_path' in obj) return 'telemetry';
588
+ if ('skill_name' in obj && 'triggered' in obj) return 'skills';
589
+ if ('query' in obj && !('skill_name' in obj)) return 'queries';
590
+ if ('proposal_id' in obj && 'action' in obj) return 'evolution';
591
+ } catch {}
592
+ }
593
+ return null;
594
+ }
595
+
596
+ function parseJSONL(text) {
597
+ return text.trim().split('\n').filter(Boolean).map(line => {
598
+ try { return JSON.parse(line); }
599
+ catch { return null; }
600
+ }).filter(Boolean);
601
+ }
602
+
603
+ // ========================================================================
604
+ // Client-side computed data generation (for drag-drop mode)
605
+ // ========================================================================
606
+ const REGRESSION_THRESHOLD = 0.4;
607
+ const DEFAULT_BASELINE_PASS_RATE = 0.5;
608
+
609
+ function computeClientSide() {
610
+ const skillNames = [...new Set(state.skills.map(r => r.skill_name))];
611
+ const triggeredQueries = new Set(
612
+ state.skills.filter(r => r.triggered).map(r => r.query.toLowerCase().trim())
613
+ );
614
+
615
+ // Per-skill snapshots
616
+ const snapshots = {};
617
+ for (const name of skillNames) {
618
+ const skillRecords = state.skills.filter(r => r.skill_name === name);
619
+ const triggered = skillRecords.filter(r => r.triggered).length;
620
+ const total = state.queries.length;
621
+ const passRate = total === 0 ? 1.0 : triggered / total;
622
+ const falseNegatives = skillRecords.filter(r => !r.triggered).length;
623
+ const fnRate = skillRecords.length === 0 ? 0 : falseNegatives / skillRecords.length;
624
+ snapshots[name] = {
625
+ timestamp: new Date().toISOString(),
626
+ skill_name: name,
627
+ window_sessions: state.telemetry.length,
628
+ pass_rate: passRate,
629
+ false_negative_rate: fnRate,
630
+ regression_detected: passRate < REGRESSION_THRESHOLD,
631
+ baseline_pass_rate: DEFAULT_BASELINE_PASS_RATE,
632
+ };
633
+ }
634
+
635
+ // Unmatched queries
636
+ const unmatched = state.queries.filter(q =>
637
+ !triggeredQueries.has(q.query.toLowerCase().trim())
638
+ ).map(q => ({ timestamp: q.timestamp, session_id: q.session_id, query: q.query }));
639
+
640
+ // Pending proposals
641
+ const proposalStatus = {};
642
+ for (const e of state.evolution) {
643
+ if (!proposalStatus[e.proposal_id]) proposalStatus[e.proposal_id] = [];
644
+ proposalStatus[e.proposal_id].push(e.action);
645
+ }
646
+ const seenProposals = new Set();
647
+ const pendingProposals = state.evolution.filter(e => {
648
+ if (e.action !== 'created' && e.action !== 'validated') return false;
649
+ const actions = proposalStatus[e.proposal_id] || [];
650
+ if (actions.includes('deployed') || actions.includes('rejected') || actions.includes('rolled_back')) return false;
651
+ if (seenProposals.has(e.proposal_id)) return false;
652
+ seenProposals.add(e.proposal_id);
653
+ return true;
654
+ });
655
+
656
+ return { snapshots, unmatched, pendingProposals };
657
+ }
658
+
659
+ // ========================================================================
660
+ // File loading
661
+ // ========================================================================
662
+ async function handleFiles(files) {
663
+ for (const file of files) {
664
+ const text = await file.text();
665
+ const lines = text.trim().split('\n').filter(Boolean);
666
+ if (!lines.length) continue;
667
+ const type = identifyFile(file.name, lines[0]);
668
+ if (!type) { console.warn('Unknown file type:', file.name); continue; }
669
+ state[type] = parseJSONL(text);
670
+ const tag = document.getElementById(`tag-${type === 'skills' ? 'skill' : type}`);
671
+ if (tag) tag.classList.add('loaded');
672
+ }
673
+ state.computed = computeClientSide();
674
+ refreshAll();
675
+ }
676
+
677
+ // ========================================================================
678
+ // Drag & drop + click
679
+ // ========================================================================
680
+ const dropZone = document.getElementById('dropZone');
681
+ const fileInput = document.getElementById('fileInput');
682
+
683
+ dropZone.addEventListener('click', () => fileInput.click());
684
+ dropZone.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); fileInput.click(); } });
685
+ fileInput.addEventListener('change', e => handleFiles(e.target.files));
686
+
687
+ dropZone.addEventListener('dragover', e => {
688
+ e.preventDefault();
689
+ dropZone.classList.add('drag-over');
690
+ });
691
+ dropZone.addEventListener('dragleave', () => dropZone.classList.remove('drag-over'));
692
+ dropZone.addEventListener('drop', e => {
693
+ e.preventDefault();
694
+ dropZone.classList.remove('drag-over');
695
+ handleFiles(e.dataTransfer.files);
696
+ });
697
+
698
+ // ========================================================================
699
+ // Data loading from embedded JSON (when served by CLI)
700
+ // ========================================================================
701
+ function loadEmbeddedData() {
702
+ const el = document.getElementById('embedded-data');
703
+ if (!el) return false;
704
+ try {
705
+ const data = JSON.parse(el.textContent);
706
+ if (data.telemetry) state.telemetry = data.telemetry;
707
+ if (data.skills) state.skills = data.skills;
708
+ if (data.queries) state.queries = data.queries;
709
+ if (data.evolution) state.evolution = data.evolution;
710
+ if (data.computed) {
711
+ state.computed = data.computed;
712
+ } else {
713
+ state.computed = computeClientSide();
714
+ }
715
+ return state.telemetry.length || state.skills.length || state.queries.length || state.evolution.length;
716
+ } catch { return false; }
717
+ }
718
+
719
+ // ========================================================================
720
+ // Helpers
721
+ // ========================================================================
722
+ function toDate(ts) { return new Date(ts); }
723
+
724
+ function formatDate(ts) {
725
+ const d = toDate(ts);
726
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
727
+ }
728
+
729
+ function formatTimestamp(ts) {
730
+ const d = toDate(ts);
731
+ return d.toLocaleString('en-US', {
732
+ month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
733
+ });
734
+ }
735
+
736
+ function truncate(s, max = 60) {
737
+ if (!s) return '\u2014';
738
+ return s.length > max ? s.slice(0, max) + '...' : s;
739
+ }
740
+
741
+ function escapeHtml(s) {
742
+ if (!s) return '';
743
+ return String(s).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
744
+ }
745
+
746
+ function groupByDay(records) {
747
+ const map = {};
748
+ for (const r of records) {
749
+ const day = formatDate(r.timestamp);
750
+ map[day] = (map[day] || 0) + 1;
751
+ }
752
+ return map;
753
+ }
754
+
755
+ function getSkillStatus(passRate, regressionDetected) {
756
+ if (regressionDetected || passRate < 0.5) return 'regressed';
757
+ if (passRate < 0.7) return 'drifting';
758
+ return 'healthy';
759
+ }
760
+
761
+ function getStatusBadge(status) {
762
+ const map = {
763
+ healthy: '<span class="badge badge-green">Healthy</span>',
764
+ drifting: '<span class="badge badge-amber">Drifting</span>',
765
+ regressed: '<span class="badge badge-red">Regressed</span>',
766
+ };
767
+ return map[status] || '';
768
+ }
769
+
770
+ const CHART_COLORS = [
771
+ '#d97757', '#788c5d', '#4a7fd4', '#c49133', '#9b6bb0',
772
+ '#5ba3a3', '#d46a9f', '#7b8fa1', '#c47a5a', '#6b9b6b'
773
+ ];
774
+
775
+ // ========================================================================
776
+ // Refresh all views
777
+ // ========================================================================
778
+ function refreshAll() {
779
+ const hasData = state.telemetry.length || state.skills.length ||
780
+ state.queries.length || state.evolution.length;
781
+
782
+ if (hasData) {
783
+ dropZone.style.display = 'none';
784
+ document.getElementById('healthSummary').style.display = 'block';
785
+ document.getElementById('skillHealthSection').style.display = 'block';
786
+ }
787
+
788
+ updateHeader();
789
+ updateHealthSummary();
790
+ updateSkillHealthGrid();
791
+ updateUnmatched();
792
+ updatePending();
793
+ }
794
+
795
+ // ========================================================================
796
+ // Header
797
+ // ========================================================================
798
+ function updateHeader() {
799
+ const parts = [];
800
+ if (state.telemetry.length) parts.push(`<span class="count">${state.telemetry.length}</span> sessions`);
801
+ if (state.skills.length) parts.push(`<span class="count">${state.skills.length}</span> skill events`);
802
+ if (state.queries.length) parts.push(`<span class="count">${state.queries.length}</span> queries`);
803
+ if (state.evolution.length) parts.push(`<span class="count">${state.evolution.length}</span> evolution actions`);
804
+ document.getElementById('headerStatus').innerHTML = parts.length ? parts.join(' &middot; ') : 'Drop log files to get started';
805
+ }
806
+
807
+ // ========================================================================
808
+ // Health Summary KPIs
809
+ // ========================================================================
810
+ function updateHealthSummary() {
811
+ const computed = state.computed;
812
+ if (!computed) return;
813
+
814
+ const snapshots = computed.snapshots || {};
815
+ const skillNames = Object.keys(snapshots);
816
+ const regressions = skillNames.filter(n => snapshots[n].regression_detected);
817
+
818
+ document.getElementById('kpi-skills-monitored').textContent = skillNames.length;
819
+ document.getElementById('kpi-skills-sub').textContent =
820
+ regressions.length ? `${regressions.length} need attention` : 'all stable';
821
+
822
+ if (skillNames.length > 0) {
823
+ const avgPR = skillNames.reduce((sum, n) => sum + snapshots[n].pass_rate, 0) / skillNames.length;
824
+ document.getElementById('kpi-avg-pass-rate').textContent = (avgPR * 100).toFixed(0) + '%';
825
+ const status = getSkillStatus(avgPR, false);
826
+ document.getElementById('kpi-pass-rate-sub').textContent =
827
+ status === 'healthy' ? 'system healthy' : status === 'drifting' ? 'needs monitoring' : 'action required';
828
+ }
829
+
830
+ document.getElementById('kpi-regressions').textContent = regressions.length;
831
+ document.getElementById('kpi-regressions-sub').textContent =
832
+ regressions.length ? regressions.join(', ') : 'none detected';
833
+
834
+ const unmatched = computed.unmatched || [];
835
+ document.getElementById('kpi-unmatched').textContent = unmatched.length;
836
+ document.getElementById('kpi-unmatched-sub').textContent =
837
+ unmatched.length ? 'queries not matched to any skill' : 'all queries matched';
838
+
839
+ document.getElementById('kpi-sessions').textContent = state.telemetry.length;
840
+ if (state.telemetry.length) {
841
+ const sorted = [...state.telemetry].sort((a, b) => new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime());
842
+ const first = formatDate(sorted[0].timestamp);
843
+ const last = formatDate(sorted[sorted.length - 1].timestamp);
844
+ document.getElementById('kpi-sessions-sub').textContent = first + ' \u2014 ' + last;
845
+ }
846
+
847
+ const pending = computed.pendingProposals || [];
848
+ document.getElementById('kpi-pending').textContent = pending.length;
849
+ document.getElementById('kpi-pending-sub').textContent =
850
+ pending.length ? 'awaiting deployment' : 'none pending';
851
+ }
852
+
853
+ // ========================================================================
854
+ // Skill Health Grid
855
+ // ========================================================================
856
+ function updateSkillHealthGrid() {
857
+ const computed = state.computed;
858
+ if (!computed) return;
859
+
860
+ const snapshots = computed.snapshots || {};
861
+ const skillNames = Object.keys(snapshots);
862
+
863
+ if (!skillNames.length) return;
864
+
865
+ // Sort worst-first
866
+ const sorted = skillNames.sort((a, b) => {
867
+ const sa = snapshots[a];
868
+ const sb = snapshots[b];
869
+ if (sa.regression_detected && !sb.regression_detected) return -1;
870
+ if (!sa.regression_detected && sb.regression_detected) return 1;
871
+ return sa.pass_rate - sb.pass_rate;
872
+ });
873
+
874
+ const grid = document.getElementById('skillHealthGrid');
875
+ grid.innerHTML = sorted.map(name => {
876
+ const snap = snapshots[name];
877
+ const status = getSkillStatus(snap.pass_rate, snap.regression_detected);
878
+ const pct = (snap.pass_rate * 100).toFixed(0);
879
+ const missed = state.skills.filter(r => r.skill_name === name && !r.triggered).length;
880
+ const trend = snap.regression_detected ? '\u2193' : (snap.pass_rate >= snap.baseline_pass_rate ? '\u2191' : '\u2192');
881
+ const trendClass = snap.regression_detected ? 'trend-down' : (snap.pass_rate >= snap.baseline_pass_rate ? 'trend-up' : 'trend-flat');
882
+
883
+ const safeName = escapeHtml(name);
884
+ return `<div class="skill-health-row" data-skill="${safeName}" role="button" tabindex="0" aria-label="View details for skill ${safeName}">
885
+ <div class="skill-name" title="${safeName}">${safeName}</div>
886
+ <div class="pass-rate-bar">
887
+ <div class="pass-rate-track">
888
+ <div class="pass-rate-fill ${status}" style="width:${pct}%"></div>
889
+ </div>
890
+ <div class="pass-rate-label">${pct}%</div>
891
+ </div>
892
+ <div class="trend-arrow ${trendClass}">${trend}</div>
893
+ <div class="missed-count">${missed}</div>
894
+ <div>${getStatusBadge(status)}</div>
895
+ </div>`;
896
+ }).join('');
897
+
898
+ // Click + keyboard handlers for drill-down
899
+ grid.querySelectorAll('.skill-health-row').forEach(row => {
900
+ const handler = () => {
901
+ const skillName = row.dataset.skill;
902
+ grid.querySelectorAll('.skill-health-row').forEach(r => r.classList.remove('selected'));
903
+ row.classList.add('selected');
904
+ openDrillDown(skillName);
905
+ };
906
+ row.addEventListener('click', handler);
907
+ row.addEventListener('keydown', e => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); handler(); } });
908
+ });
909
+ }
910
+
911
+ // ========================================================================
912
+ // Drill-down panel
913
+ // ========================================================================
914
+ function openDrillDown(skillName) {
915
+ selectedSkill = skillName;
916
+ const panel = document.getElementById('drillDownPanel');
917
+ panel.classList.add('visible');
918
+ document.getElementById('drillDownTitle').textContent = `Skill: ${skillName}`;
919
+
920
+ // Pass rate over time chart
921
+ updateDrillPassRateChart(skillName);
922
+ updateDrillMissedQueries(skillName);
923
+ updateDrillEvolution(skillName);
924
+ updateDrillSessions(skillName);
925
+ }
926
+
927
+ document.getElementById('drillDownClose').addEventListener('click', () => {
928
+ document.getElementById('drillDownPanel').classList.remove('visible');
929
+ document.getElementById('skillHealthGrid').querySelectorAll('.skill-health-row').forEach(r => r.classList.remove('selected'));
930
+ selectedSkill = null;
931
+ });
932
+
933
+ function updateDrillPassRateChart(skillName) {
934
+ // Group skill records by day and compute daily pass rate
935
+ const records = state.skills.filter(r => r.skill_name === skillName);
936
+ const byDay = {};
937
+ for (const r of records) {
938
+ const day = formatDate(r.timestamp);
939
+ if (!byDay[day]) byDay[day] = { triggered: 0, total: 0 };
940
+ byDay[day].total++;
941
+ if (r.triggered) byDay[day].triggered++;
942
+ }
943
+
944
+ const labels = Object.keys(byDay);
945
+ const data = labels.map(d => ((byDay[d].triggered / byDay[d].total) * 100).toFixed(1));
946
+
947
+ // Deploy events as annotations
948
+ const deployDays = new Set(
949
+ state.evolution
950
+ .filter(e => e.action === 'deployed' && (e.details || '').toLowerCase().includes(skillName.toLowerCase()))
951
+ .map(e => formatDate(e.timestamp))
952
+ );
953
+
954
+ const pointColors = labels.map(d => deployDays.has(d) ? '#d97757' : '#788c5d');
955
+ const pointSizes = labels.map(d => deployDays.has(d) ? 8 : 3);
956
+
957
+ if (charts.drillPassRate) charts.drillPassRate.destroy();
958
+ charts.drillPassRate = new Chart(document.getElementById('chartDrillPassRate'), {
959
+ type: 'line',
960
+ data: {
961
+ labels,
962
+ datasets: [{
963
+ label: 'Pass Rate %',
964
+ data,
965
+ borderColor: '#788c5d',
966
+ backgroundColor: 'rgba(120, 140, 93, 0.1)',
967
+ fill: true,
968
+ tension: 0.3,
969
+ pointRadius: pointSizes,
970
+ pointBackgroundColor: pointColors,
971
+ }]
972
+ },
973
+ options: {
974
+ responsive: true,
975
+ maintainAspectRatio: false,
976
+ plugins: { legend: { display: false } },
977
+ scales: {
978
+ y: { min: 0, max: 100, ticks: { callback: v => v + '%' } },
979
+ x: { grid: { display: false } }
980
+ }
981
+ }
982
+ });
983
+ }
984
+
985
+ function updateDrillMissedQueries(skillName) {
986
+ const missed = state.skills.filter(r => r.skill_name === skillName && !r.triggered);
987
+ const tbody = document.querySelector('#drillMissedTable tbody');
988
+ tbody.innerHTML = missed.slice(0, 50).map(r => `<tr>
989
+ <td class="mono">${escapeHtml(formatTimestamp(r.timestamp))}</td>
990
+ <td class="mono">${escapeHtml((r.session_id || '').slice(0, 8))}</td>
991
+ <td>${escapeHtml(truncate(r.query, 50))}</td>
992
+ </tr>`).join('') || '<tr><td colspan="3" class="empty-state">No missed queries</td></tr>';
993
+ }
994
+
995
+ function updateDrillEvolution(skillName) {
996
+ const needle = skillName.toLowerCase();
997
+ const entries = state.evolution.filter(e => (e.details || '').toLowerCase().includes(needle));
998
+ const container = document.getElementById('drillEvoTimeline');
999
+ const actionBadge = {
1000
+ created: 'badge-blue', validated: 'badge-amber', deployed: 'badge-green',
1001
+ rolled_back: 'badge-red', rejected: 'badge-red',
1002
+ };
1003
+ if (entries.length) {
1004
+ const sorted = [...entries].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
1005
+ container.innerHTML = sorted.map(r => `<div class="timeline-item">
1006
+ <div class="timeline-date">${escapeHtml(formatTimestamp(r.timestamp))}</div>
1007
+ <div><span class="badge ${actionBadge[r.action] || 'badge-blue'}">${escapeHtml(r.action)}</span>
1008
+ <span class="timeline-action" style="margin-left:0.5rem">${escapeHtml(r.proposal_id.slice(0, 8))}</span>
1009
+ </div>
1010
+ <div style="flex:1;color:var(--text-secondary);font-size:0.8125rem;">${escapeHtml(truncate(r.details, 60))}</div>
1011
+ </div>`).join('');
1012
+ } else {
1013
+ container.innerHTML = '<div class="empty-state">No evolution history for this skill</div>';
1014
+ }
1015
+ }
1016
+
1017
+ function updateDrillSessions(skillName) {
1018
+ const sessionIds = new Set(
1019
+ state.skills.filter(r => r.skill_name === skillName).map(r => r.session_id)
1020
+ );
1021
+ const sessions = state.telemetry.filter(r => sessionIds.has(r.session_id));
1022
+ const tbody = document.querySelector('#drillSessionsTable tbody');
1023
+ const sorted = [...sessions].sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp));
1024
+ tbody.innerHTML = sorted.slice(0, 30).map(r => {
1025
+ const skills = (r.skills_triggered || []).join(', ') || '\u2014';
1026
+ const errorBadge = r.errors_encountered > 0
1027
+ ? `<span class="badge badge-red">${r.errors_encountered}</span>`
1028
+ : '<span class="badge badge-green">0</span>';
1029
+ return `<tr>
1030
+ <td class="mono">${escapeHtml(formatTimestamp(r.timestamp))}</td>
1031
+ <td>${r.total_tool_calls || 0}</td>
1032
+ <td>${escapeHtml(truncate(skills, 30))}</td>
1033
+ <td>${errorBadge}</td>
1034
+ </tr>`;
1035
+ }).join('') || '<tr><td colspan="4" class="empty-state">No sessions</td></tr>';
1036
+ }
1037
+
1038
+ // ========================================================================
1039
+ // Unmatched queries section
1040
+ // ========================================================================
1041
+ function updateUnmatched() {
1042
+ const computed = state.computed;
1043
+ if (!computed) return;
1044
+ const unmatched = computed.unmatched || [];
1045
+ if (!unmatched.length) return;
1046
+
1047
+ document.getElementById('unmatchedSection').style.display = 'block';
1048
+ const tbody = document.querySelector('#unmatchedTable tbody');
1049
+ tbody.innerHTML = unmatched.slice(0, 100).map(q => `<tr>
1050
+ <td class="mono">${escapeHtml(formatTimestamp(q.timestamp))}</td>
1051
+ <td class="mono">${escapeHtml((q.session_id || '').slice(0, 8))}</td>
1052
+ <td>${escapeHtml(truncate(q.query, 60))}</td>
1053
+ </tr>`).join('');
1054
+ }
1055
+
1056
+ // ========================================================================
1057
+ // Pending proposals section
1058
+ // ========================================================================
1059
+ function updatePending() {
1060
+ const computed = state.computed;
1061
+ if (!computed) return;
1062
+ const pending = computed.pendingProposals || [];
1063
+ if (!pending.length) return;
1064
+
1065
+ document.getElementById('pendingSection').style.display = 'block';
1066
+ const container = document.getElementById('pendingProposals');
1067
+ const actionBadge = {
1068
+ created: 'badge-blue', validated: 'badge-amber',
1069
+ };
1070
+ container.innerHTML = pending.map(r => `<div class="timeline-item">
1071
+ <div class="timeline-date">${escapeHtml(formatTimestamp(r.timestamp))}</div>
1072
+ <div><span class="badge ${actionBadge[r.action] || 'badge-blue'}">${escapeHtml(r.action)}</span>
1073
+ <span class="timeline-action" style="margin-left:0.5rem">${escapeHtml(r.proposal_id.slice(0, 8))}</span>
1074
+ </div>
1075
+ <div style="flex:1;color:var(--text-secondary);font-size:0.8125rem;">${escapeHtml(truncate(r.details, 80))}</div>
1076
+ </div>`).join('');
1077
+ }
1078
+
1079
+ // ========================================================================
1080
+ // CSV export
1081
+ // ========================================================================
1082
+ document.getElementById('exportCsvBtn').addEventListener('click', () => {
1083
+ if (!state.computed || !state.computed.snapshots) return;
1084
+ const snapshots = state.computed.snapshots;
1085
+ const headers = ['skill_name','pass_rate','regression_detected','baseline_pass_rate','window_sessions','false_negative_rate'];
1086
+ const rows = Object.keys(snapshots).map(name => {
1087
+ const s = snapshots[name];
1088
+ return headers.map(h => {
1089
+ const v = s[h];
1090
+ if (typeof v === 'boolean') return v ? 'true' : 'false';
1091
+ if (typeof v === 'number') return v.toFixed(4);
1092
+ if (typeof v === 'string' && (v.includes(',') || v.includes('"')))
1093
+ return '"' + v.replace(/"/g, '""') + '"';
1094
+ return v ?? '';
1095
+ }).join(',');
1096
+ });
1097
+ const csv = [headers.join(','), ...rows].join('\n');
1098
+ const blob = new Blob([csv], { type: 'text/csv' });
1099
+ const a = document.createElement('a');
1100
+ a.href = URL.createObjectURL(blob);
1101
+ a.download = 'selftune-skill-health.csv';
1102
+ a.click();
1103
+ });
1104
+
1105
+ // ========================================================================
1106
+ // Init: try loading embedded data
1107
+ // ========================================================================
1108
+ if (loadEmbeddedData()) {
1109
+ refreshAll();
1110
+ }
1111
+ </script>
1112
+
1113
+ </body>
1114
+ </html>