Spotify Wrapped for a Claude Code session: an animated stat reveal and a shareable PNG card

Spotify Wrapped for a Claude Code session: an animated stat reveal and a shareable PNG card.

/wrapped plays an animated stat reveal (session length, tool calls, MVP tool, red-to-green test runs, longest turn, cost) and writes a 1200px PNG card to your Desktop. Week and month totals come from scripts/usage.py, which reads your local transcripts in ~/.claude/projects and caches the rollups. Needs python3. Nothing leaves your machine.
In a Claude Code session (2.1.287 or later):
/plugin marketplace add OneWave-AI/claude-code-mods
/plugin install session-wrapped@claude-code-mods
Or load this folder for one session: claude --plugin-dir ./session-wrapped
| Mod | Network | Runs processes | Files | Calls a model | Sends data anywhere |
|---|---|---|---|---|---|
| session-wrapped | No | python3 (reads local transcripts), base64 (writes the PNG) | Writes one PNG to ~/Desktop, a cache in ~/.cache/session-wrapped | No | No |
Run claude plugin validate ./session-wrapped to list every event it hooks and every call it makes. See the repository README for the full security notes.
Part of claude-code-mods by OneWave AI. MIT licensed.
hooks/register.tsx 255 lines1import { atom, read, update } from 'claude-code'
2import type { EngineInterface, Register } from 'claude-code'
3
4import type { Reveal, Stats, Usage } from '../types'
5import { cardHeight, renderCard } from './card'
6import { GLYPH_H, GLYPH_W, lit, printable } from './font'
7import { toBase64 } from './png'
8import { addCall, addTurn, allCards, demoStats, demoUsage, empty, limitsOf } from './stats'
9
10const PANE = 'session-wrapped'
11const STEP_MS = 650
12
13const stats = atom({ plugin: 'session-wrapped', key: 'stats' } as const, empty(0))
14const reveal = atom({ plugin: 'session-wrapped', key: 'reveal' } as const, {
15 shown: 0,
16 isDemo: false,
17 savedPath: null,
18 generation: 0,
19 stats: null,
20 usage: null,
21 at: 0,
22} as Reveal)
23
24const GOLD = '#c2a87e'
25const BLUE = '#61A5FA'
26const PAPER = '#E8E2D6'
27const INK = '#0e0c0a'
28const HEX = { gold: GOLD, blue: BLUE, paper: PAPER } as const
29const RGB = { gold: 0xc2a87e, blue: 0x61a5fa, paper: 0xe8e2d6 } as const
30
31let turnStartedAt = 0
32
33/** Week and month usage from `scripts/usage.py`: a cached rollup of every transcript. */
34const scanUsage = ($: EngineInterface): Promise<Usage | null> =>
35 (async () => {
36 const ran = await $.process.run(['python3', `${$.plugin.root}/scripts/usage.py`], { timeoutMs: 120_000 })
37 if (ran.exitCode !== 0) return null
38 const parsed = JSON.parse(ran.stdout) as Usage
39 return parsed.week && parsed.month ? parsed : null
40 })().catch(() => null)
41let revealTimer: { cancel: () => void } | null = null
42
43/** Big text for a Raster: the 5x7 font in half blocks, 4 rows tall. */
44const bigCells = (value: string, color: number) => {
45 const chars = [...printable(value)]
46 const columns = Math.max(1, chars.length * (GLYPH_W + 1) - 1)
47 const rows = Math.ceil(GLYPH_H / 2)
48 const words = new Uint32Array(columns * rows * 3)
49 const on = (x: number, y: number) => {
50 const n = Math.floor(x / (GLYPH_W + 1))
51 const gx = x % (GLYPH_W + 1)
52 return gx < GLYPH_W && y < GLYPH_H && lit(chars[n] ?? ' ', gx, y)
53 }
54 for (let r = 0; r < rows; r++) {
55 for (let x = 0; x < columns; x++) {
56 const top = on(x, r * 2)
57 const bottom = on(x, r * 2 + 1)
58 const i = (r * columns + x) * 3
59 words[i] = top && bottom ? 0x2588 : top ? 0x2580 : bottom ? 0x2584 : 0x20
60 words[i + 1] = top || bottom ? color : 0x01000000
61 words[i + 2] = 0x01000000
62 }
63 }
64 return { columns, rows, cells: toBase64(new Uint8Array(words.buffer)) }
65}
66
67const dateLabel = (now: number) => {
68 const d = new Date(now)
69 const months = ['JAN', 'FEB', 'MAR', 'APR', 'MAY', 'JUN', 'JUL', 'AUG', 'SEP', 'OCT', 'NOV', 'DEC']
70 return `${months[d.getMonth()]} ${d.getDate()} ${d.getFullYear()}`
71}
72
73const stamp = (now: number) => {
74 const d = new Date(now)
75 const two = (n: number) => String(n).padStart(2, '0')
76 return `${d.getFullYear()}-${two(d.getMonth() + 1)}-${two(d.getDate())}-${two(d.getHours())}${two(d.getMinutes())}`
77}
78
79/** Writes the PNG with the system's base64 decoder (`$.fs.write` takes text alone), or a bundled Python helper where base64 has no -o. */
80const savePng = async ($: EngineInterface, s: Stats, usage: Usage | null, now: number, isDemo: boolean) => {
81 const home = (await $.env.get('HOME')) ?? '/tmp'
82 const path = `${home}/Desktop/claude-wrapped-${stamp(now)}${isDemo ? '-demo' : ''}.png`
83 const png = toBase64(renderCard(allCards(s, usage, now), dateLabel(now), isDemo))
84 const ran = await $.process.run(['base64', '-D', '-o', path], { stdin: png, timeoutMs: 20_000 })
85 if (ran.exitCode !== 0) {
86 const fallback = await $.process.run(['python3', `${$.plugin.root}/scripts/write_b64.py`, path], { stdin: png, timeoutMs: 20_000 })
87 if (fallback.exitCode !== 0) throw new Error(ran.stderr || fallback.stderr || 'base64 failed')
88 }
89 return path
90}
91
92const startReveal = async ($: EngineInterface, isDemo: boolean) => {
93 const now = await $.clock.now()
94 const meter = await $.session.usage()
95 const usd = meter.cost?.usd ?? 0
96 const model = await $.session.model().catch(() => '')
97 const live = { ...(await read($, stats)), usd, model, limits: limitsOf(meter.rateLimits) }
98 const frozen = isDemo ? demoStats(now) : live
99 // Fresh numbers each reveal: the scan reads only transcripts changed since the last one.
100 const usage = isDemo ? demoUsage(now) : await scanUsage($)
101 await update($, reveal, r => ({ shown: 0, isDemo, savedPath: null, generation: r.generation + 1, stats: frozen, usage, at: now }))
102 const opened = await $.ui.open({ id: PANE, title: 'Session Wrapped', focus: true, rows: 36 })
103 revealTimer?.cancel()
104 const total = allCards(frozen, usage, now).length
105 const timer = $.clock.every(STEP_MS, () => {
106 void read($, reveal).then(async r => {
107 if (r.shown >= total) {
108 timer.cancel()
109 if (revealTimer === timer) revealTimer = null
110 return
111 }
112 await update($, reveal, x => ({ ...x, shown: x.shown + 1 }))
113 })
114 })
115 revealTimer = timer
116 try {
117 const path = await savePng($, frozen, usage, now, isDemo)
118 await update($, reveal, r => ({ ...r, savedPath: path }))
119 return { path, isPlaced: opened.isPlaced }
120 } catch (err) {
121 return { path: null, isPlaced: opened.isPlaced, error: String(err) }
122 }
123}
124
125export const register: Register = on => {
126 on('session.start', async ($, e, next) => {
127 await $.command.register({
128 name: 'wrapped',
129 description: 'Session Wrapped: animated stat reveal + a shareable PNG card. /wrapped [demo]',
130 })
131 const s = await read($, stats)
132 if (s.startedAt === 0) {
133 const now = await $.clock.now()
134 await update($, stats, () => empty(now))
135 }
136 // Warm the transcript cache in the background so /wrapped answers fast.
137 void scanUsage($)
138 return next(e)
139 })
140
141 on('prompt.submit', async ($, e, next) => {
142 turnStartedAt = await $.clock.now()
143 return next(e)
144 })
145
146 on('tool.call', async ($, e, next) => {
147 const ran = await next(e)
148 if (ran.deny !== undefined) return ran
149 const input = e as { file_path?: unknown; notebook_path?: unknown; command?: unknown; skill?: unknown }
150 const filePath = typeof input.file_path === 'string' ? input.file_path : typeof input.notebook_path === 'string' ? input.notebook_path : undefined
151 const command = typeof input.command === 'string' ? input.command : undefined
152 const skill = typeof input.skill === 'string' ? input.skill : undefined
153 await update($, stats, s => addCall(s, { tool: String(e.tool), filePath, command, skill, isError: ran.isError === true }))
154 return ran
155 })
156
157 on('turn.complete', async ($, e, next) => {
158 const done = await next(e)
159 if (e.agentId === undefined && turnStartedAt > 0) {
160 const ms = (await $.clock.now()) - turnStartedAt
161 await update($, stats, s => addTurn(s, ms))
162 turnStartedAt = 0
163 }
164 return done
165 })
166
167 on('session.end', async ($, e, next) => {
168 $.ui.toast('Session Wrapped is ready: run /wrapped for your recap card', { timeoutMs: 8000 })
169 return next(e)
170 })
171
172 on('command.run', { command: 'wrapped' }, async ($, e) => {
173 const isDemo = e.args.trim().toLowerCase() === 'demo'
174 const done = await startReveal($, isDemo)
175 if (!done.path) return { text: `Wrapped is revealing in the pane, but the PNG did not save: ${done.error ?? 'unknown error'}` }
176 return { text: `Wrapped${isDemo ? ' (demo)' : ''}: card saved to ${done.path}` }
177 })
178
179 on('ui.render', { component: 'Pane', requestId: PANE }, async ($, e) => {
180 const elements = $.ui.resolve(e)
181 const { Box, Text } = elements
182 const Raster = 'Raster' in elements ? elements.Raster : null
183 const Image = 'Image' in elements ? elements.Image : null
184 const r = await read($, reveal)
185 if (!r.stats) {
186 return (
187 <Box paddingX={1}>
188 <Text dimColor>Run /wrapped to reveal this session.</Text>
189 </Box>
190 )
191 }
192 const list = allCards(r.stats, r.usage, r.at)
193 const width = Math.max(40, (e.props.bodyColumns || e.viewport?.columns || 80) - 2)
194 const latest = r.shown > 0 ? list[r.shown - 1] : undefined
195 const isDone = r.shown >= list.length
196 const big = latest ? bigCells(latest.value, RGB[latest.accent]) : null
197
198 return (
199 <Box flexDirection="column" paddingX={1}>
200 <Box justifyContent="space-between">
201 <Text>
202 <Text bold color={GOLD}>SESSION WRAPPED</Text>
203 <Text dimColor> {dateLabel(r.at)}{r.isDemo ? ' demo' : ''}</Text>
204 </Text>
205 <Text dimColor>{Math.min(r.shown, list.length)}/{list.length}</Text>
206 </Box>
207 <Text color="#2a2520">{'─'.repeat(width)}</Text>
208
209 {latest && big && (
210 <Box flexDirection="column" marginY={1}>
211 <Text dimColor>{latest.label}</Text>
212 {Raster && e.surface === 'terminal' && big.columns <= width ? (
213 <Raster key={`big-${r.shown}`} columns={big.columns} rows={big.rows} cells={big.cells} />
214 ) : (
215 <Text bold color={HEX[latest.accent]}>{latest.value}</Text>
216 )}
217 <Text color={PAPER}>{latest.note}</Text>
218 </Box>
219 )}
220
221 <Box flexDirection="column">
222 {list.slice(0, Math.max(0, r.shown - 1)).map(card => (
223 <Box key={card.label} gap={1}>
224 <Box width={1} backgroundColor={HEX[card.accent]} />
225 <Box width={15}>
226 <Text dimColor>{card.label}</Text>
227 </Box>
228 <Text bold color={HEX[card.accent]}>{card.value}</Text>
229 <Text dimColor wrap="truncate-end">{card.note}</Text>
230 </Box>
231 ))}
232 </Box>
233
234 {isDone && (
235 <Box flexDirection="column" marginTop={1}>
236 {r.savedPath && Image && e.surface === 'terminal' && (
237 <Image
238 key="card"
239 source={{ file: r.savedPath, format: 'png', generation: r.generation }}
240 columns={Math.min(width, 64)}
241 rows={Math.round(Math.min(width, 64) * (cardHeight(list.length) / 1200) / 2)}
242 alt=" "
243 />
244 )}
245 <Text>
246 <Text backgroundColor={GOLD} color={INK} bold> SHARE </Text>
247 <Text dimColor> {r.savedPath ? r.savedPath : 'saving the card...'}</Text>
248 </Text>
249 </Box>
250 )}
251 </Box>
252 )
253 })
254}
255hooks/card.ts 67 lines1/** The shareable card: a title, a 4-wide grid of stat tiles (as many rows as it takes), a footer. */
2
3import type { Card } from './stats'
4import { canvas, encodePng, fit, rect, text, textWidth } from './png'
5
6const W = 1200
7const TILE_H = 210
8const GAP = 18
9const PAD = 48
10
11/** The card's height for `count` tiles: 675 for eight, taller per extra row. */
12export const cardHeight = (count: number) => {
13 const rows = Math.max(1, Math.ceil(count / 4))
14 return PAD + 90 + rows * TILE_H + (rows - 1) * GAP + 99
15}
16const BG = 0x0e0c0a
17const TILE = 0x1a1714
18const RULE = 0x2a2520
19const PAPER = 0xe8e2d6
20const MUTED = 0x8a8278
21const GOLD = 0xc2a87e
22const BLUE = 0x61a5fa
23
24const ACCENT = { gold: GOLD, blue: BLUE, paper: PAPER } as const
25
26export const renderCard = (cards: Card[], dateLabel: string, isDemo: boolean) => {
27 const H = cardHeight(cards.length)
28 const c = canvas(W, H, BG)
29 const pad = PAD
30
31 // Title row.
32 rect(c, pad, pad, 10, 42, GOLD)
33 text(c, pad + 26, pad, 'CLAUDE CODE', 3, MUTED)
34 text(c, pad + 26, pad + 27, 'SESSION WRAPPED', 3, PAPER)
35 const right = isDemo ? `${dateLabel} DEMO` : dateLabel
36 text(c, W - pad - textWidth(right, 3), pad + 14, right, 3, GOLD)
37 rect(c, pad, pad + 64, W - pad * 2, 2, RULE)
38
39 // Tiles: 4 x 2.
40 const top = pad + 90
41 const gap = GAP
42 const cols = 4
43 const tileW = Math.floor((W - pad * 2 - gap * (cols - 1)) / cols)
44 const tileH = TILE_H
45 cards.forEach((card, i) => {
46 const x = pad + (i % cols) * (tileW + gap)
47 const y = top + Math.floor(i / cols) * (tileH + gap)
48 const accent = ACCENT[card.accent]
49 rect(c, x, y, tileW, tileH, TILE)
50 rect(c, x, y, tileW, 4, accent)
51 text(c, x + 20, y + 24, card.label, 2, MUTED)
52 const inner = tileW - 40
53 const scale = fit(card.value, inner, 9)
54 text(c, x + 20, y + 62 + Math.round((9 - scale) * 3.5), card.value, scale, accent)
55 const note = card.note.toUpperCase()
56 const noteScale = fit(note, inner, 2)
57 text(c, x + 20, y + tileH - 38, note, noteScale, PAPER)
58 })
59
60 // Footer.
61 const foot = 'MADE WITH A CLAUDE CODE MOD /WRAPPED'
62 text(c, pad, H - pad - 14, foot, 2, MUTED)
63 rect(c, W - pad - 120, H - pad - 12, 120, 4, GOLD)
64 rect(c, W - pad - 60, H - pad - 4, 60, 4, BLUE)
65 return encodePng(c)
66}
67hooks/font.ts 67 lines1/** A 5x7 bitmap font: uppercase, digits and the punctuation a stat card needs. */
2
3const ROWS: Record<string, string> = {
4 A: '01110 10001 10001 11111 10001 10001 10001',
5 B: '11110 10001 10001 11110 10001 10001 11110',
6 C: '01110 10001 10000 10000 10000 10001 01110',
7 D: '11110 10001 10001 10001 10001 10001 11110',
8 E: '11111 10000 10000 11110 10000 10000 11111',
9 F: '11111 10000 10000 11110 10000 10000 10000',
10 G: '01110 10001 10000 10111 10001 10001 01111',
11 H: '10001 10001 10001 11111 10001 10001 10001',
12 I: '01110 00100 00100 00100 00100 00100 01110',
13 J: '00111 00010 00010 00010 00010 10010 01100',
14 K: '10001 10010 10100 11000 10100 10010 10001',
15 L: '10000 10000 10000 10000 10000 10000 11111',
16 M: '10001 11011 10101 10101 10001 10001 10001',
17 N: '10001 10001 11001 10101 10011 10001 10001',
18 O: '01110 10001 10001 10001 10001 10001 01110',
19 P: '11110 10001 10001 11110 10000 10000 10000',
20 Q: '01110 10001 10001 10001 10101 10010 01101',
21 R: '11110 10001 10001 11110 10100 10010 10001',
22 S: '01111 10000 10000 01110 00001 00001 11110',
23 T: '11111 00100 00100 00100 00100 00100 00100',
24 U: '10001 10001 10001 10001 10001 10001 01110',
25 V: '10001 10001 10001 10001 10001 01010 00100',
26 W: '10001 10001 10001 10101 10101 10101 01010',
27 X: '10001 10001 01010 00100 01010 10001 10001',
28 Y: '10001 10001 01010 00100 00100 00100 00100',
29 Z: '11111 00001 00010 00100 01000 10000 11111',
30 '0': '01110 10001 10011 10101 11001 10001 01110',
31 '1': '00100 01100 00100 00100 00100 00100 01110',
32 '2': '01110 10001 00001 00010 00100 01000 11111',
33 '3': '11111 00010 00100 00010 00001 10001 01110',
34 '4': '00010 00110 01010 10010 11111 00010 00010',
35 '5': '11111 10000 11110 00001 00001 10001 01110',
36 '6': '00110 01000 10000 11110 10001 10001 01110',
37 '7': '11111 00001 00010 00100 01000 01000 01000',
38 '8': '01110 10001 10001 01110 10001 10001 01110',
39 '9': '01110 10001 10001 01111 00001 00010 01100',
40 ' ': '00000 00000 00000 00000 00000 00000 00000',
41 '.': '00000 00000 00000 00000 00000 01100 01100',
42 ',': '00000 00000 00000 00000 01100 00100 01000',
43 ':': '00000 01100 01100 00000 01100 01100 00000',
44 '-': '00000 00000 00000 11111 00000 00000 00000',
45 '+': '00000 00100 00100 11111 00100 00100 00000',
46 '/': '00001 00010 00010 00100 01000 01000 10000',
47 '%': '11001 11010 00010 00100 01000 01011 10011',
48 $: '00100 01111 10100 01110 00101 11110 00100',
49 "'": '00100 00100 01000 00000 00000 00000 00000',
50 '>': '01000 00100 00010 00001 00010 00100 01000',
51 '_': '00000 00000 00000 00000 00000 00000 11111',
52 x: '00000 00000 10001 01010 00100 01010 10001',
53}
54
55export const GLYPH_W = 5
56export const GLYPH_H = 7
57
58/** Whether pixel (x, y) of `ch` is lit; unknown characters draw blank. */
59export const lit = (ch: string, x: number, y: number) => {
60 const rows = ROWS[ch] ?? ROWS[ch.toUpperCase()] ?? ROWS[' ']!
61 return rows.split(' ')[y]?.[x] === '1'
62}
63
64/** The characters the font can draw: anything else becomes a space. */
65export const printable = (text: string) =>
66 [...text].map(ch => (ch === 'x' ? 'x' : ROWS[ch.toUpperCase()] ? ch.toUpperCase() : ' ')).join('')
67hooks/png.ts 168 lines1/** A tiny RGB canvas, a bitmap-font text painter and a store-only PNG encoder. */
2
3import { GLYPH_H, GLYPH_W, lit, printable } from './font'
4
5export type Canvas = { width: number; height: number; px: Uint8Array }
6
7export const canvas = (width: number, height: number, color: number): Canvas => {
8 const px = new Uint8Array(width * height * 3)
9 for (let i = 0; i < width * height; i++) {
10 px[i * 3] = (color >> 16) & 0xff
11 px[i * 3 + 1] = (color >> 8) & 0xff
12 px[i * 3 + 2] = color & 0xff
13 }
14 return { width, height, px }
15}
16
17export const rect = (c: Canvas, x: number, y: number, w: number, h: number, color: number) => {
18 const x0 = Math.max(0, Math.floor(x))
19 const y0 = Math.max(0, Math.floor(y))
20 const x1 = Math.min(c.width, Math.floor(x + w))
21 const y1 = Math.min(c.height, Math.floor(y + h))
22 for (let yy = y0; yy < y1; yy++) {
23 for (let xx = x0; xx < x1; xx++) {
24 const i = (yy * c.width + xx) * 3
25 c.px[i] = (color >> 16) & 0xff
26 c.px[i + 1] = (color >> 8) & 0xff
27 c.px[i + 2] = color & 0xff
28 }
29 }
30}
31
32/** Width in pixels of `text` at `scale` (one blank column between glyphs). */
33export const textWidth = (text: string, scale: number) =>
34 Math.max(0, [...text].length * (GLYPH_W + 1) * scale - scale)
35
36/** Paints `text` with its top-left at (x, y), each font pixel a `scale` square. */
37export const text = (c: Canvas, x: number, y: number, value: string, scale: number, color: number) => {
38 const chars = [...printable(value)]
39 chars.forEach((ch, n) => {
40 const ox = x + n * (GLYPH_W + 1) * scale
41 for (let gy = 0; gy < GLYPH_H; gy++) {
42 for (let gx = 0; gx < GLYPH_W; gx++) {
43 if (lit(ch, gx, gy)) rect(c, ox + gx * scale, y + gy * scale, scale, scale, color)
44 }
45 }
46 })
47}
48
49/** The largest scale up to `max` at which `value` fits in `width`. */
50export const fit = (value: string, width: number, max: number) => {
51 for (let s = max; s > 1; s--) if (textWidth(value, s) <= width) return s
52 return 1
53}
54
55// --- PNG -----------------------------------------------------------------
56
57const CRC_TABLE = (() => {
58 const table = new Uint32Array(256)
59 for (let n = 0; n < 256; n++) {
60 let c = n
61 for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1
62 table[n] = c >>> 0
63 }
64 return table
65})()
66
67export const crc32 = (bytes: Uint8Array, start = 0, end = bytes.length) => {
68 let c = 0xffffffff
69 for (let i = start; i < end; i++) c = CRC_TABLE[(c ^ bytes[i]!) & 0xff]! ^ (c >>> 8)
70 return (c ^ 0xffffffff) >>> 0
71}
72
73export const adler32 = (bytes: Uint8Array) => {
74 let a = 1
75 let b = 0
76 for (let i = 0; i < bytes.length; i++) {
77 a = (a + bytes[i]!) % 65521
78 b = (b + a) % 65521
79 }
80 return ((b << 16) | a) >>> 0
81}
82
83/** zlib around stored (uncompressed) deflate blocks: valid everywhere, no compressor needed. */
84export const zlibStore = (raw: Uint8Array) => {
85 const blocks = Math.max(1, Math.ceil(raw.length / 65535))
86 const out = new Uint8Array(2 + raw.length + blocks * 5 + 4)
87 out[0] = 0x78
88 out[1] = 0x01
89 let at = 2
90 for (let b = 0; b < blocks; b++) {
91 const start = b * 65535
92 const len = Math.min(65535, raw.length - start)
93 out[at++] = b === blocks - 1 ? 1 : 0
94 out[at++] = len & 0xff
95 out[at++] = len >> 8
96 out[at++] = ~len & 0xff
97 out[at++] = (~len >> 8) & 0xff
98 out.set(raw.subarray(start, start + len), at)
99 at += len
100 }
101 const sum = adler32(raw)
102 out[at++] = sum >>> 24
103 out[at++] = (sum >>> 16) & 0xff
104 out[at++] = (sum >>> 8) & 0xff
105 out[at++] = sum & 0xff
106 return out
107}
108
109const chunk = (type: string, data: Uint8Array) => {
110 const out = new Uint8Array(12 + data.length)
111 const view = new DataView(out.buffer)
112 view.setUint32(0, data.length)
113 for (let i = 0; i < 4; i++) out[4 + i] = type.charCodeAt(i)
114 out.set(data, 8)
115 view.setUint32(8 + data.length, crc32(out, 4, 8 + data.length))
116 return out
117}
118
119export const encodePng = (c: Canvas) => {
120 const stride = c.width * 3
121 const raw = new Uint8Array((stride + 1) * c.height)
122 for (let y = 0; y < c.height; y++) {
123 raw[y * (stride + 1)] = 0
124 raw.set(c.px.subarray(y * stride, (y + 1) * stride), y * (stride + 1) + 1)
125 }
126 const header = new Uint8Array(13)
127 const hv = new DataView(header.buffer)
128 hv.setUint32(0, c.width)
129 hv.setUint32(4, c.height)
130 header[8] = 8
131 header[9] = 2
132 const parts = [
133 Uint8Array.of(0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a),
134 chunk('IHDR', header),
135 chunk('IDAT', zlibStore(raw)),
136 chunk('IEND', new Uint8Array(0)),
137 ]
138 const out = new Uint8Array(parts.reduce((n, p) => n + p.length, 0))
139 let at = 0
140 for (const p of parts) {
141 out.set(p, at)
142 at += p.length
143 }
144 return out
145}
146
147const B64 = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/'
148
149export const toBase64 = (bytes: Uint8Array) => {
150 const parts: string[] = []
151 let out = ''
152 for (let i = 0; i < bytes.length; i += 3) {
153 const a = bytes[i] ?? 0
154 const b = bytes[i + 1] ?? 0
155 const c = bytes[i + 2] ?? 0
156 const n = (a << 16) | (b << 8) | c
157 out += B64[(n >> 18) & 63]! + B64[(n >> 12) & 63]!
158 out += i + 1 < bytes.length ? B64[(n >> 6) & 63]! : '='
159 out += i + 2 < bytes.length ? B64[n & 63]! : '='
160 if (out.length >= 8192) {
161 parts.push(out)
162 out = ''
163 }
164 }
165 parts.push(out)
166 return parts.join('')
167}
168hooks/stats.ts 278 lines1/** Session stats: the reducers the hooks feed, and the cards they reveal. Pure. */
2
3import type { Limits, Stats, Usage, UsageWindow } from '../types'
4
5export const empty = (startedAt: number): Stats => ({
6 startedAt,
7 tools: {},
8 files: {},
9 bash: 0,
10 tests: { pass: 0, fail: 0, comebacks: 0, last: null },
11 turns: 0,
12 longestTurnMs: 0,
13 usd: 0,
14 skills: {},
15 connectors: {},
16 model: '',
17})
18
19const TEST_RUN = /\b(vitest|jest|pytest|playwright\s+test|mocha|cargo\s+test|go\s+test|bun\s+test|deno\s+test|claude\s+plugin\s+test|(npm|pnpm|yarn)\s+(run\s+)?test\b|npm\s+t\b|rspec|phpunit)/
20
21export const isTestRun = (command: string) => TEST_RUN.test(command)
22
23const EDITS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
24
25/** `mcp__claude_ai_Gmail__search_threads` -> `Gmail`; null for a built-in tool. */
26export const connectorOf = (tool: string) => {
27 const parts = tool.split('__')
28 if (parts[0] !== 'mcp' || parts.length < 3) return null
29 const raw = parts[1] ?? ''
30 let name = raw.replace(/^claude_ai_/, '').replace(/^plugin_/, '')
31 if (raw.startsWith('plugin_') && name.includes('_')) name = name.slice(name.indexOf('_') + 1)
32 if (/^[0-9a-f]{8}[-_][0-9a-f]{4}/.test(name)) return null
33 return name.replace(/[_-]+/g, ' ').trim() || null
34}
35
36const bump = (counts: Record<string, number>, key: string | null | undefined) =>
37 key ? { ...counts, [key]: (counts[key] ?? 0) + 1 } : counts
38
39/** Folds one finished tool call into the stats. */
40export const addCall = (
41 s: Stats,
42 call: { tool: string; filePath?: string; command?: string; skill?: string; isError: boolean },
43): Stats => {
44 const tools = { ...s.tools, [call.tool]: (s.tools[call.tool] ?? 0) + 1 }
45 const skills = call.tool === 'Skill' ? bump(s.skills ?? {}, call.skill) : s.skills ?? {}
46 const connectors = bump(s.connectors ?? {}, connectorOf(call.tool))
47 const files = { ...s.files }
48 if (EDITS.has(call.tool) && call.filePath) files[call.filePath] = (files[call.filePath] ?? 0) + 1
49 let { bash, tests } = s
50 if (call.tool === 'Bash') {
51 bash += 1
52 if (call.command && isTestRun(call.command)) {
53 const result = call.isError ? 'fail' : 'pass'
54 tests = {
55 pass: tests.pass + (result === 'pass' ? 1 : 0),
56 fail: tests.fail + (result === 'fail' ? 1 : 0),
57 comebacks: tests.comebacks + (tests.last === 'fail' && result === 'pass' ? 1 : 0),
58 last: result,
59 }
60 }
61 }
62 return { ...s, tools, files, bash, tests, skills, connectors }
63}
64
65export const addTurn = (s: Stats, ms: number): Stats => ({
66 ...s,
67 turns: s.turns + 1,
68 longestTurnMs: Math.max(s.longestTurnMs, ms),
69})
70
71const top = (counts: Record<string, number>) =>
72 Object.entries(counts).sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))[0] ?? null
73
74export const mvp = (s: Stats) => top(s.tools)
75export const topFile = (s: Stats) => top(s.files)
76export const totalCalls = (s: Stats) => Object.values(s.tools).reduce((a, b) => a + b, 0)
77
78/** `3.5B`, `840M`, `12K`, `950`. */
79export const compact = (n: number) => {
80 const units: [number, string][] = [[1e12, 'T'], [1e9, 'B'], [1e6, 'M'], [1e3, 'K']]
81 for (const [size, unit] of units) {
82 if (n >= size) {
83 const v = n / size
84 return `${v >= 100 ? Math.round(v) : v.toFixed(1).replace(/\.0$/, '')}${unit}`
85 }
86 }
87 return String(Math.round(n))
88}
89
90/** `claude-opus-5-5[1m]` -> `Opus 5.5`; a name already pretty passes through. */
91export const prettyModel = (model: string) => {
92 const id = model.replace(/\[.*\]$/, '').trim()
93 if (!/^claude-/.test(id)) return id
94 const bits = id.replace(/^claude-/, '').split('-').filter(b => !/^\d{8}$/.test(b))
95 const family = bits.find(b => /^[a-z]+$/i.test(b))
96 const nums = bits.filter(b => /^\d+$/.test(b))
97 if (!family) return id
98 return `${family[0]!.toUpperCase()}${family.slice(1)}${nums.length ? ` ${nums.join('.')}` : ''}`
99}
100
101const comma = (n: number) => Math.round(n).toLocaleString('en-US')
102
103/** `Opus 5.5 52%, Opus 5 44%`: the leaders after the first, by share. */
104const rest = (pairs: [string, number][], skip: number, total: number, count = 2) =>
105 pairs
106 .slice(skip, skip + count)
107 .map(([name, n]) => (total > 0 ? `${name} ${Math.round((n / total) * 100)}%` : name))
108 .join(', ')
109
110const names = (pairs: [string, number][], skip: number, count = 2) =>
111 pairs.slice(skip, skip + count).map(([name]) => name).join(', ')
112
113/** `1H 23M`, `14M`, `45S`. */
114export const span = (ms: number) => {
115 const m = Math.floor(ms / 60000)
116 if (m >= 60) return `${Math.floor(m / 60)}H ${String(m % 60).padStart(2, '0')}M`
117 if (m >= 1) return `${m}M`
118 return `${Math.max(0, Math.round(ms / 1000))}S`
119}
120
121const base = (path: string) => path.split('/').pop() ?? path
122
123export type Card = { label: string; value: string; note: string; accent: 'gold' | 'blue' | 'paper' }
124
125/** The reveal, in order: each card one stat, the big number first. */
126export const cards = (s: Stats, now: number): Card[] => {
127 const best = mvp(s)
128 const file = topFile(s)
129 return [
130 {
131 label: 'SESSION LENGTH',
132 value: span(now - s.startedAt),
133 note: `${s.turns} turn${s.turns === 1 ? '' : 's'}${s.model ? ` on ${prettyModel(s.model)}` : ''}`,
134 accent: 'paper',
135 },
136 { label: 'TOOL CALLS', value: String(totalCalls(s)), note: `${Object.keys(s.tools).length} different tools`, accent: 'blue' },
137 { label: 'MVP', value: best ? best[0].toUpperCase().slice(0, 10) : 'NONE', note: best ? `called ${best[1]} times` : 'quiet session', accent: 'gold' },
138 { label: 'FILES TOUCHED', value: String(Object.keys(s.files).length), note: file ? `most: ${base(file[0])} x${file[1]}` : 'read-only session', accent: 'blue' },
139 { label: 'BASH RUNS', value: String(s.bash), note: `${s.tests.pass + s.tests.fail} test runs`, accent: 'paper' },
140 { label: 'RED TO GREEN', value: String(s.tests.comebacks), note: `${s.tests.fail} red, ${s.tests.pass} green`, accent: 'gold' },
141 { label: 'LONGEST TURN', value: span(s.longestTurnMs), note: 'one prompt', accent: 'paper' },
142 { label: 'COST', value: `$${s.usd.toFixed(2)}`, note: `$${(s.usd / Math.max(1, totalCalls(s))).toFixed(3)} / call`, accent: 'gold' },
143 ]
144}
145
146/** The session's kit: skills and connectors. */
147export const kitCards = (s: Stats, usage: Usage | null): Card[] => {
148 const skills = Object.entries(s.skills ?? {}).sort((a, b) => b[1] - a[1])
149 const connectors = Object.entries(s.connectors ?? {}).sort((a, b) => b[1] - a[1])
150 const month = usage?.month
151 return [
152 {
153 label: 'SKILLS USED',
154 value: String(skills.length),
155 note: skills.length ? names(skills, 0, 2) : month ? `${month.skillKinds} this month` : 'none this session',
156 accent: 'gold',
157 },
158 {
159 label: 'CONNECTORS',
160 value: String(connectors.length),
161 note: connectors.length ? names(connectors, 0, 2) : month ? `${month.connectorKinds} this month` : 'none this session',
162 accent: 'paper',
163 },
164 ]
165}
166
167/** Week and month, read from every transcript. */
168export const usageCards = (u: Usage | null): Card[] => {
169 if (!u) return []
170 const w: UsageWindow = u.week
171 const m: UsageWindow = u.month
172 const monthTokens = m.models.reduce((a, [, n]) => a + n, 0)
173 const topModel = m.models[0]
174 const topSkill = w.skills[0] ?? m.skills[0]
175 const topConn = w.connectors[0] ?? m.connectors[0]
176 return [
177 { label: 'THIS WEEK', value: compact(w.tokens), note: `tokens, ${comma(w.sessions)} sessions, ${comma(w.prompts)} prompts`, accent: 'gold' },
178 { label: 'THIS MONTH', value: compact(m.tokens), note: `tokens, ${m.activeDays} days active, ${comma(m.prompts)} prompts`, accent: 'blue' },
179 {
180 label: 'TOP MODEL',
181 value: topModel ? topModel[0].toUpperCase() : 'NONE',
182 note: topModel ? `${Math.round((topModel[1] / Math.max(1, monthTokens)) * 100)}% of month, then ${rest(m.models, 1, monthTokens)}` : 'no model calls',
183 accent: 'paper',
184 },
185 {
186 label: 'TOP SKILL',
187 value: topSkill ? topSkill[0].toUpperCase().slice(0, 14) : 'NONE',
188 note: topSkill ? `x${comma(topSkill[1])} this week, ${w.skillKinds} skills used` : 'no skills yet',
189 accent: 'gold',
190 },
191 {
192 label: 'TOP CONNECTOR',
193 value: topConn ? topConn[0].toUpperCase().slice(0, 14) : 'NONE',
194 note: topConn ? `x${comma(topConn[1])} this week, ${w.connectorKinds} in all` : 'no connectors yet',
195 accent: 'blue',
196 },
197 ]
198}
199
200/** The plan rate-limit windows from `$.session.usage()`'s `rateLimits`. */
201export const limitsOf = (rateLimits: readonly { kind: string; percentUsed: number; resetsAt?: string }[]): Limits => {
202 const find = (kind: string) => rateLimits.find(l => l.kind === kind)
203 const s = find('five_hour')
204 const w = find('seven_day')
205 return { week: w ? w.percentUsed : -1, session: s ? s.percentUsed : -1, weekResets: w?.resetsAt ?? '', sessionResets: s?.resetsAt ?? '' }
206}
207
208/** `2D 4H`, `3H 12M`, `40M`; '' when unknown. */
209export const resetsIn = (iso: string, now: number) => {
210 const ms = iso ? Date.parse(iso) - now : NaN
211 if (!Number.isFinite(ms)) return ''
212 if (ms <= 0) return 'now'
213 const m = Math.floor(ms / 60_000)
214 const d = Math.floor(m / 1440)
215 const h = Math.floor((m % 1440) / 60)
216 return d > 0 ? `${d}d ${h}h` : h > 0 ? `${h}h ${m % 60}m` : `${m}m`
217}
218
219/** The weekly plan limit: the share used is the number, the 5-hour window and the reset the note. */
220export const limitCard = (s: Stats, now: number): Card => {
221 const l = s.limits
222 if (!l || l.week < 0) return { label: 'WEEKLY LIMIT', value: '--', note: 'no plan limits on this login', accent: 'blue' }
223 const resets = resetsIn(l.weekResets, now)
224 const session = l.session >= 0 ? `5h ${Math.round(l.session)}%` : ''
225 return {
226 label: 'WEEKLY LIMIT',
227 value: `${Math.round(l.week)}%`,
228 note: [session, resets && `resets ${resets}`].filter(Boolean).join(', ') || 'used this week',
229 accent: l.week >= 80 ? 'gold' : 'blue',
230 }
231}
232
233/** Every card the reveal shows, in order: the weekly limit takes the top-right tile. */
234export const allCards = (s: Stats, usage: Usage | null, now: number): Card[] => {
235 const session = cards(s, now)
236 return [...session.slice(0, 3), limitCard(s, now), ...session.slice(3), ...kitCards(s, usage), ...usageCards(usage)]
237}
238
239/** Believable week and month numbers for `/wrapped demo`. */
240export const demoUsage = (now: number): Usage => ({
241 at: now,
242 week: {
243 sessions: 41, prompts: 612, tokens: 1_840_000_000, out: 3_100_000, activeDays: 6,
244 models: [['Opus 5.5', 1_420_000_000], ['Sonnet 5.5', 380_000_000], ['Haiku 4.5', 40_000_000]],
245 tools: [['Bash', 2210], ['Read', 1480], ['Edit', 930]], toolKinds: 38,
246 skills: [['write-docs', 9], ['create-image', 7], ['hyperframes', 4]], skillKinds: 14,
247 connectors: [['Supabase', 212], ['Slack', 96], ['Gmail', 61]], connectorKinds: 7,
248 },
249 month: {
250 sessions: 168, prompts: 2490, tokens: 7_260_000_000, out: 12_400_000, activeDays: 27,
251 models: [['Opus 5.5', 5_610_000_000], ['Sonnet 5.5', 1_390_000_000], ['Haiku 4.5', 260_000_000]],
252 tools: [['Bash', 8890], ['Read', 6020], ['Edit', 3710]], toolKinds: 61,
253 skills: [['write-docs', 31], ['create-image', 22], ['hyperframes', 17]], skillKinds: 29,
254 connectors: [['Supabase', 804], ['Slack', 377], ['Gmail', 240]], connectorKinds: 11,
255 },
256})
257
258/** A believable busy session for `/wrapped demo`. */
259export const demoStats = (now: number): Stats => ({
260 startedAt: now - (2 * 60 + 47) * 60000,
261 tools: { Edit: 142, Bash: 88, Read: 131, Grep: 54, Write: 19, Agent: 6, WebSearch: 4 },
262 files: {
263 'src/app/dashboard/page.tsx': 31,
264 'src/lib/stripe.ts': 18,
265 'supabase/migrations/058_connection.sql': 7,
266 'src/components/PricingTable.tsx': 22,
267 },
268 bash: 88,
269 tests: { pass: 23, fail: 9, comebacks: 6, last: 'pass' },
270 turns: 37,
271 longestTurnMs: 18 * 60000 + 22000,
272 usd: 14.62,
273 skills: { 'write-docs': 2, 'create-image': 1 },
274 connectors: { Supabase: 14, Slack: 3 },
275 model: 'Opus 5.5',
276 limits: { week: 62, session: 34, weekResets: new Date(now + (2 * 24 + 4) * 3_600_000).toISOString(), sessionResets: '' },
277})
278types/index.d.ts 59 lines1export type Stats = {
2 startedAt: number
3 tools: Record<string, number>
4 files: Record<string, number>
5 bash: number
6 tests: { pass: number; fail: number; comebacks: number; last: 'pass' | 'fail' | null }
7 turns: number
8 longestTurnMs: number
9 usd: number
10 /** Skills run this session (Skill tool calls), by name. */
11 skills: Record<string, number>
12 /** MCP connectors called this session, by server. */
13 connectors: Record<string, number>
14 /** The main loop's model when the reveal froze. */
15 model: string
16 /** Plan rate-limit windows when the reveal froze: percent used (-1 unknown) and ISO reset times. */
17 limits?: Limits
18}
19
20export type Limits = { week: number; session: number; weekResets: string; sessionResets: string }
21
22/** One window of `scripts/usage.py`'s rollup: [name, count] pairs, most first. */
23export type UsageWindow = {
24 sessions: number
25 prompts: number
26 tokens: number
27 out: number
28 activeDays: number
29 models: [string, number][]
30 tools: [string, number][]
31 toolKinds: number
32 skills: [string, number][]
33 skillKinds: number
34 connectors: [string, number][]
35 connectorKinds: number
36}
37
38export type Usage = { week: UsageWindow; month: UsageWindow; at: number }
39
40export type Reveal = {
41 /** How many cards are showing. */
42 shown: number
43 isDemo: boolean
44 /** The PNG written for this reveal, once it is on disk. */
45 savedPath: string | null
46 generation: number
47 /** Frozen stats the reveal draws (demo or a snapshot of the live ones). */
48 stats: Stats | null
49 /** Week and month usage from the transcripts, or null when the scan failed. */
50 usage: Usage | null
51 at: number
52}
53
54declare module 'claude-code' {
55 interface PluginState {
56 'session-wrapped': { stats: Stats; reveal: Reveal }
57 }
58}
59