selftune 0.2.0 → 0.2.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (122) hide show
  1. package/.claude/agents/diagnosis-analyst.md +20 -10
  2. package/.claude/agents/evolution-reviewer.md +14 -1
  3. package/.claude/agents/integration-guide.md +18 -6
  4. package/.claude/agents/pattern-analyst.md +18 -5
  5. package/CHANGELOG.md +12 -4
  6. package/README.md +43 -35
  7. package/apps/local-dashboard/dist/assets/geist-cyrillic-wght-normal-CHSlOQsW.woff2 +0 -0
  8. package/apps/local-dashboard/dist/assets/geist-latin-ext-wght-normal-DMtmJ5ZE.woff2 +0 -0
  9. package/apps/local-dashboard/dist/assets/geist-latin-wght-normal-Dm3htQBi.woff2 +0 -0
  10. package/apps/local-dashboard/dist/assets/index-C4EOTFZ2.js +15 -0
  11. package/apps/local-dashboard/dist/assets/index-bl-Webyd.css +1 -0
  12. package/apps/local-dashboard/dist/assets/vendor-react-U7zYD9Rg.js +60 -0
  13. package/apps/local-dashboard/dist/assets/vendor-table-B7VF2Ipl.js +26 -0
  14. package/apps/local-dashboard/dist/assets/vendor-ui-D7_zX_qy.js +346 -0
  15. package/apps/local-dashboard/dist/favicon.png +0 -0
  16. package/apps/local-dashboard/dist/index.html +17 -0
  17. package/apps/local-dashboard/dist/logo.png +0 -0
  18. package/apps/local-dashboard/dist/logo.svg +9 -0
  19. package/cli/selftune/badge/badge-data.ts +1 -1
  20. package/cli/selftune/badge/badge.ts +4 -8
  21. package/cli/selftune/canonical-export.ts +183 -0
  22. package/cli/selftune/constants.ts +28 -0
  23. package/cli/selftune/contribute/contribute.ts +1 -1
  24. package/cli/selftune/cron/setup.ts +17 -17
  25. package/cli/selftune/dashboard-contract.ts +202 -0
  26. package/cli/selftune/dashboard-server.ts +653 -186
  27. package/cli/selftune/dashboard.ts +41 -176
  28. package/cli/selftune/eval/baseline.ts +5 -4
  29. package/cli/selftune/eval/composability-v2.ts +273 -0
  30. package/cli/selftune/eval/hooks-to-evals.ts +34 -15
  31. package/cli/selftune/eval/unit-test-cli.ts +1 -1
  32. package/cli/selftune/evolution/evidence.ts +26 -0
  33. package/cli/selftune/evolution/evolve-body.ts +105 -11
  34. package/cli/selftune/evolution/evolve.ts +371 -25
  35. package/cli/selftune/evolution/extract-patterns.ts +87 -29
  36. package/cli/selftune/evolution/rollback.ts +2 -2
  37. package/cli/selftune/grading/auto-grade.ts +200 -0
  38. package/cli/selftune/grading/grade-session.ts +448 -97
  39. package/cli/selftune/grading/results.ts +42 -0
  40. package/cli/selftune/hooks/prompt-log.ts +172 -2
  41. package/cli/selftune/hooks/session-stop.ts +123 -3
  42. package/cli/selftune/hooks/skill-eval.ts +119 -3
  43. package/cli/selftune/index.ts +395 -116
  44. package/cli/selftune/ingestors/claude-replay.ts +140 -114
  45. package/cli/selftune/ingestors/codex-rollout.ts +345 -46
  46. package/cli/selftune/ingestors/codex-wrapper.ts +207 -39
  47. package/cli/selftune/ingestors/openclaw-ingest.ts +141 -8
  48. package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
  49. package/cli/selftune/init.ts +227 -14
  50. package/cli/selftune/last.ts +14 -5
  51. package/cli/selftune/localdb/db.ts +63 -0
  52. package/cli/selftune/localdb/materialize.ts +428 -0
  53. package/cli/selftune/localdb/queries.ts +376 -0
  54. package/cli/selftune/localdb/schema.ts +204 -0
  55. package/cli/selftune/monitoring/watch.ts +66 -15
  56. package/cli/selftune/normalization.ts +682 -0
  57. package/cli/selftune/observability.ts +19 -44
  58. package/cli/selftune/orchestrate.ts +1073 -0
  59. package/cli/selftune/quickstart.ts +203 -0
  60. package/cli/selftune/repair/skill-usage.ts +576 -0
  61. package/cli/selftune/schedule.ts +561 -0
  62. package/cli/selftune/status.ts +48 -26
  63. package/cli/selftune/sync.ts +627 -0
  64. package/cli/selftune/types.ts +148 -0
  65. package/cli/selftune/utils/canonical-log.ts +45 -0
  66. package/cli/selftune/utils/hooks.ts +41 -0
  67. package/cli/selftune/utils/html.ts +27 -0
  68. package/cli/selftune/utils/llm-call.ts +78 -20
  69. package/cli/selftune/utils/math.ts +10 -0
  70. package/cli/selftune/utils/query-filter.ts +139 -0
  71. package/cli/selftune/utils/skill-discovery.ts +340 -0
  72. package/cli/selftune/utils/skill-log.ts +68 -0
  73. package/cli/selftune/utils/skill-usage-confidence.ts +18 -0
  74. package/cli/selftune/utils/transcript.ts +272 -26
  75. package/cli/selftune/workflows/discover.ts +254 -0
  76. package/cli/selftune/workflows/skill-md-writer.ts +288 -0
  77. package/cli/selftune/workflows/workflows.ts +188 -0
  78. package/package.json +21 -8
  79. package/packages/telemetry-contract/README.md +11 -0
  80. package/packages/telemetry-contract/fixtures/golden.json +87 -0
  81. package/packages/telemetry-contract/fixtures/golden.test.ts +42 -0
  82. package/packages/telemetry-contract/index.ts +1 -0
  83. package/packages/telemetry-contract/package.json +19 -0
  84. package/packages/telemetry-contract/src/index.ts +2 -0
  85. package/packages/telemetry-contract/src/types.ts +163 -0
  86. package/packages/telemetry-contract/src/validators.ts +109 -0
  87. package/skill/SKILL.md +84 -53
  88. package/skill/Workflows/AutoActivation.md +17 -16
  89. package/skill/Workflows/Badge.md +6 -0
  90. package/skill/Workflows/Baseline.md +46 -23
  91. package/skill/Workflows/Composability.md +12 -5
  92. package/skill/Workflows/Contribute.md +17 -14
  93. package/skill/Workflows/Cron.md +56 -79
  94. package/skill/Workflows/Dashboard.md +45 -34
  95. package/skill/Workflows/Doctor.md +30 -17
  96. package/skill/Workflows/Evals.md +64 -40
  97. package/skill/Workflows/EvolutionMemory.md +2 -0
  98. package/skill/Workflows/Evolve.md +102 -47
  99. package/skill/Workflows/EvolveBody.md +6 -6
  100. package/skill/Workflows/Grade.md +36 -31
  101. package/skill/Workflows/ImportSkillsBench.md +11 -5
  102. package/skill/Workflows/Ingest.md +43 -36
  103. package/skill/Workflows/Initialize.md +44 -30
  104. package/skill/Workflows/Orchestrate.md +139 -0
  105. package/skill/Workflows/Replay.md +39 -18
  106. package/skill/Workflows/Rollback.md +3 -3
  107. package/skill/Workflows/Schedule.md +61 -0
  108. package/skill/Workflows/Sync.md +88 -0
  109. package/skill/Workflows/UnitTest.md +34 -22
  110. package/skill/Workflows/Watch.md +14 -4
  111. package/skill/Workflows/Workflows.md +129 -0
  112. package/skill/assets/activation-rules-default.json +26 -0
  113. package/skill/assets/multi-skill-settings.json +63 -0
  114. package/skill/assets/single-skill-settings.json +57 -0
  115. package/skill/references/invocation-taxonomy.md +2 -2
  116. package/skill/references/logs.md +164 -2
  117. package/skill/references/setup-patterns.md +65 -0
  118. package/skill/references/version-history.md +40 -0
  119. package/skill/settings_snippet.json +1 -1
  120. package/templates/multi-skill-settings.json +7 -7
  121. package/templates/single-skill-settings.json +6 -6
  122. package/dashboard/index.html +0 -1680
