selftune 0.1.4 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (86) hide show
  1. package/.claude/agents/diagnosis-analyst.md +146 -0
  2. package/.claude/agents/evolution-reviewer.md +167 -0
  3. package/.claude/agents/integration-guide.md +200 -0
  4. package/.claude/agents/pattern-analyst.md +147 -0
  5. package/CHANGELOG.md +37 -0
  6. package/README.md +96 -256
  7. package/assets/BeforeAfter.gif +0 -0
  8. package/assets/FeedbackLoop.gif +0 -0
  9. package/assets/logo.svg +9 -0
  10. package/assets/skill-health-badge.svg +20 -0
  11. package/cli/selftune/activation-rules.ts +171 -0
  12. package/cli/selftune/badge/badge-data.ts +108 -0
  13. package/cli/selftune/badge/badge-svg.ts +212 -0
  14. package/cli/selftune/badge/badge.ts +103 -0
  15. package/cli/selftune/constants.ts +75 -1
  16. package/cli/selftune/contribute/bundle.ts +314 -0
  17. package/cli/selftune/contribute/contribute.ts +214 -0
  18. package/cli/selftune/contribute/sanitize.ts +162 -0
  19. package/cli/selftune/cron/setup.ts +266 -0
  20. package/cli/selftune/dashboard-server.ts +582 -0
  21. package/cli/selftune/dashboard.ts +25 -3
  22. package/cli/selftune/eval/baseline.ts +247 -0
  23. package/cli/selftune/eval/composability.ts +117 -0
  24. package/cli/selftune/eval/generate-unit-tests.ts +143 -0
  25. package/cli/selftune/eval/hooks-to-evals.ts +68 -2
  26. package/cli/selftune/eval/import-skillsbench.ts +221 -0
  27. package/cli/selftune/eval/synthetic-evals.ts +172 -0
  28. package/cli/selftune/eval/unit-test-cli.ts +152 -0
  29. package/cli/selftune/eval/unit-test.ts +196 -0
  30. package/cli/selftune/evolution/deploy-proposal.ts +142 -1
  31. package/cli/selftune/evolution/evolve-body.ts +492 -0
  32. package/cli/selftune/evolution/evolve.ts +466 -103
  33. package/cli/selftune/evolution/extract-patterns.ts +32 -1
  34. package/cli/selftune/evolution/pareto.ts +314 -0
  35. package/cli/selftune/evolution/propose-body.ts +171 -0
  36. package/cli/selftune/evolution/propose-description.ts +100 -2
  37. package/cli/selftune/evolution/propose-routing.ts +166 -0
  38. package/cli/selftune/evolution/refine-body.ts +141 -0
  39. package/cli/selftune/evolution/rollback.ts +19 -2
  40. package/cli/selftune/evolution/validate-body.ts +254 -0
  41. package/cli/selftune/evolution/validate-proposal.ts +257 -35
  42. package/cli/selftune/evolution/validate-routing.ts +177 -0
  43. package/cli/selftune/grading/grade-session.ts +138 -18
  44. package/cli/selftune/grading/pre-gates.ts +104 -0
  45. package/cli/selftune/hooks/auto-activate.ts +185 -0
  46. package/cli/selftune/hooks/evolution-guard.ts +165 -0
  47. package/cli/selftune/hooks/skill-change-guard.ts +112 -0
  48. package/cli/selftune/index.ts +88 -0
  49. package/cli/selftune/ingestors/claude-replay.ts +351 -0
  50. package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
  51. package/cli/selftune/init.ts +150 -3
  52. package/cli/selftune/memory/writer.ts +447 -0
  53. package/cli/selftune/monitoring/watch.ts +25 -2
  54. package/cli/selftune/status.ts +17 -13
  55. package/cli/selftune/types.ts +377 -5
  56. package/cli/selftune/utils/frontmatter.ts +217 -0
  57. package/cli/selftune/utils/llm-call.ts +29 -3
  58. package/cli/selftune/utils/transcript.ts +35 -0
  59. package/cli/selftune/utils/trigger-check.ts +89 -0
  60. package/cli/selftune/utils/tui.ts +156 -0
  61. package/dashboard/index.html +569 -8
  62. package/package.json +8 -4
  63. package/skill/SKILL.md +124 -8
  64. package/skill/Workflows/AutoActivation.md +144 -0
  65. package/skill/Workflows/Badge.md +118 -0
  66. package/skill/Workflows/Baseline.md +121 -0
  67. package/skill/Workflows/Composability.md +100 -0
  68. package/skill/Workflows/Contribute.md +91 -0
  69. package/skill/Workflows/Cron.md +155 -0
  70. package/skill/Workflows/Dashboard.md +203 -0
  71. package/skill/Workflows/Doctor.md +37 -1
  72. package/skill/Workflows/Evals.md +69 -1
  73. package/skill/Workflows/EvolutionMemory.md +152 -0
  74. package/skill/Workflows/Evolve.md +111 -6
  75. package/skill/Workflows/EvolveBody.md +159 -0
  76. package/skill/Workflows/ImportSkillsBench.md +111 -0
  77. package/skill/Workflows/Ingest.md +117 -3
  78. package/skill/Workflows/Initialize.md +57 -3
  79. package/skill/Workflows/Replay.md +70 -0
  80. package/skill/Workflows/Rollback.md +20 -1
  81. package/skill/Workflows/UnitTest.md +138 -0
  82. package/skill/Workflows/Watch.md +22 -0
  83. package/skill/settings_snippet.json +23 -0
  84. package/templates/activation-rules-default.json +27 -0
  85. package/templates/multi-skill-settings.json +64 -0
  86. package/templates/single-skill-settings.json +58 -0
