vibewrapped 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 eliseomdq
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,67 @@
1
- # Temporary Holding Version
1
+ # vibewrapped
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ **Spotify Wrapped for your AI coding agent.** One command turns your local Claude Code history into a card you'll actually want to post.
4
+
5
+ ```bash
6
+ npx vibewrapped
7
+ ```
8
+
9
+ ![Vibe Coding Wrapped card](https://raw.githubusercontent.com/eliseomdq/vibewrapped/main/docs/card.png)
10
+
11
+ *Sample card. Yours is built from your own sessions.*
12
+
13
+ ## What's on your card
14
+
15
+ - **Tokens** you pushed through Claude Code (cache included, deduplicated per message)
16
+ - **Your coding persona**: The 3AM Shipper, The Orchestrator, The Backseat Driver, The Terminal Goblin…
17
+ - Sessions, hours with your agent, longest day streak, prompts, tool calls, peak hour
18
+ - **Claude's catchphrase**: the verbal tic it repeated most to you ("Let me…", "Perfect!", "Good catch"…), and if it still says it, the legendary **"You're absolutely right!"** count
19
+
20
+ ## ⚠️ Claude Code is deleting your history
21
+
22
+ By default Claude Code deletes session transcripts older than **30 days** ([`cleanupPeriodDays`](https://code.claude.com/docs/en/settings-reference#cleanupperioddays)). If you want your December wrapped to cover more than November, run this today:
23
+
24
+ ```bash
25
+ npx vibewrapped --keep
26
+ ```
27
+
28
+ It sets `cleanupPeriodDays` to 3650 in your `~/.claude/settings.json` and leaves every other setting alone.
29
+
30
+ ## Options
31
+
32
+ ```
33
+ --year <yyyy> Only count one calendar year (default: all history)
34
+ --dir <path> Claude config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude); repeatable
35
+ --out <file> Where to save the card (default: ./vibewrapped.png)
36
+ --json Print the raw stats as JSON
37
+ --share Open a pre-filled post on X (you attach the image)
38
+ --keep Stop Claude Code from deleting history after 30 days
39
+ --no-open Do not open the card when done
40
+ ```
41
+
42
+ ## As a Claude Code plugin
43
+
44
+ ```
45
+ /plugin marketplace add eliseomdq/vibewrapped
46
+ /plugin install vibewrapped@vibewrapped
47
+ ```
48
+
49
+ Then ask Claude for your wrapped, or run `/vibewrapped:wrapped`.
50
+
51
+ ## Privacy
52
+
53
+ Everything runs on your machine. vibewrapped reads the JSONL transcripts in `~/.claude/projects`, renders the PNG locally with bundled fonts and makes no network requests. The card only shows aggregate numbers: no project names, paths, branches or prompts.
54
+
55
+ ## How the numbers are counted
56
+
57
+ - Claude Code writes one line per content block, so usage is counted once per message id and tool calls once per tool_use id.
58
+ - Prompts are your own messages: tool results, system notifications and interrupts are excluded.
59
+ - Active time adds up the gaps between events in a session, ignoring gaps over 30 minutes.
60
+ - Subagent runs count toward tokens and tool calls, not toward sessions or prompts.
61
+ - Days and hours use your machine's time zone.
62
+
63
+ Codex, Cursor and Gemini CLI support is next. PRs welcome.
64
+
65
+ ## License
66
+
67
+ MIT. Card fonts: [Geist](https://github.com/vercel/geist-font), SIL Open Font License.
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,93 @@
1
+ Copyright 2024 The Geist Project Authors (https://github.com/vercel/geist-font)
2
+
3
+ This Font Software is licensed under the SIL Open Font License, Version 1.1.
4
+ This license is copied below, and is also available with a FAQ at:
5
+ https://openfontlicense.org
6
+
7
+
8
+ -----------------------------------------------------------
9
+ SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
10
+ -----------------------------------------------------------
11
+
12
+ PREAMBLE
13
+ The goals of the Open Font License (OFL) are to stimulate worldwide
14
+ development of collaborative font projects, to support the font creation
15
+ efforts of academic and linguistic communities, and to provide a free and
16
+ open framework in which fonts may be shared and improved in partnership
17
+ with others.
18
+
19
+ The OFL allows the licensed fonts to be used, studied, modified and
20
+ redistributed freely as long as they are not sold by themselves. The
21
+ fonts, including any derivative works, can be bundled, embedded,
22
+ redistributed and/or sold with any software provided that any reserved
23
+ names are not used by derivative works. The fonts and derivatives,
24
+ however, cannot be released under any other type of license. The
25
+ requirement for fonts to remain under this license does not apply
26
+ to any document created using the fonts or their derivatives.
27
+
28
+ DEFINITIONS
29
+ "Font Software" refers to the set of files released by the Copyright
30
+ Holder(s) under this license and clearly marked as such. This may
31
+ include source files, build scripts and documentation.
32
+
33
+ "Reserved Font Name" refers to any names specified as such after the
34
+ copyright statement(s).
35
+
36
+ "Original Version" refers to the collection of Font Software components as
37
+ distributed by the Copyright Holder(s).
38
+
39
+ "Modified Version" refers to any derivative made by adding to, deleting,
40
+ or substituting -- in part or in whole -- any of the components of the
41
+ Original Version, by changing formats or by porting the Font Software to a
42
+ new environment.
43
+
44
+ "Author" refers to any designer, engineer, programmer, technical
45
+ writer or other person who contributed to the Font Software.
46
+
47
+ PERMISSION & CONDITIONS
48
+ Permission is hereby granted, free of charge, to any person obtaining
49
+ a copy of the Font Software, to use, study, copy, merge, embed, modify,
50
+ redistribute, and sell modified and unmodified copies of the Font
51
+ Software, subject to the following conditions:
52
+
53
+ 1) Neither the Font Software nor any of its individual components,
54
+ in Original or Modified Versions, may be sold by itself.
55
+
56
+ 2) Original or Modified Versions of the Font Software may be bundled,
57
+ redistributed and/or sold with any software, provided that each copy
58
+ contains the above copyright notice and this license. These can be
59
+ included either as stand-alone text files, human-readable headers or
60
+ in the appropriate machine-readable metadata fields within text or
61
+ binary files as long as those fields can be easily viewed by the user.
62
+
63
+ 3) No Modified Version of the Font Software may use the Reserved Font
64
+ Name(s) unless explicit written permission is granted by the corresponding
65
+ Copyright Holder. This restriction only applies to the primary font name as
66
+ presented to the users.
67
+
68
+ 4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
69
+ Software shall not be used to promote, endorse or advertise any
70
+ Modified Version, except to acknowledge the contribution(s) of the
71
+ Copyright Holder(s) and the Author(s) or with their explicit written
72
+ permission.
73
+
74
+ 5) The Font Software, modified or unmodified, in part or in whole,
75
+ must be distributed entirely under this license, and must not be
76
+ distributed under any other license. The requirement for fonts to
77
+ remain under this license does not apply to any document created
78
+ using the Font Software.
79
+
80
+ TERMINATION
81
+ This license becomes null and void if any of the above conditions are
82
+ not met.
83
+
84
+ DISCLAIMER
85
+ THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
86
+ EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
87
+ MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
88
+ OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
89
+ COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
90
+ INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
91
+ DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
92
+ FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
93
+ OTHER DEALINGS IN THE FONT SOFTWARE.
@@ -0,0 +1,150 @@
1
+ #!/usr/bin/env node
2
+ import { mkdir, writeFile } from 'node:fs/promises';
3
+ import { homedir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { spawn } from 'node:child_process';
6
+ import { parseArgs } from 'node:util';
7
+ import { defaultClaudeDirs, findSessionFiles, readRecords } from '../src/sessions.js';
8
+ import { computeStats } from '../src/stats.js';
9
+ import { renderCardSvg, highlight } from '../src/card.js';
10
+ import { svgToPng } from '../src/render.js';
11
+ import { pickPersona } from '../src/persona.js';
12
+ import { compact, dateRange, hourLabel } from '../src/format.js';
13
+ import { DEFAULT_DAYS, keepHistory, retentionDays } from '../src/retention.js';
14
+
15
+ const HELP = `vibewrapped — Spotify Wrapped for your AI coding agent
16
+
17
+ Usage: npx vibewrapped [options]
18
+
19
+ --year <yyyy> Only count one calendar year (default: all history)
20
+ --dir <path> Claude config dir (default: $CLAUDE_CONFIG_DIR or ~/.claude); repeatable
21
+ --out <file> Where to save the card (default: ./vibewrapped.png)
22
+ --json Print the raw stats as JSON instead of the card
23
+ --share Open a pre-filled post on X (you attach the image)
24
+ --keep Stop Claude Code from deleting history after 30 days
25
+ (sets cleanupPeriodDays to 3650 in settings.json)
26
+ --no-open Do not open the card when done
27
+ -h, --help Show this help
28
+
29
+ Everything runs locally. Nothing is uploaded.`;
30
+
31
+ const { values } = parseArgs({
32
+ options: {
33
+ year: { type: 'string' },
34
+ dir: { type: 'string', multiple: true },
35
+ out: { type: 'string', default: 'vibewrapped.png' },
36
+ json: { type: 'boolean', default: false },
37
+ share: { type: 'boolean', default: false },
38
+ keep: { type: 'boolean', default: false },
39
+ 'no-open': { type: 'boolean', default: false },
40
+ help: { type: 'boolean', short: 'h', default: false },
41
+ },
42
+ });
43
+
44
+ if (values.help) {
45
+ process.stdout.write(`${HELP}\n`);
46
+ process.exit(0);
47
+ }
48
+
49
+ if (values.year && !/^\d{4}$/.test(values.year)) {
50
+ process.stderr.write(`--year must look like 2026, got "${values.year}"\n`);
51
+ process.exit(1);
52
+ }
53
+
54
+ const timeZone = Intl.DateTimeFormat().resolvedOptions().timeZone;
55
+ const dirs = values.dir ?? defaultClaudeDirs(process.env, homedir());
56
+
57
+ const files = (await Promise.all(dirs.map(findSessionFiles))).flat();
58
+
59
+ if (files.length === 0) {
60
+ process.stderr.write(`No Claude Code sessions found in ${dirs.join(', ')}.\nPoint me at your config dir with --dir <path>.\n`);
61
+ process.exit(1);
62
+ }
63
+
64
+ // Local midnight, matching the time zone used for days and hours.
65
+ const since = values.year ? new Date(Number(values.year), 0, 1) : undefined;
66
+ const until = values.year ? new Date(Number(values.year) + 1, 0, 1) : undefined;
67
+
68
+ process.stderr.write(`Reading ${files.length} session files…\n`);
69
+ let unreadable = 0;
70
+ const stats = await computeStats(readRecords(files, () => unreadable++), { timeZone, since, until });
71
+ if (unreadable) process.stderr.write(`Skipped ${unreadable} session files that could not be read.\n`);
72
+
73
+ async function keep() {
74
+ for (const dir of dirs) {
75
+ try {
76
+ const { before, after } = await keepHistory(dir);
77
+ process.stderr.write(`History retention in ${dir}: ${before ?? `${DEFAULT_DAYS} (default)`} → ${after} days\n`);
78
+ } catch (error) {
79
+ process.stderr.write(`Could not update ${dir}: ${error.message}\n`);
80
+ process.exitCode = 1;
81
+ }
82
+ }
83
+ }
84
+
85
+ if (values.json) {
86
+ process.stdout.write(`${JSON.stringify(stats, null, 2)}\n`);
87
+ if (values.keep) await keep();
88
+ process.exit();
89
+ }
90
+
91
+ if (stats.prompts === 0) {
92
+ process.stderr.write('No prompts found in that range. Try without --year.\n');
93
+ process.exit(1);
94
+ }
95
+
96
+ const out = path.resolve(values.out);
97
+ await mkdir(path.dirname(out), { recursive: true });
98
+ await writeFile(out, svgToPng(renderCardSvg(stats, { timeZone })));
99
+
100
+ const persona = pickPersona(stats);
101
+ const hi = highlight(stats);
102
+ const cost = stats.costUSD === null ? '' : `\n API-equivalent cost $${compact(stats.costUSD)}`;
103
+ process.stdout.write(`
104
+ VIBE CODING WRAPPED ${dateRange(stats.range, timeZone)}
105
+
106
+ ${compact(stats.tokens.total)} tokens through Claude Code
107
+ You are ${persona.name}. ${persona.line}
108
+
109
+ sessions ${stats.sessions}
110
+ hours with your agent ${Math.round(stats.activeMinutes / 60)}
111
+ day streak ${stats.longestStreak}
112
+ prompts ${stats.prompts}
113
+ tool calls ${stats.toolCalls}
114
+ peak hour ${hourLabel(stats.peakHour)}
115
+ favorite tool ${stats.topTools[0]?.name ?? '—'}
116
+ favorite model ${stats.topModels[0]?.name ?? '—'}${cost}
117
+
118
+ ${hi.quote} ×${hi.count}
119
+
120
+ Card saved to ${out}
121
+ `);
122
+
123
+ if (values.keep) {
124
+ await keep();
125
+ } else {
126
+ const retention = Math.min(...(await Promise.all(dirs.map(retentionDays))));
127
+ if (retention <= DEFAULT_DAYS) {
128
+ process.stdout.write(`
129
+ Heads up: Claude Code deletes sessions older than ${retention} days, so this card can't go further back.
130
+ Run "npx vibewrapped --keep" now and your December wrapped will cover the whole stretch.
131
+ `);
132
+ }
133
+ }
134
+
135
+ function open(target) {
136
+ const [cmd, args] =
137
+ process.platform === 'darwin' ? ['open', [target]]
138
+ : process.platform === 'win32' ? ['cmd', ['/c', 'start', '""', target]]
139
+ : ['xdg-open', [target]];
140
+ // Opening is a convenience: on a headless box the card is still saved and its path printed.
141
+ spawn(cmd, args, { detached: true, stdio: 'ignore' }).on('error', () => {}).unref();
142
+ }
143
+
144
+ if (!values['no-open']) open(out);
145
+
146
+ if (values.share) {
147
+ const text = `My vibe coding wrapped: ${compact(stats.tokens.total)} tokens, ${stats.sessions} sessions, and I'm "${persona.name}".\n\n${hi.quote} ×${hi.count} 💀\n\nGet yours: npx vibewrapped`;
148
+ open(`https://x.com/intent/tweet?text=${encodeURIComponent(text)}&url=${encodeURIComponent('https://github.com/eliseomdq/vibewrapped')}`);
149
+ process.stdout.write(' Opened X in your browser. Attach the card and post.\n');
150
+ }
package/package.json CHANGED
@@ -1,6 +1,40 @@
1
1
  {
2
2
  "name": "vibewrapped",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Spotify Wrapped for your AI coding agent. One command turns your local Claude Code history into a shareable card. 100% local.",
5
+ "type": "module",
6
+ "bin": {
7
+ "vibewrapped": "bin/vibewrapped.js"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "assets"
13
+ ],
14
+ "scripts": {
15
+ "test": "node --test",
16
+ "test:coverage": "node --test --experimental-test-coverage"
17
+ },
18
+ "engines": {
19
+ "node": ">=20"
20
+ },
21
+ "keywords": [
22
+ "claude-code",
23
+ "wrapped",
24
+ "ai",
25
+ "agent",
26
+ "vibe-coding",
27
+ "claude",
28
+ "stats",
29
+ "cli"
30
+ ],
31
+ "license": "MIT",
32
+ "dependencies": {
33
+ "@resvg/resvg-js": "^2.6.2"
34
+ },
35
+ "repository": {
36
+ "type": "git",
37
+ "url": "git+https://github.com/eliseomdq/vibewrapped.git"
38
+ },
39
+ "homepage": "https://github.com/eliseomdq/vibewrapped"
40
+ }
package/src/card.js ADDED
@@ -0,0 +1,114 @@
1
+ import { compact, dateRange, escapeXml, hourLabel } from './format.js';
2
+ import { pickPersona } from './persona.js';
3
+
4
+ export const CARD_WIDTH = 1200;
5
+ export const CARD_HEIGHT = 675;
6
+ const REPO = 'github.com/eliseomdq/vibewrapped';
7
+
8
+ const C = {
9
+ bg: '#0A0B14',
10
+ text: '#F5F3FF',
11
+ muted: '#9C99B8',
12
+ accent: '#FF7A59',
13
+ violet: '#8B6CFF',
14
+ };
15
+
16
+ /**
17
+ * The meme line at the bottom of the card: the most screenshot-worthy stat available.
18
+ * @param {import('./stats.js').Stats} stats
19
+ */
20
+ export function highlight(stats) {
21
+ if (stats.absolutelyRight >= 10) {
22
+ return { quote: '“You’re absolutely right!”', count: stats.absolutelyRight, caption: 'times Claude said it to you' };
23
+ }
24
+ const [top] = stats.catchphrases;
25
+ if (top) {
26
+ return { quote: `“${top.name}”`, count: top.count, caption: 'Claude’s catchphrase: times it said it to you' };
27
+ }
28
+ if (stats.interrupts > 0) {
29
+ return { quote: 'Esc pressed mid-answer', count: stats.interrupts, caption: 'times you stopped Claude to correct it' };
30
+ }
31
+ if (stats.politePrompts > 0) {
32
+ return { quote: 'please & thank you', count: stats.politePrompts, caption: 'prompts where you were polite to a robot' };
33
+ }
34
+ return { quote: 'prompts', count: stats.prompts, caption: 'prompts sent' };
35
+ }
36
+
37
+ /** Shrinks a font size so a line of text fits the given width (Geist averages ~0.56em per glyph). */
38
+ function fit(text, maxSize, width) {
39
+ return Math.min(maxSize, Math.floor(width / (text.length * 0.56)));
40
+ }
41
+
42
+ function stat(x, y, value, label) {
43
+ return `
44
+ <text x="${x}" y="${y}" font-family="Geist Mono" font-weight="700" font-size="44" fill="${C.text}">${escapeXml(value)}</text>
45
+ <text x="${x}" y="${y + 30}" font-family="Geist" font-size="17" fill="${C.muted}">${escapeXml(label)}</text>`;
46
+ }
47
+
48
+ /**
49
+ * Renders the shareable card. Only aggregate numbers appear on it: no paths, projects or branches.
50
+ * @param {import('./stats.js').Stats} stats
51
+ * @param {{ timeZone: string }} options
52
+ * @returns {string}
53
+ */
54
+ export function renderCardSvg(stats, { timeZone }) {
55
+ const persona = pickPersona(stats);
56
+ const hi = highlight(stats);
57
+ const range = stats.range ? dateRange(stats.range, timeZone) : '';
58
+ const hours = Math.round(stats.activeMinutes / 60);
59
+ const cells = [
60
+ [compact(stats.sessions), 'sessions'],
61
+ [compact(hours), 'hours with your agent'],
62
+ [`${stats.longestStreak}`, 'day streak'],
63
+ [compact(stats.prompts), 'prompts'],
64
+ [compact(stats.toolCalls), 'tool calls'],
65
+ [hourLabel(stats.peakHour), 'peak hour'],
66
+ ];
67
+ const grid = cells
68
+ .map(([value, label], i) => stat(712 + (i % 2) * 230, 190 + Math.floor(i / 2) * 100, value, label))
69
+ .join('');
70
+ const tokens = compact(stats.tokens.total);
71
+ const personaSize = fit(persona.name, 50, 580);
72
+ const quoteSize = fit(hi.quote, 30, 700);
73
+
74
+ return `<svg xmlns="http://www.w3.org/2000/svg" width="${CARD_WIDTH}" height="${CARD_HEIGHT}" viewBox="0 0 ${CARD_WIDTH} ${CARD_HEIGHT}">
75
+ <defs>
76
+ <radialGradient id="glowA" cx="0.12" cy="0.1" r="0.7">
77
+ <stop offset="0" stop-color="${C.violet}" stop-opacity="0.45"/>
78
+ <stop offset="1" stop-color="${C.violet}" stop-opacity="0"/>
79
+ </radialGradient>
80
+ <radialGradient id="glowB" cx="0.95" cy="1" r="0.65">
81
+ <stop offset="0" stop-color="${C.accent}" stop-opacity="0.35"/>
82
+ <stop offset="1" stop-color="${C.accent}" stop-opacity="0"/>
83
+ </radialGradient>
84
+ <linearGradient id="hero" x1="0" y1="0" x2="1" y2="0">
85
+ <stop offset="0" stop-color="#FFB199"/>
86
+ <stop offset="0.55" stop-color="${C.accent}"/>
87
+ <stop offset="1" stop-color="${C.violet}"/>
88
+ </linearGradient>
89
+ </defs>
90
+ <rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="${C.bg}"/>
91
+ <rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="url(#glowA)"/>
92
+ <rect width="${CARD_WIDTH}" height="${CARD_HEIGHT}" fill="url(#glowB)"/>
93
+
94
+ <text x="64" y="84" font-family="Geist Mono" font-weight="700" font-size="17" letter-spacing="4" fill="${C.accent}">VIBE CODING WRAPPED</text>
95
+ <text x="1136" y="84" text-anchor="end" font-family="Geist Mono" font-size="17" fill="${C.muted}">${escapeXml(range)}</text>
96
+
97
+ <text x="58" y="250" font-family="Geist" font-weight="900" font-size="150" letter-spacing="-3" fill="url(#hero)">${escapeXml(tokens)}</text>
98
+ <text x="64" y="292" font-family="Geist" font-size="24" fill="${C.muted}">tokens through Claude Code</text>
99
+
100
+ <text x="64" y="370" font-family="Geist" font-size="20" fill="${C.muted}">You are</text>
101
+ <text x="64" y="${370 + personaSize + 6}" font-family="Geist" font-weight="700" font-size="${personaSize}" fill="${C.text}">${escapeXml(persona.name)}</text>
102
+ <text x="64" y="${370 + personaSize + 44}" font-family="Geist" font-size="20" fill="${C.muted}">${escapeXml(persona.line)}</text>
103
+
104
+ ${grid}
105
+
106
+ <rect x="64" y="490" width="1072" height="104" rx="18" fill="#FFFFFF" fill-opacity="0.05" stroke="#FFFFFF" stroke-opacity="0.12"/>
107
+ <text x="96" y="535" font-family="Geist" font-weight="700" font-size="${quoteSize}" fill="${C.text}">${escapeXml(hi.quote)}</text>
108
+ <text x="96" y="570" font-family="Geist" font-size="18" fill="${C.muted}">${escapeXml(hi.caption)}</text>
109
+ <text x="1104" y="562" text-anchor="end" font-family="Geist" font-weight="900" font-size="64" fill="${C.accent}">×${escapeXml(compact(hi.count))}</text>
110
+
111
+ <text x="64" y="640" font-family="Geist Mono" font-size="18" fill="${C.text}">$ npx vibewrapped</text>
112
+ <text x="1136" y="640" text-anchor="end" font-family="Geist Mono" font-size="16" fill="${C.muted}">${REPO}</text>
113
+ </svg>`;
114
+ }
package/src/format.js ADDED
@@ -0,0 +1,34 @@
1
+ const UNITS = [
2
+ [1e9, 'B'],
3
+ [1e6, 'M'],
4
+ [1e3, 'K'],
5
+ ];
6
+
7
+ /** @param {number} n @returns {string} */
8
+ export function compact(n) {
9
+ for (const [size, unit] of UNITS) {
10
+ if (n >= size) return `${Number((n / size).toFixed(1))}${unit}`;
11
+ }
12
+ return String(Math.round(n));
13
+ }
14
+
15
+ /** @param {number | null} hour @returns {string} */
16
+ export function hourLabel(hour) {
17
+ if (hour === null) return '—';
18
+ const h = hour % 12 === 0 ? 12 : hour % 12;
19
+ return `${h} ${hour < 12 ? 'AM' : 'PM'}`;
20
+ }
21
+
22
+ /** @param {{ first: string, last: string }} range @param {string} timeZone @returns {string} */
23
+ export function dateRange({ first, last }, timeZone) {
24
+ const fmt = (iso, withYear) =>
25
+ new Intl.DateTimeFormat('en-US', { timeZone, month: 'short', day: 'numeric', ...(withYear && { year: 'numeric' }) }).format(new Date(iso));
26
+ const year = (iso) => new Intl.DateTimeFormat('en-US', { timeZone, year: 'numeric' }).format(new Date(iso));
27
+ const sameYear = year(first) === year(last);
28
+ return `${fmt(first, !sameYear)} – ${fmt(last, true)}`;
29
+ }
30
+
31
+ /** @param {string} s @returns {string} */
32
+ export function escapeXml(s) {
33
+ return s.replace(/[<>&"']/g, (c) => ({ '<': '&lt;', '>': '&gt;', '&': '&amp;', '"': '&quot;', "'": '&apos;' })[c]);
34
+ }
package/src/persona.js ADDED
@@ -0,0 +1,38 @@
1
+ const PERSONAS = {
2
+ shipper3am: { name: 'The 3AM Shipper', line: 'Your best ideas show up after midnight. So do your bugs.' },
3
+ orchestrator: { name: 'The Orchestrator', line: 'Why do one thing when ten agents can do it?' },
4
+ backseat: { name: 'The Backseat Driver', line: 'You hit Esc like it owes you money.' },
5
+ goblin: { name: 'The Terminal Goblin', line: "If it can't be piped, it doesn't exist." },
6
+ refactorer: { name: 'The Relentless Refactorer', line: 'No line of code is ever finished.' },
7
+ archaeologist: { name: 'The Code Archaeologist', line: 'You dig through the codebase before you touch it.' },
8
+ nightOwl: { name: 'The Night Owl', line: 'Daylight is for meetings. Night is for shipping.' },
9
+ earlyShipper: { name: 'The Early Shipper', line: 'Merged before the coffee got cold.' },
10
+ architect: { name: 'The Vibe Architect', line: 'You describe it. It ships.' },
11
+ };
12
+
13
+ const TOOL_PERSONAS = {
14
+ Bash: PERSONAS.goblin,
15
+ Edit: PERSONAS.refactorer,
16
+ MultiEdit: PERSONAS.refactorer,
17
+ Write: PERSONAS.refactorer,
18
+ Read: PERSONAS.archaeologist,
19
+ Grep: PERSONAS.archaeologist,
20
+ Glob: PERSONAS.archaeologist,
21
+ };
22
+
23
+ /**
24
+ * Picks the archetype shown on the card, from the most to the least distinctive trait.
25
+ * @param {{ peakHour: number | null, sessions: number, subagentRuns: number, prompts: number,
26
+ * interrupts: number, topTools: { name: string }[] }} stats
27
+ * @returns {{ name: string, line: string }}
28
+ */
29
+ export function pickPersona({ peakHour, sessions, subagentRuns, prompts, interrupts, topTools }) {
30
+ if (peakHour !== null && peakHour < 5) return PERSONAS.shipper3am;
31
+ if (subagentRuns >= 5 && subagentRuns >= sessions * 0.5) return PERSONAS.orchestrator;
32
+ if (interrupts >= 5 && interrupts >= prompts * 0.1) return PERSONAS.backseat;
33
+ const byTool = topTools[0] && TOOL_PERSONAS[topTools[0].name];
34
+ if (byTool) return byTool;
35
+ if (peakHour !== null && peakHour >= 20) return PERSONAS.nightOwl;
36
+ if (peakHour !== null && peakHour < 9) return PERSONAS.earlyShipper;
37
+ return PERSONAS.architect;
38
+ }
package/src/render.js ADDED
@@ -0,0 +1,22 @@
1
+ import { Resvg } from '@resvg/resvg-js';
2
+ import { fileURLToPath } from 'node:url';
3
+ import path from 'node:path';
4
+ import { CARD_WIDTH } from './card.js';
5
+
6
+ const FONTS = path.join(path.dirname(fileURLToPath(import.meta.url)), '..', 'assets', 'fonts');
7
+ const FONT_FILES = ['Geist-Regular', 'Geist-Bold', 'Geist-Black', 'GeistMono-Regular', 'GeistMono-Bold'].map((f) =>
8
+ path.join(FONTS, `${f}.ttf`),
9
+ );
10
+
11
+ /**
12
+ * Rasterizes the card at 2x with bundled fonts, so it looks the same on every machine.
13
+ * @param {string} svg
14
+ * @returns {Buffer}
15
+ */
16
+ export function svgToPng(svg) {
17
+ const resvg = new Resvg(svg, {
18
+ font: { fontFiles: FONT_FILES, loadSystemFonts: false, defaultFontFamily: 'Geist' },
19
+ fitTo: { mode: 'width', value: CARD_WIDTH * 2 },
20
+ });
21
+ return resvg.render().asPng();
22
+ }
@@ -0,0 +1,57 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { chmod, readFile, rename, stat, writeFile } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+
5
+ // Claude Code deletes transcripts older than cleanupPeriodDays, 30 by default.
6
+ export const DEFAULT_DAYS = 30;
7
+ export const KEEP_DAYS = 3650;
8
+
9
+ /** @param {string} claudeDir @returns {Promise<Record<string, unknown> | null>} */
10
+ async function readSettings(claudeDir) {
11
+ const file = path.join(claudeDir, 'settings.json');
12
+ let raw;
13
+ try {
14
+ raw = await readFile(file, 'utf8');
15
+ } catch (error) {
16
+ if (error.code === 'ENOENT') return null;
17
+ throw error;
18
+ }
19
+ let settings;
20
+ try {
21
+ settings = JSON.parse(raw);
22
+ } catch {
23
+ throw new Error(`${file} is not valid JSON; fix it before running --keep`);
24
+ }
25
+ if (typeof settings !== 'object' || settings === null || Array.isArray(settings)) {
26
+ throw new Error(`${file} is not a JSON object; fix it before running --keep`);
27
+ }
28
+ return settings;
29
+ }
30
+
31
+ /** @param {string} claudeDir @returns {Promise<number>} */
32
+ export async function retentionDays(claudeDir) {
33
+ const settings = await readSettings(claudeDir).catch(() => null);
34
+ return typeof settings?.cleanupPeriodDays === 'number' ? settings.cleanupPeriodDays : DEFAULT_DAYS;
35
+ }
36
+
37
+ /**
38
+ * Raises cleanupPeriodDays in the user's settings.json so Claude Code stops deleting history.
39
+ * Writes to a temp file and renames it, so the settings file is never left half-written.
40
+ * @param {string} claudeDir
41
+ * @returns {Promise<{ before: number | null, after: number }>}
42
+ */
43
+ export async function keepHistory(claudeDir) {
44
+ const isDir = await stat(claudeDir).then((s) => s.isDirectory(), () => false);
45
+ if (!isDir) throw new Error(`${claudeDir} does not exist`);
46
+ const settings = (await readSettings(claudeDir)) ?? {};
47
+ const before = typeof settings.cleanupPeriodDays === 'number' ? settings.cleanupPeriodDays : null;
48
+ if (before !== null && before >= KEEP_DAYS) return { before, after: before };
49
+ const file = path.join(claudeDir, 'settings.json');
50
+ // settings.json can hold API keys in its env block: keep its mode, and default new files to 0600.
51
+ const mode = await stat(file).then((s) => s.mode & 0o777, () => 0o600);
52
+ const tmp = `${file}.vibewrapped-${randomUUID()}.tmp`;
53
+ await writeFile(tmp, `${JSON.stringify({ ...settings, cleanupPeriodDays: KEEP_DAYS }, null, 2)}\n`, { mode, flag: 'wx' });
54
+ await chmod(tmp, mode);
55
+ await rename(tmp, file);
56
+ return { before, after: KEEP_DAYS };
57
+ }
@@ -0,0 +1,68 @@
1
+ import { createReadStream } from 'node:fs';
2
+ import { readdir } from 'node:fs/promises';
3
+ import path from 'node:path';
4
+ import readline from 'node:readline';
5
+
6
+ /**
7
+ * @typedef {{ path: string, isSubagent: boolean }} SessionFile
8
+ */
9
+
10
+ /**
11
+ * @param {Record<string, string | undefined>} env
12
+ * @param {string} home
13
+ * @returns {string[]}
14
+ */
15
+ export function defaultClaudeDirs(env, home) {
16
+ return env.CLAUDE_CONFIG_DIR ? [env.CLAUDE_CONFIG_DIR] : [path.join(home, '.claude')];
17
+ }
18
+
19
+ /**
20
+ * Lists every session transcript under <claudeDir>/projects, including subagent runs
21
+ * stored in <session>/subagents/.
22
+ * @param {string} claudeDir
23
+ * @returns {Promise<SessionFile[]>}
24
+ */
25
+ export async function findSessionFiles(claudeDir) {
26
+ const projects = path.join(claudeDir, 'projects');
27
+ let entries;
28
+ try {
29
+ entries = await readdir(projects, { recursive: true, withFileTypes: true });
30
+ } catch (error) {
31
+ if (error.code === 'ENOENT') return [];
32
+ throw error;
33
+ }
34
+ return entries
35
+ .filter((e) => e.isFile() && e.name.endsWith('.jsonl'))
36
+ .map((e) => {
37
+ const dir = e.parentPath ?? e.path;
38
+ return { path: path.join(dir, e.name), isSubagent: path.basename(dir) === 'subagents' };
39
+ });
40
+ }
41
+
42
+ /**
43
+ * Streams parsed JSONL records. Lines that are not valid JSON (partial writes) are skipped,
44
+ * and so are files that vanish or can't be read mid-run (Claude Code's own cleanup can delete them).
45
+ * @param {SessionFile[]} files
46
+ * @param {(file: string, error: Error) => void} onUnreadable
47
+ * @returns {AsyncGenerator<{ file: string, record: any, isSubagent: boolean }>}
48
+ */
49
+ export async function* readRecords(files, onUnreadable = () => {}) {
50
+ for (const { path: file, isSubagent } of files) {
51
+ const parsed = [];
52
+ try {
53
+ const lines = readline.createInterface({ input: createReadStream(file), crlfDelay: Infinity });
54
+ for await (const line of lines) {
55
+ if (!line.trim()) continue;
56
+ try {
57
+ parsed.push(JSON.parse(line));
58
+ } catch {
59
+ continue;
60
+ }
61
+ }
62
+ } catch (error) {
63
+ onUnreadable(file, error);
64
+ continue;
65
+ }
66
+ for (const record of parsed) yield { file, record, isSubagent };
67
+ }
68
+ }
package/src/stats.js ADDED
@@ -0,0 +1,229 @@
1
+ const BREAK_MS = 30 * 60 * 1000;
2
+ const ABSOLUTELY_RIGHT = /you('| a)?re absolutely right|ten[eé]s toda la raz[oó]n|tienes toda la raz[oó]n/i;
3
+ // Verbal tics counted in Claude's replies; the most frequent one becomes the card's catchphrase.
4
+ const CATCHPHRASES = [
5
+ ['Let me…', /\blet me\b/i],
6
+ ['You’re right', /\byou('| a)re (absolutely )?right\b/i],
7
+ ['Perfect!', /^perfect[!.]/im],
8
+ ['Great question', /\bgreat question\b/i],
9
+ ['Good catch', /\bgood catch\b/i],
10
+ ['My mistake', /\bmy (mistake|bad)\b|\bI apologi[sz]e\b/i],
11
+ ['Done.', /^done[!.]/im],
12
+ ['Déjame…', /\bd[eé]jame\b/i],
13
+ ['Tenés razón', /\bten[eé]s (toda la )?raz[oó]n|\btienes (toda la )?raz[oó]n/i],
14
+ ['Me equivoqué', /\bme equivoqu[eé]/i],
15
+ ['Listo.', /^listo[!.]/im],
16
+ ];
17
+ const POLITE = /\b(please|pls|plz|thanks|thank you|ty)\b|por favor|gracias/i;
18
+ const INTERRUPT = /^\[Request interrupted by user/;
19
+ // Tags Claude Code wraps around messages it injects as "user" turns.
20
+ const SYSTEM_TAG = /^<(task-notification|fork-boilerplate|local-command-caveat|local-command-stdout|local-command-stderr|command-name|command-message|command-args|bash-input|bash-stdout|bash-stderr|system-reminder|artifact-content-authored-by-others)\b/;
21
+
22
+ /**
23
+ * @typedef {{ file: string, record: any, isSubagent: boolean }} SourceRecord
24
+ * @typedef {{ name: string, count: number }} Ranked
25
+ * @typedef {{
26
+ * range: { first: string, last: string } | null,
27
+ * sessions: number, subagentRuns: number, prompts: number, interrupts: number,
28
+ * politePrompts: number, messages: number, toolCalls: number, absolutelyRight: number,
29
+ * tokens: { input: number, output: number, cacheRead: number, cacheWrite: number, total: number },
30
+ * costUSD: number | null, topTools: Ranked[], topModels: Ranked[], catchphrases: Ranked[],
31
+ * activeDays: number, longestStreak: number, peakHour: number | null,
32
+ * hours: number[], activeMinutes: number, longestSessionMinutes: number,
33
+ * }} Stats
34
+ */
35
+
36
+ /** @param {unknown} content @returns {string | null} */
37
+ function promptText(content) {
38
+ if (typeof content === 'string') return content;
39
+ if (!Array.isArray(content) || content.some((b) => b?.type === 'tool_result')) return null;
40
+ const text = content.find((b) => b?.type === 'text' && typeof b.text === 'string');
41
+ return text ? text.text : null;
42
+ }
43
+
44
+ /** @param {unknown} n */
45
+ function num(n) {
46
+ return typeof n === 'number' && Number.isFinite(n) ? n : 0;
47
+ }
48
+
49
+ /** @param {Map<string, number>} counts @param {number} limit @returns {Ranked[]} */
50
+ function rank(counts, limit) {
51
+ return [...counts.entries()]
52
+ .sort((a, b) => b[1] - a[1])
53
+ .slice(0, limit)
54
+ .map(([name, count]) => ({ name, count }));
55
+ }
56
+
57
+ /** @param {Date} date @param {string} timeZone */
58
+ function localParts(date, timeZone) {
59
+ const parts = new Intl.DateTimeFormat('en-CA', {
60
+ timeZone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', hourCycle: 'h23',
61
+ }).formatToParts(date);
62
+ const get = (type) => parts.find((p) => p.type === type).value;
63
+ return { day: `${get('year')}-${get('month')}-${get('day')}`, hour: Number(get('hour')) };
64
+ }
65
+
66
+ /** @param {string[]} days sorted YYYY-MM-DD */
67
+ function longestStreak(days) {
68
+ let best = 0;
69
+ let run = 0;
70
+ let prev = null;
71
+ for (const day of days) {
72
+ const t = Date.parse(`${day}T00:00:00Z`);
73
+ run = prev !== null && t - prev === 86400000 ? run + 1 : 1;
74
+ best = Math.max(best, run);
75
+ prev = t;
76
+ }
77
+ return best;
78
+ }
79
+
80
+ /** @param {number[]} times sorted epoch ms */
81
+ function activeMs(times) {
82
+ let total = 0;
83
+ for (let i = 1; i < times.length; i++) {
84
+ const gap = times[i] - times[i - 1];
85
+ if (gap <= BREAK_MS) total += gap;
86
+ }
87
+ return total;
88
+ }
89
+
90
+ /**
91
+ * Aggregates Claude Code session records into wrapped stats.
92
+ * Assistant messages are split across several lines sharing one message id,
93
+ * so usage is counted once per id and tool calls once per tool_use id.
94
+ * @param {AsyncIterable<SourceRecord>} records
95
+ * @param {{ timeZone: string, since?: Date, until?: Date }} options
96
+ * @returns {Promise<Stats>}
97
+ */
98
+ export async function computeStats(records, { timeZone, since, until }) {
99
+ const usageById = new Map();
100
+ const promptIds = new Set();
101
+ const toolIds = new Set();
102
+ const textBlocks = new Set();
103
+ const tools = new Map();
104
+ const models = new Map();
105
+ const costByFile = new Map();
106
+ const timesByFile = new Map();
107
+ const sessionFiles = new Set();
108
+ const subagentFiles = new Set();
109
+ const days = new Set();
110
+ const hours = Array(24).fill(0);
111
+ let prompts = 0;
112
+ let interrupts = 0;
113
+ let politePrompts = 0;
114
+ let absolutelyRight = 0;
115
+ const phrases = new Map();
116
+ let first = null;
117
+ let last = null;
118
+
119
+ for await (const { file, record, isSubagent } of records) {
120
+ if (record?.type === 'cost-state') {
121
+ if (typeof record.totalCostUSD === 'number') costByFile.set(file, record.totalCostUSD);
122
+ continue;
123
+ }
124
+ if (record?.type !== 'user' && record?.type !== 'assistant') continue;
125
+ if (typeof record.timestamp !== 'string' && record.timestamp !== undefined) continue;
126
+
127
+ const time = record.timestamp ? new Date(record.timestamp) : null;
128
+ if (time && Number.isNaN(time.getTime())) continue;
129
+ if ((since || until) && !time) continue;
130
+ if (time && ((since && time < since) || (until && time >= until))) continue;
131
+
132
+ if (time) {
133
+ if (!first || time < first) first = time;
134
+ if (!last || time > last) last = time;
135
+ const list = timesByFile.get(file) ?? [];
136
+ list.push(time.getTime());
137
+ timesByFile.set(file, list);
138
+ }
139
+
140
+ if (record.type === 'user') {
141
+ if (isSubagent || record.isSidechain || record.isMeta || record.isCompactSummary) continue;
142
+ const text = promptText(record.message?.content);
143
+ if (text === null || SYSTEM_TAG.test(text)) continue;
144
+ if (record.uuid) {
145
+ if (promptIds.has(record.uuid)) continue;
146
+ promptIds.add(record.uuid);
147
+ }
148
+ if (INTERRUPT.test(text)) {
149
+ interrupts++;
150
+ continue;
151
+ }
152
+ prompts++;
153
+ if (POLITE.test(text)) politePrompts++;
154
+ sessionFiles.add(file);
155
+ if (time) {
156
+ const { day, hour } = localParts(time, timeZone);
157
+ days.add(day);
158
+ hours[hour]++;
159
+ }
160
+ continue;
161
+ }
162
+
163
+ const message = typeof record.message === 'object' && record.message !== null ? record.message : {};
164
+ if (isSubagent) subagentFiles.add(file);
165
+ if (typeof message.id === 'string') {
166
+ // A message is written once per content block and output_tokens grows as it streams,
167
+ // so keep the largest value seen for each field.
168
+ const usage = message.usage ?? {};
169
+ const prev = usageById.get(message.id);
170
+ usageById.set(message.id, {
171
+ input: Math.max(prev?.input ?? 0, num(usage.input_tokens)),
172
+ output: Math.max(prev?.output ?? 0, num(usage.output_tokens)),
173
+ cacheRead: Math.max(prev?.cacheRead ?? 0, num(usage.cache_read_input_tokens)),
174
+ cacheWrite: Math.max(prev?.cacheWrite ?? 0, num(usage.cache_creation_input_tokens)),
175
+ });
176
+ if (!prev && typeof message.model === 'string' && !message.model.startsWith('<')) {
177
+ models.set(message.model, (models.get(message.model) ?? 0) + 1);
178
+ }
179
+ }
180
+ for (const block of Array.isArray(message.content) ? message.content : []) {
181
+ if (block?.type === 'tool_use' && block.id && !toolIds.has(block.id)) {
182
+ toolIds.add(block.id);
183
+ tools.set(block.name, (tools.get(block.name) ?? 0) + 1);
184
+ }
185
+ if (block?.type === 'text' && typeof block.text === 'string') {
186
+ const key = `${message.id}:${block.text}`;
187
+ if (textBlocks.has(key)) continue;
188
+ textBlocks.add(key);
189
+ if (ABSOLUTELY_RIGHT.test(block.text)) absolutelyRight++;
190
+ for (const [label, pattern] of CATCHPHRASES) {
191
+ if (pattern.test(block.text)) phrases.set(label, (phrases.get(label) ?? 0) + 1);
192
+ }
193
+ }
194
+ }
195
+ }
196
+
197
+ const sessionDurations = [...timesByFile.entries()]
198
+ .filter(([file]) => sessionFiles.has(file))
199
+ .map(([, times]) => activeMs(times.sort((a, b) => a - b)));
200
+ const peak = Math.max(...hours);
201
+ const tokens = { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 };
202
+ for (const usage of usageById.values()) {
203
+ for (const key of Object.keys(tokens)) tokens[key] += usage[key];
204
+ }
205
+
206
+ return {
207
+ range: first ? { first: first.toISOString(), last: last.toISOString() } : null,
208
+ sessions: sessionFiles.size,
209
+ subagentRuns: subagentFiles.size,
210
+ prompts,
211
+ interrupts,
212
+ politePrompts,
213
+ messages: usageById.size,
214
+ toolCalls: toolIds.size,
215
+ absolutelyRight,
216
+ tokens: { ...tokens, total: tokens.input + tokens.output + tokens.cacheRead + tokens.cacheWrite },
217
+ // cost-state is a cumulative, undated total per session, so it can't be split by date.
218
+ costUSD: costByFile.size && !since && !until ? [...costByFile.values()].reduce((a, b) => a + b, 0) : null,
219
+ topTools: rank(tools, 5),
220
+ topModels: rank(models, 3),
221
+ catchphrases: rank(phrases, 3),
222
+ activeDays: days.size,
223
+ longestStreak: longestStreak([...days].sort()),
224
+ peakHour: peak > 0 ? hours.indexOf(peak) : null,
225
+ hours,
226
+ activeMinutes: Math.round(sessionDurations.reduce((a, b) => a + b, 0) / 60000),
227
+ longestSessionMinutes: Math.round(Math.max(0, ...sessionDurations) / 60000),
228
+ };
229
+ }