@@ -1,155 +1,177 @@
1
1
  /**
2
- * selftune dashboard server — Live Bun.serve HTTP server with SSE, data API,
3
- * and action endpoints for the interactive dashboard.
2
+ * selftune dashboard server — Bun.serve HTTP server for the SPA dashboard,
3
+ * skill report HTML, badges, and action endpoints.
4
4
  *
5
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
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
9
11
  * POST /api/actions/watch — Trigger `selftune watch` for a skill
10
12
  * POST /api/actions/evolve — Trigger `selftune evolve` for a skill
11
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
12
16
  */
13
17
 
18
+ import type { Database } from "bun:sqlite";
14
19
  import { existsSync, readFileSync } from "node:fs";
15
- import { dirname, join, resolve } from "node:path";
20
+ import { dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
16
21
  import type { BadgeData } from "./badge/badge-data.js";
17
22
  import { findSkillBadgeData } from "./badge/badge-data.js";
18
23
  import type { BadgeFormat } from "./badge/badge-svg.js";
19
24
  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";
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";
24
37
  import { doctor } from "./observability.js";
25
38
  import type { StatusResult } from "./status.js";
26
39
  import { computeStatus } from "./status.js";
27
40
  import type {
28
41
  EvolutionAuditEntry,
42
+ EvolutionEvidenceEntry,
29
43
  QueryLogRecord,
30
44
  SessionTelemetryRecord,
31
- SkillUsageRecord,
32
45
  } from "./types.js";
33
46
  import { readJsonl } from "./utils/jsonl.js";
47
+ import { readEffectiveSkillUsageRecords } from "./utils/skill-log.js";
34
48
 
35
49
  export interface DashboardServerOptions {
36
50
  port?: number;
37
51
  host?: string;
52
+ spaDir?: string;
38
53
  openBrowser?: boolean;
54
+ statusLoader?: () => StatusResult;
55
+ evidenceLoader?: () => EvolutionEvidenceEntry[];
56
+ overviewLoader?: () => OverviewResponse;
57
+ skillReportLoader?: (skillName: string) => SkillReportResponse | null;
58
+ actionRunner?: typeof runAction;
39
59
  }
40
60
 
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
- };
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
52
68
  }
