@tokscale/cli 1.4.3 → 2.0.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 (188) hide show
  1. package/dist/index.d.ts +3 -0
  2. package/dist/index.d.ts.map +1 -0
  3. package/dist/index.js +128 -0
  4. package/dist/index.js.map +1 -0
  5. package/package.json +19 -26
  6. package/dist/auth.d.ts +0 -17
  7. package/dist/auth.d.ts.map +0 -1
  8. package/dist/auth.js +0 -162
  9. package/dist/auth.js.map +0 -1
  10. package/dist/cli.d.ts +0 -9
  11. package/dist/cli.d.ts.map +0 -1
  12. package/dist/cli.js +0 -1550
  13. package/dist/cli.js.map +0 -1
  14. package/dist/credentials.d.ts +0 -50
  15. package/dist/credentials.d.ts.map +0 -1
  16. package/dist/credentials.js +0 -151
  17. package/dist/credentials.js.map +0 -1
  18. package/dist/cursor.d.ts +0 -167
  19. package/dist/cursor.d.ts.map +0 -1
  20. package/dist/cursor.js +0 -906
  21. package/dist/cursor.js.map +0 -1
  22. package/dist/date-utils.d.ts +0 -10
  23. package/dist/date-utils.d.ts.map +0 -1
  24. package/dist/date-utils.js +0 -47
  25. package/dist/date-utils.js.map +0 -1
  26. package/dist/graph-types.d.ts +0 -142
  27. package/dist/graph-types.d.ts.map +0 -1
  28. package/dist/graph-types.js +0 -6
  29. package/dist/graph-types.js.map +0 -1
  30. package/dist/native-runner.d.ts +0 -11
  31. package/dist/native-runner.d.ts.map +0 -1
  32. package/dist/native-runner.js +0 -77
  33. package/dist/native-runner.js.map +0 -1
  34. package/dist/native.d.ts +0 -106
  35. package/dist/native.d.ts.map +0 -1
  36. package/dist/native.js +0 -302
  37. package/dist/native.js.map +0 -1
  38. package/dist/sessions/types.d.ts +0 -28
  39. package/dist/sessions/types.d.ts.map +0 -1
  40. package/dist/sessions/types.js +0 -27
  41. package/dist/sessions/types.js.map +0 -1
  42. package/dist/spinner.d.ts +0 -75
  43. package/dist/spinner.d.ts.map +0 -1
  44. package/dist/spinner.js +0 -203
  45. package/dist/spinner.js.map +0 -1
  46. package/dist/submit.d.ts +0 -23
  47. package/dist/submit.d.ts.map +0 -1
  48. package/dist/submit.js +0 -294
  49. package/dist/submit.js.map +0 -1
  50. package/dist/table.d.ts +0 -42
  51. package/dist/table.d.ts.map +0 -1
  52. package/dist/table.js +0 -181
  53. package/dist/table.js.map +0 -1
  54. package/dist/tui/App.d.ts +0 -4
  55. package/dist/tui/App.d.ts.map +0 -1
  56. package/dist/tui/App.js +0 -333
  57. package/dist/tui/App.js.map +0 -1
  58. package/dist/tui/components/BarChart.d.ts +0 -17
  59. package/dist/tui/components/BarChart.d.ts.map +0 -1
  60. package/dist/tui/components/BarChart.js +0 -146
  61. package/dist/tui/components/BarChart.js.map +0 -1
  62. package/dist/tui/components/DailyView.d.ts +0 -13
  63. package/dist/tui/components/DailyView.d.ts.map +0 -1
  64. package/dist/tui/components/DailyView.js +0 -86
  65. package/dist/tui/components/DailyView.js.map +0 -1
  66. package/dist/tui/components/DateBreakdownPanel.d.ts +0 -7
  67. package/dist/tui/components/DateBreakdownPanel.d.ts.map +0 -1
  68. package/dist/tui/components/DateBreakdownPanel.js +0 -36
  69. package/dist/tui/components/DateBreakdownPanel.js.map +0 -1
  70. package/dist/tui/components/Footer.d.ts +0 -28
  71. package/dist/tui/components/Footer.d.ts.map +0 -1
  72. package/dist/tui/components/Footer.js +0 -130
  73. package/dist/tui/components/Footer.js.map +0 -1
  74. package/dist/tui/components/Header.d.ts +0 -9
  75. package/dist/tui/components/Header.d.ts.map +0 -1
  76. package/dist/tui/components/Header.js +0 -20
  77. package/dist/tui/components/Header.js.map +0 -1
  78. package/dist/tui/components/Legend.d.ts +0 -7
  79. package/dist/tui/components/Legend.d.ts.map +0 -1
  80. package/dist/tui/components/Legend.js +0 -16
  81. package/dist/tui/components/Legend.js.map +0 -1
  82. package/dist/tui/components/LoadingSpinner.d.ts +0 -8
  83. package/dist/tui/components/LoadingSpinner.d.ts.map +0 -1
  84. package/dist/tui/components/LoadingSpinner.js +0 -55
  85. package/dist/tui/components/LoadingSpinner.js.map +0 -1
  86. package/dist/tui/components/ModelRow.d.ts +0 -13
  87. package/dist/tui/components/ModelRow.d.ts.map +0 -1
  88. package/dist/tui/components/ModelRow.js +0 -15
  89. package/dist/tui/components/ModelRow.js.map +0 -1
  90. package/dist/tui/components/ModelView.d.ts +0 -13
  91. package/dist/tui/components/ModelView.d.ts.map +0 -1
  92. package/dist/tui/components/ModelView.js +0 -96
  93. package/dist/tui/components/ModelView.js.map +0 -1
  94. package/dist/tui/components/OverviewView.d.ts +0 -14
  95. package/dist/tui/components/OverviewView.d.ts.map +0 -1
  96. package/dist/tui/components/OverviewView.js +0 -65
  97. package/dist/tui/components/OverviewView.js.map +0 -1
  98. package/dist/tui/components/StatsView.d.ts +0 -14
  99. package/dist/tui/components/StatsView.d.ts.map +0 -1
  100. package/dist/tui/components/StatsView.js +0 -102
  101. package/dist/tui/components/StatsView.js.map +0 -1
  102. package/dist/tui/components/TokenBreakdown.d.ts +0 -14
  103. package/dist/tui/components/TokenBreakdown.d.ts.map +0 -1
  104. package/dist/tui/components/TokenBreakdown.js +0 -10
  105. package/dist/tui/components/TokenBreakdown.js.map +0 -1
  106. package/dist/tui/components/index.d.ts +0 -16
  107. package/dist/tui/components/index.d.ts.map +0 -1
  108. package/dist/tui/components/index.js +0 -13
  109. package/dist/tui/components/index.js.map +0 -1
  110. package/dist/tui/config/settings.d.ts +0 -15
  111. package/dist/tui/config/settings.d.ts.map +0 -1
  112. package/dist/tui/config/settings.js +0 -147
  113. package/dist/tui/config/settings.js.map +0 -1
  114. package/dist/tui/config/themes.d.ts +0 -15
  115. package/dist/tui/config/themes.d.ts.map +0 -1
  116. package/dist/tui/config/themes.js +0 -82
  117. package/dist/tui/config/themes.js.map +0 -1
  118. package/dist/tui/hooks/useData.d.ts +0 -19
  119. package/dist/tui/hooks/useData.d.ts.map +0 -1
  120. package/dist/tui/hooks/useData.js +0 -468
  121. package/dist/tui/hooks/useData.js.map +0 -1
  122. package/dist/tui/index.d.ts +0 -4
  123. package/dist/tui/index.d.ts.map +0 -1
  124. package/dist/tui/index.js +0 -36
  125. package/dist/tui/index.js.map +0 -1
  126. package/dist/tui/types/index.d.ts +0 -137
  127. package/dist/tui/types/index.d.ts.map +0 -1
  128. package/dist/tui/types/index.js +0 -26
  129. package/dist/tui/types/index.js.map +0 -1
  130. package/dist/tui/utils/cleanup.d.ts +0 -22
  131. package/dist/tui/utils/cleanup.d.ts.map +0 -1
  132. package/dist/tui/utils/cleanup.js +0 -59
  133. package/dist/tui/utils/cleanup.js.map +0 -1
  134. package/dist/tui/utils/colors.d.ts +0 -19
  135. package/dist/tui/utils/colors.d.ts.map +0 -1
  136. package/dist/tui/utils/colors.js +0 -71
  137. package/dist/tui/utils/colors.js.map +0 -1
  138. package/dist/tui/utils/format.d.ts +0 -7
  139. package/dist/tui/utils/format.d.ts.map +0 -1
  140. package/dist/tui/utils/format.js +0 -45
  141. package/dist/tui/utils/format.js.map +0 -1
  142. package/dist/tui/utils/responsive.d.ts +0 -5
  143. package/dist/tui/utils/responsive.d.ts.map +0 -1
  144. package/dist/tui/utils/responsive.js +0 -5
  145. package/dist/tui/utils/responsive.js.map +0 -1
  146. package/dist/wrapped.d.ts +0 -43
  147. package/dist/wrapped.d.ts.map +0 -1
  148. package/dist/wrapped.js +0 -719
  149. package/dist/wrapped.js.map +0 -1
  150. package/src/auth.ts +0 -211
  151. package/src/cli.ts +0 -1892
  152. package/src/credentials.ts +0 -176
  153. package/src/cursor.ts +0 -1044
  154. package/src/date-utils.ts +0 -51
  155. package/src/graph-types.ts +0 -175
  156. package/src/native-runner.js +0 -4
  157. package/src/native-runner.ts +0 -91
  158. package/src/native.ts +0 -633
  159. package/src/sessions/types.ts +0 -59
  160. package/src/spinner.ts +0 -283
  161. package/src/submit.ts +0 -360
  162. package/src/table.ts +0 -233
  163. package/src/tui/App.tsx +0 -453
  164. package/src/tui/components/BarChart.tsx +0 -205
  165. package/src/tui/components/DailyView.tsx +0 -132
  166. package/src/tui/components/DateBreakdownPanel.tsx +0 -79
  167. package/src/tui/components/Footer.tsx +0 -380
  168. package/src/tui/components/Header.tsx +0 -68
  169. package/src/tui/components/Legend.tsx +0 -39
  170. package/src/tui/components/LoadingSpinner.tsx +0 -81
  171. package/src/tui/components/ModelRow.tsx +0 -47
  172. package/src/tui/components/ModelView.tsx +0 -147
  173. package/src/tui/components/OverviewView.tsx +0 -121
  174. package/src/tui/components/StatsView.tsx +0 -249
  175. package/src/tui/components/TokenBreakdown.tsx +0 -46
  176. package/src/tui/components/index.ts +0 -15
  177. package/src/tui/config/settings.ts +0 -183
  178. package/src/tui/config/themes.ts +0 -115
  179. package/src/tui/hooks/useData.ts +0 -558
  180. package/src/tui/index.tsx +0 -44
  181. package/src/tui/opentui.d.ts +0 -166
  182. package/src/tui/types/index.ts +0 -173
  183. package/src/tui/utils/cleanup.ts +0 -65
  184. package/src/tui/utils/colors.ts +0 -78
  185. package/src/tui/utils/format.ts +0 -36
  186. package/src/tui/utils/responsive.ts +0 -8
  187. package/src/types.d.ts +0 -28
  188. package/src/wrapped.ts +0 -848