@@ -0,0 +1,582 @@
1
+ /**
2
+ * selftune dashboard server — Live Bun.serve HTTP server with SSE, data API,
3
+ * and action endpoints for the interactive dashboard.
4
+ *
5
+ * Endpoints:
6
+ * GET / — Serve dashboard HTML with embedded data + live mode flag
7
+ * GET /api/data — JSON endpoint returning current telemetry data
8
+ * GET /api/events — SSE stream sending data updates every 5 seconds
9
+ * POST /api/actions/watch — Trigger `selftune watch` for a skill
10
+ * POST /api/actions/evolve — Trigger `selftune evolve` for a skill
11
+ * POST /api/actions/rollback — Trigger `selftune rollback` for a skill
12
+ */
13
+
14
+ import { existsSync, readFileSync } from "node:fs";
15
+ import { dirname, join, resolve } from "node:path";
16
+ import type { BadgeData } from "./badge/badge-data.js";
17
+ import { findSkillBadgeData } from "./badge/badge-data.js";
18
+ import type { BadgeFormat } from "./badge/badge-svg.js";
19
+ import { formatBadgeOutput, renderBadgeSvg } from "./badge/badge-svg.js";
20
+ import { EVOLUTION_AUDIT_LOG, QUERY_LOG, SKILL_LOG, TELEMETRY_LOG } from "./constants.js";
21
+ import { getLastDeployedProposal } from "./evolution/audit.js";
22
+ import { readDecisions } from "./memory/writer.js";
23
+ import { computeMonitoringSnapshot } from "./monitoring/watch.js";
24
+ import { doctor } from "./observability.js";
25
+ import type { StatusResult } from "./status.js";
26
+ import { computeStatus } from "./status.js";
27
+ import type {
28
+ EvolutionAuditEntry,
29
+ QueryLogRecord,
30
+ SessionTelemetryRecord,
31
+ SkillUsageRecord,
32
+ } from "./types.js";
33
+ import { readJsonl } from "./utils/jsonl.js";
34
+
35
+ export interface DashboardServerOptions {
36
+ port?: number;
37
+ host?: string;
38
+ openBrowser?: boolean;
39
+ }
40
+
41
+ interface DashboardData {
42
+ telemetry: SessionTelemetryRecord[];
43
+ skills: SkillUsageRecord[];
44
+ queries: QueryLogRecord[];
45
+ evolution: EvolutionAuditEntry[];
46
+ decisions: import("./types.js").DecisionRecord[];
47
+ computed: {
48
+ snapshots: Record<string, ReturnType<typeof computeMonitoringSnapshot>>;
49
+ unmatched: Array<{ timestamp: string; session_id: string; query: string }>;
50
+ pendingProposals: EvolutionAuditEntry[];
51
+ };
52
+ }
53
+
54
+ function findViewerHTML(): string {
55
+ const candidates = [
56
+ join(dirname(import.meta.dir), "..", "dashboard", "index.html"),
57
+ join(dirname(import.meta.dir), "dashboard", "index.html"),
58
+ resolve("dashboard", "index.html"),
59
+ ];
60
+ for (const c of candidates) {
61
+ if (existsSync(c)) return c;
62
+ }
63
+ throw new Error("Could not find dashboard/index.html. Ensure it exists in the selftune repo.");
64
+ }
65
+
66
+ function collectData(): DashboardData {
67
+ const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
68
+ const skills = readJsonl<SkillUsageRecord>(SKILL_LOG);
69
+ const queries = readJsonl<QueryLogRecord>(QUERY_LOG);
70
+ const evolution = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
71
+ const decisions = readDecisions();
72
+
73
+ // Compute per-skill monitoring snapshots
74
+ const skillNames = [...new Set(skills.map((r) => r.skill_name))];
75
+ const snapshots: Record<string, ReturnType<typeof computeMonitoringSnapshot>> = {};
76
+ for (const name of skillNames) {
77
+ const lastDeployed = getLastDeployedProposal(name);
78
+ const baselinePassRate = lastDeployed?.eval_snapshot?.pass_rate ?? 0.5;
79
+ snapshots[name] = computeMonitoringSnapshot(
80
+ name,
81
+ telemetry,
82
+ skills,
83
+ queries,
84
+ telemetry.length,
85
+ baselinePassRate,
86
+ );
87
+ }
88
+
89
+ // Compute unmatched queries
90
+ const triggeredQueries = new Set(
91
+ skills.filter((r) => r.triggered).map((r) => r.query.toLowerCase().trim()),
92
+ );
93
+ const unmatched = queries
94
+ .filter((q) => !triggeredQueries.has(q.query.toLowerCase().trim()))
95
+ .map((q) => ({
96
+ timestamp: q.timestamp,
97
+ session_id: q.session_id,
98
+ query: q.query,
99
+ }));
100
+
101
+ // Compute pending proposals (reuse already-loaded evolution entries)
102
+ const proposalStatus: Record<string, string[]> = {};
103
+ for (const e of evolution) {
104
+ if (!proposalStatus[e.proposal_id]) proposalStatus[e.proposal_id] = [];
105
+ proposalStatus[e.proposal_id].push(e.action);
106
+ }
107
+ const terminalActions = new Set(["deployed", "rejected", "rolled_back"]);
108
+ const seenProposals = new Set<string>();
109
+ const pendingProposals = evolution.filter((e) => {
110
+ if (e.action !== "created" && e.action !== "validated") return false;
111
+ if (seenProposals.has(e.proposal_id)) return false;
112
+ const actions = proposalStatus[e.proposal_id] || [];
113
+ const isPending = !actions.some((a: string) => terminalActions.has(a));
114
+ if (isPending) seenProposals.add(e.proposal_id);
115
+ return isPending;
116
+ });
117
+
118
+ return {
119
+ telemetry,
120
+ skills,
121
+ queries,
122
+ evolution,
123
+ decisions,
124
+ computed: { snapshots, unmatched, pendingProposals },
125
+ };
126
+ }
127
+
128
+ function computeStatusFromLogs(): StatusResult {
129
+ const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
130
+ const skillRecords = readJsonl<SkillUsageRecord>(SKILL_LOG);
131
+ const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
132
+ const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
133
+ const doctorResult = doctor();
134
+ return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
135
+ }
136
+
137
+ function buildLiveHTML(data: DashboardData): string {
138
+ const template = readFileSync(findViewerHTML(), "utf-8");
139
+
140
+ // Escape </script> sequences to prevent XSS via embedded JSON
141
+ const safeJson = JSON.stringify(data).replace(/<\/script>/gi, "<\\/script>");
142
+ const liveFlag = "<script>window.__SELFTUNE_LIVE__ = true;</script>";
143
+ const dataScript = `<script id="embedded-data" type="application/json">${safeJson}</script>`;
144
+
145
+ return template.replace("</body>", `${liveFlag}\n${dataScript}\n</body>`);
146
+ }
147
+
148
+ function buildReportHTML(
149
+ skillName: string,
150
+ skill: import("./status.js").SkillStatus,
151
+ statusResult: StatusResult,
152
+ ): string {
153
+ const passRateDisplay =
154
+ skill.passRate !== null ? `${Math.round(skill.passRate * 100)}%` : "No data";
155
+ const trendArrows: Record<string, string> = {
156
+ up: "\u2191",
157
+ down: "\u2193",
158
+ stable: "\u2192",
159
+ unknown: "?",
160
+ };
161
+ const trendDisplay = trendArrows[skill.trend] ?? "?";
162
+ const statusColor =
163
+ skill.status === "HEALTHY"
164
+ ? "#4c1"
165
+ : skill.status === "CRITICAL"
166
+ ? "#e05d44"
167
+ : skill.status === "WARNING"
168
+ ? "#dfb317"
169
+ : "#9f9f9f";
170
+
171
+ return `<!DOCTYPE html>
172
+ <html lang="en">
173
+ <head>
174
+ <meta charset="UTF-8">
175
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
176
+ <title>selftune report: ${escapeHtml(skillName)}</title>
177
+ <style>
178
+ body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 720px; margin: 40px auto; padding: 0 20px; color: #333; background: #fafafa; }
179
+ h1 { font-size: 1.5rem; margin-bottom: 8px; }
180
+ .badge { margin: 16px 0; }
181
+ .card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 16px 0; }
182
+ .card h2 { font-size: 1.1rem; margin-top: 0; }
183
+ .stat { display: inline-block; margin-right: 32px; }
184
+ .stat-value { font-size: 2rem; font-weight: bold; }
185
+ .stat-label { font-size: 0.85rem; color: #666; }
186
+ table { width: 100%; border-collapse: collapse; margin: 12px 0; }
187
+ th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }
188
+ th { font-weight: 600; font-size: 0.85rem; color: #666; text-transform: uppercase; }
189
+ a { color: #0366d6; text-decoration: none; }
190
+ a:hover { text-decoration: underline; }
191
+ .status-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; color: #fff; font-size: 0.85rem; font-weight: 600; }
192
+ </style>
193
+ </head>
194
+ <body>
195
+ <a href="/">\u2190 Dashboard</a>
196
+ <h1>Skill Report: ${escapeHtml(skillName)}</h1>
197
+ <div class="badge">
198
+ <img src="/badge/${encodeURIComponent(skillName)}" alt="Skill Health Badge" />
199
+ </div>
200
+
201
+ <div class="card">
202
+ <h2>Health Summary</h2>
203
+ <div class="stat">
204
+ <div class="stat-value">${passRateDisplay}</div>
205
+ <div class="stat-label">Pass Rate</div>
206
+ </div>
207
+ <div class="stat">
208
+ <div class="stat-value">${trendDisplay}</div>
209
+ <div class="stat-label">Trend</div>
210
+ </div>
211
+ <div class="stat">
212
+ <div class="stat-value">${skill.missedQueries}</div>
213
+ <div class="stat-label">Missed Queries</div>
214
+ </div>
215
+ <div class="stat">
216
+ <span class="status-badge" style="background: ${statusColor}">${skill.status}</span>
217
+ </div>
218
+ </div>
219
+
220
+ ${
221
+ skill.snapshot
222
+ ? `
223
+ <div class="card">
224
+ <h2>Monitoring Snapshot</h2>
225
+ <table>
226
+ <tr><th>Metric</th><th>Value</th></tr>
227
+ <tr><td>Window Sessions</td><td>${skill.snapshot.window_sessions}</td></tr>
228
+ <tr><td>Pass Rate</td><td>${(skill.snapshot.pass_rate * 100).toFixed(1)}%</td></tr>
229
+ <tr><td>False Negative Rate</td><td>${(skill.snapshot.false_negative_rate * 100).toFixed(1)}%</td></tr>
230
+ <tr><td>Regression Detected</td><td>${skill.snapshot.regression_detected ? "Yes" : "No"}</td></tr>
231
+ <tr><td>Baseline Pass Rate</td><td>${(skill.snapshot.baseline_pass_rate * 100).toFixed(1)}%</td></tr>
232
+ </table>
233
+ </div>`
234
+ : ""
235
+ }
236
+
237
+ <div class="card">
238
+ <h2>System Overview</h2>
239
+ <table>
240
+ <tr><th>Metric</th><th>Value</th></tr>
241
+ <tr><td>Total Skills</td><td>${statusResult.skills.length}</td></tr>
242
+ <tr><td>Unmatched Queries</td><td>${statusResult.unmatchedQueries}</td></tr>
243
+ <tr><td>Pending Proposals</td><td>${statusResult.pendingProposals}</td></tr>
244
+ <tr><td>Last Session</td><td>${escapeHtml(statusResult.lastSession ?? "\u2014")}</td></tr>
245
+ </table>
246
+ </div>
247
+ </body>
248
+ </html>`;
249
+ }
250
+
251
+ function escapeHtml(text: string): string {
252
+ return text
253
+ .replace(/&/g, "&amp;")
254
+ .replace(/</g, "&lt;")
255
+ .replace(/>/g, "&gt;")
256
+ .replace(/"/g, "&quot;");
257
+ }
258
+
259
+ function corsHeaders(): Record<string, string> {
260
+ return {
261
+ "Access-Control-Allow-Origin": "*",
262
+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
263
+ "Access-Control-Allow-Headers": "Content-Type",
264
+ };
265
+ }
266
+
267
+ async function runAction(
268
+ command: string,
269
+ args: string[],
270
+ ): Promise<{ success: boolean; output: string; error: string | null }> {
271
+ try {
272
+ const indexPath = join(import.meta.dir, "index.ts");
273
+ const proc = Bun.spawn(["bun", "run", indexPath, command, ...args], {
274
+ stdout: "pipe",
275
+ stderr: "pipe",
276
+ });
277
+ const [stdout, stderr] = await Promise.all([
278
+ new Response(proc.stdout).text(),
279
+ new Response(proc.stderr).text(),
280
+ ]);
281
+ const exitCode = await proc.exited;
282
+ if (exitCode !== 0) {
283
+ return { success: false, output: stdout, error: stderr || `Exit code ${exitCode}` };
284
+ }
285
+ return { success: true, output: stdout, error: null };
286
+ } catch (err: unknown) {
287
+ const message = err instanceof Error ? err.message : String(err);
288
+ return { success: false, output: "", error: message };
289
+ }
290
+ }
291
+
292
+ export async function startDashboardServer(
293
+ options?: DashboardServerOptions,
294
+ ): Promise<{ server: ReturnType<typeof Bun.serve>; stop: () => void; port: number }> {
295
+ const port = options?.port ?? 3141;
296
+ const hostname = options?.host ?? "localhost";
297
+ const openBrowser = options?.openBrowser ?? true;
298
+
299
+ const sseClients = new Set<ReadableStreamDefaultController>();
300
+
301
+ const server = Bun.serve({
302
+ port,
303
+ hostname,
304
+ async fetch(req) {
305
+ const url = new URL(req.url);
306
+
307
+ // Handle CORS preflight
308
+ if (req.method === "OPTIONS") {
309
+ return new Response(null, { status: 204, headers: corsHeaders() });
310
+ }
311
+
312
+ // ---- GET / ---- Serve dashboard HTML
313
+ if (url.pathname === "/" && req.method === "GET") {
314
+ const data = collectData();
315
+ const html = buildLiveHTML(data);
316
+ return new Response(html, {
317
+ headers: { "Content-Type": "text/html; charset=utf-8", ...corsHeaders() },
318
+ });
319
+ }
320
+
321
+ // ---- GET /api/data ---- JSON data endpoint
322
+ if (url.pathname === "/api/data" && req.method === "GET") {
323
+ const data = collectData();
324
+ return Response.json(data, { headers: corsHeaders() });
325
+ }
326
+
327
+ // ---- GET /api/events ---- SSE stream
328
+ if (url.pathname === "/api/events" && req.method === "GET") {
329
+ const stream = new ReadableStream({
330
+ start(controller) {
331
+ sseClients.add(controller);
332
+
333
+ // Send initial data immediately
334
+ const data = collectData();
335
+ const payload = `event: data\ndata: ${JSON.stringify(data)}\n\n`;
336
+ controller.enqueue(new TextEncoder().encode(payload));
337
+
338
+ // Set up periodic updates every 5 seconds
339
+ const interval = setInterval(() => {
340
+ try {
341
+ const freshData = collectData();
342
+ const msg = `event: data\ndata: ${JSON.stringify(freshData)}\n\n`;
343
+ controller.enqueue(new TextEncoder().encode(msg));
344
+ } catch {
345
+ clearInterval(interval);
346
+ sseClients.delete(controller);
347
+ }
348
+ }, 5000);
349
+
350
+ // Clean up when client disconnects
351
+ req.signal.addEventListener("abort", () => {
352
+ clearInterval(interval);
353
+ sseClients.delete(controller);
354
+ try {
355
+ controller.close();
356
+ } catch {
357
+ // already closed
358
+ }
359
+ });
360
+ },
361
+ cancel() {
362
+ // Stream cancelled by client
363
+ },
364
+ });
365
+
366
+ return new Response(stream, {
367
+ headers: {
368
+ "Content-Type": "text/event-stream",
369
+ "Cache-Control": "no-cache",
370
+ Connection: "keep-alive",
371
+ ...corsHeaders(),
372
+ },
373
+ });
374
+ }
375
+
376
+ // ---- POST /api/actions/watch ----
377
+ if (url.pathname === "/api/actions/watch" && req.method === "POST") {
378
+ const body = (await req.json()) as { skill?: string; skillPath?: string };
379
+ if (!body.skill || !body.skillPath) {
380
+ return Response.json(
381
+ { success: false, error: "Missing required fields: skill, skillPath" },
382
+ { status: 400, headers: corsHeaders() },
383
+ );
384
+ }
385
+ const args = ["--skill", body.skill, "--skill-path", body.skillPath];
386
+ const result = await runAction("watch", args);
387
+ return Response.json(result, { headers: corsHeaders() });
388
+ }
389
+
390
+ // ---- POST /api/actions/evolve ----
391
+ if (url.pathname === "/api/actions/evolve" && req.method === "POST") {
392
+ const body = (await req.json()) as { skill?: string; skillPath?: string };
393
+ if (!body.skill || !body.skillPath) {
394
+ return Response.json(
395
+ { success: false, error: "Missing required fields: skill, skillPath" },
396
+ { status: 400, headers: corsHeaders() },
397
+ );
398
+ }
399
+ const args = ["--skill", body.skill, "--skill-path", body.skillPath];
400
+ const result = await runAction("evolve", args);
401
+ return Response.json(result, { headers: corsHeaders() });
402
+ }
403
+
404
+ // ---- POST /api/actions/rollback ----
405
+ if (url.pathname === "/api/actions/rollback" && req.method === "POST") {
406
+ const body = (await req.json()) as {
407
+ skill?: string;
408
+ skillPath?: string;
409
+ proposalId?: string;
410
+ };
411
+ if (!body.skill || !body.skillPath || !body.proposalId) {
412
+ return Response.json(
413
+ { success: false, error: "Missing required fields: skill, skillPath, proposalId" },
414
+ { status: 400, headers: corsHeaders() },
415
+ );
416
+ }
417
+ const args = [
418
+ "--skill",
419
+ body.skill,
420
+ "--skill-path",
421
+ body.skillPath,
422
+ "--proposal-id",
423
+ body.proposalId,
424
+ ];
425
+ const result = await runAction("rollback", args);
426
+ return Response.json(result, { headers: corsHeaders() });
427
+ }
428
+
429
+ // ---- GET /badge/:skillName ---- Badge SVG
430
+ if (url.pathname.startsWith("/badge/") && req.method === "GET") {
431
+ const skillName = decodeURIComponent(url.pathname.slice("/badge/".length));
432
+ const formatParam = url.searchParams.get("format");
433
+ const validFormats = new Set(["svg", "markdown", "url"]);
434
+ const format: BadgeFormat =
435
+ formatParam && validFormats.has(formatParam) ? (formatParam as BadgeFormat) : "svg";
436
+
437
+ const statusResult = computeStatusFromLogs();
438
+ const badgeData = findSkillBadgeData(statusResult, skillName);
439
+
440
+ if (!badgeData) {
441
+ // Return a gray "not found" badge (format-aware)
442
+ const notFoundData: BadgeData = {
443
+ label: "Skill Health",
444
+ passRate: null,
445
+ trend: "unknown",
446
+ status: "UNKNOWN",
447
+ color: "#9f9f9f",
448
+ message: "not found",
449
+ };
450
+ if (format === "markdown" || format === "url") {
451
+ const output = formatBadgeOutput(notFoundData, skillName, format);
452
+ return new Response(output, {
453
+ status: 404,
454
+ headers: {
455
+ "Content-Type": "text/plain; charset=utf-8",
456
+ "Cache-Control": "no-cache, no-store",
457
+ ...corsHeaders(),
458
+ },
459
+ });
460
+ }
461
+ const svg = renderBadgeSvg(notFoundData);
462
+ return new Response(svg, {
463
+ status: 404,
464
+ headers: {
465
+ "Content-Type": "image/svg+xml",
466
+ "Cache-Control": "no-cache, no-store",
467
+ ...corsHeaders(),
468
+ },
469
+ });
470
+ }
471
+
472
+ if (format === "markdown" || format === "url") {
473
+ const output = formatBadgeOutput(badgeData, skillName, format);
474
+ return new Response(output, {
475
+ headers: {
476
+ "Content-Type": "text/plain; charset=utf-8",
477
+ "Cache-Control": "no-cache, no-store",
478
+ ...corsHeaders(),
479
+ },
480
+ });
481
+ }
482
+
483
+ const svg = renderBadgeSvg(badgeData);
484
+ return new Response(svg, {
485
+ headers: {
486
+ "Content-Type": "image/svg+xml",
487
+ "Cache-Control": "no-cache, no-store",
488
+ ...corsHeaders(),
489
+ },
490
+ });
491
+ }
492
+
493
+ // ---- GET /report/:skillName ---- Skill health report
494
+ if (url.pathname.startsWith("/report/") && req.method === "GET") {
495
+ const skillName = decodeURIComponent(url.pathname.slice("/report/".length));
496
+ const statusResult = computeStatusFromLogs();
497
+ const skill = statusResult.skills.find((s) => s.name === skillName);
498
+
499
+ if (!skill) {
500
+ return new Response("Skill not found", {
501
+ status: 404,
502
+ headers: { "Content-Type": "text/plain", ...corsHeaders() },
503
+ });
504
+ }
505
+
506
+ const html = buildReportHTML(skillName, skill, statusResult);
507
+ return new Response(html, {
508
+ headers: {
509
+ "Content-Type": "text/html; charset=utf-8",
510
+ "Cache-Control": "no-cache, no-store",
511
+ ...corsHeaders(),
512
+ },
513
+ });
514
+ }
515
+
516
+ // ---- GET /api/evaluations/:skillName ----
517
+ if (url.pathname.startsWith("/api/evaluations/") && req.method === "GET") {
518
+ const skillName = decodeURIComponent(url.pathname.slice("/api/evaluations/".length));
519
+ const skills = readJsonl<SkillUsageRecord>(SKILL_LOG);
520
+ const filtered = skills
521
+ .filter((r) => r.skill_name === skillName)
522
+ .map((r) => ({
523
+ timestamp: r.timestamp,
524
+ session_id: r.session_id,
525
+ query: r.query,
526
+ skill_name: r.skill_name,
527
+ triggered: r.triggered,
528
+ source: r.source ?? null,
529
+ }));
530
+ return Response.json(filtered, { headers: corsHeaders() });
531
+ }
532
+
533
+ // ---- 404 ----
534
+ return new Response("Not Found", { status: 404, headers: corsHeaders() });
535
+ },
536
+ });
537
+
538
+ const boundPort = server.port;
539
+
540
+ if (openBrowser) {
541
+ const url = `http://${hostname}:${boundPort}`;
542
+ console.log(`selftune dashboard server running at ${url}`);
543
+ try {
544
+ const platform = process.platform;
545
+ if (platform === "darwin") {
546
+ Bun.spawn(["open", url]);
547
+ } else if (platform === "linux") {
548
+ Bun.spawn(["xdg-open", url]);
549
+ } else if (platform === "win32") {
550
+ Bun.spawn(["cmd", "/c", "start", "", url]);
551
+ }
552
+ } catch {
553
+ console.log(`Open manually: ${url}`);
554
+ }
555
+ }
556
+
557
+ // Graceful shutdown
558
+ const shutdownHandler = () => {
559
+ for (const client of sseClients) {
560
+ try {
561
+ client.close();
562
+ } catch {
563
+ // already closed
564
+ }
565
+ }
566
+ sseClients.clear();
567
+ server.stop();
568
+ };
569
+
570
+ process.on("SIGINT", shutdownHandler);
571
+ process.on("SIGTERM", shutdownHandler);
572
+
573
+ return {
574
+ server,
575
+ stop: () => {
576
+ process.removeListener("SIGINT", shutdownHandler);
577
+ process.removeListener("SIGTERM", shutdownHandler);
578
+ shutdownHandler();
579
+ },
580
+ port: boundPort,
581
+ };
582
+ }
@@ -5,6 +5,8 @@
5
5
  * selftune dashboard — Open dashboard in default browser
6
6
  * selftune dashboard --export — Export data-embedded HTML to stdout
7
7
  * selftune dashboard --out FILE — Write data-embedded HTML to FILE
8
+ * selftune dashboard --serve — Start live dashboard server (default port 3141)
9
+ * selftune dashboard --serve --port 8080 — Start on custom port
8
10
  */
9
11
 
10
12
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -126,12 +128,32 @@ export async function cliMain(): Promise<void> {
126
128
  console.log(`selftune dashboard — Visual data dashboard
127
129
 
128
130
  Usage:
129
- selftune dashboard Open dashboard in default browser
130
- selftune dashboard --export Export data-embedded HTML to stdout
131
- selftune dashboard --out FILE Write data-embedded HTML to FILE`);
131
+ selftune dashboard Open dashboard in default browser
132
+ selftune dashboard --export Export data-embedded HTML to stdout
133
+ selftune dashboard --out FILE Write data-embedded HTML to FILE
134
+ selftune dashboard --serve Start live dashboard server (port 3141)
135
+ selftune dashboard --serve --port 8080 Start on custom port`);
132
136
  process.exit(0);
133
137
  }
134
138
 
139
+ if (args.includes("--serve")) {
140
+ const portIdx = args.indexOf("--port");
141
+ let port: number | undefined;
142
+ if (portIdx !== -1) {
143
+ const parsed = Number.parseInt(args[portIdx + 1], 10);
144
+ if (!Number.isInteger(parsed) || parsed < 1 || parsed > 65535) {
145
+ console.error(
146
+ `Invalid port "${args[portIdx + 1]}": must be an integer between 1 and 65535.`,
147
+ );
148
+ process.exit(1);
149
+ }
150
+ port = parsed;
151
+ }
152
+ const { startDashboardServer } = await import("./dashboard-server.js");
153
+ await startDashboardServer({ port, openBrowser: true });
154
+ return;
155
+ }
156
+
135
157
  if (args.includes("--export")) {
136
158
  process.stdout.write(buildEmbeddedHTML());
137
159
  return;