53
69
 
54
- function findViewerHTML(): string {
70
+ function findSpaDir(): string | null {
55
71
  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"),
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"),
59
75
  ];
60
76
  for (const c of candidates) {
61
- if (existsSync(c)) return c;
77
+ if (existsSync(join(c, "index.html"))) return c;
62
78
  }
63
- throw new Error("Could not find dashboard/index.html. Ensure it exists in the selftune repo.");
79
+ return null;
64
80
  }
65
81
 
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);
82
+ function decodePathSegment(segment: string): string | null {
83
+ try {
84
+ return decodeURIComponent(segment);
85
+ } catch {
86
+ return null;
106
87
  }
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
88
  }
127
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
+
128
103
  function computeStatusFromLogs(): StatusResult {
129
104
  const telemetry = readJsonl<SessionTelemetryRecord>(TELEMETRY_LOG);
130
- const skillRecords = readJsonl<SkillUsageRecord>(SKILL_LOG);
105
+ const skillRecords = readEffectiveSkillUsageRecords();
131
106
  const queryRecords = readJsonl<QueryLogRecord>(QUERY_LOG);
132
107
  const auditEntries = readJsonl<EvolutionAuditEntry>(EVOLUTION_AUDIT_LOG);
133
108
  const doctorResult = doctor();
134
109
  return computeStatus(telemetry, skillRecords, queryRecords, auditEntries, doctorResult);
135
110
  }
