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 +21 -0
- package/README.md +66 -2
- package/assets/fonts/Geist-Black.ttf +0 -0
- package/assets/fonts/Geist-Bold.ttf +0 -0
- package/assets/fonts/Geist-Regular.ttf +0 -0
- package/assets/fonts/GeistMono-Bold.ttf +0 -0
- package/assets/fonts/GeistMono-Regular.ttf +0 -0
- package/assets/fonts/OFL.txt +93 -0
- package/bin/vibewrapped.js +150 -0
- package/package.json +38 -4
- package/src/card.js +114 -0
- package/src/format.js +34 -0
- package/src/persona.js +38 -0
- package/src/render.js +22 -0
- package/src/retention.js +57 -0
- package/src/sessions.js +68 -0
- package/src/stats.js +229 -0
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
|
-
#
|
|
1
|
+
# vibewrapped
|
|
2
2
|
|
|
3
|
-
|
|
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
|
+

|
|
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
|
|
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.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
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) => ({ '<': '<', '>': '>', '&': '&', '"': '"', "'": ''' })[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
|
+
}
|
package/src/retention.js
ADDED
|
@@ -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
|
+
}
|
package/src/sessions.js
ADDED
|
@@ -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
|
+
}
|