@goodandready/dsh-cost-meter 0.8.25 โ†’ 0.8.27

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.
@@ -0,0 +1,260 @@
1
+ // dsh-cost-meter โ€” slash command /cost and receipt generator (Issue #125).
2
+
3
+ export function formatNumber(num) {
4
+ if (typeof num !== 'number' || Number.isNaN(num)) return '0'
5
+ return num.toLocaleString('en-US')
6
+ }
7
+
8
+ export function formatMoney(amount, currency = '$') {
9
+ const n = Number(amount) || 0
10
+ if (n === 0) return `${currency}0.00`
11
+ if (n < 0.0001) return `${currency}${n.toExponential(2)}`
12
+ if (n < 0.1) return `${currency}${n.toFixed(4)}`
13
+ return `${currency}${n.toFixed(2)}`
14
+ }
15
+
16
+ /**
17
+ * Format structured receipt markdown from session spend stats.
18
+ */
19
+ export function formatCostReceipt(data) {
20
+ const {
21
+ model = 'unknown',
22
+ provider = '',
23
+ currency = '$',
24
+ fx = 1,
25
+ costUsd = 0,
26
+ tokens = {},
27
+ cacheSavingsUsd = 0,
28
+ cacheSavingsPercent = 0,
29
+ tier = 'Standard',
30
+ rates = {},
31
+ burnRatePerHourUsd = 0,
32
+ projectedCostUsd = 0,
33
+ turnsCount = 0,
34
+ } = data || {}
35
+
36
+ const totalCost = costUsd * fx
37
+ const uncachedInput = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
38
+ const output = Number(tokens.outputTokens) || 0
39
+ const cacheRead = Number(tokens.cacheReadTokens) || 0
40
+ const cacheWrite = Number(tokens.cacheWriteTokens) || 0
41
+ const totalTokens = uncachedInput + output + cacheRead + cacheWrite
42
+
43
+ const totalPromptTokens = uncachedInput + cacheRead
44
+ const cacheHitRate = totalPromptTokens > 0
45
+ ? Math.round((cacheRead / totalPromptTokens) * 100)
46
+ : 0
47
+
48
+ const routeLabel = provider ? `${provider}/${model}` : model
49
+
50
+ const lines = [
51
+ `### ๐Ÿงพ Cost Meter โ€” Session Spend Receipt`,
52
+ ``,
53
+ `| Metric | Value |`,
54
+ `| :--- | :--- |`,
55
+ `| **Model** | \`${routeLabel}\` |`,
56
+ `| **Session Spend** | **${formatMoney(totalCost, currency)}** |`,
57
+ `| **Total Tokens** | ${formatNumber(totalTokens)} (${turnsCount} ${turnsCount === 1 ? 'turn' : 'turns'}) |`,
58
+ `| **Uncached Input** | ${formatNumber(uncachedInput)} |`,
59
+ `| **Prompt Cache Read** | ${formatNumber(cacheRead)} (${cacheHitRate}% hit rate) |`,
60
+ `| **Output Tokens** | ${formatNumber(output)} |`,
61
+ ]
62
+
63
+ if (cacheWrite > 0) {
64
+ lines.push(`| **Cache Write** | ${formatNumber(cacheWrite)} |`)
65
+ }
66
+
67
+ if (cacheSavingsUsd > 0) {
68
+ lines.push(`| **Cache Savings** | **โ‰ˆ ${formatMoney(cacheSavingsUsd * fx, currency)}** (${cacheSavingsPercent}% saved) |`)
69
+ }
70
+
71
+ lines.push(`| **Tariff Tier** | ${tier} |`)
72
+
73
+ if (rates.input !== undefined) {
74
+ const inR = rates.input ? `$${rates.input}/1M` : 'โ€”'
75
+ const outR = rates.output ? `$${rates.output}/1M` : 'โ€”'
76
+ const hitR = rates.cacheHit ? `$${rates.cacheHit}/1M` : 'โ€”'
77
+ lines.push(`| **Rates (1M tok)** | In ${inR} ยท Out ${outR} ยท Cache ${hitR} |`)
78
+ }
79
+
80
+ if (burnRatePerHourUsd > 0) {
81
+ lines.push(`| **Burn Rate** | โ‰ˆ ${formatMoney(burnRatePerHourUsd * fx, currency)}/hr (proj. ${formatMoney(projectedCostUsd * fx, currency)}) |`)
82
+ }
83
+
84
+ return lines.join('\n')
85
+ }
86
+
87
+ /**
88
+ * Format compact 1-line or 2-line summary.
89
+ */
90
+ export function formatCostSummary(data) {
91
+ const {
92
+ model = 'model',
93
+ currency = '$',
94
+ fx = 1,
95
+ costUsd = 0,
96
+ tokens = {},
97
+ cacheSavingsPercent = 0,
98
+ } = data || {}
99
+
100
+ const totalCost = costUsd * fx
101
+ const uncachedInput = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
102
+ const output = Number(tokens.outputTokens) || 0
103
+ const cacheRead = Number(tokens.cacheReadTokens) || 0
104
+ const totalTokens = uncachedInput + output + cacheRead
105
+ const totalPrompt = uncachedInput + cacheRead
106
+ const cacheHit = totalPrompt > 0 ? Math.round((cacheRead / totalPrompt) * 100) : 0
107
+
108
+ return `๐Ÿ’ฐ **Cost Meter**: โ‰ˆ ${formatMoney(totalCost, currency)} ยท ${formatNumber(totalTokens)} tokens (${cacheHit}% cache hit) ยท \`${model}\``
109
+ }
110
+
111
+ /**
112
+ * Register DSH slash command /cost and /cost-summary.
113
+ */
114
+ export function registerSlashCommand(ctx, options) {
115
+ if (typeof ctx?.inject !== 'function') return
116
+ const {
117
+ getCurrentConfig,
118
+ getCachedTariff,
119
+ getLastTurns,
120
+ resetLastTurns,
121
+ calculateBurnRateAndProjection,
122
+ calculateCacheSavings,
123
+ ratesAt,
124
+ splitRoute,
125
+ defaultRoute,
126
+ } = options || {}
127
+
128
+ ctx.inject(['commands'], (cmdCtx) => {
129
+ const commands = cmdCtx.commands
130
+ if (typeof commands?.register !== 'function') return
131
+
132
+ const executeCommand = async (rawArgs) => {
133
+ try {
134
+ const subcmd = String(rawArgs || '').trim().toLowerCase().split(/\s+/)[0] || 'receipt'
135
+ const cfg = typeof getCurrentConfig === 'function' ? getCurrentConfig() : {}
136
+ const currency = cfg.currency || '$'
137
+ const fx = Number(cfg.usdRate) || 1
138
+ const route = defaultRoute ? (defaultRoute(ctx) || { model: 'default' }) : { model: 'default' }
139
+ const tariff = typeof getCachedTariff === 'function' ? getCachedTariff(cfg, route) : null
140
+ const schedule = tariff?.schedule
141
+ const minute = (new Date().getUTCHours() * 60 + new Date().getUTCMinutes()) % 1440
142
+ const activeRates = (schedule && typeof ratesAt === 'function')
143
+ ? ratesAt(schedule, minute)
144
+ : (schedule?.base || {})
145
+
146
+ const turns = typeof getLastTurns === 'function' ? getLastTurns() : []
147
+
148
+ if (subcmd === 'reset') {
149
+ if (typeof resetLastTurns === 'function') resetLastTurns()
150
+ return 'โœจ Cost Meter: session turn spend history has been reset.'
151
+ }
152
+
153
+ if (subcmd === 'help') {
154
+ return [
155
+ '**Cost Meter Commands**:',
156
+ '- `/cost` or `/cost receipt`: generate detailed markdown spend receipt for this session',
157
+ '- `/cost summary`: compact spend and token usage summary',
158
+ '- `/cost reset`: reset per-session turn counter',
159
+ '- `/cost help`: show this help message',
160
+ ].join('\n')
161
+ }
162
+
163
+ // Aggregate tokens across turns
164
+ let totalUncachedInput = 0
165
+ let totalOutput = 0
166
+ let totalCacheRead = 0
167
+ let totalCacheWrite = 0
168
+ let totalCostUsd = 0
169
+
170
+ for (const t of turns) {
171
+ totalUncachedInput += Number(t.uncachedInputTokens) || 0
172
+ totalOutput += Number(t.outputTokens) || 0
173
+ totalCacheRead += Number(t.cacheReadTokens) || 0
174
+ totalCacheWrite += Number(t.cacheWriteTokens) || 0
175
+ totalCostUsd += Number(t.costDeltaUsd) || 0
176
+ }
177
+
178
+ const tokensObj = {
179
+ uncachedInputTokens: totalUncachedInput,
180
+ outputTokens: totalOutput,
181
+ cacheReadTokens: totalCacheRead,
182
+ cacheWriteTokens: totalCacheWrite,
183
+ }
184
+
185
+ const savings = (typeof calculateCacheSavings === 'function' && activeRates)
186
+ ? calculateCacheSavings(tokensObj, activeRates)
187
+ : { savedUsd: 0, percent: 0 }
188
+
189
+ const analytics = typeof calculateBurnRateAndProjection === 'function'
190
+ ? calculateBurnRateAndProjection(turns, totalCostUsd)
191
+ : { burnRatePerHourUsd: 0, projectedCostUsd: 0 }
192
+
193
+ const payload = {
194
+ model: route.model || 'active model',
195
+ provider: route.provider || '',
196
+ currency,
197
+ fx,
198
+ costUsd: totalCostUsd,
199
+ tokens: tokensObj,
200
+ cacheSavingsUsd: savings.savedUsd,
201
+ cacheSavingsPercent: savings.percent,
202
+ tier: tariff?.source === 'deepseek' ? 'DeepSeek Tariff' : (tariff?.source || 'Standard'),
203
+ rates: activeRates,
204
+ burnRatePerHourUsd: analytics.burnRatePerHourUsd,
205
+ projectedCostUsd: analytics.projectedCostUsd,
206
+ turnsCount: turns.length,
207
+ }
208
+
209
+ if (subcmd === 'summary' || subcmd === 'brief') {
210
+ return formatCostSummary(payload)
211
+ }
212
+
213
+ return formatCostReceipt(payload)
214
+ } catch (err) {
215
+ return `Cost Meter error: ${String(err?.message || err)}`
216
+ }
217
+ }
218
+
219
+ const commandHandler = async (invocation) => {
220
+ const rawArgs = typeof invocation === 'string'
221
+ ? invocation
222
+ : (invocation?.rawInput || invocation?.input || invocation?.text || invocation?.args || '')
223
+ const text = await executeCommand(rawArgs)
224
+ return {
225
+ kind: 'success',
226
+ text,
227
+ toString() { return text },
228
+ }
229
+ }
230
+
231
+ const commandExecute = async (rawArgs, invocation = null) => {
232
+ const result = await commandHandler(invocation || rawArgs)
233
+ return result.text
234
+ }
235
+
236
+ const unregister1 = commands.register({
237
+ definitionId: '@goodandready/dsh-cost-meter',
238
+ name: 'cost',
239
+ description: 'Show session spend receipt, tokens, cache savings and burn rate (/cost [receipt|summary|reset|help])',
240
+ input: { hint: '[receipt|summary|reset|help]' },
241
+ handler: commandHandler,
242
+ execute: commandExecute,
243
+ })
244
+
245
+ const unregister2 = commands.register({
246
+ definitionId: '@goodandready/dsh-cost-meter',
247
+ name: 'cost-summary',
248
+ description: 'Show compact 1-line session spend summary',
249
+ handler: async () => commandHandler('summary'),
250
+ execute: async () => commandExecute('summary'),
251
+ })
252
+
253
+ if (typeof ctx.effect === 'function') {
254
+ ctx.effect(() => () => {
255
+ unregister1?.()
256
+ unregister2?.()
257
+ }, 'dsh-cost-meter: slash-command')
258
+ }
259
+ })
260
+ }
package/lib/export.js ADDED
@@ -0,0 +1,378 @@
1
+ // dsh-cost-meter โ€” session spend export to CSV, JSON, and PNG canvas card (Issue #118).
2
+
3
+ /**
4
+ * Sanitize cell values for CSV and prevent CSV formula injection (CWE-1236).
5
+ * Prepends a single quote if string starts with dangerous characters.
6
+ */
7
+ export function sanitizeCsvCell(value) {
8
+ if (value === null || value === undefined) return ''
9
+ let str = String(value)
10
+ if (/^[=+\-@\t\r]/.test(str)) {
11
+ str = "'" + str
12
+ }
13
+ if (/[",\n\r]/.test(str)) {
14
+ str = '"' + str.replace(/"/g, '""') + '"'
15
+ }
16
+ return str
17
+ }
18
+
19
+ /**
20
+ * Generate formatted CSV with UTF-8 BOM for Excel / spreadsheet compatibility.
21
+ */
22
+ export function generateSessionCsv(data) {
23
+ const {
24
+ model = 'unknown',
25
+ provider = '',
26
+ currency = '$',
27
+ fx = 1,
28
+ turns = [],
29
+ totalCostUsd = 0,
30
+ exportedAt = new Date().toISOString(),
31
+ } = data || {}
32
+
33
+ const headers = [
34
+ 'Timestamp',
35
+ 'ISO_Time',
36
+ 'Turn',
37
+ 'Step',
38
+ 'Model',
39
+ 'Uncached_Input_Tokens',
40
+ 'Cache_Read_Tokens',
41
+ 'Cache_Hit_Rate_%',
42
+ 'Output_Tokens',
43
+ 'Total_Tokens',
44
+ 'Cost_USD',
45
+ 'Cost_Local',
46
+ 'Currency',
47
+ ]
48
+
49
+ const rows = [headers.map(sanitizeCsvCell).join(',')]
50
+
51
+ let sumUncached = 0
52
+ let sumHit = 0
53
+ let sumOut = 0
54
+ let sumTotalTok = 0
55
+ let sumCostUsd = 0
56
+
57
+ for (const t of turns) {
58
+ const ts = t.timestamp ? Number(t.timestamp) : Date.now()
59
+ const iso = new Date(ts).toISOString()
60
+ const uncached = Number(t.uncachedInputTokens) || 0
61
+ const hit = Number(t.cacheReadTokens) || 0
62
+ const out = Number(t.outputTokens) || 0
63
+ const totalTok = uncached + hit + out
64
+ const hitRate = t.cacheHitRate !== undefined
65
+ ? t.cacheHitRate
66
+ : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0)
67
+ const costUsd = Number(t.costDeltaUsd) || 0
68
+ const costLocal = costUsd * fx
69
+
70
+ sumUncached += uncached
71
+ sumHit += hit
72
+ sumOut += out
73
+ sumTotalTok += totalTok
74
+ sumCostUsd += costUsd
75
+
76
+ const row = [
77
+ ts,
78
+ iso,
79
+ t.turn ?? '',
80
+ t.step ?? 0,
81
+ provider ? `${provider}/${model}` : model,
82
+ uncached,
83
+ hit,
84
+ hitRate,
85
+ out,
86
+ totalTok,
87
+ costUsd.toFixed(6),
88
+ costLocal.toFixed(4),
89
+ currency,
90
+ ]
91
+ rows.push(row.map(sanitizeCsvCell).join(','))
92
+ }
93
+
94
+ // Summary row
95
+ const overallHitRate = sumUncached + sumHit > 0 ? Math.round((sumHit / (sumUncached + sumHit)) * 100) : 0
96
+ const summaryRow = [
97
+ 'TOTAL',
98
+ exportedAt,
99
+ turns.length,
100
+ '',
101
+ provider ? `${provider}/${model}` : model,
102
+ sumUncached,
103
+ sumHit,
104
+ overallHitRate,
105
+ sumOut,
106
+ sumTotalTok,
107
+ (totalCostUsd || sumCostUsd).toFixed(6),
108
+ ((totalCostUsd || sumCostUsd) * fx).toFixed(4),
109
+ currency,
110
+ ]
111
+ rows.push(summaryRow.map(sanitizeCsvCell).join(','))
112
+
113
+ // Prepend UTF-8 BOM (\uFEFF)
114
+ return '\uFEFF' + rows.join('\r\n')
115
+ }
116
+
117
+ /**
118
+ * Generate structured, versioned JSON export.
119
+ */
120
+ export function generateSessionJson(data) {
121
+ const {
122
+ model = 'unknown',
123
+ provider = '',
124
+ currency = '$',
125
+ fx = 1,
126
+ turns = [],
127
+ totalCostUsd = 0,
128
+ cacheSavingsUsd = 0,
129
+ cacheSavingsPercent = 0,
130
+ tier = 'Standard',
131
+ rates = {},
132
+ burnRatePerHourUsd = 0,
133
+ projectedCostUsd = 0,
134
+ exportedAt = new Date().toISOString(),
135
+ } = data || {}
136
+
137
+ let sumUncached = 0
138
+ let sumHit = 0
139
+ let sumOut = 0
140
+
141
+ const turnsFormatted = turns.map((t) => {
142
+ const uncached = Number(t.uncachedInputTokens) || 0
143
+ const hit = Number(t.cacheReadTokens) || 0
144
+ const out = Number(t.outputTokens) || 0
145
+ sumUncached += uncached
146
+ sumHit += hit
147
+ sumOut += out
148
+ return {
149
+ turn: t.turn,
150
+ step: t.step ?? 0,
151
+ timestamp: t.timestamp,
152
+ tokens: {
153
+ uncachedInput: uncached,
154
+ cacheRead: hit,
155
+ output: out,
156
+ total: uncached + hit + out,
157
+ cacheHitRate: t.cacheHitRate !== undefined ? t.cacheHitRate : (uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0),
158
+ },
159
+ durationMs: t.durationMs || 0,
160
+ costDeltaUsd: Number(t.costDeltaUsd) || 0,
161
+ costDeltaLocal: (Number(t.costDeltaUsd) || 0) * fx,
162
+ }
163
+ })
164
+
165
+ const totalPrompt = sumUncached + sumHit
166
+ const overallHitRate = totalPrompt > 0 ? Math.round((sumHit / totalPrompt) * 100) : 0
167
+
168
+ return {
169
+ version: 1,
170
+ generator: '@goodandready/dsh-cost-meter',
171
+ exportedAt,
172
+ session: {
173
+ model,
174
+ provider,
175
+ currency,
176
+ fxRate: fx,
177
+ totalCostUsd,
178
+ totalCostLocal: totalCostUsd * fx,
179
+ tokens: {
180
+ uncachedInput: sumUncached,
181
+ cacheRead: sumHit,
182
+ output: sumOut,
183
+ total: sumUncached + sumHit + sumOut,
184
+ cacheHitRate: overallHitRate,
185
+ },
186
+ cacheSavings: {
187
+ savedUsd: cacheSavingsUsd,
188
+ savedLocal: cacheSavingsUsd * fx,
189
+ percent: cacheSavingsPercent,
190
+ },
191
+ tier,
192
+ rates,
193
+ burnRate: {
194
+ perHourUsd: burnRatePerHourUsd,
195
+ perHourLocal: burnRatePerHourUsd * fx,
196
+ projectedUsd: projectedCostUsd,
197
+ projectedLocal: projectedCostUsd * fx,
198
+ },
199
+ turnsCount: turns.length,
200
+ },
201
+ turns: turnsFormatted,
202
+ }
203
+ }
204
+
205
+ /**
206
+ * Render visual summary card on an HTML5 Canvas (high-res 1200x680, 2x retina).
207
+ */
208
+ export function renderSummaryCardToCanvas(canvas, data) {
209
+ if (!canvas || typeof canvas.getContext !== 'function') return null
210
+
211
+ const width = 1200
212
+ const height = 680
213
+ canvas.width = width
214
+ canvas.height = height
215
+
216
+ const ctx = canvas.getContext('2d')
217
+ if (!ctx) return null
218
+
219
+ const {
220
+ model = 'deepseek-chat',
221
+ provider = 'deepseek',
222
+ currency = '$',
223
+ fx = 1,
224
+ costUsd = 0,
225
+ tokens = {},
226
+ cacheSavingsUsd = 0,
227
+ cacheSavingsPercent = 0,
228
+ tier = 'Off-Peak Tier (-50%)',
229
+ turns = [],
230
+ } = data || {}
231
+
232
+ const totalCost = costUsd * fx
233
+ const costFormatted = totalCost >= 1 ? `${currency}${totalCost.toFixed(2)}` : (totalCost > 0 ? `${currency}${totalCost.toFixed(4)}` : `${currency}0.00`)
234
+ const uncached = Number(tokens.uncachedInputTokens || tokens.inputTokens) || 0
235
+ const hit = Number(tokens.cacheReadTokens) || 0
236
+ const out = Number(tokens.outputTokens) || 0
237
+ const totalTok = uncached + hit + out
238
+ const hitRate = uncached + hit > 0 ? Math.round((hit / (uncached + hit)) * 100) : 0
239
+ const savedAmt = cacheSavingsUsd * fx
240
+ const savedFormatted = savedAmt >= 1 ? `${currency}${savedAmt.toFixed(2)}` : `${currency}${savedAmt.toFixed(4)}`
241
+
242
+ // Background gradient
243
+ const bgGrad = ctx.createLinearGradient(0, 0, width, height)
244
+ bgGrad.addColorStop(0, '#0f172a') // dark slate 900
245
+ bgGrad.addColorStop(1, '#1e293b') // dark slate 800
246
+ ctx.fillStyle = bgGrad
247
+ ctx.fillRect(0, 0, width, height)
248
+
249
+ // Outer border with subtle neon glow
250
+ ctx.strokeStyle = '#334155'
251
+ ctx.lineWidth = 3
252
+ ctx.strokeRect(20, 20, width - 40, height - 40)
253
+
254
+ // Top header pill
255
+ ctx.fillStyle = '#1e293b'
256
+ ctx.beginPath()
257
+ ctx.roundRect(50, 45, 220, 44, 22)
258
+ ctx.fill()
259
+ ctx.strokeStyle = '#3b82f6'
260
+ ctx.lineWidth = 2
261
+ ctx.stroke()
262
+
263
+ ctx.fillStyle = '#38bdf8'
264
+ ctx.font = 'bold 20px system-ui, -apple-system, sans-serif'
265
+ ctx.fillText('โšก Cost Meter', 75, 74)
266
+
267
+ // Date / timestamp
268
+ ctx.fillStyle = '#94a3b8'
269
+ ctx.font = '18px system-ui, -apple-system, sans-serif'
270
+ ctx.textAlign = 'right'
271
+ ctx.fillText(new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }), width - 60, 74)
272
+ ctx.textAlign = 'left'
273
+
274
+ // Model Name & Provider
275
+ ctx.fillStyle = '#f8fafc'
276
+ ctx.font = 'bold 36px system-ui, -apple-system, sans-serif'
277
+ const routeStr = provider ? `${provider}/${model}` : model
278
+ ctx.fillText(routeStr, 50, 145)
279
+
280
+ // Tariff Tier badge
281
+ ctx.fillStyle = tier.includes('Off-Peak') ? '#10b981' : '#f59e0b'
282
+ ctx.font = 'bold 20px system-ui, -apple-system, sans-serif'
283
+ ctx.fillText(tier, 50, 180)
284
+
285
+ // 4 Metric Tiles
286
+ const tiles = [
287
+ { label: 'SESSION SPEND', val: costFormatted, color: '#38bdf8' },
288
+ { label: 'TOTAL TOKENS', val: totalTok.toLocaleString(), color: '#f8fafc' },
289
+ { label: 'CACHE HIT RATE', val: `${hitRate}%`, color: '#34d399' },
290
+ { label: 'CACHE SAVED', val: `โ‰ˆ ${savedFormatted} (${cacheSavingsPercent}%)`, color: '#a78bfa' },
291
+ ]
292
+
293
+ const tileW = 250
294
+ const tileH = 110
295
+ const startX = 50
296
+ const tileY = 215
297
+
298
+ tiles.forEach((t, i) => {
299
+ const x = startX + i * (tileW + 20)
300
+ ctx.fillStyle = 'rgba(30, 41, 59, 0.7)'
301
+ ctx.beginPath()
302
+ ctx.roundRect(x, tileY, tileW, tileH, 12)
303
+ ctx.fill()
304
+ ctx.strokeStyle = '#334155'
305
+ ctx.lineWidth = 1.5
306
+ ctx.stroke()
307
+
308
+ ctx.fillStyle = '#94a3b8'
309
+ ctx.font = 'bold 14px system-ui, -apple-system, sans-serif'
310
+ ctx.fillText(t.label, x + 16, tileY + 36)
311
+
312
+ ctx.fillStyle = t.color
313
+ ctx.font = 'bold 26px system-ui, -apple-system, sans-serif'
314
+ ctx.fillText(t.val, x + 16, tileY + 80)
315
+ })
316
+
317
+ // Turns mini-chart
318
+ const chartY = 360
319
+ const chartH = 190
320
+ const chartW = width - 100
321
+ ctx.fillStyle = 'rgba(15, 23, 42, 0.6)'
322
+ ctx.beginPath()
323
+ ctx.roundRect(50, chartY, chartW, chartH, 12)
324
+ ctx.fill()
325
+ ctx.strokeStyle = '#334155'
326
+ ctx.lineWidth = 1.5
327
+ ctx.stroke()
328
+
329
+ ctx.fillStyle = '#64748b'
330
+ ctx.font = 'bold 15px system-ui, -apple-system, sans-serif'
331
+ ctx.fillText('TURN HISTORY & STEP DYNAMICS', 70, chartY + 35)
332
+
333
+ const activeTurns = turns.slice(-25)
334
+ if (activeTurns.length > 0) {
335
+ const maxTok = Math.max(...activeTurns.map((t) => Number(t.tokens) || 1), 100)
336
+ const barW = Math.min(32, Math.floor((chartW - 60) / activeTurns.length) - 6)
337
+ const baseY = chartY + chartH - 30
338
+ const availH = 100
339
+
340
+ activeTurns.forEach((t, i) => {
341
+ const x = 70 + i * (barW + 8)
342
+ const tok = Number(t.tokens) || 0
343
+ const h = Math.max(6, Math.round((tok / maxTok) * availH))
344
+ const y = baseY - h
345
+
346
+ // Bar fill with cache hit proportion
347
+ ctx.fillStyle = '#3b82f6'
348
+ ctx.beginPath()
349
+ ctx.roundRect(x, y, barW, h, 4)
350
+ ctx.fill()
351
+
352
+ const hitTokens = Number(t.cacheReadTokens) || 0
353
+ if (hitTokens > 0) {
354
+ const hitH = Math.round((hitTokens / tok) * h)
355
+ ctx.fillStyle = '#10b981'
356
+ ctx.beginPath()
357
+ ctx.roundRect(x, baseY - hitH, barW, hitH, 4)
358
+ ctx.fill()
359
+ }
360
+ })
361
+ } else {
362
+ ctx.fillStyle = '#64748b'
363
+ ctx.font = '16px system-ui, -apple-system, sans-serif'
364
+ ctx.fillText('No turn history recorded yet.', 70, chartY + 100)
365
+ }
366
+
367
+ // Footer
368
+ ctx.fillStyle = '#64748b'
369
+ ctx.font = '16px system-ui, -apple-system, sans-serif'
370
+ ctx.fillText('Generated by @goodandready/dsh-cost-meter', 50, height - 35)
371
+
372
+ ctx.fillStyle = '#475569'
373
+ ctx.textAlign = 'right'
374
+ ctx.fillText('goodandready.app', width - 50, height - 35)
375
+ ctx.textAlign = 'left'
376
+
377
+ return canvas
378
+ }