136
111
 
137
- function buildLiveHTML(data: DashboardData): string {
138
- const template = readFileSync(findViewerHTML(), "utf-8");
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
+ }
139
144
 
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>`;
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
+ }
144
161
 
145
- return template.replace("</body>", `${liveFlag}\n${dataScript}\n</body>`);
162
+ return [...merged.values()].sort((a, b) => b.latest_timestamp.localeCompare(a.latest_timestamp));
146
163
  }
147
164
 
148
165
  function buildReportHTML(
149
166
  skillName: string,
150
167
  skill: import("./status.js").SkillStatus,
151
168
  statusResult: StatusResult,
169
+ evidenceEntries: EvolutionEvidenceEntry[],
152
170
  ): string {
171
+ const mergedEvidence = mergeEvidenceEntries(evidenceEntries);
172
+ const latestValidation = mergedEvidence.find(
173
+ (entry) => entry.validation?.per_entry_results?.length,
174
+ );
153
175
  const passRateDisplay =
154
176
  skill.passRate !== null ? `${Math.round(skill.passRate * 100)}%` : "No data";
155
177
  const trendArrows: Record<string, string> = {
@@ -175,7 +197,7 @@ function buildReportHTML(
175
197
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
176
198
  <title>selftune report: ${escapeHtml(skillName)}</title>
177
199
  <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; }
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; }
179
201
  h1 { font-size: 1.5rem; margin-bottom: 8px; }
180
202
  .badge { margin: 16px 0; }
181
203
  .card { background: #fff; border: 1px solid #e0e0e0; border-radius: 8px; padding: 20px; margin: 16px 0; }
@@ -189,6 +211,17 @@ function buildReportHTML(
189
211
  a { color: #0366d6; text-decoration: none; }
190
212
  a:hover { text-decoration: underline; }
191
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
+ }
192
225
  </style>
193
226
  </head>
194
227
  <body>
@@ -244,6 +277,86 @@ function buildReportHTML(
244
277
  <tr><td>Last Session</td><td>${escapeHtml(statusResult.lastSession ?? "\u2014")}</td></tr>
245
278
  </table>
246
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>
247
360
  </body>
248
361
  </html>`;
249
362
  }
@@ -256,6 +369,15 @@ function escapeHtml(text: string): string {
256
369
  .replace(/"/g, "&quot;");
257
370
  }
258
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
+
259
381
  function corsHeaders(): Record<string, string> {
260
382
  return {
261
383
  "Access-Control-Allow-Origin": "*",
@@ -295,8 +417,93 @@ export async function startDashboardServer(
295
417
  const port = options?.port ?? 3141;
296
418
  const hostname = options?.host ?? "localhost";
297
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
+ }
298
498
 
299
- const sseClients = new Set<ReadableStreamDefaultController>();
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
+ }
300
507
 
301
508
  const server = Bun.serve({
302
509
  port,
@@ -309,67 +516,58 @@ export async function startDashboardServer(
309
516
  return new Response(null, { status: 204, headers: corsHeaders() });
310
517
  }
311
518
 
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
- });
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
+ );
319
530
  }
320
531
 
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() });
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() });
325
536
  }
326
537
 
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
- });
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
+ }
365
559
 
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
- },
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() },
373
571
  });
374
572
  }
375
573
 
@@ -382,8 +580,8 @@ export async function startDashboardServer(
382
580
  { status: 400, headers: corsHeaders() },
383
581
  );
