selftune 0.1.4 → 0.2.1
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 +156 -0
- package/.claude/agents/evolution-reviewer.md +180 -0
- package/.claude/agents/integration-guide.md +212 -0
- package/.claude/agents/pattern-analyst.md +160 -0
- package/CHANGELOG.md +46 -1
- package/README.md +105 -257
- package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
- package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
- package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
- package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
- package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
- package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
- package/apps/local-dashboard/dist/favicon.png +0 -0
- package/apps/local-dashboard/dist/index.html +17 -0
- package/apps/local-dashboard/dist/logo.png +0 -0
- package/apps/local-dashboard/dist/logo.svg +9 -0
- 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 +99 -0
- package/cli/selftune/canonical-export.ts +183 -0
- package/cli/selftune/constants.ts +103 -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-contract.ts +202 -0
- package/cli/selftune/dashboard-server.ts +1049 -0
- package/cli/selftune/dashboard.ts +43 -156
- package/cli/selftune/eval/baseline.ts +248 -0
- package/cli/selftune/eval/composability-v2.ts +273 -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 +101 -16
- 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/evidence.ts +26 -0
- package/cli/selftune/evolution/evolve-body.ts +586 -0
- package/cli/selftune/evolution/evolve.ts +825 -116
- package/cli/selftune/evolution/extract-patterns.ts +105 -16
- 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 +21 -4
- 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/auto-grade.ts +200 -0
- package/cli/selftune/grading/grade-session.ts +513 -42
- package/cli/selftune/grading/pre-gates.ts +104 -0
- package/cli/selftune/grading/results.ts +42 -0
- package/cli/selftune/hooks/auto-activate.ts +185 -0
- package/cli/selftune/hooks/evolution-guard.ts +165 -0
- package/cli/selftune/hooks/prompt-log.ts +172 -2
- package/cli/selftune/hooks/session-stop.ts +123 -3
- package/cli/selftune/hooks/skill-change-guard.ts +112 -0
- package/cli/selftune/hooks/skill-eval.ts +119 -3
- package/cli/selftune/index.ts +415 -48
- package/cli/selftune/ingestors/claude-replay.ts +377 -0
- package/cli/selftune/ingestors/codex-rollout.ts +345 -46
- package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
- package/cli/selftune/ingestors/openclaw-ingest.ts +573 -0
- package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
- package/cli/selftune/init.ts +376 -16
- package/cli/selftune/last.ts +14 -5
- package/cli/selftune/localdb/db.ts +63 -0
- package/cli/selftune/localdb/materialize.ts +428 -0
- package/cli/selftune/localdb/queries.ts +376 -0
- package/cli/selftune/localdb/schema.ts +204 -0
- package/cli/selftune/memory/writer.ts +447 -0
- package/cli/selftune/monitoring/watch.ts +90 -16
- package/cli/selftune/normalization.ts +682 -0
- package/cli/selftune/observability.ts +19 -44
- package/cli/selftune/orchestrate.ts +1073 -0
- package/cli/selftune/quickstart.ts +203 -0
- package/cli/selftune/repair/skill-usage.ts +576 -0
- package/cli/selftune/schedule.ts +561 -0
- package/cli/selftune/status.ts +59 -33
- package/cli/selftune/sync.ts +627 -0
- package/cli/selftune/types.ts +525 -5
- package/cli/selftune/utils/canonical-log.ts +45 -0
- package/cli/selftune/utils/frontmatter.ts +217 -0
- package/cli/selftune/utils/hooks.ts +41 -0
- package/cli/selftune/utils/html.ts +27 -0
- package/cli/selftune/utils/llm-call.ts +103 -19
- package/cli/selftune/utils/math.ts +10 -0
- package/cli/selftune/utils/query-filter.ts +139 -0
- package/cli/selftune/utils/skill-discovery.ts +340 -0
- package/cli/selftune/utils/skill-log.ts +68 -0
- package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
- package/cli/selftune/utils/transcript.ts +307 -26
- package/cli/selftune/utils/trigger-check.ts +89 -0
- package/cli/selftune/utils/tui.ts +156 -0
- package/cli/selftune/workflows/discover.ts +254 -0
- package/cli/selftune/workflows/skill-md-writer.ts +288 -0
- package/cli/selftune/workflows/workflows.ts +188 -0
- package/package.json +28 -11
- package/packages/telemetry-contract/README.md +11 -0
- package/packages/telemetry-contract/fixtures/golden.json +87 -0
- package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
- package/packages/telemetry-contract/index.ts +1 -0
- package/packages/telemetry-contract/package.json +19 -0
- package/packages/telemetry-contract/src/index.ts +2 -0
- package/packages/telemetry-contract/src/types.ts +163 -0
- package/packages/telemetry-contract/src/validators.ts +109 -0
- package/skill/SKILL.md +180 -33
- package/skill/Workflows/AutoActivation.md +145 -0
- package/skill/Workflows/Badge.md +124 -0
- package/skill/Workflows/Baseline.md +144 -0
- package/skill/Workflows/Composability.md +107 -0
- package/skill/Workflows/Contribute.md +94 -0
- package/skill/Workflows/Cron.md +132 -0
- package/skill/Workflows/Dashboard.md +214 -0
- package/skill/Workflows/Doctor.md +63 -14
- package/skill/Workflows/Evals.md +110 -18
- package/skill/Workflows/EvolutionMemory.md +154 -0
- package/skill/Workflows/Evolve.md +181 -21
- package/skill/Workflows/EvolveBody.md +159 -0
- package/skill/Workflows/Grade.md +36 -31
- package/skill/Workflows/ImportSkillsBench.md +117 -0
- package/skill/Workflows/Ingest.md +142 -21
- package/skill/Workflows/Initialize.md +91 -23
- package/skill/Workflows/Orchestrate.md +139 -0
- package/skill/Workflows/Replay.md +91 -0
- package/skill/Workflows/Rollback.md +23 -4
- package/skill/Workflows/Schedule.md +61 -0
- package/skill/Workflows/Sync.md +88 -0
- package/skill/Workflows/UnitTest.md +150 -0
- package/skill/Workflows/Watch.md +33 -1
- package/skill/Workflows/Workflows.md +129 -0
- package/skill/assets/activation-rules-default.json +26 -0
- package/skill/assets/multi-skill-settings.json +63 -0
- package/skill/assets/single-skill-settings.json +57 -0
- package/skill/references/invocation-taxonomy.md +2 -2
- package/skill/references/logs.md +164 -2
- package/skill/references/setup-patterns.md +65 -0
- package/skill/references/version-history.md +40 -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
- package/dashboard/index.html +0 -1119
|
@@ -0,0 +1,1049 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* selftune dashboard server — Bun.serve HTTP server for the SPA dashboard,
|
|
3
|
+
* skill report HTML, badges, and action endpoints.
|
|
4
|
+
*
|
|
5
|
+
* Endpoints:
|
|
6
|
+
* GET / — Serve dashboard SPA shell
|
|
7
|
+
* GET /api/health — Dashboard server health probe
|
|
8
|
+
* GET /api/v2/doctor — System health diagnostics (config, logs, hooks, evolution)
|
|
9
|
+
* GET /api/v2/overview — SQLite-backed overview payload
|
|
10
|
+
* GET /api/v2/skills/:name — SQLite-backed per-skill report
|
|
11
|
+
* POST /api/actions/watch — Trigger `selftune watch` for a skill
|
|
12
|
+
* POST /api/actions/evolve — Trigger `selftune evolve` for a skill
|
|
13
|
+
* POST /api/actions/rollback — Trigger `selftune rollback` for a skill
|
|
14
|
+
* GET /badge/:name — Skill health badge
|
|
15
|
+
* GET /report/:name — Skill health report HTML
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import type { Database } from "bun:sqlite";
|
|
19
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
20
|
+
import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
21
|
+
import type { BadgeData } from "./badge/badge-data.js";
|
|
22
|
+
import { findSkillBadgeData } from "./badge/badge-data.js";
|
|
23
|
+
import type { BadgeFormat } from "./badge/badge-svg.js";
|
|
24
|
+
import { formatBadgeOutput, renderBadgeSvg } from "./badge/badge-svg.js";
|
|
25
|
+
import { EVOLUTION_AUDIT_LOG, QUERY_LOG, TELEMETRY_LOG } from "./constants.js";
|
|
26
|
+
import type { OverviewResponse, SkillReportResponse } from "./dashboard-contract.js";
|
|
27
|
+
import { readEvidenceTrail } from "./evolution/evidence.js";
|
|
28
|
+
import { openDb } from "./localdb/db.js";
|
|
29
|
+
import { materializeIncremental } from "./localdb/materialize.js";
|
|
30
|
+
import {
|
|
31
|
+
getOrchestrateRuns,
|
|
32
|
+
getOverviewPayload,
|
|
33
|
+
getPendingProposals,
|
|
34
|
+
getSkillReportPayload,
|
|
35
|
+
getSkillsList,
|
|
36
|
+
} from "./localdb/queries.js";
|
|
37
|
+
import { doctor } from "./observability.js";
|
|
38
|
+
import type { StatusResult } from "./status.js";
|
|
39
|
+
import { computeStatus } from "./status.js";
|
|
40
|
+
import type {
|
|
41
|
+
EvolutionAuditEntry,
|
|
42
|
+
EvolutionEvidenceEntry,
|
|
43
|
+
QueryLogRecord,
|
|
44
|
+
SessionTelemetryRecord,
|
|
45
|
+
} from "./types.js";
|
|
46
|
+
import { readJsonl } from "./utils/jsonl.js";
|
|
47
|
+
import { readEffectiveSkillUsageRecords } from "./utils/skill-log.js";
|
|
48
|
+
|
|
49
|
+
export interface DashboardServerOptions {
|
|
50
|
+
port?: number;
|
|
51
|
+
host?: string;
|
|
52
|
+
spaDir?: string;
|
|
53
|
+
openBrowser?: boolean;
|
|
54
|
+
statusLoader?: () => StatusResult;
|
|
55
|
+
evidenceLoader?: () => EvolutionEvidenceEntry[];
|
|
56
|
+
overviewLoader?: () => OverviewResponse;
|
|
57
|
+
skillReportLoader?: (skillName: string) => SkillReportResponse | null;
|
|
58
|
+
actionRunner?: typeof runAction;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read selftune version from package.json once at startup */
|
|
62
|
+
let selftuneVersion = "unknown";
|
|
63
|
+
try {
|
|
64
|
+
const pkgPath = join(import.meta.dir, "..", "..", "package.json");
|
|
65
|
+
selftuneVersion = JSON.parse(readFileSync(pkgPath, "utf-8")).version;
|
|
66
|
+
} catch {
|
|
67
|
+
// fallback already set
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function findSpaDir(): string | null {
|
|
71
|
+
const candidates = [
|
|
72
|
+
join(dirname(import.meta.dir), "..", "apps", "local-dashboard", "dist"),
|
|
73
|
+
join(dirname(import.meta.dir), "apps", "local-dashboard", "dist"),
|
|
74
|
+
resolve("apps", "local-dashboard", "dist"),
|
|
75
|
+
];
|
|
76
|
+
for (const c of candidates) {
|
|
77
|
+
if (existsSync(join(c, "index.html"))) return c;
|
|
78
|
+
}
|
|
79
|
+
return null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function decodePathSegment(segment: string): string | null {
|
|
83
|
+
try {
|
|
84
|
+
return decodeURIComponent(segment);
|
|
85
|
+
} catch {
|
|
86
|
+
return null;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const MIME_TYPES: Record<string, string> = {
|
|
91
|
+
".html": "text/html; charset=utf-8",
|
|
92
|
+
".js": "application/javascript; charset=utf-8",
|
|
93
|
+
".css": "text/css; charset=utf-8",
|
|
94
|
+
".json": "application/json",
|
|
95
|
+
".svg": "image/svg+xml",
|
|
96
|
+
".png": "image/png",
|
|
97
|
+
".woff2": "font/woff2",
|
|
98
|
+
".woff": "font/woff",
|
|
99
|
+
".ttf": "font/ttf",
|
|
100
|
+
".ico": "image/x-icon",
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function computeStatusFromLogs(): StatusResult {
|
|
104
|
+
const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
|
|
105
|
+
const skillRecords = readEffectiveSkillUsageRecords();
|
|
106
|
+
const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
|
|
107
|
+
const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
|
|
108
|
+
const doctorResult = doctor();
|
|
109
|
+
return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
interface MergedEvidenceEntry {
|
|
113
|
+
proposal_id: string;
|
|
114
|
+
target: string;
|
|
115
|
+
rationale: string;
|
|
116
|
+
confidence?: number;
|
|
117
|
+
original_text: string;
|
|
118
|
+
proposed_text: string;
|
|
119
|
+
eval_set: import("./types.js").EvalEntry[];
|
|
120
|
+
validation: import("./types.js").EvolutionEvidenceValidation | null;
|
|
121
|
+
stages: Array<{ stage: string; timestamp: string; details: string }>;
|
|
122
|
+
latest_timestamp: string;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function mergeEvidenceEntries(entries: EvolutionEvidenceEntry[]): MergedEvidenceEntry[] {
|
|
126
|
+
const merged = new Map<string, MergedEvidenceEntry>();
|
|
127
|
+
const sorted = [...entries].sort((a, b) => b.timestamp.localeCompare(a.timestamp));
|
|
128
|
+
|
|
129
|
+
for (const entry of sorted) {
|
|
130
|
+
if (!merged.has(entry.proposal_id)) {
|
|
131
|
+
merged.set(entry.proposal_id, {
|
|
132
|
+
proposal_id: entry.proposal_id,
|
|
133
|
+
target: entry.target,
|
|
134
|
+
rationale: entry.rationale ?? "",
|
|
135
|
+
confidence: entry.confidence,
|
|
136
|
+
original_text: entry.original_text ?? "",
|
|
137
|
+
proposed_text: entry.proposed_text ?? "",
|
|
138
|
+
eval_set: entry.eval_set ?? [],
|
|
139
|
+
validation: entry.validation ?? null,
|
|
140
|
+
stages: [],
|
|
141
|
+
latest_timestamp: entry.timestamp,
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const current = merged.get(entry.proposal_id);
|
|
146
|
+
if (!current) continue;
|
|
147
|
+
current.stages.push({
|
|
148
|
+
stage: entry.stage,
|
|
149
|
+
timestamp: entry.timestamp,
|
|
150
|
+
details: entry.details ?? "",
|
|
151
|
+
});
|
|
152
|
+
if (!current.rationale && entry.rationale) current.rationale = entry.rationale;
|
|
153
|
+
if (current.confidence === undefined && entry.confidence !== undefined) {
|
|
154
|
+
current.confidence = entry.confidence;
|
|
155
|
+
}
|
|
156
|
+
if (!current.original_text && entry.original_text) current.original_text = entry.original_text;
|
|
157
|
+
if (!current.proposed_text && entry.proposed_text) current.proposed_text = entry.proposed_text;
|
|
158
|
+
if (current.eval_set.length === 0 && entry.eval_set) current.eval_set = entry.eval_set;
|
|
159
|
+
if (!current.validation && entry.validation) current.validation = entry.validation;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return [...merged.values()].sort((a, b) => b.latest_timestamp.localeCompare(a.latest_timestamp));
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function buildReportHTML(
|
|
166
|
+
skillName: string,
|
|
167
|
+
skill: import("./status.js").SkillStatus,
|
|
168
|
+
statusResult: StatusResult,
|
|
169
|
+
evidenceEntries: EvolutionEvidenceEntry[],
|
|
170
|
+
): string {
|
|
171
|
+
const mergedEvidence = mergeEvidenceEntries(evidenceEntries);
|
|
172
|
+
const latestValidation = mergedEvidence.find(
|
|
173
|
+
(entry) => entry.validation?.per_entry_results?.length,
|
|
174
|
+
);
|
|
175
|
+
const passRateDisplay =
|
|
176
|
+
skill.passRate !== null ? `${Math.round(skill.passRate * 100)}%` : "No data";
|
|
177
|
+
const trendArrows: Record<string, string> = {
|
|
178
|
+
up: "\u2191",
|
|
179
|
+
down: "\u2193",
|
|
180
|
+
stable: "\u2192",
|
|
181
|
+
unknown: "?",
|
|
182
|
+
};
|
|
183
|
+
const trendDisplay = trendArrows[skill.trend] ?? "?";
|
|
184
|
+
const statusColor =
|
|
185
|
+
skill.status === "HEALTHY"
|
|
186
|
+
? "#4c1"
|
|
187
|
+
: skill.status === "CRITICAL"
|
|
188
|
+
? "#e05d44"
|
|
189
|
+
: skill.status === "WARNING"
|
|
190
|
+
? "#dfb317"
|
|
191
|
+
: "#9f9f9f";
|
|
192
|
+
|
|
193
|
+
return `<!DOCTYPE html>
|
|
194
|
+
<html lang="en">
|
|
195
|
+
<head>
|
|
196
|
+
<meta charset="UTF-8">
|
|
197
|
+
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
|
198
|
+
<title>selftune report: ${escapeHtml(skillName)}</title>
|
|
199
|
+
<style>
|
|
200
|
+
body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; max-width: 1100px; margin: 40px auto; padding: 0 20px; color: #333; background: #fafafa; }
|
|
201
|
+
h1 { font-size: 1.5rem; margin-bottom: 8px; }
|
|
202
|
+
.badge { margin: 16px 0; }
|
|
203
|
+
.card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 16px 0; }
|
|
204
|
+
.card h2 { font-size: 1.1rem; margin-top: 0; }
|
|
205
|
+
.stat { display: inline-block; margin-right: 32px; }
|
|
206
|
+
.stat-value { font-size: 2rem; font-weight: bold; }
|
|
207
|
+
.stat-label { font-size: 0.85rem; color: #666; }
|
|
208
|
+
table { width: 100%; border-collapse: collapse; margin: 12px 0; }
|
|
209
|
+
th, td { text-align: left; padding: 8px 12px; border-bottom: 1px solid #eee; }
|
|
210
|
+
th { font-weight: 600; font-size: 0.85rem; color: #666; text-transform: uppercase; }
|
|
211
|
+
a { color: #0366d6; text-decoration: none; }
|
|
212
|
+
a:hover { text-decoration: underline; }
|
|
213
|
+
.status-badge { display: inline-block; padding: 2px 8px; border-radius: 4px; color: #fff; font-size: 0.85rem; font-weight: 600; }
|
|
214
|
+
.grid { display: grid; gap: 16px; grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
215
|
+
.muted { color: #666; font-size: 0.9rem; }
|
|
216
|
+
.chip { display: inline-flex; align-items: center; padding: 4px 8px; border-radius: 999px; border: 1px solid #e2e8f0; background: #f8fafc; color: #475569; font-size: 0.75rem; margin-right: 6px; margin-bottom: 6px; }
|
|
217
|
+
.artifact { border: 1px solid #e2e8f0; border-radius: 8px; padding: 16px; margin-top: 12px; background: #f8fafc; }
|
|
218
|
+
.artifact pre { white-space: pre-wrap; word-break: break-word; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: 0.8rem; line-height: 1.5; margin: 0; }
|
|
219
|
+
.diff { display: grid; gap: 12px; grid-template-columns: repeat(2, minmax(0, 1fr)); margin-top: 12px; }
|
|
220
|
+
.empty { color: #666; font-size: 0.9rem; }
|
|
221
|
+
@media (max-width: 800px) {
|
|
222
|
+
.grid, .diff { grid-template-columns: 1fr; }
|
|
223
|
+
.stat { display: block; margin-right: 0; margin-bottom: 16px; }
|
|
224
|
+
}
|
|
225
|
+
</style>
|
|
226
|
+
</head>
|
|
227
|
+
<body>
|
|
228
|
+
<a href="/">\u2190 Dashboard</a>
|
|
229
|
+
<h1>Skill Report: ${escapeHtml(skillName)}</h1>
|
|
230
|
+
<div class="badge">
|
|
231
|
+
<img src="/badge/${encodeURIComponent(skillName)}" alt="Skill Health Badge" />
|
|
232
|
+
</div>
|
|
233
|
+
|
|
234
|
+
<div class="card">
|
|
235
|
+
<h2>Health Summary</h2>
|
|
236
|
+
<div class="stat">
|
|
237
|
+
<div class="stat-value">${passRateDisplay}</div>
|
|
238
|
+
<div class="stat-label">Pass Rate</div>
|
|
239
|
+
</div>
|
|
240
|
+
<div class="stat">
|
|
241
|
+
<div class="stat-value">${trendDisplay}</div>
|
|
242
|
+
<div class="stat-label">Trend</div>
|
|
243
|
+
</div>
|
|
244
|
+
<div class="stat">
|
|
245
|
+
<div class="stat-value">${skill.missedQueries}</div>
|
|
246
|
+
<div class="stat-label">Missed Queries</div>
|
|
247
|
+
</div>
|
|
248
|
+
<div class="stat">
|
|
249
|
+
<span class="status-badge" style="background: ${statusColor}">${skill.status}</span>
|
|
250
|
+
</div>
|
|
251
|
+
</div>
|
|
252
|
+
|
|
253
|
+
${
|
|
254
|
+
skill.snapshot
|
|
255
|
+
? `
|
|
256
|
+
<div class="card">
|
|
257
|
+
<h2>Monitoring Snapshot</h2>
|
|
258
|
+
<table>
|
|
259
|
+
<tr><th>Metric</th><th>Value</th></tr>
|
|
260
|
+
<tr><td>Window Sessions</td><td>${skill.snapshot.window_sessions}</td></tr>
|
|
261
|
+
<tr><td>Pass Rate</td><td>${(skill.snapshot.pass_rate * 100).toFixed(1)}%</td></tr>
|
|
262
|
+
<tr><td>False Negative Rate</td><td>${(skill.snapshot.false_negative_rate * 100).toFixed(1)}%</td></tr>
|
|
263
|
+
<tr><td>Regression Detected</td><td>${skill.snapshot.regression_detected ? "Yes" : "No"}</td></tr>
|
|
264
|
+
<tr><td>Baseline Pass Rate</td><td>${(skill.snapshot.baseline_pass_rate * 100).toFixed(1)}%</td></tr>
|
|
265
|
+
</table>
|
|
266
|
+
</div>`
|
|
267
|
+
: ""
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
<div class="card">
|
|
271
|
+
<h2>System Overview</h2>
|
|
272
|
+
<table>
|
|
273
|
+
<tr><th>Metric</th><th>Value</th></tr>
|
|
274
|
+
<tr><td>Total Skills</td><td>${statusResult.skills.length}</td></tr>
|
|
275
|
+
<tr><td>Unmatched Queries</td><td>${statusResult.unmatchedQueries}</td></tr>
|
|
276
|
+
<tr><td>Pending Proposals</td><td>${statusResult.pendingProposals}</td></tr>
|
|
277
|
+
<tr><td>Last Session</td><td>${escapeHtml(statusResult.lastSession ?? "\u2014")}</td></tr>
|
|
278
|
+
</table>
|
|
279
|
+
</div>
|
|
280
|
+
|
|
281
|
+
<div class="card">
|
|
282
|
+
<h2>Description Versions</h2>
|
|
283
|
+
${
|
|
284
|
+
mergedEvidence.length === 0
|
|
285
|
+
? '<p class="empty">No proposal evidence recorded for this skill yet.</p>'
|
|
286
|
+
: mergedEvidence
|
|
287
|
+
.slice(0, 6)
|
|
288
|
+
.map((entry) => {
|
|
289
|
+
const before = entry.validation?.before_pass_rate;
|
|
290
|
+
const after = entry.validation?.after_pass_rate;
|
|
291
|
+
const net = entry.validation?.net_change;
|
|
292
|
+
return `<div class="artifact">
|
|
293
|
+
<div><strong>${escapeHtml(entry.proposal_id)}</strong></div>
|
|
294
|
+
<div class="muted" style="margin-top:6px;">${escapeHtml(
|
|
295
|
+
entry.stages
|
|
296
|
+
.sort((a, b) => b.timestamp.localeCompare(a.timestamp))
|
|
297
|
+
.map(
|
|
298
|
+
(stage) =>
|
|
299
|
+
`${stage.stage} ${new Date(stage.timestamp).toLocaleString("en-US")}`,
|
|
300
|
+
)
|
|
301
|
+
.join(" · "),
|
|
302
|
+
)}</div>
|
|
303
|
+
<div style="margin-top:10px;">
|
|
304
|
+
<span class="chip">${escapeHtml(entry.target)}</span>
|
|
305
|
+
${
|
|
306
|
+
entry.confidence !== undefined
|
|
307
|
+
? `<span class="chip">conf ${entry.confidence.toFixed(2)}</span>`
|
|
308
|
+
: ""
|
|
309
|
+
}
|
|
310
|
+
<span class="chip">before ${before !== undefined ? `${(before * 100).toFixed(1)}%` : "—"}</span>
|
|
311
|
+
<span class="chip">after ${after !== undefined ? `${(after * 100).toFixed(1)}%` : "—"}</span>
|
|
312
|
+
<span class="chip">net ${net !== undefined ? `${net >= 0 ? "+" : ""}${(net * 100).toFixed(1)}pp` : "—"}</span>
|
|
313
|
+
</div>
|
|
314
|
+
<p class="muted" style="margin-top:10px;">${escapeHtml(entry.rationale || "No rationale recorded")}</p>
|
|
315
|
+
<div class="diff">
|
|
316
|
+
<div>
|
|
317
|
+
<h3 style="font-size:0.8rem;text-transform:uppercase;color:#666;">Original</h3>
|
|
318
|
+
<pre>${escapeHtml(entry.original_text || "No original text recorded")}</pre>
|
|
319
|
+
</div>
|
|
320
|
+
<div>
|
|
321
|
+
<h3 style="font-size:0.8rem;text-transform:uppercase;color:#666;">Proposed</h3>
|
|
322
|
+
<pre>${escapeHtml(entry.proposed_text || "No proposed text recorded")}</pre>
|
|
323
|
+
</div>
|
|
324
|
+
</div>
|
|
325
|
+
</div>`;
|
|
326
|
+
})
|
|
327
|
+
.join("")
|
|
328
|
+
}
|
|
329
|
+
</div>
|
|
330
|
+
|
|
331
|
+
<div class="card">
|
|
332
|
+
<h2>Validation Evidence</h2>
|
|
333
|
+
${
|
|
334
|
+
latestValidation?.validation?.per_entry_results?.length
|
|
335
|
+
? `<p class="muted">Latest proposal with per-entry validation: ${escapeHtml(latestValidation.proposal_id)}</p>
|
|
336
|
+
<table>
|
|
337
|
+
<tr><th>Query</th><th>Expected</th><th>Before</th><th>After</th><th>Delta</th></tr>
|
|
338
|
+
${latestValidation.validation.per_entry_results
|
|
339
|
+
.slice(0, 100)
|
|
340
|
+
.map((result) => {
|
|
341
|
+
const delta =
|
|
342
|
+
result.before_pass === result.after_pass
|
|
343
|
+
? "Unchanged"
|
|
344
|
+
: result.after_pass
|
|
345
|
+
? "New pass"
|
|
346
|
+
: "Regression";
|
|
347
|
+
return `<tr>
|
|
348
|
+
<td>${escapeHtml(result.entry.query)}</td>
|
|
349
|
+
<td>${result.entry.should_trigger ? "Yes" : "No"}</td>
|
|
350
|
+
<td>${result.before_pass ? "Yes" : "No"}</td>
|
|
351
|
+
<td>${result.after_pass ? "Yes" : "No"}</td>
|
|
352
|
+
<td>${delta}</td>
|
|
353
|
+
</tr>`;
|
|
354
|
+
})
|
|
355
|
+
.join("")}
|
|
356
|
+
</table>`
|
|
357
|
+
: '<p class="empty">No per-entry validation evidence recorded for this skill yet.</p>'
|
|
358
|
+
}
|
|
359
|
+
</div>
|
|
360
|
+
</body>
|
|
361
|
+
</html>`;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
function escapeHtml(text: string): string {
|
|
365
|
+
return text
|
|
366
|
+
.replace(/&/g, "&")
|
|
367
|
+
.replace(/</g, "<")
|
|
368
|
+
.replace(/>/g, ">")
|
|
369
|
+
.replace(/"/g, """);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
function safeParseJson(json: string | null): Record<string, unknown> | null {
|
|
373
|
+
if (!json) return null;
|
|
374
|
+
try {
|
|
375
|
+
return JSON.parse(json);
|
|
376
|
+
} catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function corsHeaders(): Record<string, string> {
|
|
382
|
+
return {
|
|
383
|
+
"Access-Control-Allow-Origin": "*",
|
|
384
|
+
"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
|
|
385
|
+
"Access-Control-Allow-Headers": "Content-Type",
|
|
386
|
+
};
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
async function runAction(
|
|
390
|
+
command: string,
|
|
391
|
+
args: string[],
|
|
392
|
+
): Promise<{ success: boolean; output: string; error: string | null }> {
|
|
393
|
+
try {
|
|
394
|
+
const indexPath = join(import.meta.dir, "index.ts");
|
|
395
|
+
const proc = Bun.spawn(["bun", "run", indexPath, command, ...args], {
|
|
396
|
+
stdout: "pipe",
|
|
397
|
+
stderr: "pipe",
|
|
398
|
+
});
|
|
399
|
+
const [stdout, stderr] = await Promise.all([
|
|
400
|
+
new Response(proc.stdout).text(),
|
|
401
|
+
new Response(proc.stderr).text(),
|
|
402
|
+
]);
|
|
403
|
+
const exitCode = await proc.exited;
|
|
404
|
+
if (exitCode !== 0) {
|
|
405
|
+
return { success: false, output: stdout, error: stderr || `Exit code ${exitCode}` };
|
|
406
|
+
}
|
|
407
|
+
return { success: true, output: stdout, error: null };
|
|
408
|
+
} catch (err: unknown) {
|
|
409
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
410
|
+
return { success: false, output: "", error: message };
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export async function startDashboardServer(
|
|
415
|
+
options?: DashboardServerOptions,
|
|
416
|
+
): Promise<{ server: ReturnType<typeof Bun.serve>; stop: () => void; port: number }> {
|
|
417
|
+
const port = options?.port ?? 3141;
|
|
418
|
+
const hostname = options?.host ?? "localhost";
|
|
419
|
+
const openBrowser = options?.openBrowser ?? true;
|
|
420
|
+
const getStatusResult = options?.statusLoader ?? computeStatusFromLogs;
|
|
421
|
+
const getEvidenceEntries = options?.evidenceLoader ?? readEvidenceTrail;
|
|
422
|
+
const getOverviewResponse = options?.overviewLoader;
|
|
423
|
+
const getSkillReportResponse = options?.skillReportLoader;
|
|
424
|
+
const executeAction = options?.actionRunner ?? runAction;
|
|
425
|
+
|
|
426
|
+
// -- SPA serving -------------------------------------------------------------
|
|
427
|
+
const requestedSpaDir = options?.spaDir ?? findSpaDir();
|
|
428
|
+
const spaDir =
|
|
429
|
+
requestedSpaDir && existsSync(join(requestedSpaDir, "index.html")) ? requestedSpaDir : null;
|
|
430
|
+
if (spaDir) {
|
|
431
|
+
console.log(`SPA found at ${spaDir}, serving as default dashboard`);
|
|
432
|
+
} else {
|
|
433
|
+
if (options?.spaDir) {
|
|
434
|
+
console.warn(`Configured spaDir is missing index.html: ${options.spaDir}`);
|
|
435
|
+
}
|
|
436
|
+
console.warn(
|
|
437
|
+
"SPA build not found. Run `bun run build:dashboard` before using `selftune dashboard`.",
|
|
438
|
+
);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// -- SQLite v2 data layer ---------------------------------------------------
|
|
442
|
+
let db: Database | null = null;
|
|
443
|
+
let lastV2MaterializedAt = 0;
|
|
444
|
+
let lastV2RefreshAttemptAt = 0;
|
|
445
|
+
const needsDb = !getOverviewResponse || !getSkillReportResponse;
|
|
446
|
+
if (needsDb) {
|
|
447
|
+
try {
|
|
448
|
+
db = openDb();
|
|
449
|
+
materializeIncremental(db);
|
|
450
|
+
lastV2MaterializedAt = Date.now();
|
|
451
|
+
} catch (error: unknown) {
|
|
452
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
453
|
+
console.error(`V2 dashboard data unavailable: ${message}`);
|
|
454
|
+
// Continue serving; refreshV2Data will retry on demand.
|
|
455
|
+
}
|
|
456
|
+
}
|
|
457
|
+
const V2_MATERIALIZE_TTL_MS = 15_000;
|
|
458
|
+
|
|
459
|
+
function refreshV2Data(): void {
|
|
460
|
+
if (!db) return;
|
|
461
|
+
const now = Date.now();
|
|
462
|
+
if (now - Math.max(lastV2MaterializedAt, lastV2RefreshAttemptAt) < V2_MATERIALIZE_TTL_MS) {
|
|
463
|
+
return;
|
|
464
|
+
}
|
|
465
|
+
lastV2RefreshAttemptAt = now;
|
|
466
|
+
try {
|
|
467
|
+
materializeIncremental(db);
|
|
468
|
+
lastV2MaterializedAt = now;
|
|
469
|
+
} catch (error: unknown) {
|
|
470
|
+
console.error("Failed to refresh v2 dashboard data", error);
|
|
471
|
+
// Keep serving the last successful materialization.
|
|
472
|
+
}
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
let cachedStatusResult: StatusResult | null = null;
|
|
476
|
+
let lastStatusCacheRefreshAt = 0;
|
|
477
|
+
let statusRefreshPromise: Promise<void> | null = null;
|
|
478
|
+
|
|
479
|
+
const STATUS_CACHE_TTL_MS = 30_000;
|
|
480
|
+
|
|
481
|
+
async function refreshStatusCache(force = false): Promise<void> {
|
|
482
|
+
const cacheIsFresh =
|
|
483
|
+
cachedStatusResult !== null && Date.now() - lastStatusCacheRefreshAt < STATUS_CACHE_TTL_MS;
|
|
484
|
+
if (!force && cacheIsFresh) return;
|
|
485
|
+
if (statusRefreshPromise) return statusRefreshPromise;
|
|
486
|
+
|
|
487
|
+
statusRefreshPromise = (async () => {
|
|
488
|
+
cachedStatusResult = getStatusResult();
|
|
489
|
+
lastStatusCacheRefreshAt = Date.now();
|
|
490
|
+
})();
|
|
491
|
+
|
|
492
|
+
try {
|
|
493
|
+
await statusRefreshPromise;
|
|
494
|
+
} finally {
|
|
495
|
+
statusRefreshPromise = null;
|
|
496
|
+
}
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
async function getCachedStatusResult(): Promise<StatusResult> {
|
|
500
|
+
if (!cachedStatusResult) {
|
|
501
|
+
await refreshStatusCache(true);
|
|
502
|
+
} else {
|
|
503
|
+
void refreshStatusCache(false);
|
|
504
|
+
}
|
|
505
|
+
return cachedStatusResult as StatusResult;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
const server = Bun.serve({
|
|
509
|
+
port,
|
|
510
|
+
hostname,
|
|
511
|
+
async fetch(req) {
|
|
512
|
+
const url = new URL(req.url);
|
|
513
|
+
|
|
514
|
+
// Handle CORS preflight
|
|
515
|
+
if (req.method === "OPTIONS") {
|
|
516
|
+
return new Response(null, { status: 204, headers: corsHeaders() });
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
if (url.pathname === "/api/health" && req.method === "GET") {
|
|
520
|
+
return Response.json(
|
|
521
|
+
{
|
|
522
|
+
ok: true,
|
|
523
|
+
service: "selftune-dashboard",
|
|
524
|
+
version: selftuneVersion,
|
|
525
|
+
spa: Boolean(spaDir),
|
|
526
|
+
v2_data_available: Boolean(getOverviewResponse || db),
|
|
527
|
+
},
|
|
528
|
+
{ headers: corsHeaders() },
|
|
529
|
+
);
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// ---- GET /api/v2/doctor ---- System health diagnostics
|
|
533
|
+
if (url.pathname === "/api/v2/doctor" && req.method === "GET") {
|
|
534
|
+
const result = doctor();
|
|
535
|
+
return Response.json(result, { headers: corsHeaders() });
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// ---- SPA static assets ---- Serve from dist/assets/
|
|
539
|
+
if (spaDir && req.method === "GET" && url.pathname.startsWith("/assets/")) {
|
|
540
|
+
const filePath = resolve(spaDir, `.${url.pathname}`);
|
|
541
|
+
const rel = relative(spaDir, filePath);
|
|
542
|
+
if (rel.startsWith("..") || isAbsolute(rel)) {
|
|
543
|
+
return new Response("Not Found", { status: 404, headers: corsHeaders() });
|
|
544
|
+
}
|
|
545
|
+
const bunFile = Bun.file(filePath);
|
|
546
|
+
if (await bunFile.exists()) {
|
|
547
|
+
const ext = extname(filePath);
|
|
548
|
+
const contentType = MIME_TYPES[ext] ?? "application/octet-stream";
|
|
549
|
+
return new Response(bunFile, {
|
|
550
|
+
headers: {
|
|
551
|
+
"Content-Type": contentType,
|
|
552
|
+
"Cache-Control": ext === ".html" ? "no-cache" : "public, max-age=31536000, immutable",
|
|
553
|
+
...corsHeaders(),
|
|
554
|
+
},
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
return new Response("Not Found", { status: 404, headers: corsHeaders() });
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
// ---- GET / ---- Serve SPA shell
|
|
561
|
+
if (url.pathname === "/" && req.method === "GET") {
|
|
562
|
+
if (spaDir) {
|
|
563
|
+
const html = await Bun.file(join(spaDir, "index.html")).text();
|
|
564
|
+
return new Response(html, {
|
|
565
|
+
headers: { "Content-Type": "text/html; charset=utf-8", ...corsHeaders() },
|
|
566
|
+
});
|
|
567
|
+
}
|
|
568
|
+
return new Response("Dashboard build not found. Run `bun run build:dashboard` first.", {
|
|
569
|
+
status: 503,
|
|
570
|
+
headers: { "Content-Type": "text/plain; charset=utf-8", ...corsHeaders() },
|
|
571
|
+
});
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---- POST /api/actions/watch ----
|
|
575
|
+
if (url.pathname === "/api/actions/watch" && req.method === "POST") {
|
|
576
|
+
const body = (await req.json()) as { skill?: string; skillPath?: string };
|
|
577
|
+
if (!body.skill || !body.skillPath) {
|
|
578
|
+
return Response.json(
|
|
579
|
+
{ success: false, error: "Missing required fields: skill, skillPath" },
|
|
580
|
+
{ status: 400, headers: corsHeaders() },
|
|
581
|
+
);
|
|
582
|
+
}
|
|
583
|
+
const args = ["--skill", body.skill, "--skill-path", body.skillPath, "--sync-first"];
|
|
584
|
+
const result = await executeAction("watch", args);
|
|
585
|
+
return Response.json(result, { headers: corsHeaders() });
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
// ---- POST /api/actions/evolve ----
|
|
589
|
+
if (url.pathname === "/api/actions/evolve" && req.method === "POST") {
|
|
590
|
+
const body = (await req.json()) as { skill?: string; skillPath?: string };
|
|
591
|
+
if (!body.skill || !body.skillPath) {
|
|
592
|
+
return Response.json(
|
|
593
|
+
{ success: false, error: "Missing required fields: skill, skillPath" },
|
|
594
|
+
{ status: 400, headers: corsHeaders() },
|
|
595
|
+
);
|
|
596
|
+
}
|
|
597
|
+
const args = ["--skill", body.skill, "--skill-path", body.skillPath, "--sync-first"];
|
|
598
|
+
const result = await executeAction("evolve", args);
|
|
599
|
+
return Response.json(result, { headers: corsHeaders() });
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// ---- POST /api/actions/rollback ----
|
|
603
|
+
if (url.pathname === "/api/actions/rollback" && req.method === "POST") {
|
|
604
|
+
const body = (await req.json()) as {
|
|
605
|
+
skill?: string;
|
|
606
|
+
skillPath?: string;
|
|
607
|
+
proposalId?: string;
|
|
608
|
+
};
|
|
609
|
+
if (!body.skill || !body.skillPath || !body.proposalId) {
|
|
610
|
+
return Response.json(
|
|
611
|
+
{ success: false, error: "Missing required fields: skill, skillPath, proposalId" },
|
|
612
|
+
{ status: 400, headers: corsHeaders() },
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
const args = [
|
|
616
|
+
"--skill",
|
|
617
|
+
body.skill,
|
|
618
|
+
"--skill-path",
|
|
619
|
+
body.skillPath,
|
|
620
|
+
"--proposal-id",
|
|
621
|
+
body.proposalId,
|
|
622
|
+
];
|
|
623
|
+
const result = await executeAction("rollback", args);
|
|
624
|
+
return Response.json(result, { headers: corsHeaders() });
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// ---- GET /badge/:skillName ---- Badge SVG
|
|
628
|
+
if (url.pathname.startsWith("/badge/") && req.method === "GET") {
|
|
629
|
+
const skillName = decodePathSegment(url.pathname.slice("/badge/".length));
|
|
630
|
+
if (skillName === null) {
|
|
631
|
+
return Response.json(
|
|
632
|
+
{ error: "Malformed skill name" },
|
|
633
|
+
{ status: 400, headers: corsHeaders() },
|
|
634
|
+
);
|
|
635
|
+
}
|
|
636
|
+
const formatParam = url.searchParams.get("format");
|
|
637
|
+
const validFormats = new Set(["svg", "markdown", "url"]);
|
|
638
|
+
const format: BadgeFormat =
|
|
639
|
+
formatParam && validFormats.has(formatParam) ? (formatParam as BadgeFormat) : "svg";
|
|
640
|
+
|
|
641
|
+
const statusResult = await getCachedStatusResult();
|
|
642
|
+
const badgeData = findSkillBadgeData(statusResult, skillName);
|
|
643
|
+
|
|
644
|
+
if (!badgeData) {
|
|
645
|
+
// Return a gray "not found" badge (format-aware)
|
|
646
|
+
const notFoundData: BadgeData = {
|
|
647
|
+
label: "Skill Health",
|
|
648
|
+
passRate: null,
|
|
649
|
+
trend: "unknown",
|
|
650
|
+
status: "UNKNOWN",
|
|
651
|
+
color: "#9f9f9f",
|
|
652
|
+
message: "not found",
|
|
653
|
+
};
|
|
654
|
+
if (format === "markdown" || format === "url") {
|
|
655
|
+
const output = formatBadgeOutput(notFoundData, skillName, format);
|
|
656
|
+
return new Response(output, {
|
|
657
|
+
status: 404,
|
|
658
|
+
headers: {
|
|
659
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
660
|
+
"Cache-Control": "no-cache, no-store",
|
|
661
|
+
...corsHeaders(),
|
|
662
|
+
},
|
|
663
|
+
});
|
|
664
|
+
}
|
|
665
|
+
const svg = renderBadgeSvg(notFoundData);
|
|
666
|
+
return new Response(svg, {
|
|
667
|
+
status: 404,
|
|
668
|
+
headers: {
|
|
669
|
+
"Content-Type": "image/svg+xml",
|
|
670
|
+
"Cache-Control": "no-cache, no-store",
|
|
671
|
+
...corsHeaders(),
|
|
672
|
+
},
|
|
673
|
+
});
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
if (format === "markdown" || format === "url") {
|
|
677
|
+
const output = formatBadgeOutput(badgeData, skillName, format);
|
|
678
|
+
return new Response(output, {
|
|
679
|
+
headers: {
|
|
680
|
+
"Content-Type": "text/plain; charset=utf-8",
|
|
681
|
+
"Cache-Control": "no-cache, no-store",
|
|
682
|
+
...corsHeaders(),
|
|
683
|
+
},
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
const svg = renderBadgeSvg(badgeData);
|
|
688
|
+
return new Response(svg, {
|
|
689
|
+
headers: {
|
|
690
|
+
"Content-Type": "image/svg+xml",
|
|
691
|
+
"Cache-Control": "no-cache, no-store",
|
|
692
|
+
...corsHeaders(),
|
|
693
|
+
},
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
// ---- GET /report/:skillName ---- Skill health report
|
|
698
|
+
if (url.pathname.startsWith("/report/") && req.method === "GET") {
|
|
699
|
+
const skillName = decodePathSegment(url.pathname.slice("/report/".length));
|
|
700
|
+
if (skillName === null) {
|
|
701
|
+
return Response.json(
|
|
702
|
+
{ error: "Malformed skill name" },
|
|
703
|
+
{ status: 400, headers: corsHeaders() },
|
|
704
|
+
);
|
|
705
|
+
}
|
|
706
|
+
const statusResult = await getCachedStatusResult();
|
|
707
|
+
const skill = statusResult.skills.find((s) => s.name === skillName);
|
|
708
|
+
const evidenceEntries = getEvidenceEntries().filter(
|
|
709
|
+
(entry) => entry.skill_name === skillName,
|
|
710
|
+
);
|
|
711
|
+
|
|
712
|
+
if (!skill) {
|
|
713
|
+
return new Response("Skill not found", {
|
|
714
|
+
status: 404,
|
|
715
|
+
headers: { "Content-Type": "text/plain", ...corsHeaders() },
|
|
716
|
+
});
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
const html = buildReportHTML(skillName, skill, statusResult, evidenceEntries);
|
|
720
|
+
return new Response(html, {
|
|
721
|
+
headers: {
|
|
722
|
+
"Content-Type": "text/html; charset=utf-8",
|
|
723
|
+
"Cache-Control": "no-cache, no-store",
|
|
724
|
+
...corsHeaders(),
|
|
725
|
+
},
|
|
726
|
+
});
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
// ---- GET /api/v2/overview ---- SQLite-backed overview
|
|
730
|
+
if (url.pathname === "/api/v2/overview" && req.method === "GET") {
|
|
731
|
+
if (getOverviewResponse) {
|
|
732
|
+
return Response.json(getOverviewResponse(), { headers: corsHeaders() });
|
|
733
|
+
}
|
|
734
|
+
if (!db) {
|
|
735
|
+
return Response.json(
|
|
736
|
+
{ error: "V2 data unavailable" },
|
|
737
|
+
{ status: 503, headers: corsHeaders() },
|
|
738
|
+
);
|
|
739
|
+
}
|
|
740
|
+
refreshV2Data();
|
|
741
|
+
const overview = getOverviewPayload(db);
|
|
742
|
+
const skills = getSkillsList(db);
|
|
743
|
+
return Response.json(
|
|
744
|
+
{ overview, skills, version: selftuneVersion },
|
|
745
|
+
{ headers: corsHeaders() },
|
|
746
|
+
);
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
// ---- GET /api/v2/orchestrate-runs ---- Recent orchestrate run reports
|
|
750
|
+
if (url.pathname === "/api/v2/orchestrate-runs" && req.method === "GET") {
|
|
751
|
+
if (!db) {
|
|
752
|
+
return Response.json(
|
|
753
|
+
{ error: "V2 data unavailable" },
|
|
754
|
+
{ status: 503, headers: corsHeaders() },
|
|
755
|
+
);
|
|
756
|
+
}
|
|
757
|
+
refreshV2Data();
|
|
758
|
+
const limitParam = url.searchParams.get("limit");
|
|
759
|
+
const parsedLimit = limitParam === null ? null : Number.parseInt(limitParam, 10);
|
|
760
|
+
if (parsedLimit !== null && Number.isNaN(parsedLimit)) {
|
|
761
|
+
return Response.json({ error: "Invalid limit" }, { status: 400, headers: corsHeaders() });
|
|
762
|
+
}
|
|
763
|
+
const limit = parsedLimit === null ? 20 : Math.min(Math.max(parsedLimit, 1), 100);
|
|
764
|
+
const runs = getOrchestrateRuns(db, limit);
|
|
765
|
+
return Response.json({ runs }, { headers: corsHeaders() });
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
// ---- GET /api/v2/skills/:name ---- SQLite-backed skill report
|
|
769
|
+
if (url.pathname.startsWith("/api/v2/skills/") && req.method === "GET") {
|
|
770
|
+
const skillName = decodePathSegment(url.pathname.slice("/api/v2/skills/".length));
|
|
771
|
+
if (skillName === null) {
|
|
772
|
+
return Response.json(
|
|
773
|
+
{ error: "Malformed skill name" },
|
|
774
|
+
{ status: 400, headers: corsHeaders() },
|
|
775
|
+
);
|
|
776
|
+
}
|
|
777
|
+
if (getSkillReportResponse) {
|
|
778
|
+
const report = getSkillReportResponse(skillName);
|
|
779
|
+
if (!report) {
|
|
780
|
+
return Response.json(
|
|
781
|
+
{ error: "Skill not found" },
|
|
782
|
+
{ status: 404, headers: corsHeaders() },
|
|
783
|
+
);
|
|
784
|
+
}
|
|
785
|
+
return Response.json(report, { headers: corsHeaders() });
|
|
786
|
+
}
|
|
787
|
+
if (!db) {
|
|
788
|
+
return Response.json(
|
|
789
|
+
{ error: "V2 data unavailable" },
|
|
790
|
+
{ status: 503, headers: corsHeaders() },
|
|
791
|
+
);
|
|
792
|
+
}
|
|
793
|
+
refreshV2Data();
|
|
794
|
+
const report = getSkillReportPayload(db, skillName);
|
|
795
|
+
|
|
796
|
+
// 1. Evolution audit with eval_snapshot
|
|
797
|
+
const evolution = db
|
|
798
|
+
.query(
|
|
799
|
+
`SELECT timestamp, proposal_id, action, details, eval_snapshot_json
|
|
800
|
+
FROM evolution_audit
|
|
801
|
+
WHERE skill_name = ?
|
|
802
|
+
ORDER BY timestamp DESC
|
|
803
|
+
LIMIT 100`,
|
|
804
|
+
)
|
|
805
|
+
.all(skillName) as Array<{
|
|
806
|
+
timestamp: string;
|
|
807
|
+
proposal_id: string;
|
|
808
|
+
action: string;
|
|
809
|
+
details: string;
|
|
810
|
+
eval_snapshot_json: string | null;
|
|
811
|
+
}>;
|
|
812
|
+
const evolutionWithSnapshot = evolution.map((e) => ({
|
|
813
|
+
...e,
|
|
814
|
+
eval_snapshot: e.eval_snapshot_json ? safeParseJson(e.eval_snapshot_json) : null,
|
|
815
|
+
eval_snapshot_json: undefined,
|
|
816
|
+
}));
|
|
817
|
+
|
|
818
|
+
// 2. Pending proposals (shared helper from queries.ts)
|
|
819
|
+
const pending_proposals = getPendingProposals(db, skillName);
|
|
820
|
+
|
|
821
|
+
// CTE subquery for session IDs — avoids expanding bind parameters
|
|
822
|
+
const skillSessionsCte = `
|
|
823
|
+
WITH skill_sessions AS (
|
|
824
|
+
SELECT DISTINCT session_id FROM skill_usage WHERE skill_name = ?
|
|
825
|
+
)`;
|
|
826
|
+
|
|
827
|
+
// 3. Selftune resource usage from orchestrate runs that touched this skill
|
|
828
|
+
const orchestrateRows = db
|
|
829
|
+
.query(
|
|
830
|
+
`SELECT skill_actions_json FROM orchestrate_runs
|
|
831
|
+
WHERE skill_actions_json LIKE ? ESCAPE '\\'`,
|
|
832
|
+
)
|
|
833
|
+
.all(
|
|
834
|
+
`%${skillName.replace(/\\/g, "\\\\").replace(/%/g, "\\%").replace(/_/g, "\\_")}%`,
|
|
835
|
+
) as Array<{
|
|
836
|
+
skill_actions_json: string;
|
|
837
|
+
}>;
|
|
838
|
+
|
|
839
|
+
let totalLlmCalls = 0;
|
|
840
|
+
let totalSelftunElapsedMs = 0;
|
|
841
|
+
let selftuneRunCount = 0;
|
|
842
|
+
for (const row of orchestrateRows) {
|
|
843
|
+
try {
|
|
844
|
+
const actions = JSON.parse(row.skill_actions_json) as Array<{
|
|
845
|
+
skill: string;
|
|
846
|
+
action?: string;
|
|
847
|
+
elapsed_ms?: number;
|
|
848
|
+
llm_calls?: number;
|
|
849
|
+
}>;
|
|
850
|
+
for (const a of actions) {
|
|
851
|
+
if (a.skill !== skillName || a.action === "skip" || a.action === "watch") continue;
|
|
852
|
+
if (a.elapsed_ms === undefined && a.llm_calls === undefined) continue;
|
|
853
|
+
totalSelftunElapsedMs += a.elapsed_ms ?? 0;
|
|
854
|
+
totalLlmCalls += a.llm_calls ?? 0;
|
|
855
|
+
selftuneRunCount++;
|
|
856
|
+
}
|
|
857
|
+
} catch {
|
|
858
|
+
// skip malformed JSON
|
|
859
|
+
}
|
|
860
|
+
}
|
|
861
|
+
const selftuneStats = {
|
|
862
|
+
total_llm_calls: totalLlmCalls,
|
|
863
|
+
total_elapsed_ms: totalSelftunElapsedMs,
|
|
864
|
+
avg_elapsed_ms: selftuneRunCount > 0 ? totalSelftunElapsedMs / selftuneRunCount : 0,
|
|
865
|
+
run_count: selftuneRunCount,
|
|
866
|
+
};
|
|
867
|
+
|
|
868
|
+
// 4. Skill invocations with confidence scores
|
|
869
|
+
const invocationsWithConfidence = db
|
|
870
|
+
.query(
|
|
871
|
+
`SELECT si.occurred_at as timestamp, si.session_id, si.skill_name,
|
|
872
|
+
si.invocation_mode, si.triggered, si.confidence, si.tool_name
|
|
873
|
+
FROM skill_invocations si
|
|
874
|
+
WHERE si.skill_name = ?
|
|
875
|
+
ORDER BY si.occurred_at DESC
|
|
876
|
+
LIMIT 100`,
|
|
877
|
+
)
|
|
878
|
+
.all(skillName) as Array<{
|
|
879
|
+
timestamp: string;
|
|
880
|
+
session_id: string;
|
|
881
|
+
skill_name: string;
|
|
882
|
+
invocation_mode: string | null;
|
|
883
|
+
triggered: number;
|
|
884
|
+
confidence: number | null;
|
|
885
|
+
tool_name: string | null;
|
|
886
|
+
}>;
|
|
887
|
+
|
|
888
|
+
// Not-found check — after all enrichment queries so evidence-only skills aren't 404'd
|
|
889
|
+
const hasData =
|
|
890
|
+
report.usage.total_checks > 0 ||
|
|
891
|
+
report.recent_invocations.length > 0 ||
|
|
892
|
+
report.evidence.length > 0 ||
|
|
893
|
+
evolution.length > 0 ||
|
|
894
|
+
pending_proposals.length > 0 ||
|
|
895
|
+
invocationsWithConfidence.length > 0;
|
|
896
|
+
if (!hasData) {
|
|
897
|
+
return Response.json(
|
|
898
|
+
{ error: "Skill not found" },
|
|
899
|
+
{ status: 404, headers: corsHeaders() },
|
|
900
|
+
);
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
// 5. Duration/error stats from execution_facts (session-level metrics)
|
|
904
|
+
const executionRow = db
|
|
905
|
+
.query(
|
|
906
|
+
`${skillSessionsCte}
|
|
907
|
+
SELECT
|
|
908
|
+
COALESCE(AVG(ef.duration_ms), 0) AS avg_duration_ms,
|
|
909
|
+
COALESCE(SUM(ef.duration_ms), 0) AS total_duration_ms,
|
|
910
|
+
COUNT(ef.duration_ms) AS execution_count,
|
|
911
|
+
COALESCE(SUM(ef.errors_encountered), 0) AS total_errors,
|
|
912
|
+
COALESCE(SUM(ef.input_tokens), 0) AS total_input_tokens,
|
|
913
|
+
COALESCE(SUM(ef.output_tokens), 0) AS total_output_tokens
|
|
914
|
+
FROM execution_facts ef
|
|
915
|
+
WHERE ef.session_id IN (SELECT session_id FROM skill_sessions)`,
|
|
916
|
+
)
|
|
917
|
+
.get(skillName) as {
|
|
918
|
+
avg_duration_ms: number;
|
|
919
|
+
total_duration_ms: number;
|
|
920
|
+
execution_count: number;
|
|
921
|
+
total_errors: number;
|
|
922
|
+
total_input_tokens: number;
|
|
923
|
+
total_output_tokens: number;
|
|
924
|
+
} | null;
|
|
925
|
+
|
|
926
|
+
// 6. Prompt texts from sessions that invoked this skill
|
|
927
|
+
const promptSamples = db
|
|
928
|
+
.query(
|
|
929
|
+
`${skillSessionsCte}
|
|
930
|
+
SELECT p.prompt_text, p.prompt_kind, p.is_actionable, p.occurred_at, p.session_id
|
|
931
|
+
FROM prompts p
|
|
932
|
+
WHERE p.session_id IN (SELECT session_id FROM skill_sessions)
|
|
933
|
+
AND p.prompt_text IS NOT NULL
|
|
934
|
+
AND p.prompt_text != ''
|
|
935
|
+
ORDER BY p.occurred_at DESC
|
|
936
|
+
LIMIT 50`,
|
|
937
|
+
)
|
|
938
|
+
.all(skillName) as Array<{
|
|
939
|
+
prompt_text: string;
|
|
940
|
+
prompt_kind: string | null;
|
|
941
|
+
is_actionable: number;
|
|
942
|
+
occurred_at: string;
|
|
943
|
+
session_id: string;
|
|
944
|
+
}>;
|
|
945
|
+
|
|
946
|
+
// 7. Session metadata for sessions that used this skill
|
|
947
|
+
const sessionMeta = db
|
|
948
|
+
.query(
|
|
949
|
+
`${skillSessionsCte}
|
|
950
|
+
SELECT s.session_id, s.platform, s.model, s.agent_cli, s.branch,
|
|
951
|
+
s.workspace_path, s.started_at, s.ended_at, s.completion_status
|
|
952
|
+
FROM sessions s
|
|
953
|
+
WHERE s.session_id IN (SELECT session_id FROM skill_sessions)
|
|
954
|
+
ORDER BY s.started_at DESC
|
|
955
|
+
LIMIT 50`,
|
|
956
|
+
)
|
|
957
|
+
.all(skillName) as Array<{
|
|
958
|
+
session_id: string;
|
|
959
|
+
platform: string | null;
|
|
960
|
+
model: string | null;
|
|
961
|
+
agent_cli: string | null;
|
|
962
|
+
branch: string | null;
|
|
963
|
+
workspace_path: string | null;
|
|
964
|
+
started_at: string | null;
|
|
965
|
+
ended_at: string | null;
|
|
966
|
+
completion_status: string | null;
|
|
967
|
+
}>;
|
|
968
|
+
|
|
969
|
+
return Response.json(
|
|
970
|
+
{
|
|
971
|
+
...report,
|
|
972
|
+
evolution: evolutionWithSnapshot,
|
|
973
|
+
pending_proposals,
|
|
974
|
+
token_usage: {
|
|
975
|
+
total_input_tokens: executionRow?.total_input_tokens ?? 0,
|
|
976
|
+
total_output_tokens: executionRow?.total_output_tokens ?? 0,
|
|
977
|
+
},
|
|
978
|
+
canonical_invocations: invocationsWithConfidence.map((i) => ({
|
|
979
|
+
...i,
|
|
980
|
+
triggered: i.triggered === 1,
|
|
981
|
+
})),
|
|
982
|
+
duration_stats: {
|
|
983
|
+
avg_duration_ms: executionRow?.avg_duration_ms ?? 0,
|
|
984
|
+
total_duration_ms: executionRow?.total_duration_ms ?? 0,
|
|
985
|
+
execution_count: executionRow?.execution_count ?? 0,
|
|
986
|
+
total_errors: executionRow?.total_errors ?? 0,
|
|
987
|
+
},
|
|
988
|
+
selftune_stats: selftuneStats,
|
|
989
|
+
prompt_samples: promptSamples.map((p) => ({
|
|
990
|
+
...p,
|
|
991
|
+
is_actionable: p.is_actionable === 1,
|
|
992
|
+
})),
|
|
993
|
+
session_metadata: sessionMeta,
|
|
994
|
+
},
|
|
995
|
+
{ headers: corsHeaders() },
|
|
996
|
+
);
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
// ---- SPA fallback ---- Serve index.html for client-side routes
|
|
1000
|
+
if (spaDir && req.method === "GET" && !url.pathname.startsWith("/api/")) {
|
|
1001
|
+
const html = await Bun.file(join(spaDir, "index.html")).text();
|
|
1002
|
+
return new Response(html, {
|
|
1003
|
+
headers: { "Content-Type": "text/html; charset=utf-8", ...corsHeaders() },
|
|
1004
|
+
});
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// ---- 404 ----
|
|
1008
|
+
return new Response("Not Found", { status: 404, headers: corsHeaders() });
|
|
1009
|
+
},
|
|
1010
|
+
});
|
|
1011
|
+
|
|
1012
|
+
const boundPort = server.port;
|
|
1013
|
+
|
|
1014
|
+
if (openBrowser) {
|
|
1015
|
+
const url = `http://${hostname}:${boundPort}`;
|
|
1016
|
+
console.log(`selftune dashboard server running at ${url}`);
|
|
1017
|
+
try {
|
|
1018
|
+
const platform = process.platform;
|
|
1019
|
+
if (platform === "darwin") {
|
|
1020
|
+
Bun.spawn(["open", url]);
|
|
1021
|
+
} else if (platform === "linux") {
|
|
1022
|
+
Bun.spawn(["xdg-open", url]);
|
|
1023
|
+
} else if (platform === "win32") {
|
|
1024
|
+
Bun.spawn(["cmd", "/c", "start", "", url]);
|
|
1025
|
+
}
|
|
1026
|
+
} catch {
|
|
1027
|
+
console.log(`Open manually: ${url}`);
|
|
1028
|
+
}
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
// Graceful shutdown
|
|
1032
|
+
const shutdownHandler = () => {
|
|
1033
|
+
db?.close();
|
|
1034
|
+
server.stop();
|
|
1035
|
+
};
|
|
1036
|
+
|
|
1037
|
+
process.on("SIGINT", shutdownHandler);
|
|
1038
|
+
process.on("SIGTERM", shutdownHandler);
|
|
1039
|
+
|
|
1040
|
+
return {
|
|
1041
|
+
server,
|
|
1042
|
+
stop: () => {
|
|
1043
|
+
process.removeListener("SIGINT", shutdownHandler);
|
|
1044
|
+
process.removeListener("SIGTERM", shutdownHandler);
|
|
1045
|
+
shutdownHandler();
|
|
1046
|
+
},
|
|
1047
|
+
port: boundPort,
|
|
1048
|
+
};
|
|
1049
|
+
}
|