#!/usr/bin/env node
// Ratchet Dashboard — HTML Visualization
// Generates a self-contained HTML dashboard from all ratchet data.
//
// Usage:
// node dashboard.js # generate only
// node dashboard.js --no-open # same (alias)
// node dashboard.js --serve # serve on port 9077
// node dashboard.js --serve=PORT # serve on custom port
const fs = require('fs')
const path = require('path')
const { execSync } = require('child_process')
const DATA_DIR = path.join(__dirname, 'data')
const OUTPUT_FILE = path.join(DATA_DIR, 'dashboard.html')
function loadJSON (name) {
const file = path.join(DATA_DIR, name)
if (!fs.existsSync(file)) return null
try { return JSON.parse(fs.readFileSync(file, 'utf8')) }
catch { return null }
}
function generate () {
// Live session state
let liveSession = null
try {
if (fs.existsSync('/tmp/claude-session-score.json')) {
liveSession = JSON.parse(fs.readFileSync('/tmp/claude-session-score.json', 'utf8'))
}
} catch {}
const fused = loadJSON('fused-scores.json')
const loops = loadJSON('loops.json')
const projectMaps = loadJSON('project-maps.json')
const benchmarks = loadJSON('benchmark-results.json')
const benchmarkHistory = loadJSON('benchmark-history.json') || []
const weightHistory = loadJSON('weight-history.json')
const coverage = loadJSON('coverage-map.json')
const learnedRules = loadJSON('learned-rules.json')
// ─── Prepare chart data ───
// Weekly trend: density + fused score
const weeklyData = []
if (fused?.weekly) {
for (const [week, data] of Object.entries(fused.weekly).sort()) {
const density = data.totalScore != null
? null
: (fused.sessions || [])
.filter(s => {
const d = new Date(s.date)
d.setDate(d.getDate() - d.getDay())
return d.toISOString().slice(0, 10) === week
})
.reduce((acc, s) => {
acc.turns += s.turns
acc.corr += s.corrections
return acc
}, { turns: 0, corr: 0 })
// Find last session date in this week for range labels
const weekSessions = (fused.sessions || []).filter(s => {
const d = new Date(s.date)
d.setDate(d.getDate() - d.getDay())
return d.toISOString().slice(0, 10) === week
})
const lastDate = weekSessions.length > 0
? weekSessions.sort((a, b) => (a.date || '').localeCompare(b.date || ''))[weekSessions.length - 1].date
: week
const weekLabel = lastDate !== week
? `${week.slice(5)}…${lastDate.slice(8)}`
: week.slice(5)
weeklyData.push({
week,
weekLabel,
score: data.avgScore,
// Per-turn score: quality independent of session length
perTurnScore: data.avgPerTurnScore || data.avgScore,
// Trusted score: top-5 sensors only — PRIMARY quality signal
trustedScore: data.avgTrustedScore,
// Correction burst: longest streak of consecutive corrections (lower = healthier)
avgCorrectionBurst: data.avgCorrectionBurst,
maxCorrectionBurst: data.maxCorrectionBurst || 0,
avgTurns: data.avgTurns || 0,
avgDensity: data.avgDensity || null,
sessions: data.sessions,
corrections: data.corrections,
approvals: data.approvals,
density: density ? (density.turns > 0 ? density.corr / density.turns : 0) : null,
})
}
}
// Session scores for scatter plot
const sessionData = (fused?.sessions || []).map(s => ({
date: s.date,
score: s.score,
turns: s.turns,
corrections: s.corrections,
approvals: s.approvals,
branch: s.gitBranch,
}))
// Loops by project
const loopsByProject = {}
if (loops?.loops) {
for (const l of loops.loops) {
if (!loopsByProject[l.project]) loopsByProject[l.project] = { total: 0, resolved: 0, avgDuration: 0, totalDuration: 0 }
loopsByProject[l.project].total++
if (l.resolved) loopsByProject[l.project].resolved++
loopsByProject[l.project].totalDuration += l.duration
}
for (const v of Object.values(loopsByProject)) {
v.avgDuration = v.total > 0 ? (v.totalDuration / v.total) : 0
}
}
// Loops over time (weekly)
const loopsWeekly = {}
if (loops?.loops) {
for (const l of loops.loops) {
if (!l.date) continue
const d = new Date(l.date)
d.setDate(d.getDate() - d.getDay())
const week = d.toISOString().slice(0, 10)
if (!loopsWeekly[week]) loopsWeekly[week] = { total: 0, resolved: 0 }
loopsWeekly[week].total++
if (l.resolved) loopsWeekly[week].resolved++
}
}
// Project subsystem danger map
const dangerMap = {}
if (projectMaps?.projects) {
for (const [proj, data] of Object.entries(projectMaps.projects)) {
dangerMap[proj] = Object.entries(data.subsystems || {})
.filter(([, s]) => s.loopCount > 0)
.map(([name, s]) => ({ name, files: s.fileCount, loops: s.loopCount, danger: s.dangerLevel }))
.sort((a, b) => b.loops - a.loops)
}
}
// Sensor weight evolution
const weightData = (weightHistory || []).map(h => ({
date: h.timestamp?.slice(0, 10),
weights: h.weights,
changes: h.changes?.length || 0,
}))
// Coverage summary
const coverageSummary = coverage?.summary || {}
// ─── Pre-compute ratchet status cards ───
const latest = benchmarkHistory.length > 0 ? benchmarkHistory[benchmarkHistory.length - 1] : null
const prev = benchmarkHistory.length > 1 ? benchmarkHistory[benchmarkHistory.length - 2] : null
const novelRate = latest?.novelRate != null ? (latest.novelRate * 100).toFixed(0) : '—'
const densityPct = latest?.density != null ? (latest.density * 100).toFixed(1) : '—'
const resolution = latest?.avgResolution != null ? latest.avgResolution.toFixed(1) : '—'
const rulesCount = latest?.rulesCount ?? learnedRules?.totalRules ?? 0
function ratchetArrow (delta, invertGood) {
if (delta == null) return ''
const good = invertGood ? delta < 0 : delta > 0
const bad = invertGood ? delta > 0 : delta < 0
if (good) return '<span class="green"> ▲</span>'
if (bad) return '<span class="red"> ▼</span>'
return '<span class="dim"> ▬</span>'
}
function ratchetColor (val, goodThresh, warnThresh, invert) {
if (val === '—') return ''
const v = parseFloat(val)
if (invert) return v < goodThresh ? 'green' : v < warnThresh ? 'yellow' : 'red'
return v > goodThresh ? 'green' : v > warnThresh ? 'yellow' : 'red'
}
const novelDelta = prev?.novelRate != null && latest?.novelRate != null ? latest.novelRate - prev.novelRate : null
const densityDelta = prev?.density != null && latest?.density != null ? latest.density - prev.density : null
const resDelta = prev?.avgResolution != null && latest?.avgResolution != null ? latest.avgResolution - prev.avgResolution : null
const novelColor = ratchetColor(novelRate, 90, 80, false)
const densityColor = ratchetColor(densityPct, 15, 25, true)
const resColor = ratchetColor(resolution, 6, 10, true)
const novelArrow = ratchetArrow(novelDelta, false)
const densityArrow = ratchetArrow(densityDelta, true)
const resArrow = ratchetArrow(resDelta, true)
// Live session
const liveScore = liveSession?.score?.toFixed(2) || '—'
const liveColor = liveSession?.score > 0.7 ? 'green' : liveSession?.score > 0.5 ? 'yellow' : 'red'
const liveBorder = liveSession?._loopActive ? '#da3633' : liveSession?.score > 0.7 ? '#238636' : '#9e6a03'
const liveLabel = liveSession?._loopActive ? '⟳ LIVE — LOOP ACTIVE' : '● LIVE SESSION'
const liveLabelColor = liveSession?._loopActive ? '#f85149' : '#3fb950'
const liveTraj = liveSession?.trajectory === 'improving' ? '▲ improving' : liveSession?.trajectory === 'declining' ? '▼ declining' : '▬ stable'
const liveTrajColor = liveSession?.trajectory === 'improving' ? 'green' : liveSession?.trajectory === 'declining' ? 'red' : 'dim'
// Live learning rate
let liveLearningRate = null
if (liveSession?.turnScores) {
let learned = 0, failed = 0
const ts = liveSession.turnScores
for (let i = 0; i < ts.length; i++) {
if (ts[i] === 0.2) {
const after = ts.slice(i + 1, i + 4)
if (after.includes(0.2)) failed++
else learned++
}
}
const total = learned + failed
if (total > 0) liveLearningRate = Math.round(learned / total * 100)
}
const liveLearningStr = liveLearningRate !== null ? `${liveLearningRate}% learning` : ''
const liveLearningColor = liveLearningRate > 70 ? 'green' : liveLearningRate > 40 ? 'yellow' : 'red'
// ─── Generate HTML ───
const html = `<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Knowledge Ratchet Dashboard</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', monospace; background: #0d1117; color: #c9d1d9; padding: 20px; }
h1 { color: #58a6ff; margin-bottom: 8px; font-size: 24px; }
h2 { color: #8b949e; font-size: 14px; margin-bottom: 20px; }
h3 { color: #58a6ff; font-size: 16px; margin: 20px 0 10px; border-bottom: 1px solid #21262d; padding-bottom: 5px; }
.grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); gap: 16px; margin-bottom: 20px; }
.card { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; }
.card-title { color: #8b949e; font-size: 12px; text-transform: uppercase; margin-bottom: 8px; }
.big-number { font-size: 36px; font-weight: bold; }
.green { color: #3fb950; }
.red { color: #f85149; }
.yellow { color: #d29922; }
.blue { color: #58a6ff; }
.dim { color: #484f58; }
svg { width: 100%; }
.chart-container { background: #161b22; border: 1px solid #21262d; border-radius: 8px; padding: 16px; margin-bottom: 16px; }
table { width: 100%; border-collapse: collapse; font-size: 13px; }
th { text-align: left; color: #8b949e; padding: 6px 8px; border-bottom: 1px solid #21262d; font-weight: normal; }
td { padding: 6px 8px; border-bottom: 1px solid #21262d; }
.bar { display: inline-block; height: 14px; border-radius: 2px; }
.bar-green { background: #238636; }
.bar-red { background: #da3633; }
.bar-yellow { background: #9e6a03; }
.danger-high { color: #f85149; font-weight: bold; }
.danger-medium { color: #d29922; }
.danger-low { color: #3fb950; }
.tag { display: inline-block; padding: 2px 6px; border-radius: 4px; font-size: 11px; margin: 1px; }
.tag-high { background: #da363322; color: #f85149; border: 1px solid #da363355; }
.tag-medium { background: #9e6a0322; color: #d29922; border: 1px solid #9e6a0355; }
.tag-low { background: #23863622; color: #3fb950; border: 1px solid #23863655; }
.timestamp { color: #484f58; font-size: 11px; margin-top: 20px; }
</style>
</head>
<body>
<h1>Knowledge Ratchet <a href="graph" style="font-size: 14px; color: #58a6ff; text-decoration: none; margin-left: 16px;">→ AtomSpace Graph</a></h1>
<h2>Self-improving AI quality system — tracks corrections, detects failure loops, auto-generates prevention rules
<br>Data through: ${benchmarkHistory.length > 0 ? benchmarkHistory[benchmarkHistory.length - 1].date : 'n/a'} | ${sessionData.length} sessions</h2>
${liveSession && liveSession.turns > 0 ? `
<div class="card" style="border-color: ${liveBorder}; margin-bottom: 16px;">
<div class="card-title" style="color: ${liveLabelColor}">${liveLabel}</div>
<div style="display: flex; gap: 32px; align-items: baseline;">
<div><span class="big-number ${liveColor}">${liveScore}</span><span class="dim"> score</span></div>
<div><span class="green" style="font-size:20px">${liveSession.approvals} approved</span> <span class="red" style="font-size:20px">${liveSession.corrections} corrected</span><span class="dim"> in ${liveSession.turns} turns</span></div>
<div><span class="dim">${liveSession.toolCalls} tool calls</span></div>
<div><span class="${liveTrajColor}">${liveTraj}</span></div>
${liveLearningRate !== null ? `<div><span class="${liveLearningColor}" style="font-size:18px">${liveLearningStr}</span></div>` : ''}
</div>
</div>
` : ''}
<!-- Ratchet Status — Is Claude getting smarter? -->
<div class="grid">
<div class="card">
<div class="card-title">Novel Corrections (target: >90%)</div>
<div class="big-number ${novelColor}">${novelRate}%${novelArrow}</div>
<div class="dim">% of corrections that are new mistakes, not repeats. Higher = old mistakes stay fixed.</div>
</div>
<div class="card">
<div class="card-title">Correction Density (target: <15%)</div>
<div class="big-number ${densityColor}">${densityPct}%${densityArrow}</div>
<div class="dim">How often the user has to correct Claude per turn. 20% = 1 in 5 turns. Lower = fewer mistakes.</div>
</div>
<div class="card">
<div class="card-title">Loop Resolution (target: <6 turns)</div>
<div class="big-number ${resColor}">${resolution}${resArrow}</div>
<div class="dim">When Claude gets stuck in a correction loop, how many turns to escape. Lower = learns faster.</div>
</div>
<div class="card">
<div class="card-title">Learned Rules</div>
<div class="big-number blue">${rulesCount}</div>
<div class="dim">${sessionData.length} sessions, ${loops?.totalLoops || 0} loops, ${loops?.resolved || 0} resolved</div>
</div>
</div>
<!-- Daily Ratchet Trends (primary) -->
${benchmarkHistory.length >= 1 ? `
<h3>Daily Ratchet Trends</h3>
<div class="grid">
<div class="card">
<div class="card-title">Novel Correction Rate (higher = better)</div>
${renderLineChart(
benchmarkHistory.filter(h => h.novelRate != null).map(h => h.novelRate),
benchmarkHistory.filter(h => h.novelRate != null).map(h => h.date),
{ height: 120, color: '#3fb950', label: 'Novel Rate', format: 'pct' }
)}
</div>
<div class="card">
<div class="card-title">Correction Density (lower = better)</div>
${renderLineChart(
benchmarkHistory.filter(h => h.density != null).map(h => h.density),
benchmarkHistory.filter(h => h.density != null).map(h => h.date),
{ height: 120, color: '#f85149', label: 'Density', format: 'pct' }
)}
</div>
<div class="card">
<div class="card-title">Loop Resolution Speed (lower = better)</div>
${renderLineChart(
benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.avgResolution),
benchmarkHistory.filter(h => h.avgResolution != null).map(h => h.date),
{ height: 120, color: '#d29922', label: 'Turns to Resolve', format: 'auto' }
)}
</div>
<div class="card">
<div class="card-title">Learned Rules (higher = more knowledge)</div>
${renderLineChart(
benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.rulesCount),
benchmarkHistory.filter(h => h.rulesCount != null).map(h => h.date),
{ height: 120, color: '#58a6ff', label: 'Rules', format: 'int' }
)}
</div>
</div>
<div class="card" style="margin-top:16px; margin-bottom:16px">
<div class="card-title">Ratchet Scorecard</div>
<table>
<tr><th>Metric</th><th>Current</th><th>Target</th><th>Status</th></tr>
<tr>
<td>Novel correction rate</td>
<td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate != null ? (benchmarkHistory[benchmarkHistory.length-1].novelRate * 100).toFixed(0) + '%' : 'n/a'}</td>
<td>>90%</td>
<td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].novelRate > 0.9 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.novelRate > 0.8 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td>
</tr>
<tr>
<td>Correction density</td>
<td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density != null ? (benchmarkHistory[benchmarkHistory.length-1].density * 100).toFixed(1) + '%' : 'n/a'}</td>
<td><15%</td>
<td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].density < 0.15 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.density < 0.25 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td>
</tr>
<tr>
<td>Avg loop resolution</td>
<td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution != null ? benchmarkHistory[benchmarkHistory.length-1].avgResolution.toFixed(1) + ' turns' : 'n/a'}</td>
<td><6 turns</td>
<td>${benchmarkHistory.length > 0 && benchmarkHistory[benchmarkHistory.length-1].avgResolution < 6 ? '<span class="green">✓</span>' : benchmarkHistory[benchmarkHistory.length-1]?.avgResolution < 10 ? '<span class="yellow">~</span>' : '<span class="red">✗</span>'}</td>
</tr>
<tr>
<td>Learned rules</td>
<td>${learnedRules?.totalRules || 0}</td>
<td>growing</td>
<td><span class="blue">${learnedRules?.totalRules > 0 ? '✓' : '~'}</span></td>
</tr>
</table>
</div>
` : ''}
<!-- Primary quality metric: Trusted score (top-5 sensors, noise-free) -->
<div class="chart-container">
<div class="card-title">★ Trusted Score (PRIMARY) — top-5 sensors only, ratchet's real quality signal</div>
${renderLineChart(weeklyData.map(w => w.trustedScore || 0), weeklyData.map(w => w.weekLabel), { height: 140, color: '#3fb950', label: 'Trusted Score', format: 'auto' })}
</div>
<!-- Secondary metrics for debugging -->
<div class="chart-container">
<div class="card-title">Per-Turn Score (all 16 sensors) — includes noise; see audit for caveats</div>
${renderLineChart(weeklyData.map(w => w.perTurnScore), weeklyData.map(w => w.weekLabel), { height: 100, color: '#8b949e', label: 'Per-Turn Score', format: 'auto' })}
</div>
<div class="chart-container">
<div class="card-title">Avg Correction Burst — longest run of consecutive corrections per session (lower = healthier; 1 = isolated mistakes, 2+ = stuck patterns)</div>
${renderLineChart(weeklyData.map(w => w.avgCorrectionBurst || 0), weeklyData.map(w => w.weekLabel), { height: 100, color: '#f0883e', label: 'Avg Burst', format: 'auto' })}
</div>
<!-- Session Length: ambition, not quality -->
<div class="chart-container">
<div class="card-title">Avg Turns Per Session — ambition level (not a quality metric)</div>
${renderLineChart(weeklyData.map(w => w.avgTurns), weeklyData.map(w => w.weekLabel), { height: 100, color: '#bc8cff', label: 'Avg Turns', format: 'int' })}
</div>
<!-- Weekly Correction Density -->
<div class="chart-container">
<div class="card-title">Weekly Corrections vs Approvals (green overtaking red = improving)</div>
${renderBarChart(weeklyData, { height: 120 })}
</div>
<!-- Loops Over Time -->
<div class="chart-container">
<div class="card-title">Loops Per Week (lower = better)</div>
${renderLineChart(
Object.entries(loopsWeekly).sort().map(([, v]) => v.total),
Object.entries(loopsWeekly).sort().map(([k]) => k),
{ height: 120, color: '#f85149', label: 'Loops Per Week', format: 'int' }
)}
</div>
<!-- Session Scatter -->
<div class="chart-container">
<div class="card-title">Session Scores — green dots = clean sessions, red = heavy corrections, size = session length. Higher = better.</div>
${renderScatter(sessionData, { height: 150 })}
</div>
<!-- Loops by Project -->
<h3>Loops by Project</h3>
<p class="dim" style="margin-bottom:8px">A loop is a correction spiral — Claude gets corrected, tries to fix it, gets corrected again. More loops = more frustration. Higher resolved % = problems eventually get fixed.</p>
<table>
<tr><th>Project</th><th>Loops</th><th>Resolved</th><th>Avg Duration</th><th></th></tr>
${Object.entries(loopsByProject).sort((a, b) => b[1].total - a[1].total).map(([proj, data]) => {
const pct = data.total > 0 ? (data.resolved / data.total * 100) : 0
return `<tr>
<td>${proj}</td>
<td>${data.total}</td>
<td>${data.resolved} (${pct.toFixed(0)}%)</td>
<td>${data.avgDuration.toFixed(1)} turns</td>
<td><span class="bar bar-red" style="width:${Math.min(data.total, 100)}px"></span></td>
</tr>`
}).join('\n ')}
</table>
<!-- Subsystem Danger Map -->
<h3>Subsystem Danger Map</h3>
<p class="dim" style="margin-bottom:8px">Auto-discovered from code. Red = high loop count (Claude struggles here). Yellow = moderate. Green = stable. L=loops, F=files in subsystem.</p>
${Object.entries(dangerMap).map(([proj, subs]) => {
if (subs.length === 0) return ''
return `<div style="margin-bottom:12px">
<strong>${proj}</strong>
<div>${subs.map(s =>
`<span class="tag tag-${s.danger}">${s.name} (${s.loops}L/${s.files}F)</span>`
).join(' ')}</div>
</div>`
}).join('\n')}
<!-- Sensor Weights -->
<h3>Sensor Weights</h3>
<p class="dim" style="margin-bottom:8px">The system uses 16 sensors to score each turn. Weights auto-adjust based on which sensors best predict corrections. Higher weight = more influence on per-turn scoring.</p>
<table>
<tr><th>Sensor</th><th>Weight</th><th>Bar</th></tr>
${fused?.sensorWeights ? Object.entries(fused.sensorWeights).sort((a, b) => b[1] - a[1]).map(([name, weight]) => {
const w = Math.round(weight * 100)
return `<tr>
<td>${name}</td>
<td>${weight.toFixed(3)}</td>
<td><span class="bar bar-green" style="width:${w}px"></span></td>
</tr>`
}).join('\n ') : '<tr><td colspan="3" class="dim">No weight data</td></tr>'}
</table>
<!-- Ratchet Intelligence Trends (formerly buried at bottom) -->
<div class="dim" style="margin-bottom: 4px; font-size: 11px;">Remaining sections: sensor weights, loops by project, subsystem danger map are below.</div>
<div class="timestamp">
Dashboard generated: ${new Date().toISOString()}<br>
Ratchet active since: 2026-04-07 | ${benchmarkHistory.length} benchmark snapshots
</div>
</body>
</html>`
fs.writeFileSync(OUTPUT_FILE, html)
console.log(`Dashboard: ${OUTPUT_FILE}`)
}
function serve (port) {
const http = require('http')
const url = require('url')
generate() // initial generation
http.createServer(async (req, res) => {
const parsed = url.parse(req.url, true)
try {
// /graph — render the D3 graph visualization page
if (parsed.pathname === '/graph' || parsed.pathname === '/graph.html') {
const graphHtml = fs.readFileSync(path.join(__dirname, 'atomspace/graph.html'), 'utf8')
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(graphHtml)
return
}
// /graph.json — live AtomSpace slice as JSON
if (parsed.pathname === '/graph.json') {
const { exportGraph } = require('./atomspace/graph-export')
const opts = {}
if (parsed.query.module) opts.module = parsed.query.module
if (parsed.query.minWisdom) opts.minWisdom = parseFloat(parsed.query.minWisdom)
if (parsed.query.limit) opts.limit = parseInt(parsed.query.limit)
const data = await exportGraph(opts)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify(data))
return
}
// /fires.json — recent rule firings for live dashboard
if (parsed.pathname === '/fires.json') {
const { readRecent, readSince } = require('./atomspace/fire-log')
const since = parsed.query.since ? parseInt(parsed.query.since) : null
const limit = parsed.query.limit ? parseInt(parsed.query.limit) : 50
const fires = since ? readSince(since) : readRecent(limit)
res.writeHead(200, { 'Content-Type': 'application/json' })
res.end(JSON.stringify({ fires, count: fires.length, at: Date.now() }))
return
}
// Default: serve the regenerated dashboard
generate() // regenerate on each request — always fresh data
res.writeHead(200, { 'Content-Type': 'text/html' })
res.end(fs.readFileSync(OUTPUT_FILE, 'utf8'))
} catch (err) {
res.writeHead(500, { 'Content-Type': 'text/plain' })
res.end('Dashboard error: ' + err.message)
}
}).listen(port, () => {
console.log(`Ratchet dashboard: http://localhost:${port}`)
console.log(`AtomSpace graph: http://localhost:${port}/graph`)
})
}
// ─── CLI ───
const serveArg = process.argv.find(a => a.startsWith('--serve'))
if (serveArg) {
const port = serveArg.includes('=') ? parseInt(serveArg.split('=')[1]) : 9077
serve(port)
} else {
generate()
}
// ─── SVG Chart Renderers ───
function renderLineChart (values, labels, opts = {}) {
const { height = 120, color = '#58a6ff', label = '', format = 'auto' } = opts
if (values.length === 0) return '<div class="dim">No data</div>'
const w = 600
const h = height
const padL = 50 // left pad for Y-axis labels
const padR = 20
const padT = 25
const padB = 25
const chartW = w - padL - padR
const chartH = h - padT - padB
const rawMin = Math.min(...values)
const rawMax = Math.max(...values)
const margin = (rawMax - rawMin) * 0.15 || 0.05
const min = rawMin - margin
const max = rawMax + margin
const range = max - min || 1
// Format values for display
function fmt (v) {
if (format === 'pct') return (v * 100).toFixed(0) + '%'
if (format === 'int') return Math.round(v).toString()
if (v >= 100) return Math.round(v).toString()
if (v >= 1) return v.toFixed(1)
return (v * 100).toFixed(0) + '%'
}
// Y-axis: 3-4 gridlines with labels
const gridLines = []
const steps = 4
for (let i = 0; i <= steps; i++) {
const v = min + (range * i / steps)
const y = h - padB - (i / steps) * chartH
gridLines.push({ v, y })
}
const points = values.map((v, i) => {
const x = padL + (i / Math.max(1, values.length - 1)) * chartW
const y = h - padB - ((v - min) / range) * chartH
return { x, y, v }
})
const xLabels = labels.map((l, i) => {
if (i % Math.max(1, Math.floor(labels.length / 6)) !== 0) return ''
const x = padL + (i / Math.max(1, values.length - 1)) * chartW
return `<text x="${x}" y="${h - 3}" fill="#484f58" font-size="10" text-anchor="middle">${l.slice(5)}</text>`
}).join('')
return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
${gridLines.map(g => `
<line x1="${padL}" y1="${g.y}" x2="${w - padR}" y2="${g.y}" stroke="#21262d" stroke-width="1"/>
<text x="${padL - 5}" y="${g.y + 4}" fill="#484f58" font-size="9" text-anchor="end">${fmt(g.v)}</text>
`).join('')}
<polyline points="${points.map(p => p.x + ',' + p.y).join(' ')}" fill="none" stroke="${color}" stroke-width="2"/>
${points.map(p => `<circle cx="${p.x}" cy="${p.y}" r="3" fill="${color}"><title>${fmt(p.v)}</title></circle>`).join('')}
${points.map(p => `<text x="${p.x}" y="${p.y - 8}" fill="${color}" font-size="9" text-anchor="middle">${fmt(p.v)}</text>`).join('')}
${xLabels}
<text x="${padL}" y="14" fill="#8b949e" font-size="11">${label}</text>
</svg>`
}
function renderBarChart (weeklyData, opts = {}) {
const { height = 140 } = opts
if (weeklyData.length === 0) return '<div class="dim">No data</div>'
const w = 600
const h = height
const padL = 50
const padR = 20
const padT = 25
const padB = 25
const chartW = w - padL - padR
const chartH = h - padT - padB
const barW = Math.max(8, chartW / weeklyData.length / 2 - 2)
const maxVal = Math.max(...weeklyData.map(d => Math.max(d.corrections, d.approvals)), 1)
// Y-axis gridlines
const gridLines = []
const steps = 4
for (let i = 0; i <= steps; i++) {
const v = Math.round(maxVal * i / steps)
const y = h - padB - (i / steps) * chartH
gridLines.push({ v, y })
}
return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
${gridLines.map(g => `
<line x1="${padL}" y1="${g.y}" x2="${w - padR}" y2="${g.y}" stroke="#21262d" stroke-width="1"/>
<text x="${padL - 5}" y="${g.y + 4}" fill="#484f58" font-size="9" text-anchor="end">${g.v}</text>
`).join('')}
${weeklyData.map((d, i) => {
const x = padL + (i / Math.max(1, weeklyData.length - 1)) * chartW
const corrH = (d.corrections / maxVal) * chartH
const apprH = (d.approvals / maxVal) * chartH
return `
<rect x="${x - barW}" y="${h - padB - corrH}" width="${barW}" height="${corrH}" fill="#da3633" opacity="0.7"/>
<rect x="${x}" y="${h - padB - apprH}" width="${barW}" height="${apprH}" fill="#238636" opacity="0.7"/>
<text x="${x - barW/2}" y="${h - padB - corrH - 3}" fill="#da3633" font-size="8" text-anchor="middle">${d.corrections}</text>
<text x="${x + barW/2}" y="${h - padB - apprH - 3}" fill="#238636" font-size="8" text-anchor="middle">${d.approvals}</text>
<text x="${x}" y="${h - 5}" fill="#484f58" font-size="9" text-anchor="middle">${d.weekLabel || d.week.slice(5)}</text>
`
}).join('')}
<text x="${padL}" y="14" fill="#da3633" font-size="11">■ corrections</text>
<text x="${padL + 100}" y="14" fill="#238636" font-size="11">■ approvals</text>
</svg>`
}
function renderScatter (sessions, opts = {}) {
const { height = 150 } = opts
if (sessions.length === 0) return '<div class="dim">No data</div>'
const w = 600
const h = height
const pad = 30
// X axis = date index, Y axis = score
const sorted = [...sessions].sort((a, b) => (a.date || '').localeCompare(b.date || ''))
const minScore = 0.4
const maxScore = 1.0
const range = maxScore - minScore
return `<svg viewBox="0 0 ${w} ${h}" xmlns="http://www.w3.org/2000/svg">
${sorted.map((s, i) => {
const x = pad + (i / Math.max(1, sorted.length - 1)) * (w - pad * 2)
const y = h - pad - ((s.score - minScore) / range) * (h - pad * 2)
const r = Math.min(8, Math.max(2, Math.sqrt(s.turns) * 0.5))
const color = s.corrections === 0 ? '#3fb950' : s.score < 0.6 ? '#f85149' : '#d29922'
return `<circle cx="${x}" cy="${y}" r="${r}" fill="${color}" opacity="0.6">
<title>${s.date} score=${s.score.toFixed(3)} ${s.turns}T ${s.corrections}✗ ${s.approvals}✓</title>
</circle>`
}).join('')}
<line x1="${pad}" y1="${h - pad - ((0.7 - minScore) / range) * (h - pad * 2)}" x2="${w - pad}" y2="${h - pad - ((0.7 - minScore) / range) * (h - pad * 2)}" stroke="#484f58" stroke-dasharray="4"/>
<text x="${w - pad + 5}" y="${h - pad - ((0.7 - minScore) / range) * (h - pad * 2) + 4}" fill="#484f58" font-size="9">0.7</text>
</svg>`
}
// Entry point moved to CLI section above generate()/serve()