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.
- package/.claude/agents/diagnosis-analyst.md +20 -10
- package/.claude/agents/evolution-reviewer.md +14 -1
- package/.claude/agents/integration-guide.md +18 -6
- package/.claude/agents/pattern-analyst.md +18 -5
- package/CHANGELOG.md +12 -4
- package/README.md +43 -35
- 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/cli/selftune/badge/badge-data.ts +1 -1
- package/cli/selftune/badge/badge.ts +4 -8
- package/cli/selftune/canonical-export.ts +183 -0
- package/cli/selftune/constants.ts +28 -0
- package/cli/selftune/contribute/contribute.ts +1 -1
- package/cli/selftune/cron/setup.ts +17 -17
- package/cli/selftune/dashboard-contract.ts +202 -0
- package/cli/selftune/dashboard-server.ts +653 -186
- package/cli/selftune/dashboard.ts +41 -176
- package/cli/selftune/eval/baseline.ts +5 -4
- package/cli/selftune/eval/composability-v2.ts +273 -0
- package/cli/selftune/eval/hooks-to-evals.ts +34 -15
- package/cli/selftune/eval/unit-test-cli.ts +1 -1
- package/cli/selftune/evolution/evidence.ts +26 -0
- package/cli/selftune/evolution/evolve-body.ts +105 -11
- package/cli/selftune/evolution/evolve.ts +371 -25
- package/cli/selftune/evolution/extract-patterns.ts +87 -29
- package/cli/selftune/evolution/rollback.ts +2 -2
- package/cli/selftune/grading/auto-grade.ts +200 -0
- package/cli/selftune/grading/grade-session.ts +448 -97
- package/cli/selftune/grading/results.ts +42 -0
- package/cli/selftune/hooks/prompt-log.ts +172 -2
- package/cli/selftune/hooks/session-stop.ts +123 -3
- package/cli/selftune/hooks/skill-eval.ts +119 -3
- package/cli/selftune/index.ts +395 -116
- package/cli/selftune/ingestors/claude-replay.ts +140 -114
- 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 +141 -8
- package/cli/selftune/ingestors/opencode-ingest.ts +193 -17
- package/cli/selftune/init.ts +227 -14
- 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/monitoring/watch.ts +66 -15
- 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 +48 -26
- package/cli/selftune/sync.ts +627 -0
- package/cli/selftune/types.ts +148 -0
- package/cli/selftune/utils/canonical-log.ts +45 -0
- package/cli/selftune/utils/hooks.ts +41 -0
- package/cli/selftune/utils/html.ts +27 -0
- package/cli/selftune/utils/llm-call.ts +78 -20
- 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 +272 -26
- 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 +21 -8
- 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 +84 -53
- package/skill/Workflows/AutoActivation.md +17 -16
- package/skill/Workflows/Badge.md +6 -0
- package/skill/Workflows/Baseline.md +46 -23
- package/skill/Workflows/Composability.md +12 -5
- package/skill/Workflows/Contribute.md +17 -14
- package/skill/Workflows/Cron.md +56 -79
- package/skill/Workflows/Dashboard.md +45 -34
- package/skill/Workflows/Doctor.md +30 -17
- package/skill/Workflows/Evals.md +64 -40
- package/skill/Workflows/EvolutionMemory.md +2 -0
- package/skill/Workflows/Evolve.md +102 -47
- package/skill/Workflows/EvolveBody.md +6 -6
- package/skill/Workflows/Grade.md +36 -31
- package/skill/Workflows/ImportSkillsBench.md +11 -5
- package/skill/Workflows/Ingest.md +43 -36
- package/skill/Workflows/Initialize.md +44 -30
- package/skill/Workflows/Orchestrate.md +139 -0
- package/skill/Workflows/Replay.md +39 -18
- package/skill/Workflows/Rollback.md +3 -3
- package/skill/Workflows/Schedule.md +61 -0
- package/skill/Workflows/Sync.md +88 -0
- package/skill/Workflows/UnitTest.md +34 -22
- package/skill/Workflows/Watch.md +14 -4
- 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 +1 -1
- package/templates/multi-skill-settings.json +7 -7
- package/templates/single-skill-settings.json +6 -6
- package/dashboard/index.html +0 -1680
|
@@ -1,155 +1,177 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* selftune dashboard server —
|
|
3
|
-
*
|
|
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 /
|
|
7
|
-
* GET /api/
|
|
8
|
-
* GET /api/
|
|
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,
|
|
21
|
-
import {
|
|
22
|
-
import {
|
|
23
|
-
import {
|
|
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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
70
|
+
function findSpaDir(): string | null {
|
|
55
71
|
const candidates = [
|
|
56
|
-
join(dirname(import.meta.dir), "..", "dashboard", "
|
|
57
|
-
join(dirname(import.meta.dir), "dashboard", "
|
|
58
|
-
resolve("dashboard", "
|
|
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
|
-
|
|
79
|
+
return null;
|
|
64
80
|
}
|
|
65
81
|
|
|
66
|
-
function
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
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
|
-
|
|
138
|
-
|
|
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
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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
|
|
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:
|
|
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, """);
|
|
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
|
-
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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/
|
|
322
|
-
if (url.pathname === "/api/
|
|
323
|
-
const
|
|
324
|
-
return Response.json(
|
|
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
|
-
// ----
|
|
328
|
-
if (
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
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
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
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
|
|
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
|
|
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 =
|
|
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 =
|
|
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 =
|
|
496
|
-
|
|
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/
|
|
517
|
-
if (url.pathname
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
.
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
return Response.json(
|
|
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
|
-
|
|
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
|
|