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.
- package/.claude/agents/diagnosis-analyst.md +146 -0
- package/.claude/agents/evolution-reviewer.md +167 -0
- package/.claude/agents/integration-guide.md +200 -0
- package/.claude/agents/pattern-analyst.md +147 -0
- package/CHANGELOG.md +37 -0
- package/README.md +96 -256
- package/assets/BeforeAfter.gif +0 -0
- package/assets/FeedbackLoop.gif +0 -0
- package/assets/logo.svg +9 -0
- package/assets/skill-health-badge.svg +20 -0
- package/cli/selftune/activation-rules.ts +171 -0
- package/cli/selftune/badge/badge-data.ts +108 -0
- package/cli/selftune/badge/badge-svg.ts +212 -0
- package/cli/selftune/badge/badge.ts +103 -0
- package/cli/selftune/constants.ts +75 -1
- package/cli/selftune/contribute/bundle.ts +314 -0
- package/cli/selftune/contribute/contribute.ts +214 -0
- package/cli/selftune/contribute/sanitize.ts +162 -0
- package/cli/selftune/cron/setup.ts +266 -0
- package/cli/selftune/dashboard-server.ts +582 -0
- package/cli/selftune/dashboard.ts +25 -3
- package/cli/selftune/eval/baseline.ts +247 -0
- package/cli/selftune/eval/composability.ts +117 -0
- package/cli/selftune/eval/generate-unit-tests.ts +143 -0
- package/cli/selftune/eval/hooks-to-evals.ts +68 -2
- package/cli/selftune/eval/import-skillsbench.ts +221 -0
- package/cli/selftune/eval/synthetic-evals.ts +172 -0
- package/cli/selftune/eval/unit-test-cli.ts +152 -0
- package/cli/selftune/eval/unit-test.ts +196 -0
- package/cli/selftune/evolution/deploy-proposal.ts +142 -1
- package/cli/selftune/evolution/evolve-body.ts +492 -0
- package/cli/selftune/evolution/evolve.ts +466 -103
- package/cli/selftune/evolution/extract-patterns.ts +32 -1
- package/cli/selftune/evolution/pareto.ts +314 -0
- package/cli/selftune/evolution/propose-body.ts +171 -0
- package/cli/selftune/evolution/propose-description.ts +100 -2
- package/cli/selftune/evolution/propose-routing.ts +166 -0
- package/cli/selftune/evolution/refine-body.ts +141 -0
- package/cli/selftune/evolution/rollback.ts +19 -2
- package/cli/selftune/evolution/validate-body.ts +254 -0
- package/cli/selftune/evolution/validate-proposal.ts +257 -35
- package/cli/selftune/evolution/validate-routing.ts +177 -0
- package/cli/selftune/grading/grade-session.ts +138 -18
- package/cli/selftune/grading/pre-gates.ts +104 -0
- package/cli/selftune/hooks/auto-activate.ts +185 -0
- package/cli/selftune/hooks/evolution-guard.ts +165 -0
- package/cli/selftune/hooks/skill-change-guard.ts +112 -0
- package/cli/selftune/index.ts +88 -0
- package/cli/selftune/ingestors/claude-replay.ts +351 -0
- package/cli/selftune/ingestors/openclaw-ingest.ts +440 -0
- package/cli/selftune/init.ts +150 -3
- package/cli/selftune/memory/writer.ts +447 -0
- package/cli/selftune/monitoring/watch.ts +25 -2
- package/cli/selftune/status.ts +17 -13
- package/cli/selftune/types.ts +377 -5
- package/cli/selftune/utils/frontmatter.ts +217 -0
- package/cli/selftune/utils/llm-call.ts +29 -3
- package/cli/selftune/utils/transcript.ts +35 -0
- package/cli/selftune/utils/trigger-check.ts +89 -0
- package/cli/selftune/utils/tui.ts +156 -0
- package/dashboard/index.html +569 -8
- package/package.json +8 -4
- package/skill/SKILL.md +124 -8
- package/skill/Workflows/AutoActivation.md +144 -0
- package/skill/Workflows/Badge.md +118 -0
- package/skill/Workflows/Baseline.md +121 -0
- package/skill/Workflows/Composability.md +100 -0
- package/skill/Workflows/Contribute.md +91 -0
- package/skill/Workflows/Cron.md +155 -0
- package/skill/Workflows/Dashboard.md +203 -0
- package/skill/Workflows/Doctor.md +37 -1
- package/skill/Workflows/Evals.md +69 -1
- package/skill/Workflows/EvolutionMemory.md +152 -0
- package/skill/Workflows/Evolve.md +111 -6
- package/skill/Workflows/EvolveBody.md +159 -0
- package/skill/Workflows/ImportSkillsBench.md +111 -0
- package/skill/Workflows/Ingest.md +117 -3
- package/skill/Workflows/Initialize.md +57 -3
- package/skill/Workflows/Replay.md +70 -0
- package/skill/Workflows/Rollback.md +20 -1
- package/skill/Workflows/UnitTest.md +138 -0
- package/skill/Workflows/Watch.md +22 -0
- package/skill/settings_snippet.json +23 -0
- package/templates/activation-rules-default.json +27 -0
- package/templates/multi-skill-settings.json +64 -0
- 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, "&")
|
|
254
|
+
.replace(/</g, "<")
|
|
255
|
+
.replace(/>/g, ">")
|
|
256
|
+
.replace(/"/g, """);
|
|
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
|
|
130
|
-
selftune dashboard --export
|
|
131
|
-
selftune dashboard --out 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;
|