golem-cc 2.1.2 → 3.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 (84) hide show
  1. package/.claude/commands/golem/build.md +18 -0
  2. package/.claude/commands/golem/config.md +39 -0
  3. package/.claude/commands/golem/continue.md +73 -0
  4. package/.claude/commands/golem/doctor.md +46 -0
  5. package/.claude/commands/golem/document.md +138 -0
  6. package/.claude/commands/golem/help.md +58 -0
  7. package/.claude/commands/golem/pause.md +130 -0
  8. package/.claude/commands/golem/plan.md +111 -0
  9. package/.claude/commands/golem/review.md +166 -0
  10. package/.claude/commands/golem/security.md +186 -0
  11. package/.claude/commands/golem/simplify.md +76 -0
  12. package/.claude/commands/golem/spec.md +105 -0
  13. package/.claude/commands/golem/status.md +33 -0
  14. package/.golem/agents/code-simplifier.md +54 -0
  15. package/.golem/agents/review-architecture.md +59 -0
  16. package/.golem/agents/review-logic.md +50 -0
  17. package/.golem/agents/review-security.md +50 -0
  18. package/.golem/agents/review-style.md +48 -0
  19. package/.golem/agents/review-tests.md +48 -0
  20. package/.golem/agents/spec-builder.md +60 -0
  21. package/.golem/bin/golem.mjs +270 -0
  22. package/.golem/lib/build.mjs +557 -0
  23. package/.golem/lib/claude.mjs +95 -0
  24. package/.golem/lib/config.mjs +421 -0
  25. package/.golem/lib/display.mjs +191 -0
  26. package/.golem/lib/doctor.mjs +197 -0
  27. package/.golem/lib/document.mjs +792 -0
  28. package/.golem/lib/gates.mjs +78 -0
  29. package/.golem/lib/init.mjs +166 -0
  30. package/.golem/lib/output.mjs +40 -0
  31. package/.golem/lib/ratelimit.mjs +86 -0
  32. package/.golem/lib/security.mjs +603 -0
  33. package/.golem/lib/simplify.mjs +101 -0
  34. package/.golem/lib/tui.mjs +368 -0
  35. package/.golem/lib/usage.mjs +119 -0
  36. package/.golem/lib/worktree.mjs +509 -0
  37. package/.golem/prompts/build.md +23 -0
  38. package/.golem/prompts/document-inline.md +66 -0
  39. package/.golem/prompts/document-markdown.md +80 -0
  40. package/.golem/prompts/simplify.md +35 -0
  41. package/README.md +141 -142
  42. package/bin/golem-shim.mjs +36 -0
  43. package/bin/install.mjs +193 -0
  44. package/package.json +27 -32
  45. package/.env.example +0 -17
  46. package/bin/golem +0 -1040
  47. package/commands/golem/build.md +0 -235
  48. package/commands/golem/config.md +0 -55
  49. package/commands/golem/doctor.md +0 -137
  50. package/commands/golem/help.md +0 -212
  51. package/commands/golem/plan.md +0 -214
  52. package/commands/golem/review.md +0 -376
  53. package/commands/golem/security.md +0 -204
  54. package/commands/golem/simplify.md +0 -94
  55. package/commands/golem/spec.md +0 -226
  56. package/commands/golem/status.md +0 -60
  57. package/dist/api/freshworks.d.ts +0 -61
  58. package/dist/api/freshworks.d.ts.map +0 -1
  59. package/dist/api/freshworks.js +0 -119
  60. package/dist/api/freshworks.js.map +0 -1
  61. package/dist/api/gitea.d.ts +0 -96
  62. package/dist/api/gitea.d.ts.map +0 -1
  63. package/dist/api/gitea.js +0 -154
  64. package/dist/api/gitea.js.map +0 -1
  65. package/dist/cli/index.d.ts +0 -9
  66. package/dist/cli/index.d.ts.map +0 -1
  67. package/dist/cli/index.js +0 -352
  68. package/dist/cli/index.js.map +0 -1
  69. package/dist/sync/ticket-sync.d.ts +0 -53
  70. package/dist/sync/ticket-sync.d.ts.map +0 -1
  71. package/dist/sync/ticket-sync.js +0 -226
  72. package/dist/sync/ticket-sync.js.map +0 -1
  73. package/dist/types.d.ts +0 -125
  74. package/dist/types.d.ts.map +0 -1
  75. package/dist/types.js +0 -5
  76. package/dist/types.js.map +0 -1
  77. package/dist/worktree/manager.d.ts +0 -54
  78. package/dist/worktree/manager.d.ts.map +0 -1
  79. package/dist/worktree/manager.js +0 -190
  80. package/dist/worktree/manager.js.map +0 -1
  81. package/golem/agents/code-simplifier.md +0 -81
  82. package/golem/agents/spec-builder.md +0 -90
  83. package/golem/prompts/PROMPT_build.md +0 -71
  84. package/golem/prompts/PROMPT_plan.md +0 -102
