SLOPSHOPPER

Session Wrapped

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

newpaneguardcommandtoastprompt
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · session-wrapped
│ ┃ Session Wrapped ✕ › fix the failing auth test and add an audit log call │ ┃ SESSION WRAPPED OCT 9 2025 0/11 │ ┃ ────────────────────────────────────────── ⏺ Read(src/auth.ts) │ ┃ ──────────── ⎿ Read 6 lines │ ⏺ Update(src/auth.ts) │ ⎿ Added 2 lines, removed 1 line │ ⏺ Bash(bun test) │ ⎿ 3 pass, 1 fail │ │ ● Done. refresh now rejects expired claims and logs an audit event. │ │ ✻ Worked for 42s · done 4:20 PM │ │ › /wrapped │ ⎿ session-wrapped: Wrapped: card saved to /Users/dev/Desktop/claud │ │ ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts

Draws

Pane · Session Wrapped
SESSION WRAPPED OCT 9 2025 0/11 ──────────────────────────────────────────────────────
README

session-wrapped

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

session-wrapped

/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.

Install

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

What it reaches

ModNetworkRuns processesFilesCalls a modelSends data anywhere
session-wrappedNopython3 (reads local transcripts), base64 (writes the PNG)Writes one PNG to ~/Desktop, a cache in ~/.cache/session-wrappedNoNo

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.

Source 6 files
hooks/register.tsx 255 lines
1import { 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}
255
hooks/card.ts 67 lines
1/** 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}
67
hooks/font.ts 67 lines
1/** 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('')
67
hooks/png.ts 168 lines
1/** 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}
168
hooks/stats.ts 278 lines
1/** 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})
278
types/index.d.ts 59 lines
1export 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