package/src/wrapped.ts DELETED
@@ -1,848 +0,0 @@
1
- import { createCanvas, loadImage, GlobalFonts } from "@napi-rs/canvas";
2
- import { Resvg } from "@resvg/resvg-js";
3
- import * as fs from "node:fs";
4
- import * as path from "node:path";
5
- import * as os from "node:os";
6
- import pc from "picocolors";
7
- import {
8
- parseLocalSourcesAsync,
9
- finalizeReportAndGraphAsync,
10
- type ParsedMessages,
11
- } from "./native.js";
12
- import { syncCursorCache, isCursorLoggedIn, hasCursorUsageCache } from "./cursor.js";
13
- import { loadCredentials } from "./credentials.js";
14
- import type { SourceType } from "./graph-types.js";
15
-
16
- interface WrappedData {
17
- year: string;
18
- firstDay: string;
19
- totalDays: number;
20
- activeDays: number;
21
- totalTokens: number;
22
- totalCost: number;
23
- currentStreak: number;
24
- longestStreak: number;
25
- topModels: Array<{ name: string; cost: number; tokens: number }>;
26
- topClients: Array<{ name: string; cost: number; tokens: number }>;
27
- topAgents?: Array<{ name: string; cost: number; tokens: number; messages: number }>;
28
- contributions: Array<{ date: string; level: 0 | 1 | 2 | 3 | 4 }>;
29
- totalMessages: number;
30
- }
31
-
32
- export interface WrappedOptions {
33
- output?: string;
34
- year?: string;
35
- sources?: SourceType[];
36
- short?: boolean;
37
- includeAgents?: boolean;
38
- pinSisyphus?: boolean;
39
- }
40
-
41
- const SCALE = 2;
42
- const IMAGE_WIDTH = 1200 * SCALE;
43
- const IMAGE_HEIGHT = 1200 * SCALE;
44
- const PADDING = 56 * SCALE;
45
-
46
- const COLORS = {
47
- background: "#10121C",
48
- textPrimary: "#ffffff",
49
- textSecondary: "#888888",
50
- textMuted: "#555555",
51
- accent: "#00B2FF",
52
- grade0: "#141A25",
53
- grade1: "#00B2FF44",
54
- grade2: "#00B2FF88",
55
- grade3: "#00B2FFCC",
56
- grade4: "#00B2FF",
57
- };
58
-
59
- const SOURCE_DISPLAY_NAMES: Record<string, string> = {
60
- opencode: "OpenCode",
61
- claude: "Claude Code",
62
- codex: "Codex CLI",
63
- gemini: "Gemini CLI",
64
- cursor: "Cursor IDE",
65
- amp: "Amp",
66
- droid: "Droid",
67
- openclaw: "OpenClaw",
68
- pi: "Pi",
69
- kimi: "Kimi",
70
- };
71
-
72
- const ASSETS_BASE_URL = "https://tokscale.ai/assets/logos";
73
-
74
- const PINNED_AGENTS = ["Sisyphus", "Planner-Sisyphus"];
75
-
76
- function normalizeAgentName(agent: string): string {
77
- const agentLower = agent.toLowerCase();
78
-
79
- if (agentLower.includes("plan")) {
80
- if (agentLower.includes("omo") || agentLower.includes("sisyphus")) {
81
- return "Planner-Sisyphus";
82
- }
83
- return agent;
84
- }
85
-
86
- if (agentLower === "omo" || agentLower === "sisyphus") {
87
- return "Sisyphus";
88
- }
89
-
90
- return agent;
91
- }
92
-
93
- const CLIENT_LOGO_URLS: Record<string, string> = {
94
- "OpenCode": `${ASSETS_BASE_URL}/opencode.png`,
95
- "Claude Code": `${ASSETS_BASE_URL}/claude.jpg`,
96
- "Codex CLI": `${ASSETS_BASE_URL}/openai.jpg`,
97
- "Gemini CLI": `${ASSETS_BASE_URL}/gemini.png`,
98
- "Cursor IDE": `${ASSETS_BASE_URL}/cursor.jpg`,
99
- "Amp": `${ASSETS_BASE_URL}/amp.png`,
100
- "Droid": `${ASSETS_BASE_URL}/droid.png`,
101
- "OpenClaw": `${ASSETS_BASE_URL}/openclaw.png`,
102
- "Pi": `${ASSETS_BASE_URL}/pi.png`,
103
- "Kimi": `${ASSETS_BASE_URL}/client-kimi.png`,
104
- };
105
-
106
- const PROVIDER_LOGO_URLS: Record<string, string> = {
107
- "anthropic": `${ASSETS_BASE_URL}/claude.jpg`,
108
- "openai": `${ASSETS_BASE_URL}/openai.jpg`,
109
- "google": `${ASSETS_BASE_URL}/gemini.png`,
110
- "xai": `${ASSETS_BASE_URL}/grok.jpg`,
111
- "zai": `${ASSETS_BASE_URL}/zai.jpg`,
112
- };
113
-
114
- function getProviderFromModel(modelId: string): string | null {
115
- const lower = modelId.toLowerCase();
116
- if (lower.includes("claude") || lower.includes("opus") || lower.includes("sonnet") || lower.includes("haiku")) {
117
- return "anthropic";
118
- }
119
- if (lower.includes("gpt") || lower.includes("o1") || lower.includes("o3") || lower.includes("codex")) {
120
- return "openai";
121
- }
122
- if (lower.includes("gemini")) {
123
- return "google";
124
- }
125
- if (lower.includes("grok")) {
126
- return "xai";
127
- }
128
- if (lower.includes("glm") || lower.includes("pickle")) {
129
- return "zai";
130
- }
131
- return null;
132
- }
133
-
134
- const TOKSCALE_LOGO_SVG_URL = "https://tokscale.ai/tokscale-logo.svg";
135
- const TOKSCALE_LOGO_PNG_SIZE = 400;
136
-
137
- function getImageCacheDir(): string {
138
- return path.join(os.homedir(), ".cache", "tokscale", "images");
139
- }
140
-
141
- function getFontCacheDir(): string {
142
- return path.join(os.homedir(), ".cache", "tokscale", "fonts");
143
- }
144
-
145
- async function fetchAndCacheImage(url: string, filename: string): Promise<string> {
146
- const cacheDir = getImageCacheDir();
147
- if (!fs.existsSync(cacheDir)) {
148
- fs.mkdirSync(cacheDir, { recursive: true });
149
- }
150
-
151
- const cachedPath = path.join(cacheDir, filename);
152
-
153
- if (!fs.existsSync(cachedPath)) {
154
- const response = await fetch(url);
155
- if (!response.ok) throw new Error(`Failed to fetch ${url}`);
156
- const buffer = await response.arrayBuffer();
157
- fs.writeFileSync(cachedPath, Buffer.from(buffer));
158
- }
159
-
160
- return cachedPath;
161
- }
162
-
163
- async function fetchSvgAndConvertToPng(svgUrl: string, filename: string, size: number): Promise<string> {
164
- const cacheDir = getImageCacheDir();
165
- if (!fs.existsSync(cacheDir)) {
166
- fs.mkdirSync(cacheDir, { recursive: true });
167
- }
168
-
169
- const cachedPath = path.join(cacheDir, filename);
170
-
171
- if (!fs.existsSync(cachedPath)) {
172
- const response = await fetch(svgUrl);
173
- if (!response.ok) throw new Error(`Failed to fetch ${svgUrl}`);
174
- const svgText = await response.text();
175
-
176
- const resvg = new Resvg(svgText, {
177
- fitTo: { mode: "width", value: size },
178
- });
179
- const pngData = resvg.render();
180
- fs.writeFileSync(cachedPath, pngData.asPng());
181
- }
182
-
183
- return cachedPath;
184
- }
185
-
186
- const FIGTREE_FONTS = [
187
- { weight: "400", file: "Figtree-Regular.ttf", url: "https://fonts.gstatic.com/s/figtree/v9/_Xmz-HUzqDCFdgfMsYiV_F7wfS-Bs_d_QF5e.ttf" },
188
- { weight: "700", file: "Figtree-Bold.ttf", url: "https://fonts.gstatic.com/s/figtree/v9/_Xmz-HUzqDCFdgfMsYiV_F7wfS-Bs_eYR15e.ttf" },
189
- ];
190
-
191
- let fontsRegistered = false;
192
-
193
- async function ensureFontsLoaded(): Promise<void> {
194
- if (fontsRegistered) return;
195
-
196
- const cacheDir = getFontCacheDir();
197
- if (!fs.existsSync(cacheDir)) {
198
- fs.mkdirSync(cacheDir, { recursive: true });
199
- }
200
-
201
- for (const font of FIGTREE_FONTS) {
202
- const fontPath = path.join(cacheDir, font.file);
203
-
204
- if (!fs.existsSync(fontPath)) {
205
- const response = await fetch(font.url);
206
- if (!response.ok) continue;
207
- const buffer = await response.arrayBuffer();
208
- fs.writeFileSync(fontPath, Buffer.from(buffer));
209
- }
210
-
211
- if (fs.existsSync(fontPath)) {
212
- GlobalFonts.registerFromPath(fontPath, "Figtree");
213
- }
214
- }
215
-
216
- fontsRegistered = true;
217
- }
218
-
219
- async function loadWrappedData(options: WrappedOptions): Promise<WrappedData> {
220
- const year = options.year || new Date().getFullYear().toString();
221
- const sources = options.sources || ["opencode", "claude", "codex", "gemini", "cursor", "amp", "droid", "openclaw", "pi", "kimi"];
222
- const localSources = sources.filter(s => s !== "cursor") as ("opencode" | "claude" | "codex" | "gemini" | "amp" | "droid" | "openclaw" | "pi" | "kimi")[];
223
- const includeCursor = sources.includes("cursor");
224
-
225
- const since = `${year}-01-01`;
226
- const until = `${year}-12-31`;
227
-
228
- const phase1Results = await Promise.allSettled([
229
- includeCursor && isCursorLoggedIn() ? syncCursorCache() : Promise.resolve({ synced: false, rows: 0, error: undefined }),
230
- localSources.length > 0
231
- ? parseLocalSourcesAsync({ sources: localSources, since, until, year })
232
- : Promise.resolve({ messages: [], opencodeCount: 0, claudeCount: 0, codexCount: 0, geminiCount: 0, ampCount: 0, droidCount: 0, openclawCount: 0, piCount: 0, kimiCount: 0, processingTimeMs: 0 } as ParsedMessages),
233
- ]);
234
-
235
- const cursorSync = phase1Results[0].status === "fulfilled"
236
- ? phase1Results[0].value
237
- : { synced: false, rows: 0, error: "Cursor sync failed" };
238
- const localMessages = phase1Results[1].status === "fulfilled"
239
- ? phase1Results[1].value
240
- : null;
241
-
242
- if (includeCursor && cursorSync.error && (cursorSync.synced || hasCursorUsageCache())) {
243
- const prefix = cursorSync.synced ? "Cursor sync warning" : "Cursor sync failed; using cached data";
244
- console.log(pc.yellow(` ${prefix}: ${cursorSync.error}`));
245
- }
246
-
247
- const emptyMessages: ParsedMessages = {
248
- messages: [],
249
- opencodeCount: 0,
250
- claudeCount: 0,
251
- codexCount: 0,
252
- geminiCount: 0,
253
- ampCount: 0,
254
- droidCount: 0,
255
- openclawCount: 0,
256
- piCount: 0,
257
- kimiCount: 0,
258
- processingTimeMs: 0,
259
- };
260
-
261
- const { report, graph } = await finalizeReportAndGraphAsync({
262
- localMessages: localMessages || emptyMessages,
263
- includeCursor: includeCursor && (cursorSync.synced || hasCursorUsageCache()),
264
- since,
265
- until,
266
- year,
267
- });
268
-
269
- const modelMap = new Map<string, { cost: number; tokens: number }>();
270
- for (const entry of report.entries) {
271
- const displayName = formatModelName(entry.model);
272
- const existing = modelMap.get(displayName) || { cost: 0, tokens: 0 };
273
- modelMap.set(displayName, {
274
- cost: existing.cost + entry.cost,
275
- tokens: existing.tokens + entry.input + entry.output + entry.cacheRead + entry.cacheWrite,
276
- });
277
- }
278
- const topModels = Array.from(modelMap.entries())
279
- .map(([name, data]) => ({ name, ...data }))
280
- .sort((a, b) => b.cost - a.cost)
281
- .slice(0, 3);
282
-
283
- const clientMap = new Map<string, { cost: number; tokens: number }>();
284
- for (const entry of report.entries) {
285
- const displayName = SOURCE_DISPLAY_NAMES[entry.source] || entry.source;
286
- const existing = clientMap.get(displayName) || { cost: 0, tokens: 0 };
287
- clientMap.set(displayName, {
288
- cost: existing.cost + entry.cost,
289
- tokens: existing.tokens + entry.input + entry.output + entry.cacheRead + entry.cacheWrite,
290
- });
291
- }
292
- const topClients = Array.from(clientMap.entries())
293
- .map(([name, data]) => ({ name, ...data }))
294
- .sort((a, b) => b.cost - a.cost)
295
- .slice(0, 3);
296
-
297
- let topAgents: Array<{ name: string; cost: number; tokens: number; messages: number }> | undefined;
298
- if (options.includeAgents !== false && localMessages) {
299
- const agentMap = new Map<string, { cost: number; tokens: number; messages: number }>();
300
- for (const msg of localMessages.messages) {
301
- if (msg.source === "opencode" && msg.agent) {
302
- const normalizedAgent = normalizeAgentName(msg.agent);
303
- const existing = agentMap.get(normalizedAgent) || { cost: 0, tokens: 0, messages: 0 };
304
-
305
- const msgTokens = msg.input + msg.output + msg.cacheRead + msg.cacheWrite + msg.reasoning;
306
-
307
- agentMap.set(normalizedAgent, {
308
- cost: existing.cost,
309
- tokens: existing.tokens + msgTokens,
310
- messages: existing.messages + 1,
311
- });
312
- }
313
- }
314
-
315
- let agentsList = Array.from(agentMap.entries())
316
- .map(([name, data]) => ({ name, ...data }));
317
-
318
- if (options.pinSisyphus !== false) {
319
- const pinned = agentsList.filter(a => PINNED_AGENTS.includes(a.name));
320
- const unpinned = agentsList.filter(a => !PINNED_AGENTS.includes(a.name));
321
-
322
- pinned.sort((a, b) => PINNED_AGENTS.indexOf(a.name) - PINNED_AGENTS.indexOf(b.name));
323
- unpinned.sort((a, b) => b.messages - a.messages);
324
-
325
- agentsList = [...pinned, ...unpinned.slice(0, 2)];
326
- } else {
327
- agentsList.sort((a, b) => b.messages - a.messages);
328
- agentsList = agentsList.slice(0, 3);
329
- }
330
-
331
- topAgents = agentsList.length > 0 ? agentsList : undefined;
332
- }
333
-
334
- const maxCost = Math.max(...graph.contributions.map(c => c.totals.cost), 1);
335
- const contributions = graph.contributions.map(c => ({
336
- date: c.date,
337
- level: calculateIntensity(c.totals.cost, maxCost),
338
- }));
339
-
340
- const sortedDates = contributions.map(c => c.date).filter(d => d.startsWith(year)).sort();
341
- const { currentStreak, longestStreak } = calculateStreaks(sortedDates);
342
-
343
- const firstDay = sortedDates.length > 0 ? sortedDates[0] : `${year}-01-01`;
344
-
345
- return {
346
- year,
347
- firstDay,
348
- totalDays: graph.summary.totalDays,
349
- activeDays: graph.summary.activeDays,
350
- totalTokens: graph.summary.totalTokens,
351
- totalCost: graph.summary.totalCost,
352
- currentStreak,
353
- longestStreak,
354
- topModels,
355
- topClients,
356
- topAgents,
357
- contributions,
358
- totalMessages: report.totalMessages,
359
- };
360
- }
361
-
362
- function calculateIntensity(cost: number, maxCost: number): 0 | 1 | 2 | 3 | 4 {
363
- if (cost === 0 || maxCost === 0) return 0;
364
- const ratio = cost / maxCost;
365
- if (ratio >= 0.75) return 4;
366
- if (ratio >= 0.5) return 3;
367
- if (ratio >= 0.25) return 2;
368
- return 1;
369
- }
370
-
371
- function calculateStreaks(sortedDates: string[]): { currentStreak: number; longestStreak: number } {
372
- if (sortedDates.length === 0) return { currentStreak: 0, longestStreak: 0 };
373
-
374
- const todayStr = new Date().toISOString().split("T")[0];
375
- let currentStreak = 0;
376
- let longestStreak = 0;
377
- let streak = 1;
378
-
379
- for (let i = sortedDates.length - 1; i >= 0; i--) {
380
- if (i === sortedDates.length - 1) {
381
- const daysDiff = dateDiffDays(sortedDates[i], todayStr);
382
- if (daysDiff <= 1) {
383
- currentStreak = 1;
384
- } else {
385
- break;
386
- }
387
- } else {
388
- const daysDiff = dateDiffDays(sortedDates[i], sortedDates[i + 1]);
389
- if (daysDiff === 1) {
390
- currentStreak++;
391
- } else {
392
- break;
393
- }
394
- }
395
- }
396
-
397
- for (let i = 1; i < sortedDates.length; i++) {
398
- const daysDiff = dateDiffDays(sortedDates[i - 1], sortedDates[i]);
399
- if (daysDiff === 1) {
400
- streak++;
401
- } else {
402
- longestStreak = Math.max(longestStreak, streak);
403
- streak = 1;
404
- }
405
- }
406
- longestStreak = Math.max(longestStreak, streak);
407
-
408
- return { currentStreak, longestStreak };
409
- }
410
-
411
- function dateDiffDays(date1: string, date2: string): number {
412
- const d1 = new Date(date1 + "T00:00:00Z");
413
- const d2 = new Date(date2 + "T00:00:00Z");
414
- return Math.abs(Math.round((d2.getTime() - d1.getTime()) / (1000 * 60 * 60 * 24)));
415
- }
416
-
417
- function formatTokens(tokens: number): string {
418
- if (tokens >= 1_000_000_000) return `${(tokens / 1_000_000_000).toFixed(2)}B`;
419
- if (tokens >= 1_000_000) return `${(tokens / 1_000_000).toFixed(2)}M`;
420
- if (tokens >= 1_000) return `${(tokens / 1_000).toFixed(1)}K`;
421
- return tokens.toString();
422
- }
423
-
424
- function formatCost(cost: number): string {
425
- if (cost >= 1000) return `$${(cost / 1000).toFixed(2)}K`;
426
- return `$${cost.toFixed(2)}`;
427
- }
428
-
429
- const MODEL_DISPLAY_NAMES: Record<string, string> = {
430
- "claude-sonnet-4-20250514": "Claude Sonnet 4",
431
- "claude-3-5-sonnet-20241022": "Claude 3.5 Sonnet",
432
- "claude-3-5-sonnet-20240620": "Claude 3.5 Sonnet",
433
- "claude-3-opus-20240229": "Claude 3 Opus",
434
- "claude-3-haiku-20240307": "Claude 3 Haiku",
435
- "gpt-4o": "GPT-4o",
436
- "gpt-4o-mini": "GPT-4o Mini",
437
- "gpt-4-turbo": "GPT-4 Turbo",
438
- "o1": "o1",
439
- "o1-mini": "o1 Mini",
440
- "o1-preview": "o1 Preview",
441
- "o3-mini": "o3 Mini",
442
- "gemini-2.5-pro": "Gemini 2.5 Pro",
443
- "gemini-2.5-flash": "Gemini 2.5 Flash",
444
- "gemini-2.0-flash": "Gemini 2.0 Flash",
445
- "gemini-1.5-pro": "Gemini 1.5 Pro",
446
- "gemini-1.5-flash": "Gemini 1.5 Flash",
447
- "grok-3": "Grok 3",
448
- "grok-3-mini": "Grok 3 Mini",
449
- };
450
-
451
- function formatModelName(model: string): string {
452
- if (MODEL_DISPLAY_NAMES[model]) return MODEL_DISPLAY_NAMES[model];
453
-
454
- const suffixMatch = model.match(/[-_](high|medium|low)$/i);
455
- const suffix = suffixMatch ? ` ${suffixMatch[1].charAt(0).toUpperCase()}${suffixMatch[1].slice(1).toLowerCase()}` : "";
456
-
457
- const cleaned = model
458
- .replace(/-20\d{6,8}(-\d+)?$/, "")
459
- .replace(/-\d{8}$/, "")
460
- .replace(/:[-\w]+$/, "")
461
- .replace(/[-_](high|medium|low)$/i, "")
462
- .replace(/[-_]thinking$/i, "");
463
-
464
- if (/claude[-_]?opus[-_]?4[-_.]?5/i.test(cleaned)) return `Claude Opus 4.5${suffix}`;
465
- if (/claude[-_]?4[-_]?opus/i.test(cleaned)) return `Claude 4 Opus${suffix}`;
466
- if (/claude[-_]?opus[-_]?4/i.test(cleaned)) return `Claude Opus 4${suffix}`;
467
- if (/claude[-_]?sonnet[-_]?4[-_.]?5/i.test(cleaned)) return `Claude Sonnet 4.5${suffix}`;
468
- if (/claude[-_]?4[-_]?sonnet/i.test(cleaned)) return `Claude 4 Sonnet${suffix}`;
469
- if (/claude[-_]?sonnet[-_]?4/i.test(cleaned)) return `Claude Sonnet 4${suffix}`;
470
- if (/claude[-_]?haiku[-_]?4[-_.]?5/i.test(cleaned)) return `Claude Haiku 4.5${suffix}`;
471
- if (/claude[-_]?4[-_]?haiku/i.test(cleaned)) return `Claude 4 Haiku${suffix}`;
472
- if (/claude[-_]?haiku[-_]?4/i.test(cleaned)) return `Claude Haiku 4${suffix}`;
473
- if (/claude[-_]?3[-_.]?7[-_]?sonnet/i.test(cleaned)) return `Claude 3.7 Sonnet${suffix}`;
474
- if (/claude[-_]?3[-_.]?5[-_]?sonnet/i.test(cleaned)) return `Claude 3.5 Sonnet${suffix}`;
475
- if (/claude[-_]?3[-_.]?5[-_]?haiku/i.test(cleaned)) return `Claude 3.5 Haiku${suffix}`;
476
- if (/claude[-_]?3[-_]?opus/i.test(cleaned)) return `Claude 3 Opus${suffix}`;
477
- if (/claude[-_]?3[-_]?sonnet/i.test(cleaned)) return `Claude 3 Sonnet${suffix}`;
478
- if (/claude[-_]?3[-_]?haiku/i.test(cleaned)) return `Claude 3 Haiku${suffix}`;
479
- if (/gpt[-_]?5[-_.]?1/i.test(cleaned)) return `GPT-5.1${suffix}`;
480
- if (/gpt[-_]?5/i.test(cleaned)) return `GPT-5${suffix}`;
481
- if (/gpt[-_]?4[-_]?o[-_]?mini/i.test(cleaned)) return `GPT-4o Mini${suffix}`;
482
- if (/gpt[-_]?4[-_]?o/i.test(cleaned)) return `GPT-4o${suffix}`;
483
- if (/gpt[-_]?4[-_]?turbo/i.test(cleaned)) return `GPT-4 Turbo${suffix}`;
484
- if (/gpt[-_]?4/i.test(cleaned)) return `GPT-4${suffix}`;
485
- if (/^o1[-_]?mini/i.test(cleaned)) return `o1 Mini${suffix}`;
486
- if (/^o1[-_]?preview/i.test(cleaned)) return `o1 Preview${suffix}`;
487
- if (/^o3[-_]?mini/i.test(cleaned)) return `o3 Mini${suffix}`;
488
- if (/^o1$/i.test(cleaned)) return `o1${suffix}`;
489
- if (/^o3$/i.test(cleaned)) return `o3${suffix}`;
490
- if (/gemini[-_]?3[-_]?pro/i.test(cleaned)) return `Gemini 3 Pro${suffix}`;
491
- if (/gemini[-_]?3[-_]?flash/i.test(cleaned)) return `Gemini 3 Flash${suffix}`;
492
- if (/gemini[-_]?2[-_.]?5[-_]?pro/i.test(cleaned)) return `Gemini 2.5 Pro${suffix}`;
493
- if (/gemini[-_]?2[-_.]?5[-_]?flash/i.test(cleaned)) return `Gemini 2.5 Flash${suffix}`;
494
- if (/gemini[-_]?2[-_.]?0[-_]?flash/i.test(cleaned)) return `Gemini 2.0 Flash${suffix}`;
495
- if (/gemini[-_]?1[-_.]?5[-_]?pro/i.test(cleaned)) return `Gemini 1.5 Pro${suffix}`;
496
- if (/gemini[-_]?1[-_.]?5[-_]?flash/i.test(cleaned)) return `Gemini 1.5 Flash${suffix}`;
497
- if (/grok[-_]?3[-_]?mini/i.test(cleaned)) return `Grok Code 3 Mini${suffix}`;
498
- if (/grok[-_]?3/i.test(cleaned)) return `Grok Code 3${suffix}`;
499
- if (/grok/i.test(cleaned)) return `Grok Code${suffix}`;
500
- if (/deepseek[-_]?v3/i.test(cleaned)) return `DeepSeek V3${suffix}`;
501
- if (/deepseek[-_]?r1/i.test(cleaned)) return `DeepSeek R1${suffix}`;
502
- if (/deepseek/i.test(cleaned)) return `DeepSeek${suffix}`;
503
-
504
- const baseName = cleaned
505
- .replace(/^claude[-_]/i, "Claude ")
506
- .replace(/^gpt[-_]/i, "GPT-")
507
- .replace(/^gemini[-_]/i, "Gemini ")
508
- .replace(/^grok[-_]/i, "Grok Code ")
509
- .split(/[-_]/)
510
- .filter(Boolean)
511
- .map(word => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase())
512
- .join(" ")
513
- .trim();
514
-
515
- return `${baseName}${suffix}`;
516
- }
517
-
518
- function drawRoundedRect(
519
- ctx: ReturnType<ReturnType<typeof createCanvas>["getContext"]>,
520
- x: number,
521
- y: number,
522
- width: number,
523
- height: number,
524
- radius: number
525
- ) {
526
- ctx.beginPath();
527
- ctx.moveTo(x + radius, y);
528
- ctx.lineTo(x + width - radius, y);
529
- ctx.quadraticCurveTo(x + width, y, x + width, y + radius);
530
- ctx.lineTo(x + width, y + height - radius);
531
- ctx.quadraticCurveTo(x + width, y + height, x + width - radius, y + height);
532
- ctx.lineTo(x + radius, y + height);
533
- ctx.quadraticCurveTo(x, y + height, x, y + height - radius);
534
- ctx.lineTo(x, y + radius);
535
- ctx.quadraticCurveTo(x, y, x + radius, y);
536
- ctx.closePath();
537
- }
538
-
539
- function drawContributionGraph(
540
- ctx: ReturnType<ReturnType<typeof createCanvas>["getContext"]>,
541
- data: WrappedData,
542
- x: number,
543
- y: number,
544
- width: number,
545
- height: number
546
- ) {
547
- const year = parseInt(data.year);
548
- const startDate = new Date(year, 0, 1);
549
- const endDate = new Date(year, 11, 31);
550
-
551
- const contribMap = new Map(data.contributions.map(c => [c.date, c.level]));
552
-
553
- const DAYS_PER_ROW = 14;
554
- const totalDays = Math.ceil((endDate.getTime() - startDate.getTime()) / (1000 * 60 * 60 * 24)) + 1;
555
- const totalRows = Math.ceil(totalDays / DAYS_PER_ROW);
556
-
557
- const cellSize = Math.min(
558
- Math.floor(height / totalRows),
559
- Math.floor(width / DAYS_PER_ROW)
560
- );
561
- const dotRadius = (cellSize - 2 * SCALE) / 2;
562
-
563
- const graphWidth = DAYS_PER_ROW * cellSize;
564
- const graphHeight = totalRows * cellSize;
565
- const offsetX = x + (width - graphWidth) / 2;
566
- const offsetY = y;
567
-
568
- const gradeColors = [COLORS.grade0, COLORS.grade1, COLORS.grade2, COLORS.grade3, COLORS.grade4];
569
-
570
- const currentDate = new Date(startDate);
571
- let dayIndex = 0;
572
-
573
- while (currentDate <= endDate) {
574
- const dateStr = currentDate.toISOString().split("T")[0];
575
- const level = contribMap.get(dateStr) || 0;
576
-
577
- const col = dayIndex % DAYS_PER_ROW;
578
- const row = Math.floor(dayIndex / DAYS_PER_ROW);
579
-
580
- const centerX = offsetX + col * cellSize + cellSize / 2;
581
- const centerY = offsetY + row * cellSize + cellSize / 2;
582
-
583
- ctx.beginPath();
584
- ctx.arc(centerX, centerY, dotRadius, 0, Math.PI * 2);
585
- ctx.fillStyle = gradeColors[level];
586
- ctx.fill();
587
-
588
- currentDate.setDate(currentDate.getDate() + 1);
589
- dayIndex++;
590
- }
591
- }
592
-
593
- function drawStat(
594
- ctx: ReturnType<ReturnType<typeof createCanvas>["getContext"]>,
595
- x: number,
596
- y: number,
597
- label: string,
598
- value: string
599
- ) {
600
- ctx.fillStyle = COLORS.textSecondary;
601
- ctx.font = `${18 * SCALE}px Figtree, sans-serif`;
602
- ctx.fillText(label, x, y);
603
-
604
- ctx.fillStyle = COLORS.textPrimary;
605
- ctx.font = `bold ${36 * SCALE}px Figtree, sans-serif`;
606
- ctx.fillText(value, x, y + 48 * SCALE);
607
- }
608
-
609
- function formatDate(dateStr: string): string {
610
- const date = new Date(dateStr + "T00:00:00");
611
- return date.toLocaleDateString("en-US", { month: "short", day: "numeric", year: "numeric" });
612
- }
613
-
614
- async function generateWrappedImage(data: WrappedData, options: { short?: boolean; includeAgents?: boolean; pinSisyphus?: boolean } = {}): Promise<Buffer> {
615
- await ensureFontsLoaded();
616
-
617
- const canvas = createCanvas(IMAGE_WIDTH, IMAGE_HEIGHT);
618
- const ctx = canvas.getContext("2d");
619
-
620
- ctx.fillStyle = COLORS.background;
621
- ctx.fillRect(0, 0, IMAGE_WIDTH, IMAGE_HEIGHT);
622
-
623
- const leftWidth = IMAGE_WIDTH * 0.45;
624
- const rightWidth = IMAGE_WIDTH * 0.55;
625
- const rightX = leftWidth;
626
-
627
- let yPos = PADDING + 24 * SCALE;
628
-
629
- const credentials = loadCredentials();
630
- const MAX_USERNAME_LENGTH = 30; // GitHub max is 39, but leave room for layout
631
- const displayUsername = credentials?.username
632
- ? credentials.username.length > MAX_USERNAME_LENGTH
633
- ? credentials.username.substring(0, MAX_USERNAME_LENGTH - 1) + '…'
634
- : credentials.username
635
- : null;
636
- const titleText = displayUsername
637
- ? `@${displayUsername}'s Wrapped ${data.year}`
638
- : `My Wrapped ${data.year}`;
639
- ctx.fillStyle = COLORS.textPrimary;
640
- ctx.font = `bold ${28 * SCALE}px Figtree, sans-serif`;
641
- ctx.fillText(titleText, PADDING, yPos);
642
- yPos += 60 * SCALE;
643
-
644
- ctx.fillStyle = COLORS.textSecondary;
645
- ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
646
- ctx.fillText("Total Tokens", PADDING, yPos);
647
- yPos += 64 * SCALE;
648
-
649
- ctx.fillStyle = COLORS.grade4;
650
- ctx.font = `bold ${56 * SCALE}px Figtree, sans-serif`;
651
- const totalTokensDisplay = options.short
652
- ? formatTokens(data.totalTokens)
653
- : data.totalTokens.toLocaleString();
654
- ctx.fillText(totalTokensDisplay, PADDING, yPos);
655
- yPos += 50 * SCALE + 40 * SCALE;
656
-
657
- const logoSize = 32 * SCALE;
658
- const logoRadius = 6 * SCALE;
659
-
660
- ctx.fillStyle = COLORS.textSecondary;
661
- ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
662
- ctx.fillText("Top Models", PADDING, yPos);
663
- yPos += 48 * SCALE;
664
-
665
- for (let i = 0; i < data.topModels.length; i++) {
666
- const model = data.topModels[i];
667
- ctx.fillStyle = COLORS.textPrimary;
668
- ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
669
- ctx.fillText(`${i + 1}`, PADDING, yPos);
670
-
671
- const provider = getProviderFromModel(model.name);
672
- const providerLogoUrl = provider ? PROVIDER_LOGO_URLS[provider] : null;
673
- let textX = PADDING + 40 * SCALE;
674
-
675
- if (providerLogoUrl) {
676
- try {
677
- const filename = `provider-${provider}@2x.jpg`;
678
- const logoPath = await fetchAndCacheImage(providerLogoUrl, filename);
679
- const logo = await loadImage(logoPath);
680
- const logoY = yPos - logoSize + 6 * SCALE;
681
- const logoX = PADDING + 40 * SCALE;
682
-
683
- ctx.save();
684
- drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
685
- ctx.clip();
686
- ctx.drawImage(logo, logoX, logoY, logoSize, logoSize);
687
- ctx.restore();
688
-
689
- drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
690
- ctx.strokeStyle = "#141A25";
691
- ctx.lineWidth = 1 * SCALE;
692
- ctx.stroke();
693
-
694
- textX = logoX + logoSize + 12 * SCALE;
695
- } catch {
696
- }
697
- }
698
-
699
- ctx.fillStyle = COLORS.textPrimary;
700
- ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
701
- ctx.fillText(model.name, textX, yPos);
702
- yPos += 50 * SCALE;
703
- }
704
- yPos += 40 * SCALE;
705
-
706
- if (options.includeAgents !== false) {
707
- ctx.fillStyle = COLORS.textSecondary;
708
- ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
709
- ctx.fillText("Top OpenCode Agents", PADDING, yPos);
710
- yPos += 48 * SCALE;
711
-
712
- const agents = data.topAgents || [];
713
- const SISYPHUS_COLOR = "#00CED1";
714
- let rankIndex = 1;
715
-
716
- for (let i = 0; i < agents.length; i++) {
717
- const agent = agents[i];
718
- const isSisyphusAgent = PINNED_AGENTS.includes(agent.name);
719
- const showWithDash = options.pinSisyphus !== false && isSisyphusAgent;
720
-
721
- ctx.fillStyle = showWithDash ? SISYPHUS_COLOR : COLORS.textPrimary;
722
- ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
723
- const prefix = showWithDash ? "•" : `${rankIndex}`;
724
- ctx.fillText(prefix, PADDING, yPos);
725
- if (!showWithDash) rankIndex++;
726
-
727
- const nameX = PADDING + 40 * SCALE;
728
- ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
729
- ctx.fillStyle = isSisyphusAgent ? SISYPHUS_COLOR : COLORS.textPrimary;
730
- ctx.fillText(agent.name, nameX, yPos);
731
-
732
- const nameWidth = ctx.measureText(agent.name).width;
733
- ctx.fillStyle = COLORS.textSecondary;
734
- ctx.fillText(` (${agent.messages.toLocaleString()})`, nameX + nameWidth, yPos);
735
-
736
- yPos += 50 * SCALE;
737
- }
738
- } else {
739
- ctx.fillStyle = COLORS.textSecondary;
740
- ctx.font = `${20 * SCALE}px Figtree, sans-serif`;
741
- ctx.fillText("Top Clients", PADDING, yPos);
742
- yPos += 48 * SCALE;
743
-
744
- for (let i = 0; i < data.topClients.length; i++) {
745
- const client = data.topClients[i];
746
- ctx.fillStyle = COLORS.textPrimary;
747
- ctx.font = `bold ${32 * SCALE}px Figtree, sans-serif`;
748
- ctx.fillText(`${i + 1}`, PADDING, yPos);
749
-
750
- const logoUrl = CLIENT_LOGO_URLS[client.name];
751
- if (logoUrl) {
752
- try {
753
- const filename = `client-${client.name.toLowerCase().replace(/\s+/g, "-")}@2x.png`;
754
- const logoPath = await fetchAndCacheImage(logoUrl, filename);
755
- const logo = await loadImage(logoPath);
756
- const logoY = yPos - logoSize + 6 * SCALE;
757
-
758
- const logoX = PADDING + 40 * SCALE;
759
- const logoRadius = 6 * SCALE;
760
-
761
- ctx.save();
762
- drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
763
- ctx.clip();
764
- ctx.drawImage(logo, logoX, logoY, logoSize, logoSize);
765
- ctx.restore();
766
-
767
- drawRoundedRect(ctx, logoX, logoY, logoSize, logoSize, logoRadius);
768
- ctx.strokeStyle = "#141A25";
769
- ctx.lineWidth = 1 * SCALE;
770
- ctx.stroke();
771
- } catch {
772
- }
773
- }
774
-
775
- ctx.font = `${32 * SCALE}px Figtree, sans-serif`;
776
- ctx.fillText(client.name, PADDING + 40 * SCALE + logoSize + 12 * SCALE, yPos);
777
- yPos += 50 * SCALE;
778
- }
779
- }
780
- yPos += 40 * SCALE;
781
-
782
- const statsStartY = yPos;
783
- const statWidth = (leftWidth - PADDING * 2) / 2;
784
-
785
- drawStat(ctx, PADDING, statsStartY, "Messages", data.totalMessages.toLocaleString());
786
- drawStat(ctx, PADDING + statWidth, statsStartY, "Active Days", `${data.activeDays}`);
787
-
788
- drawStat(ctx, PADDING, statsStartY + 100 * SCALE, "Cost", formatCost(data.totalCost));
789
- drawStat(ctx, PADDING + statWidth, statsStartY + 100 * SCALE, "Streak", `${data.longestStreak}d`);
790
-
791
- const footerBottomY = IMAGE_HEIGHT - PADDING;
792
- const tokscaleLogoHeight = 72 * SCALE;
793
-
794
- drawContributionGraph(
795
- ctx,
796
- data,
797
- rightX,
798
- PADDING,
799
- rightWidth - PADDING,
800
- IMAGE_HEIGHT - PADDING * 2
801
- );
802
-
803
- try {
804
- const logoPath = await fetchSvgAndConvertToPng(TOKSCALE_LOGO_SVG_URL, "tokscale-logo@2x.png", TOKSCALE_LOGO_PNG_SIZE * SCALE);
805
- const tokscaleLogo = await loadImage(logoPath);
806
- const logoWidth = (tokscaleLogo.width / tokscaleLogo.height) * tokscaleLogoHeight;
807
-
808
- ctx.fillStyle = COLORS.textSecondary;
809
- ctx.font = `${18 * SCALE}px Figtree, sans-serif`;
810
- ctx.fillText("github.com/junhoyeo/tokscale", PADDING, footerBottomY);
811
-
812
- const logoY = footerBottomY - 18 * SCALE - 16 * SCALE - tokscaleLogoHeight;
813
- ctx.drawImage(tokscaleLogo, PADDING, logoY, logoWidth, tokscaleLogoHeight);
814
- } catch {
815
- }
816
-
817
- return canvas.toBuffer("image/png");
818
- }
819
-
820
- export async function generateWrapped(options: WrappedOptions): Promise<string> {
821
- const data = await loadWrappedData(options);
822
-
823
- const agentsRequested = options.includeAgents !== false;
824
- const hasAgentData = !!data.topAgents?.length;
825
- const opencodeEnabled = !options.sources || options.sources.includes("opencode");
826
- let effectiveIncludeAgents = agentsRequested && hasAgentData;
827
-
828
- if (agentsRequested && opencodeEnabled && !hasAgentData) {
829
- console.warn(pc.yellow(`\n ⚠ No OpenCode agent data found for ${data.year}.`));
830
- console.warn(pc.gray(" Falling back to clients view."));
831
- console.warn(pc.gray(" Use --clients to always show clients view.\n"));
832
- }
833
-
834
- const imageBuffer = await generateWrappedImage(data, {
835
- short: options.short,
836
- includeAgents: effectiveIncludeAgents,
837
- pinSisyphus: options.pinSisyphus,
838
- });
839
-
840
- const outputPath = options.output || `tokscale-${data.year}-wrapped.png`;
841
- const absolutePath = path.resolve(outputPath);
842
-
843
- fs.writeFileSync(absolutePath, imageBuffer);
844
-
845
- return absolutePath;
846
- }
847
-
848
- export { type WrappedData };