tokenleak 0.1.0 → 0.2.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.
Files changed (3) hide show
  1. package/README.md +259 -82
  2. package/package.json +1 -1
  3. package/tokenleak.js +421 -43
package/README.md CHANGED
@@ -1,160 +1,337 @@
1
1
  # Tokenleak
2
2
 
3
- A CLI tool that surfaces your AI coding-assistant token usage as beautiful heatmaps, terminal dashboards, and shareable cards. Supports **Claude Code**, **Codex**, and **Open Code**.
3
+ See where your AI tokens actually go. Tokenleak reads local usage logs from **Claude Code**, **Codex**, and **Open Code**, then renders heatmaps, dashboards, and shareable cards — all from your terminal.
4
4
 
5
- ## Features
5
+ ## Install
6
6
 
7
- - Heatmap visualisation of daily token usage (contribution-graph style)
8
- - Terminal dashboard with ANSI colours and Unicode block characters
9
- - SVG and PNG image export for sharing
10
- - JSON export for downstream tooling
11
- - Streak tracking (current and longest)
12
- - Cost estimation with per-model pricing
13
- - Rolling 30-day window statistics
14
- - Day-of-week usage breakdown
15
- - Multi-provider support with automatic detection
16
- - Configuration file support (`~/.tokenleakrc`)
17
-
18
- ## Quick Start
7
+ Tokenleak requires [Bun](https://bun.sh) (v1.0+).
19
8
 
20
9
  ```bash
21
- # Install globally with bun
22
10
  bun install -g tokenleak
11
+ ```
12
+
13
+ After installing, run `tokenleak` in your terminal. It will automatically detect which AI coding tools you have installed and display your usage.
14
+
15
+ ### From source
23
16
 
24
- # Or clone and build from source
17
+ ```bash
25
18
  git clone https://github.com/ya-nsh/tokenleak.git
26
19
  cd tokenleak
27
20
  bun install
28
21
  bun run build
22
+ bun run bundle
29
23
 
30
- # Run
31
- bun run packages/cli/dist/cli.js
24
+ # Run directly
25
+ bun dist/tokenleak.js
32
26
  ```
33
27
 
34
28
  ## Usage
35
29
 
36
30
  ```bash
37
- # Terminal dashboard (default)
31
+ # Show a terminal dashboard of your token usage (default)
38
32
  tokenleak
39
33
 
40
- # JSON output
34
+ # Output as JSON
41
35
  tokenleak --format json
42
36
 
43
- # SVG heatmap
37
+ # Export an SVG heatmap
44
38
  tokenleak --format svg --output usage.svg
45
39
 
46
- # PNG heatmap
40
+ # Export a PNG image
47
41
  tokenleak --format png --output usage.png
48
42
 
49
- # Filter to last 90 days
50
- tokenleak --days 90
43
+ # Save to a file (format is inferred from the extension)
44
+ tokenleak -o report.json
45
+ tokenleak -o heatmap.svg
46
+ tokenleak -o card.png
47
+ ```
48
+
49
+ ### Date filtering
50
+
51
+ By default, Tokenleak shows the last **90 days** of usage.
51
52
 
52
- # Custom date range
53
- tokenleak --since 2025-01-01 --until 2025-12-31
53
+ ```bash
54
+ # Last 30 days
55
+ tokenleak --days 30
56
+
57
+ # Specific date range
58
+ tokenleak --since 2025-06-01 --until 2025-12-31
59
+
60
+ # Everything since a date (until defaults to today)
61
+ tokenleak --since 2025-01-01
62
+
63
+ # --since takes priority over --days when both are provided
64
+ ```
65
+
66
+ ### Provider filtering
54
67
 
55
- # Filter to a specific provider
68
+ Tokenleak auto-detects all installed providers. You can filter to specific ones:
69
+
70
+ ```bash
71
+ # Only Claude Code
56
72
  tokenleak --provider claude-code
57
73
 
58
- # Compare two date ranges
59
- tokenleak --compare 2025-01-01..2025-06-30
74
+ # Only Codex
75
+ tokenleak --provider codex
60
76
 
61
- # Dark theme (default) / light theme
77
+ # Multiple providers (comma-separated)
78
+ tokenleak --provider claude-code,codex
79
+ ```
80
+
81
+ ### Compare mode
82
+
83
+ Compare your usage across two time periods to see how your token consumption has changed:
84
+
85
+ ```bash
86
+ # Auto-compare: splits your date range in half
87
+ # (e.g. with --days 60, compares last 30 days vs. previous 30 days)
88
+ tokenleak --compare auto
89
+
90
+ # Compare against a specific previous period
91
+ tokenleak --compare 2025-01-01..2025-03-31
92
+
93
+ # Compare outputs deltas for total tokens, cost, streaks, etc.
94
+ tokenleak --compare auto --format json
95
+ ```
96
+
97
+ Compare mode outputs a JSON object with `currentPeriod`, `previousPeriod`, and `deltas` showing the difference between the two periods (positive = increase, negative = decrease).
98
+
99
+ ### Themes
100
+
101
+ ```bash
102
+ # Dark theme (default)
103
+ tokenleak --theme dark
104
+
105
+ # Light theme
62
106
  tokenleak --theme light
107
+ ```
108
+
109
+ ### Terminal options
110
+
111
+ ```bash
112
+ # Set terminal width (affects heatmap and dashboard layout)
113
+ tokenleak --width 120
63
114
 
64
- # Disable ANSI colours
115
+ # Disable ANSI colours (useful for piping output)
65
116
  tokenleak --no-color
66
117
 
67
- # Write output to a file (format inferred from extension)
68
- tokenleak --output report.json
118
+ # Hide the insights panel
119
+ tokenleak --no-insights
120
+ ```
121
+
122
+ ### Sharing
123
+
124
+ ```bash
125
+ # Copy rendered output to clipboard
126
+ tokenleak --format json --clipboard
127
+
128
+ # Open the output file in your default application after saving
129
+ tokenleak -o usage.svg --open
130
+
131
+ # Upload to a GitHub Gist (requires gh CLI to be authenticated)
132
+ tokenleak --format json --upload gist
69
133
  ```
70
134
 
71
- ### All Flags
72
-
73
- | Flag | Alias | Description |
74
- |------|-------|-------------|
75
- | `--format` | `-f` | Output format: `json`, `svg`, `png`, `terminal` |
76
- | `--theme` | `-t` | Colour theme: `dark`, `light` |
77
- | `--since` | `-s` | Start date (`YYYY-MM-DD`) |
78
- | `--until` | `-u` | End date (`YYYY-MM-DD`), defaults to today |
79
- | `--days` | `-d` | Number of days to look back (default: 365) |
80
- | `--output` | `-o` | Output file path |
81
- | `--width` | `-w` | Terminal width (default: 80) |
82
- | `--no-color` | | Disable ANSI colours in terminal output |
83
- | `--no-insights` | | Hide the insights panel |
84
- | `--compare` | | Compare two date ranges (`YYYY-MM-DD..YYYY-MM-DD`) |
85
- | `--provider` | `-p` | Filter to specific provider(s), comma-separated |
86
- | `--version` | `-v` | Print version |
87
- | `--help` | `-h` | Print usage information |
88
-
89
- ## Supported Providers
135
+ ## All flags
136
+
137
+ | Flag | Alias | Default | Description |
138
+ |------|-------|---------|-------------|
139
+ | `--format` | `-f` | `terminal` | Output format: `json`, `svg`, `png`, `terminal` |
140
+ | `--theme` | `-t` | `dark` | Colour theme: `dark`, `light` |
141
+ | `--since` | `-s` | | Start date (`YYYY-MM-DD`). Overrides `--days` |
142
+ | `--until` | `-u` | today | End date (`YYYY-MM-DD`) |
143
+ | `--days` | `-d` | `90` | Number of days to look back |
144
+ | `--output` | `-o` | stdout | Output file path. Format is inferred from extension |
145
+ | `--width` | `-w` | `80` | Terminal width for dashboard layout |
146
+ | `--no-color` | | `false` | Strip ANSI escape codes from terminal output |
147
+ | `--no-insights` | | `false` | Hide the insights panel |
148
+ | `--compare` | | | Compare two date ranges. Use `auto` or `YYYY-MM-DD..YYYY-MM-DD` |
149
+ | `--provider` | `-p` | all | Filter to specific provider(s), comma-separated |
150
+ | `--clipboard` | | `false` | Copy output to clipboard after rendering |
151
+ | `--open` | | `false` | Open output file in default app (requires `--output`) |
152
+ | `--upload` | | | Upload output to a service. Supported: `gist` |
153
+ | `--version` | | | Print version number |
154
+ | `--help` | | | Print usage information |
155
+
156
+ ## Supported providers
90
157
 
91
158
  ### Claude Code
92
159
 
93
- Reads JSONL conversation logs from the Claude Code configuration directory.
160
+ Reads JSONL conversation logs from the Claude Code projects directory. Each assistant message with a `usage` field is parsed for input/output/cache token counts.
94
161
 
95
- - **macOS/Linux**: `~/.claude/projects/*/`
96
- - **Custom**: Set `CLAUDE_CONFIG_DIR` environment variable
162
+ | | |
163
+ |---|---|
164
+ | **Data location** | `~/.claude/projects/*/*.jsonl` |
165
+ | **Override** | Set `CLAUDE_CONFIG_DIR` environment variable |
166
+ | **Provider name** | `claude-code` |
97
167
 
98
168
  ### Codex
99
169
 
100
- Reads JSONL session logs from the Codex home directory.
170
+ Reads JSONL session logs from the Codex sessions directory. Parses `response` events for token usage with cumulative delta extraction.
101
171
 
102
- - **Default**: `~/.codex/sessions/`
103
- - **Custom**: Set `CODEX_HOME` environment variable
172
+ | | |
173
+ |---|---|
174
+ | **Data location** | `~/.codex/sessions/*.jsonl` |
175
+ | **Override** | Set `CODEX_HOME` environment variable |
176
+ | **Provider name** | `codex` |
104
177
 
105
178
  ### Open Code
106
179
 
107
- Reads usage data from the Open Code SQLite database or legacy JSON files.
180
+ Reads usage data from the Open Code SQLite database. Falls back to legacy JSON session files if no database is found.
181
+
182
+ | | |
183
+ |---|---|
184
+ | **Data location** | `~/.opencode/sessions.db` (primary) or `~/.opencode/sessions/*.json` (fallback) |
185
+ | **Provider name** | `open-code` |
186
+
187
+ ## Output formats
188
+
189
+ ### `terminal` (default)
190
+
191
+ A full-width dashboard rendered in your terminal with:
192
+
193
+ - GitHub-style heatmap using Unicode block characters (`░▒▓█`)
194
+ - Stats panel: current streak, longest streak, total tokens, total cost, 30-day rolling totals, daily averages, cache hit rate
195
+ - Day-of-week breakdown showing which days you code most
196
+ - Top models ranked by token usage
197
+ - Insights: peak day, most active day, top model, top provider
198
+
199
+ Falls back to a compact one-liner when terminal width is under 40 characters.
200
+
201
+ ### `json`
202
+
203
+ Structured JSON output containing:
204
+
205
+ ```jsonc
206
+ {
207
+ "schemaVersion": 1,
208
+ "generated": "2025-12-01T00:00:00.000Z",
209
+ "dateRange": { "since": "2025-09-01", "until": "2025-12-01" },
210
+ "providers": [
211
+ {
212
+ "name": "claude-code",
213
+ "displayName": "Claude Code",
214
+ "daily": [
215
+ {
216
+ "date": "2025-11-30",
217
+ "inputTokens": 15000,
218
+ "outputTokens": 5000,
219
+ "cacheReadTokens": 2000,
220
+ "cacheWriteTokens": 500,
221
+ "totalTokens": 22500,
222
+ "cost": 0.0825
223
+ }
224
+ // ...
225
+ ],
226
+ "models": [
227
+ { "model": "claude-sonnet-4", "inputTokens": 10000, "outputTokens": 3000, "totalTokens": 13000, "cost": 0.075 }
228
+ ],
229
+ "totalTokens": 22500,
230
+ "totalCost": 0.0825
231
+ }
232
+ ],
233
+ "aggregated": {
234
+ "currentStreak": 12,
235
+ "longestStreak": 45,
236
+ "totalTokens": 1500000,
237
+ "totalCost": 52.50,
238
+ // ... rolling windows, peaks, averages, day-of-week, top models
239
+ }
240
+ }
241
+ ```
242
+
243
+ ### `svg`
108
244
 
109
- - **Default**: `~/.opencode/opencode.db`
245
+ A self-contained SVG image with:
110
246
 
111
- ## Configuration
247
+ - Heatmap grid (7 rows x N weeks) with quantile-based colour intensity
248
+ - Month labels and day-of-week labels
249
+ - Stats panel and insights panel
250
+ - Supports `dark` and `light` themes
112
251
 
113
- Create a `~/.tokenleakrc` file with JSON to set defaults:
252
+ ### `png`
253
+
254
+ Same layout as SVG, rendered to a PNG image via [sharp](https://sharp.pixelplumbing.com/). Useful for embedding in documents or sharing on platforms that don't support SVG.
255
+
256
+ ## Configuration file
257
+
258
+ Create `~/.tokenleakrc` to set persistent defaults:
114
259
 
115
260
  ```json
116
261
  {
117
262
  "format": "terminal",
118
263
  "theme": "dark",
119
- "days": 365,
264
+ "days": 90,
120
265
  "width": 120,
121
266
  "noColor": false,
122
267
  "noInsights": false
123
268
  }
124
269
  ```
125
270
 
126
- CLI flags always override configuration file values.
271
+ **Priority order** (highest wins): CLI flags > environment variables > config file > built-in defaults.
272
+
273
+ All fields are optional. Only include the ones you want to override.
127
274
 
128
- ## Environment Variables
275
+ ## Environment variables
129
276
 
130
277
  | Variable | Default | Description |
131
278
  |----------|---------|-------------|
132
- | `TOKENLEAK_FILE_PROCESS_CONCURRENCY` | `4` | Number of files to process concurrently |
133
- | `TOKENLEAK_MAX_JSONL_RECORD_BYTES` | `67108864` (64 MB) | Maximum size of a single JSONL record |
134
- | `TOKENLEAK_PRICING_OVERRIDE` | | Path to a custom pricing JSON file |
279
+ | `TOKENLEAK_FORMAT` | `terminal` | Default output format |
280
+ | `TOKENLEAK_THEME` | `dark` | Default colour theme |
281
+ | `TOKENLEAK_DAYS` | `90` | Default lookback period in days |
282
+ | `TOKENLEAK_MAX_JSONL_RECORD_BYTES` | `10485760` (10 MB) | Max size of a single JSONL record before it is rejected |
135
283
  | `CLAUDE_CONFIG_DIR` | `~/.claude` | Claude Code configuration directory |
136
284
  | `CODEX_HOME` | `~/.codex` | Codex home directory |
137
285
 
138
- ## Output Formats
286
+ ## What Tokenleak tracks
287
+
288
+ Tokenleak reads your **local** log files only. It never sends data anywhere (unless you explicitly use `--upload`).
289
+
290
+ For each day of usage, it tracks:
291
+
292
+ - **Input tokens** — tokens sent to the model
293
+ - **Output tokens** — tokens generated by the model
294
+ - **Cache read tokens** — tokens served from prompt cache
295
+ - **Cache write tokens** — tokens written to prompt cache
296
+ - **Cost** — estimated USD cost based on per-model pricing
297
+
298
+ It then computes:
299
+
300
+ - **Streaks** — consecutive days with any token usage
301
+ - **Rolling 30-day totals** — tokens and cost over a sliding window
302
+ - **Peak day** — the single day with the highest token usage
303
+ - **Day-of-week breakdown** — which days of the week you use AI most
304
+ - **Cache hit rate** — percentage of input tokens served from cache
305
+ - **Top models** — models ranked by total token consumption
306
+ - **Daily averages** — mean tokens and cost per day
307
+
308
+ ### Supported models and pricing
309
+
310
+ Tokenleak includes pricing for these model families:
311
+
312
+ | Family | Models |
313
+ |--------|--------|
314
+ | Claude 3 | `claude-3-haiku`, `claude-3-sonnet`, `claude-3-opus` |
315
+ | Claude 3.5 | `claude-3.5-haiku`, `claude-3.5-sonnet` |
316
+ | Claude 4 | `claude-sonnet-4`, `claude-opus-4` |
317
+ | GPT-4o | `gpt-4o`, `gpt-4o-mini` |
318
+ | o-series | `o1`, `o1-mini`, `o3`, `o3-mini`, `o4-mini` |
139
319
 
140
- | Format | Description |
141
- |--------|-------------|
142
- | `terminal` | ANSI dashboard with heatmap and stats, rendered in the shell |
143
- | `json` | Structured JSON export with daily data, insights, and aggregated stats |
144
- | `svg` | SVG heatmap with stats and insights panels |
145
- | `png` | PNG heatmap rendered via `@napi-rs/canvas` |
320
+ Model names with date suffixes (e.g. `claude-sonnet-4-20250514`) are automatically normalised. Unknown models show `$0.00` cost but tokens are still tracked.
146
321
 
147
- ## Project Structure
322
+ ## Project structure
148
323
 
149
324
  ```
150
325
  tokenleak/
151
326
  packages/
152
- cli/ -- Main CLI entrypoint
153
- core/ -- Data types, aggregation engine
154
- registry/ -- Provider parsers (Claude Code, Codex, Open Code)
155
- renderers/ -- JSON, SVG, PNG, terminal renderers
156
- tooling/
157
- typescript-config/
327
+ core/ Shared types, constants, aggregation engine
328
+ registry/ Provider parsers and model pricing
329
+ renderers/ JSON, SVG, PNG, and terminal output
330
+ cli/ CLI entrypoint and config handling
331
+ scripts/
332
+ build-npm.ts Bundles CLI for npm publishing
333
+ dist/
334
+ tokenleak.js Bundled CLI (generated)
158
335
  ```
159
336
 
160
337
  ## Contributing
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokenleak",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Visualise your AI coding-assistant token usage across providers — heatmaps, dashboards, and shareable cards.",
5
5
  "type": "module",
6
6
  "bin": {
package/tokenleak.js CHANGED
@@ -695,7 +695,7 @@ function computePreviousPeriod(current) {
695
695
  };
696
696
  }
697
697
  // packages/core/dist/index.js
698
- var VERSION = "0.1.0";
698
+ var VERSION = "0.2.0";
699
699
 
700
700
  // packages/registry/dist/models/normalizer.js
701
701
  var DATE_SUFFIX_PATTERN = /-\d{8}$/;
@@ -735,18 +735,48 @@ var MODEL_PRICING = {
735
735
  cacheRead: 0.3,
736
736
  cacheWrite: 3.75
737
737
  },
738
+ "claude-haiku-4-5": {
739
+ input: 0.8,
740
+ output: 4,
741
+ cacheRead: 0.08,
742
+ cacheWrite: 1
743
+ },
744
+ "claude-sonnet-4-5": {
745
+ input: 3,
746
+ output: 15,
747
+ cacheRead: 0.3,
748
+ cacheWrite: 3.75
749
+ },
750
+ "claude-opus-4-5": {
751
+ input: 15,
752
+ output: 75,
753
+ cacheRead: 1.5,
754
+ cacheWrite: 18.75
755
+ },
738
756
  "claude-sonnet-4": {
739
757
  input: 3,
740
758
  output: 15,
741
759
  cacheRead: 0.3,
742
760
  cacheWrite: 3.75
743
761
  },
762
+ "claude-sonnet-4-6": {
763
+ input: 3,
764
+ output: 15,
765
+ cacheRead: 0.3,
766
+ cacheWrite: 3.75
767
+ },
744
768
  "claude-opus-4": {
745
769
  input: 15,
746
770
  output: 75,
747
771
  cacheRead: 1.5,
748
772
  cacheWrite: 18.75
749
773
  },
774
+ "claude-opus-4-6": {
775
+ input: 15,
776
+ output: 75,
777
+ cacheRead: 1.5,
778
+ cacheWrite: 18.75
779
+ },
750
780
  "gpt-4o": {
751
781
  input: 2.5,
752
782
  output: 10,
@@ -867,17 +897,17 @@ async function* splitJsonlRecords(filePath) {
867
897
  let lineNumber = 0;
868
898
  for (const line of lines) {
869
899
  lineNumber++;
870
- if (line.trim() === "") {
900
+ if (line.trim() === "" || /^\x00+$/.test(line) || !/[^\x00]/.test(line)) {
871
901
  continue;
872
902
  }
873
903
  const byteLength = new TextEncoder().encode(line).byteLength;
874
904
  if (byteLength > maxBytes) {
875
- throw new Error(`Oversized JSONL record in ${filePath} at line ${lineNumber}: ${byteLength} bytes exceeds limit of ${maxBytes} bytes`);
905
+ continue;
876
906
  }
877
907
  try {
878
908
  yield JSON.parse(line);
879
909
  } catch {
880
- throw new Error(`Malformed JSON in ${filePath} at line ${lineNumber}: unable to parse`);
910
+ continue;
881
911
  }
882
912
  }
883
913
  }
@@ -1027,11 +1057,15 @@ class ClaudeCodeProvider {
1027
1057
  const files = collectJsonlFiles(this.baseDir);
1028
1058
  const allRecords = [];
1029
1059
  for (const file of files) {
1030
- for await (const record of splitJsonlRecords(file)) {
1031
- const usage = extractUsage(record);
1032
- if (usage !== null && isInRange(usage.date, range)) {
1033
- allRecords.push(usage);
1060
+ try {
1061
+ for await (const record of splitJsonlRecords(file)) {
1062
+ const usage = extractUsage(record);
1063
+ if (usage !== null && isInRange(usage.date, range)) {
1064
+ allRecords.push(usage);
1065
+ }
1034
1066
  }
1067
+ } catch {
1068
+ continue;
1035
1069
  }
1036
1070
  }
1037
1071
  const daily = buildDailyUsage(allRecords);
@@ -1120,41 +1154,45 @@ class CodexProvider {
1120
1154
  }
1121
1155
  for (const file of files) {
1122
1156
  const filePath = join2(this.sessionsDir, file);
1123
- for await (const record of splitJsonlRecords(filePath)) {
1124
- const event = parseResponseEvent(record);
1125
- if (!event) {
1126
- continue;
1127
- }
1128
- const date = extractDate(event.timestamp);
1129
- if (!date || !isInRange2(date, range)) {
1130
- continue;
1131
- }
1132
- const normalizedModel = normalizeModelName(compactModelDateSuffix(event.model));
1133
- const inputTokens = event.usage.input_tokens;
1134
- const outputTokens = event.usage.output_tokens;
1135
- const cacheReadTokens = 0;
1136
- const cacheWriteTokens = 0;
1137
- const cost = estimateCost(normalizedModel, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
1138
- if (!dailyMap.has(date)) {
1139
- dailyMap.set(date, new Map);
1157
+ try {
1158
+ for await (const record of splitJsonlRecords(filePath)) {
1159
+ const event = parseResponseEvent(record);
1160
+ if (!event) {
1161
+ continue;
1162
+ }
1163
+ const date = extractDate(event.timestamp);
1164
+ if (!date || !isInRange2(date, range)) {
1165
+ continue;
1166
+ }
1167
+ const normalizedModel = normalizeModelName(compactModelDateSuffix(event.model));
1168
+ const inputTokens = event.usage.input_tokens;
1169
+ const outputTokens = event.usage.output_tokens;
1170
+ const cacheReadTokens = 0;
1171
+ const cacheWriteTokens = 0;
1172
+ const cost = estimateCost(normalizedModel, inputTokens, outputTokens, cacheReadTokens, cacheWriteTokens);
1173
+ if (!dailyMap.has(date)) {
1174
+ dailyMap.set(date, new Map);
1175
+ }
1176
+ const modelMap = dailyMap.get(date);
1177
+ if (!modelMap.has(normalizedModel)) {
1178
+ modelMap.set(normalizedModel, {
1179
+ model: normalizedModel,
1180
+ inputTokens: 0,
1181
+ outputTokens: 0,
1182
+ cacheReadTokens: 0,
1183
+ cacheWriteTokens: 0,
1184
+ totalTokens: 0,
1185
+ cost: 0
1186
+ });
1187
+ }
1188
+ const breakdown = modelMap.get(normalizedModel);
1189
+ breakdown.inputTokens += inputTokens;
1190
+ breakdown.outputTokens += outputTokens;
1191
+ breakdown.totalTokens += inputTokens + outputTokens;
1192
+ breakdown.cost += cost;
1140
1193
  }
1141
- const modelMap = dailyMap.get(date);
1142
- if (!modelMap.has(normalizedModel)) {
1143
- modelMap.set(normalizedModel, {
1144
- model: normalizedModel,
1145
- inputTokens: 0,
1146
- outputTokens: 0,
1147
- cacheReadTokens: 0,
1148
- cacheWriteTokens: 0,
1149
- totalTokens: 0,
1150
- cost: 0
1151
- });
1152
- }
1153
- const breakdown = modelMap.get(normalizedModel);
1154
- breakdown.inputTokens += inputTokens;
1155
- breakdown.outputTokens += outputTokens;
1156
- breakdown.totalTokens += inputTokens + outputTokens;
1157
- breakdown.cost += cost;
1194
+ } catch {
1195
+ continue;
1158
1196
  }
1159
1197
  }
1160
1198
  const daily = [...dailyMap.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([date, modelMap]) => {
@@ -1849,6 +1887,340 @@ var CODES = {
1849
1887
  dim: `${ESC}2m`,
1850
1888
  reset: `${ESC}0m`
1851
1889
  };
1890
+ function colorize(text2, color, noColor2) {
1891
+ if (noColor2) {
1892
+ return text2;
1893
+ }
1894
+ return `${CODES[color]}${text2}${CODES.reset}`;
1895
+ }
1896
+ var HEATMAP_BLOCKS = {
1897
+ FULL: "\u2588",
1898
+ DARK: "\u2593",
1899
+ MEDIUM: "\u2592",
1900
+ LIGHT: "\u2591",
1901
+ EMPTY: " "
1902
+ };
1903
+ function intensityBlock(value, max) {
1904
+ if (max <= 0 || value <= 0)
1905
+ return HEATMAP_BLOCKS.EMPTY;
1906
+ const ratio = value / max;
1907
+ if (ratio >= 0.75)
1908
+ return HEATMAP_BLOCKS.FULL;
1909
+ if (ratio >= 0.5)
1910
+ return HEATMAP_BLOCKS.DARK;
1911
+ if (ratio >= 0.25)
1912
+ return HEATMAP_BLOCKS.MEDIUM;
1913
+ return HEATMAP_BLOCKS.LIGHT;
1914
+ }
1915
+ function intensityColor(value, max) {
1916
+ if (max <= 0 || value <= 0)
1917
+ return "dim";
1918
+ const ratio = value / max;
1919
+ if (ratio >= 0.75)
1920
+ return "green";
1921
+ if (ratio >= 0.5)
1922
+ return "yellow";
1923
+ if (ratio >= 0.25)
1924
+ return "cyan";
1925
+ return "dim";
1926
+ }
1927
+
1928
+ // packages/renderers/dist/terminal/heatmap.js
1929
+ var DAY_LABELS3 = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
1930
+ var MONTH_LABELS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
1931
+ var DAY_LABEL_WIDTH2 = 4;
1932
+ var LEGEND_TEXT = "Less";
1933
+ var LEGEND_TEXT_MORE = "More";
1934
+ function buildUsageMap(daily) {
1935
+ const map = new Map;
1936
+ for (const entry of daily) {
1937
+ map.set(entry.date, (map.get(entry.date) ?? 0) + entry.totalTokens);
1938
+ }
1939
+ return map;
1940
+ }
1941
+ function renderTerminalHeatmap(daily, options) {
1942
+ if (daily.length === 0) {
1943
+ return " No usage data available.";
1944
+ }
1945
+ const usageMap = buildUsageMap(daily);
1946
+ const maxTokens = Math.max(...usageMap.values(), 0);
1947
+ const dates = daily.map((d) => d.date).sort();
1948
+ const startDate = new Date(dates[0]);
1949
+ const endDate = new Date(dates[dates.length - 1]);
1950
+ const alignedStart = new Date(startDate);
1951
+ alignedStart.setDate(alignedStart.getDate() - alignedStart.getDay());
1952
+ const weeks = [];
1953
+ const current = new Date(alignedStart);
1954
+ while (current <= endDate) {
1955
+ const week = [];
1956
+ for (let d = 0;d < 7; d++) {
1957
+ week.push(new Date(current));
1958
+ current.setDate(current.getDate() + 1);
1959
+ }
1960
+ weeks.push(week);
1961
+ }
1962
+ const availableWidth = options.width - DAY_LABEL_WIDTH2;
1963
+ const maxWeeks = Math.min(weeks.length, availableWidth);
1964
+ const displayWeeks = weeks.slice(Math.max(0, weeks.length - maxWeeks));
1965
+ const lines = [];
1966
+ let monthHeader = " ".repeat(DAY_LABEL_WIDTH2);
1967
+ let lastMonth = -1;
1968
+ for (const week of displayWeeks) {
1969
+ const month = week[0].getMonth();
1970
+ if (month !== lastMonth) {
1971
+ monthHeader += MONTH_LABELS[month];
1972
+ lastMonth = month;
1973
+ const labelLen = MONTH_LABELS[month].length;
1974
+ } else {
1975
+ monthHeader += " ";
1976
+ }
1977
+ }
1978
+ if (monthHeader.length > options.width) {
1979
+ monthHeader = monthHeader.slice(0, options.width);
1980
+ }
1981
+ lines.push(monthHeader);
1982
+ for (let dayIdx = 0;dayIdx < 7; dayIdx++) {
1983
+ const label = dayIdx % 2 === 1 ? DAY_LABELS3[dayIdx] : " ";
1984
+ let line = label + " ";
1985
+ line = line.slice(0, DAY_LABEL_WIDTH2);
1986
+ for (const week of displayWeeks) {
1987
+ const date = week[dayIdx];
1988
+ if (!date || date > endDate || date < startDate) {
1989
+ line += " ";
1990
+ continue;
1991
+ }
1992
+ const dateStr = formatDate(date);
1993
+ const tokens = usageMap.get(dateStr) ?? 0;
1994
+ const block = intensityBlock(tokens, maxTokens);
1995
+ const color = intensityColor(tokens, maxTokens);
1996
+ line += colorize(block, color, options.noColor);
1997
+ }
1998
+ lines.push(line);
1999
+ }
2000
+ const legendBlocks = [
2001
+ HEATMAP_BLOCKS.EMPTY,
2002
+ HEATMAP_BLOCKS.LIGHT,
2003
+ HEATMAP_BLOCKS.MEDIUM,
2004
+ HEATMAP_BLOCKS.DARK,
2005
+ HEATMAP_BLOCKS.FULL
2006
+ ];
2007
+ const legend = `${" ".repeat(DAY_LABEL_WIDTH2)}${LEGEND_TEXT} ${legendBlocks.join("")} ${LEGEND_TEXT_MORE}`;
2008
+ lines.push(legend);
2009
+ return lines.join(`
2010
+ `);
2011
+ }
2012
+ function formatDate(date) {
2013
+ const y = date.getFullYear();
2014
+ const m = String(date.getMonth() + 1).padStart(2, "0");
2015
+ const d = String(date.getDate()).padStart(2, "0");
2016
+ return `${y}-${m}-${d}`;
2017
+ }
2018
+
2019
+ // packages/renderers/dist/terminal/dashboard.js
2020
+ var BOX_H = "\u2500";
2021
+ var BOX_V = "\u2502";
2022
+ var BOX_TL = "\u250C";
2023
+ var BOX_TR = "\u2510";
2024
+ var BOX_BL = "\u2514";
2025
+ var BOX_BR = "\u2518";
2026
+ var DAY_NAMES2 = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
2027
+ var BAR_CHAR = "\u2588";
2028
+ var MAX_BAR_LENGTH = 20;
2029
+ function formatTokens(count) {
2030
+ if (count >= 1e6) {
2031
+ return `${(count / 1e6).toFixed(1)}M`;
2032
+ }
2033
+ if (count >= 1000) {
2034
+ return `${(count / 1000).toFixed(0)}K`;
2035
+ }
2036
+ return String(count);
2037
+ }
2038
+ function formatCost2(cost) {
2039
+ return `$${cost.toFixed(2)}`;
2040
+ }
2041
+ function formatPercent2(rate) {
2042
+ return `${(rate * 100).toFixed(1)}%`;
2043
+ }
2044
+ function divider(width) {
2045
+ return BOX_H.repeat(width);
2046
+ }
2047
+ function boxedHeader(title, width, noColor2) {
2048
+ const inner = width - 2;
2049
+ const padded = ` ${title} `;
2050
+ const remaining = Math.max(0, inner - padded.length);
2051
+ const left = Math.floor(remaining / 2);
2052
+ const right = remaining - left;
2053
+ const content = `${BOX_H.repeat(left)}${padded}${BOX_H.repeat(right)}`;
2054
+ const top = `${BOX_TL}${BOX_H.repeat(inner)}${BOX_TR}`;
2055
+ const headerLine = `${BOX_V}${colorize(content, "bold", noColor2)}${BOX_V}`;
2056
+ const bottom = `${BOX_BL}${BOX_H.repeat(inner)}${BOX_BR}`;
2057
+ return [top, headerLine, bottom].join(`
2058
+ `);
2059
+ }
2060
+ function dayBar(tokens, maxTokens, noColor2) {
2061
+ if (maxTokens <= 0)
2062
+ return "";
2063
+ const length = Math.round(tokens / maxTokens * MAX_BAR_LENGTH);
2064
+ const bar = BAR_CHAR.repeat(length);
2065
+ return colorize(bar, "green", noColor2);
2066
+ }
2067
+ function renderStats(stats, width, noColor2) {
2068
+ const lines = [];
2069
+ const labelWidth = 20;
2070
+ const entries = [
2071
+ ["Current Streak", `${stats.currentStreak}d`],
2072
+ ["Longest Streak", `${stats.longestStreak}d`],
2073
+ ["Total Tokens", formatTokens(stats.totalTokens)],
2074
+ ["Total Cost", formatCost2(stats.totalCost)],
2075
+ ["30d Tokens", formatTokens(stats.rolling30dTokens)],
2076
+ ["30d Cost", formatCost2(stats.rolling30dCost)],
2077
+ ["7d Tokens", formatTokens(stats.rolling7dTokens)],
2078
+ ["7d Cost", formatCost2(stats.rolling7dCost)],
2079
+ ["Avg Daily Tokens", formatTokens(stats.averageDailyTokens)],
2080
+ ["Avg Daily Cost", formatCost2(stats.averageDailyCost)],
2081
+ ["Cache Hit Rate", formatPercent2(stats.cacheHitRate)],
2082
+ ["Active Days", `${stats.activeDays} / ${stats.totalDays}`]
2083
+ ];
2084
+ if (stats.peakDay) {
2085
+ entries.push(["Peak Day", `${stats.peakDay.date} (${formatTokens(stats.peakDay.tokens)})`]);
2086
+ }
2087
+ for (const [label, value] of entries) {
2088
+ const line = ` ${label.padEnd(labelWidth)} ${colorize(value, "cyan", noColor2)}`;
2089
+ lines.push(line.length > width ? line.slice(0, width) : line);
2090
+ }
2091
+ return lines.join(`
2092
+ `);
2093
+ }
2094
+ function renderDayOfWeek(stats, width, noColor2) {
2095
+ const lines = [];
2096
+ const maxTokens = Math.max(...stats.dayOfWeek.map((d) => d.tokens), 0);
2097
+ for (const entry of stats.dayOfWeek) {
2098
+ const label = DAY_NAMES2[entry.day] ?? `Day${entry.day}`;
2099
+ const bar = dayBar(entry.tokens, maxTokens, noColor2);
2100
+ const tokenStr = formatTokens(entry.tokens);
2101
+ const line = ` ${label} ${bar} ${tokenStr}`;
2102
+ lines.push(line.length > width ? line.slice(0, width) : line);
2103
+ }
2104
+ return lines.join(`
2105
+ `);
2106
+ }
2107
+ function renderTopModels(stats, width, noColor2) {
2108
+ const lines = [];
2109
+ for (const model of stats.topModels.slice(0, 5)) {
2110
+ const pct = formatPercent2(model.percentage / 100);
2111
+ const tokens = formatTokens(model.tokens);
2112
+ const line = ` ${colorize(model.model, "yellow", noColor2)} ${tokens} ${pct}`;
2113
+ lines.push(line.length > width ? line.slice(0, width) : line);
2114
+ }
2115
+ return lines.join(`
2116
+ `);
2117
+ }
2118
+ function renderInsights(stats, noColor2) {
2119
+ const insights = [];
2120
+ if (stats.currentStreak > 7) {
2121
+ insights.push(`You have a ${stats.currentStreak}-day coding streak going!`);
2122
+ }
2123
+ if (stats.cacheHitRate > 0.5) {
2124
+ insights.push(`Cache hit rate is ${formatPercent2(stats.cacheHitRate)} - good cache reuse.`);
2125
+ }
2126
+ if (stats.cacheHitRate < 0.1 && stats.totalTokens > 0) {
2127
+ insights.push("Cache hit rate is low - consider enabling prompt caching.");
2128
+ }
2129
+ if (stats.peakDay) {
2130
+ insights.push(`Peak usage was on ${stats.peakDay.date} with ${formatTokens(stats.peakDay.tokens)} tokens.`);
2131
+ }
2132
+ if (insights.length === 0)
2133
+ return "";
2134
+ return insights.map((i) => ` ${colorize("*", "green", noColor2)} ${i}`).join(`
2135
+ `);
2136
+ }
2137
+ function renderProviderSection(provider, stats, width, noColor2, showInsights) {
2138
+ const sections = [];
2139
+ sections.push(boxedHeader(provider.displayName, width, noColor2));
2140
+ sections.push("");
2141
+ sections.push(colorize(" Heatmap", "bold", noColor2));
2142
+ sections.push(renderTerminalHeatmap(provider.daily, { width, noColor: noColor2 }));
2143
+ sections.push("");
2144
+ sections.push(colorize(" Stats", "bold", noColor2));
2145
+ sections.push(renderStats(stats, width, noColor2));
2146
+ if (stats.dayOfWeek.length > 0) {
2147
+ sections.push("");
2148
+ sections.push(colorize(" Day of Week", "bold", noColor2));
2149
+ sections.push(renderDayOfWeek(stats, width, noColor2));
2150
+ }
2151
+ if (stats.topModels.length > 0) {
2152
+ sections.push("");
2153
+ sections.push(colorize(" Top Models", "bold", noColor2));
2154
+ sections.push(renderTopModels(stats, width, noColor2));
2155
+ }
2156
+ if (showInsights) {
2157
+ const insightsText = renderInsights(stats, noColor2);
2158
+ if (insightsText) {
2159
+ sections.push("");
2160
+ sections.push(colorize(" Insights", "bold", noColor2));
2161
+ sections.push(insightsText);
2162
+ }
2163
+ }
2164
+ return sections.join(`
2165
+ `);
2166
+ }
2167
+ function renderDashboard(output, options) {
2168
+ const width = options.width;
2169
+ const noColor2 = options.noColor;
2170
+ const sections = [];
2171
+ sections.push(boxedHeader("Tokenleak", width, noColor2));
2172
+ sections.push("");
2173
+ if (output.providers.length === 0) {
2174
+ sections.push(" No provider data available.");
2175
+ return sections.join(`
2176
+ `);
2177
+ }
2178
+ for (let i = 0;i < output.providers.length; i++) {
2179
+ const provider = output.providers[i];
2180
+ sections.push(renderProviderSection(provider, output.aggregated, width, noColor2, options.showInsights));
2181
+ if (i < output.providers.length - 1) {
2182
+ sections.push("");
2183
+ sections.push(divider(width));
2184
+ sections.push("");
2185
+ }
2186
+ }
2187
+ if (output.providers.length > 1) {
2188
+ sections.push("");
2189
+ sections.push(divider(width));
2190
+ sections.push("");
2191
+ sections.push(boxedHeader("Overall", width, noColor2));
2192
+ sections.push("");
2193
+ sections.push(renderStats(output.aggregated, width, noColor2));
2194
+ }
2195
+ return sections.join(`
2196
+ `);
2197
+ }
2198
+
2199
+ // packages/renderers/dist/terminal/oneliner.js
2200
+ function renderOneliner(output, _options) {
2201
+ const streak = output.aggregated.currentStreak;
2202
+ const tokens = formatTokens(output.aggregated.totalTokens);
2203
+ const cost = formatCost2(output.aggregated.totalCost);
2204
+ const providerCount = output.providers.length;
2205
+ return `\uD83D\uDD25 ${streak}d streak | ${tokens} tokens | ${cost} | ${providerCount} provider${providerCount !== 1 ? "s" : ""}`;
2206
+ }
2207
+
2208
+ // packages/renderers/dist/terminal/terminal-renderer.js
2209
+ var MIN_DASHBOARD_WIDTH = 40;
2210
+
2211
+ class TerminalRenderer {
2212
+ format = "terminal";
2213
+ async render(output, options) {
2214
+ const effectiveOptions = {
2215
+ ...options,
2216
+ noColor: options.noColor
2217
+ };
2218
+ if (effectiveOptions.width < MIN_DASHBOARD_WIDTH) {
2219
+ return renderOneliner(output, effectiveOptions);
2220
+ }
2221
+ return renderDashboard(output, effectiveOptions);
2222
+ }
2223
+ }
1852
2224
  // packages/cli/src/config.ts
1853
2225
  import { readFileSync as readFileSync2 } from "fs";
1854
2226
  import { join as join4 } from "path";
@@ -2129,8 +2501,14 @@ function getRenderer(format) {
2129
2501
  switch (format) {
2130
2502
  case "json":
2131
2503
  return new JsonRenderer;
2504
+ case "svg":
2505
+ return new SvgRenderer;
2506
+ case "terminal":
2507
+ return new TerminalRenderer;
2508
+ case "png":
2509
+ return new PngRenderer;
2132
2510
  default:
2133
- throw new TokenleakError(`Format "${format}" is not yet supported. Available formats: json`);
2511
+ throw new TokenleakError(`Format "${format}" is not supported. Available formats: json, svg, png, terminal`);
2134
2512
  }
2135
2513
  }
2136
2514
  async function loadAndAggregate(range, providers) {