@@ -0,0 +1,368 @@
1
+ import chalk from 'chalk';
2
+ import { sparkline, formatTokens } from './usage.mjs';
3
+ import { formatResetTime } from './ratelimit.mjs';
4
+
5
+ const ESC = '\x1b';
6
+ const write = (s) => process.stdout.write(s);
7
+
8
+ // ANSI helpers
9
+ const altScreenOn = () => write(`${ESC}[?1049h`);
10
+ const altScreenOff = () => write(`${ESC}[?1049l`);
11
+ const mouseOn = () => write(`${ESC}[?1000h${ESC}[?1006h`);
12
+ const mouseOff = () => write(`${ESC}[?1000l${ESC}[?1006l`);
13
+ const cursorHide = () => write(`${ESC}[?25l`);
14
+ const cursorShow = () => write(`${ESC}[?25h`);
15
+ const moveTo = (row, col = 1) => write(`${ESC}[${row};${col}H`);
16
+ const clearLine = () => write(`${ESC}[K`);
17
+ const setScrollRegion = (top, bottom) => write(`${ESC}[${top};${bottom}r`);
18
+ const resetScrollRegion = () => write(`${ESC}[r`);
19
+ const saveCursor = () => write(`${ESC}7`);
20
+ const restoreCursor = () => write(`${ESC}8`);
21
+
22
+ const HEADER_LINES = 8;
23
+
24
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*m/g, '');
25
+
26
+ function wordWrap(str, maxWidth, indent = 0) {
27
+ if (maxWidth <= 0) return [str];
28
+ const visible = stripAnsi(str);
29
+ if (visible.length <= maxWidth) return [str];
30
+
31
+ const lines = [];
32
+ let visPos = 0;
33
+ let strPos = 0;
34
+ let lineStart = 0;
35
+ let lineVisStart = 0;
36
+ let lastSpaceVis = -1;
37
+ let lastSpaceStr = -1;
38
+ const prefix = ' '.repeat(indent);
39
+
40
+ while (strPos < str.length) {
41
+ // Skip ANSI sequences
42
+ const ansiMatch = str.slice(strPos).match(/^\x1b\[[0-9;]*m/);
43
+ if (ansiMatch) {
44
+ strPos += ansiMatch[0].length;
45
+ continue;
46
+ }
47
+
48
+ if (str[strPos] === ' ') {
49
+ lastSpaceVis = visPos;
50
+ lastSpaceStr = strPos;
51
+ }
52
+
53
+ visPos++;
54
+ strPos++;
55
+
56
+ const lineWidth = lines.length === 0 ? maxWidth : maxWidth - indent;
57
+ if (visPos - lineVisStart >= lineWidth) {
58
+ if (lastSpaceStr > lineStart) {
59
+ // Break at last space
60
+ lines.push(str.slice(lineStart, lastSpaceStr));
61
+ lineStart = lastSpaceStr + 1;
62
+ lineVisStart = lastSpaceVis + 1;
63
+ } else {
64
+ // No space found, hard break
65
+ lines.push(str.slice(lineStart, strPos));
66
+ lineStart = strPos;
67
+ lineVisStart = visPos;
68
+ }
69
+ lastSpaceVis = -1;
70
+ lastSpaceStr = -1;
71
+ }
72
+ }
73
+
74
+ if (lineStart < str.length) {
75
+ lines.push(str.slice(lineStart));
76
+ }
77
+
78
+ return lines.map((l, i) => i === 0 ? l : prefix + l);
79
+ }
80
+
81
+ export function createTui() {
82
+ let rows = process.stdout.rows || 24;
83
+ let cols = process.stdout.columns || 80;
84
+ let timer = null;
85
+ let startTime = Date.now();
86
+ let cumulativeMs = 0;
87
+ let destroyed = false;
88
+
89
+ // State for header
90
+ let state = {
91
+ taskId: '',
92
+ taskTitle: '',
93
+ stage: '',
94
+ done: 0,
95
+ total: 0,
96
+ attempt: 0,
97
+ maxAttempts: 0,
98
+ claudeCalls: 0,
99
+ inputTokens: 0,
100
+ outputTokens: 0,
101
+ todaySparkline: '',
102
+ todayTotal: '0',
103
+ weekSparkline: '',
104
+ weekTotal: '0',
105
+ opus5hPct: null,
106
+ opus5hResetsAt: null,
107
+ opus7dPct: null,
108
+ opus7dResetsAt: null,
109
+ sonnet5hPct: null,
110
+ sonnet5hResetsAt: null,
111
+ sonnet7dPct: null,
112
+ sonnet7dResetsAt: null,
113
+ taskModel: '',
114
+ extraUsed: null,
115
+ extraLimit: null,
116
+ extraPct: null,
117
+ };
118
+
119
+ function formatElapsed() {
120
+ const secs = Math.floor((Date.now() - startTime) / 1000);
121
+ const m = Math.floor(secs / 60);
122
+ const s = secs % 60;
123
+ return `${m}m ${String(s).padStart(2, '0')}s`;
124
+ }
125
+
126
+ function progressBar(done, total, width) {
127
+ if (total === 0) return '░'.repeat(width);
128
+ const filled = Math.round((done / total) * width);
129
+ return '█'.repeat(filled) + '░'.repeat(width - filled);
130
+ }
131
+
132
+ function pctStr(pct) {
133
+ return String(Math.round(pct)).padStart(3) + '%';
134
+ }
135
+
136
+ function colorPct(pct) {
137
+ const colorFn = pct >= 80 ? chalk.red : pct >= 50 ? chalk.yellow : chalk.green;
138
+ return colorFn(pctStr(pct));
139
+ }
140
+
141
+ function modelLine(name, pct5h, reset5h, pct7d, reset7d, isCurrent) {
142
+ const label = isCurrent ? chalk.white.bold(name.padEnd(6)) : chalk.dim(name.padEnd(6));
143
+ const marker = isCurrent ? chalk.white(' ←') : '';
144
+ const r5 = reset5h ? ' ' + formatResetTime(reset5h) : '';
145
+ const r7 = reset7d ? ' ' + formatResetTime(reset7d) : '';
146
+ const sep = chalk.dim(' │ ');
147
+ return ` ${label}${sep}${chalk.dim('5h')} ${colorPct(pct5h)}${chalk.dim(r5)}${sep}${chalk.dim('7d')} ${colorPct(pct7d)}${chalk.dim(r7)}${marker}`;
148
+ }
149
+
150
+ function drawHeader() {
151
+ if (destroyed) return;
152
+ saveCursor();
153
+
154
+ const pct = state.total > 0 ? Math.round((state.done / state.total) * 100) : 0;
155
+ const barWidth = Math.min(20, cols - 40);
156
+ const bar = progressBar(state.done, state.total, barWidth);
157
+
158
+ // Line 1: Title + progress bar
159
+ moveTo(1);
160
+ clearLine();
161
+ write(` ${chalk.bold.cyan('GOLEM BUILD')} ${chalk.green(bar)} ${state.done}/${state.total} (${pct}%)`);
162
+
163
+ // Line 2: Stage + claude call count
164
+ moveTo(2);
165
+ clearLine();
166
+ const stagePart = state.stage ? ` ${chalk.dim(state.stage)}` : '';
167
+ const callsPart = state.claudeCalls > 0 ? ` ${chalk.dim('calls:')} ${chalk.white(String(state.claudeCalls))}` : '';
168
+ write(`${stagePart}${callsPart}`);
169
+
170
+ // Line 3: Task info + attempt counter + timer
171
+ moveTo(3);
172
+ clearLine();
173
+ const taskLabel = state.taskId ? `Task ${state.taskId}: ${state.taskTitle}` : '';
174
+ const elapsed = formatElapsed();
175
+ const showAttempt = state.maxAttempts > 1 || state.attempt > 1;
176
+ const attemptStr = showAttempt ? `attempt ${state.attempt}/${state.maxAttempts}` : '';
177
+ const reservedWidth = elapsed.length + 6 + (showAttempt ? attemptStr.length + 4 : 0);
178
+ const taskStr = taskLabel.slice(0, Math.max(10, cols - reservedWidth - 4));
179
+ const attemptColored = showAttempt
180
+ ? (state.attempt >= 3 ? chalk.red : state.attempt === 2 ? chalk.yellow : chalk.green)(attemptStr)
181
+ : '';
182
+ const rightSide = (showAttempt ? attemptColored + ' ' : '') + chalk.dim('⏱') + ' ' + chalk.yellow(elapsed);
183
+ const rightLen = (showAttempt ? attemptStr.length + 2 : 0) + 2 + elapsed.length;
184
+ const gap = Math.max(1, cols - taskStr.length - rightLen - 2);
185
+ write(` ${chalk.white(taskStr)}${' '.repeat(gap)}${rightSide}`)
186
+
187
+ // Lines 4-5: Per-model rate limits or sparklines fallback
188
+ moveTo(4);
189
+ clearLine();
190
+ moveTo(5);
191
+ clearLine();
192
+ if (state.opus5hPct !== null) {
193
+ const cur = (state.taskModel || '').toLowerCase();
194
+ moveTo(4);
195
+ write(modelLine('opus', state.opus5hPct, state.opus5hResetsAt, state.opus7dPct, state.opus7dResetsAt, cur.includes('opus')));
196
+ moveTo(5);
197
+ write(modelLine('sonnet', state.sonnet5hPct, state.sonnet5hResetsAt, state.sonnet7dPct, state.sonnet7dResetsAt, cur.includes('sonnet')));
198
+ moveTo(6);
199
+ clearLine();
200
+ if (state.extraPct !== null) {
201
+ const used = `$${state.extraUsed.toFixed(2)}`;
202
+ const limit = `$${state.extraLimit}`;
203
+ const colorFn = state.extraPct >= 80 ? chalk.red : state.extraPct >= 50 ? chalk.yellow : chalk.green;
204
+ write(` ${chalk.dim('extra')} ${colorFn(used)}${chalk.dim('/')}${chalk.dim(limit)} ${colorFn(pctStr(state.extraPct))}`);
205
+ }
206
+ } else {
207
+ moveTo(4);
208
+ const todayPart = `Today ${chalk.cyan(state.todaySparkline)} ${chalk.white(state.todayTotal)} tokens`;
209
+ const weekPart = `Week ${chalk.cyan(state.weekSparkline)} ${chalk.white(state.weekTotal)}`;
210
+ write(` ${todayPart} ${chalk.dim('│')} ${weekPart}`);
211
+ }
212
+
213
+ // Line 7: Separator
214
+ moveTo(7);
215
+ clearLine();
216
+ write(chalk.dim('─'.repeat(cols)));
217
+
218
+ restoreCursor();
219
+ }
220
+
221
+ function setupScrollRegion() {
222
+ rows = process.stdout.rows || 24;
223
+ cols = process.stdout.columns || 80;
224
+ setScrollRegion(HEADER_LINES, rows);
225
+ moveTo(rows); // cursor at bottom of scroll region
226
+ }
227
+
228
+ const tui = {
229
+ init(taskData, usageData) {
230
+ startTime = Date.now();
231
+
232
+ state.taskId = taskData.taskId || '';
233
+ state.taskTitle = taskData.taskTitle || '';
234
+ state.stage = taskData.stage || '';
235
+ state.done = taskData.done || 0;
236
+ state.total = taskData.total || 0;
237
+
238
+ if (usageData) {
239
+ state.todaySparkline = sparkline(usageData.today);
240
+ state.todayTotal = formatTokens(usageData.todayTotal);
241
+ state.weekSparkline = sparkline(usageData.week);
242
+ state.weekTotal = formatTokens(usageData.weekTotal);
243
+ }
244
+
245
+ altScreenOn();
246
+ cursorHide();
247
+ mouseOn();
248
+
249
+ if (process.stdin.isTTY && !process.stdin.isRaw) {
250
+ process.stdin.setRawMode(true);
251
+ process.stdin.resume();
252
+ process.stdin.on('data', (data) => {
253
+ if (data[0] === 3) process.emit('SIGINT');
254
+ });
255
+ }
256
+
257
+ setupScrollRegion();
258
+ drawHeader();
259
+
260
+ timer = setInterval(() => drawHeader(), 1000);
261
+
262
+ process.stdout.on('resize', () => {
263
+ setupScrollRegion();
264
+ drawHeader();
265
+ });
266
+ },
267
+
268
+ resetTimer() {
269
+ cumulativeMs += Date.now() - startTime;
270
+ startTime = Date.now();
271
+ },
272
+
273
+ getCumulativeElapsed() {
274
+ return cumulativeMs + (Date.now() - startTime);
275
+ },
276
+
277
+ updateTask(taskData) {
278
+ if (taskData.taskId !== undefined) state.taskId = taskData.taskId;
279
+ if (taskData.taskTitle !== undefined) state.taskTitle = taskData.taskTitle;
280
+ if (taskData.stage !== undefined) state.stage = taskData.stage;
281
+ if (taskData.done !== undefined) state.done = taskData.done;
282
+ if (taskData.total !== undefined) state.total = taskData.total;
283
+ if (taskData.attempt !== undefined) state.attempt = taskData.attempt;
284
+ if (taskData.maxAttempts !== undefined) state.maxAttempts = taskData.maxAttempts;
285
+ if (taskData.claudeCalls !== undefined) state.claudeCalls = taskData.claudeCalls;
286
+ drawHeader();
287
+ },
288
+
289
+ updateTokens(input, output) {
290
+ state.inputTokens += input;
291
+ state.outputTokens += output;
292
+ drawHeader();
293
+ },
294
+
295
+ updateRateLimit(usage) {
296
+ // 5h is shared across models
297
+ state.opus5hPct = usage.fiveHour.utilization;
298
+ state.opus5hResetsAt = usage.fiveHour.resetsAt;
299
+ state.sonnet5hPct = usage.fiveHour.utilization;
300
+ state.sonnet5hResetsAt = usage.fiveHour.resetsAt;
301
+
302
+ // 7d: opus uses its own bucket, or falls back to overall seven_day
303
+ const opusBucket = usage.opus || usage.sevenDay;
304
+ state.opus7dPct = opusBucket.utilization;
305
+ state.opus7dResetsAt = opusBucket.resetsAt;
306
+
307
+ // 7d: sonnet has its own bucket, or falls back to overall
308
+ const sonnetBucket = usage.sonnet || usage.sevenDay;
309
+ state.sonnet7dPct = sonnetBucket.utilization;
310
+ state.sonnet7dResetsAt = sonnetBucket.resetsAt;
311
+
312
+ // Extra usage (overage billing)
313
+ if (usage.extraUsage && usage.extraUsage.enabled) {
314
+ state.extraUsed = usage.extraUsage.used;
315
+ state.extraLimit = usage.extraUsage.limit;
316
+ state.extraPct = usage.extraUsage.utilization;
317
+ }
318
+
319
+ drawHeader();
320
+ },
321
+
322
+ updateUsage(usageData) {
323
+ state.todaySparkline = sparkline(usageData.today);
324
+ state.todayTotal = formatTokens(usageData.todayTotal);
325
+ state.weekSparkline = sparkline(usageData.week);
326
+ state.weekTotal = formatTokens(usageData.weekTotal);
327
+ drawHeader();
328
+ },
329
+
330
+ setTaskModel(raw) {
331
+ // Normalize full model IDs to short names
332
+ const m = (raw || '').toLowerCase();
333
+ if (m.includes('opus')) state.taskModel = 'opus';
334
+ else if (m.includes('sonnet')) state.taskModel = 'sonnet';
335
+ else if (m.includes('haiku')) state.taskModel = 'haiku';
336
+ else state.taskModel = raw || '';
337
+ drawHeader();
338
+ },
339
+
340
+ appendLog(line) {
341
+ if (destroyed) return;
342
+ const maxWidth = cols - 2; // 2 char right padding
343
+ const wrapped = wordWrap(line, maxWidth, 8);
344
+ for (const wl of wrapped) {
345
+ moveTo(rows);
346
+ write('\n' + wl);
347
+ }
348
+ },
349
+
350
+ destroy() {
351
+ if (destroyed) return;
352
+ destroyed = true;
353
+ if (timer) clearInterval(timer);
354
+ process.stdout.removeAllListeners('resize');
355
+ mouseOff();
356
+ if (process.stdin.isTTY && process.stdin.isRaw) {
357
+ process.stdin.setRawMode(false);
358
+ process.stdin.removeAllListeners('data');
359
+ process.stdin.pause();
360
+ }
361
+ resetScrollRegion();
362
+ altScreenOff();
363
+ cursorShow();
364
+ },
365
+ };
366
+
367
+ return tui;
368
+ }
@@ -0,0 +1,119 @@
1
+ import { readdir, readFile, stat } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { homedir } from 'node:os';
4
+
5
+ const PROJECTS_DIR = join(homedir(), '.claude', 'projects');
6
+ const SPARK_CHARS = '▁▂▃▄▅▆▇█';
7
+
8
+ /**
9
+ * Generate a sparkline string from an array of numeric values.
10
+ */
11
+ export function sparkline(values) {
12
+ if (!values.length) return '';
13
+ const max = Math.max(...values);
14
+ if (max === 0) return SPARK_CHARS[0].repeat(values.length);
15
+ return values.map(v => SPARK_CHARS[Math.min(Math.floor((v / max) * 7), 7)]).join('');
16
+ }
17
+
18
+ /**
19
+ * Format token count for display: 1.2k, 142k, 1.3M
20
+ */
21
+ export function formatTokens(n) {
22
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
23
+ if (n >= 1_000) return `${Math.round(n / 1_000)}k`;
24
+ return String(n);
25
+ }
26
+
27
+ /**
28
+ * Scan all JSONL session files, sum input+output tokens.
29
+ * Returns { today: number[], todayTotal: number, week: number[], weekTotal: number }
30
+ * today = 24-element array (tokens per hour)
31
+ * week = 7-element array (tokens per day, index 0 = 6 days ago, 6 = today)
32
+ */
33
+ export async function getUsageData() {
34
+ const now = new Date();
35
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate());
36
+ const weekStart = new Date(todayStart);
37
+ weekStart.setDate(weekStart.getDate() - 6);
38
+
39
+ const hourly = new Array(24).fill(0);
40
+ const daily = new Array(7).fill(0);
41
+ let todayTotal = 0;
42
+ let weekTotal = 0;
43
+
44
+ let projectDirs;
45
+ try {
46
+ projectDirs = await readdir(PROJECTS_DIR);
47
+ } catch {
48
+ return { today: hourly, todayTotal: 0, week: daily, weekTotal: 0 };
49
+ }
50
+
51
+ for (const dir of projectDirs) {
52
+ const projectPath = join(PROJECTS_DIR, dir);
53
+ let files;
54
+ try {
55
+ files = await readdir(projectPath);
56
+ } catch {
57
+ continue;
58
+ }
59
+
60
+ for (const file of files) {
61
+ if (!file.endsWith('.jsonl')) continue;
62
+ const filePath = join(projectPath, file);
63
+
64
+ let fileStat;
65
+ try {
66
+ fileStat = await stat(filePath);
67
+ } catch {
68
+ continue;
69
+ }
70
+
71
+ // Skip files not modified in the last 7 days
72
+ if (fileStat.mtimeMs < weekStart.getTime()) continue;
73
+
74
+ let content;
75
+ try {
76
+ content = await readFile(filePath, 'utf-8');
77
+ } catch {
78
+ continue;
79
+ }
80
+
81
+ for (const line of content.split('\n')) {
82
+ if (!line) continue;
83
+ let obj;
84
+ try {
85
+ obj = JSON.parse(line);
86
+ } catch {
87
+ continue;
88
+ }
89
+
90
+ const msg = obj.message;
91
+ if (!msg || typeof msg !== 'object') continue;
92
+ const usage = msg.usage;
93
+ if (!usage) continue;
94
+
95
+ const tokens = (usage.input_tokens || 0) + (usage.output_tokens || 0);
96
+ if (tokens === 0) continue;
97
+
98
+ const ts = obj.timestamp ? new Date(obj.timestamp) : null;
99
+ if (!ts || ts < weekStart) continue;
100
+
101
+ // Weekly bucket
102
+ const dayIndex = Math.floor((ts - weekStart) / 86_400_000);
103
+ if (dayIndex >= 0 && dayIndex < 7) {
104
+ daily[dayIndex] += tokens;
105
+ weekTotal += tokens;
106
+ }
107
+
108
+ // Hourly bucket (today only)
109
+ if (ts >= todayStart) {
110
+ const hour = ts.getHours();
111
+ hourly[hour] += tokens;
112
+ todayTotal += tokens;
113
+ }
114
+ }
115
+ }
116
+ }
117
+
118
+ return { today: hourly, todayTotal, week: daily, weekTotal };
119
+ }