384
582
  }
385
- const args = ["--skill", body.skill, "--skill-path", body.skillPath];
386
- const result = await runAction("watch", args);
583
+ const args = ["--skill", body.skill, "--skill-path", body.skillPath, "--sync-first"];
584
+ const result = await executeAction("watch", args);
387
585
  return Response.json(result, { headers: corsHeaders() });
388
586
  }
389
587
 
@@ -396,8 +594,8 @@ export async function startDashboardServer(
396
594
  { status: 400, headers: corsHeaders() },
397
595
  );
398
596
  }
399
- const args = ["--skill", body.skill, "--skill-path", body.skillPath];
400
- const result = await runAction("evolve", args);
597
+ const args = ["--skill", body.skill, "--skill-path", body.skillPath, "--sync-first"];
598
+ const result = await executeAction("evolve", args);
401
599
  return Response.json(result, { headers: corsHeaders() });
402
600
  }
403
601
 
@@ -422,19 +620,25 @@ export async function startDashboardServer(
422
620
  "--proposal-id",
423
621
  body.proposalId,
424
622
  ];
425
- const result = await runAction("rollback", args);
623
+ const result = await executeAction("rollback", args);
426
624
  return Response.json(result, { headers: corsHeaders() });
427
625
  }
428
626
 
429
627
  // ---- GET /badge/:skillName ---- Badge SVG
430
628
  if (url.pathname.startsWith("/badge/") && req.method === "GET") {
431
- const skillName = decodeURIComponent(url.pathname.slice("/badge/".length));
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
+ }
432
636
  const formatParam = url.searchParams.get("format");
433
637
  const validFormats = new Set(["svg", "markdown", "url"]);
434
638
  const format: BadgeFormat =
435
639
  formatParam && validFormats.has(formatParam) ? (formatParam as BadgeFormat) : "svg";
436
640
 
437
- const statusResult = computeStatusFromLogs();
641
+ const statusResult = await getCachedStatusResult();
438
642
  const badgeData = findSkillBadgeData(statusResult, skillName);
439
643
 
440
644
  if (!badgeData) {
@@ -492,9 +696,18 @@ export async function startDashboardServer(
492
696
 
493
697
  // ---- GET /report/:skillName ---- Skill health report
494
698
  if (url.pathname.startsWith("/report/") && req.method === "GET") {
495
- const skillName = decodeURIComponent(url.pathname.slice("/report/".length));
496
- const statusResult = computeStatusFromLogs();
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();
497
707
  const skill = statusResult.skills.find((s) => s.name === skillName);
708
+ const evidenceEntries = getEvidenceEntries().filter(
709
+ (entry) => entry.skill_name === skillName,
710
+ );
498
711
 
499
712
  if (!skill) {
500
713
  return new Response("Skill not found", {
@@ -503,7 +716,7 @@ export async function startDashboardServer(
503
716
  });
504
717
  }
505
718
 
506
- const html = buildReportHTML(skillName, skill, statusResult);
719
+ const html = buildReportHTML(skillName, skill, statusResult, evidenceEntries);
507
720
  return new Response(html, {
508
721
  headers: {
509
722
  "Content-Type": "text/html; charset=utf-8",
@@ -513,21 +726,282 @@ export async function startDashboardServer(
513
726
  });
514
727
  }
515
728
 
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() });
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
+ });
531
1005
  }
532
1006
 
533
1007
  // ---- 404 ----
@@ -556,14 +1030,7 @@ export async function startDashboardServer(
556
1030
 
557
1031
  // Graceful shutdown
558
1032
  const shutdownHandler = () => {
559
- for (const client of sseClients) {
560
- try {
561
- client.close();
562
- } catch {
563
- // already closed
564
- }
565
- }
566
- sseClients.clear();
1033
+ db?.close();
567
1034
  server.stop();
568
1035
  };
569
1036