@ugurcandede/cc-cost 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,410 @@
1
+ import { agentOf, avgContext, byCost, cacheHitRatio, contextTokens, daysBetween, groupBy, modelLabel, repriceAs, totals, weekOf, } from "./aggregate.js";
2
+ import { PLANS } from "./config.js";
3
+ import { int, paint, table, tokens, usd } from "./format.js";
4
+ import { fill, L, lang } from "./i18n.js";
5
+ import { localClock } from "./scan.js";
6
+ import { T, UNKNOWN } from "./types.js";
7
+ const col = (title, kind) => ({ title, kind });
8
+ const FMT = {
9
+ text: String, usd, int, tok: tokens, pct: (v) => v.toFixed(1) + '%', x: (v) => v.toFixed(1) + '×',
10
+ };
11
+ export function renderTable(r) {
12
+ const cell = (v, i) => (typeof v === 'number' ? FMT[r.cols[i].kind](v) : v);
13
+ const left = r.cols.flatMap((c, i) => (c.kind === 'text' ? [i] : []));
14
+ return table(r.cols.map((c) => c.title), r.rows.map((row) => row.map(cell)), { foot: r.foot?.map(cell), left, dim: r.dim });
15
+ }
16
+ const csvCell = (v) => typeof v === 'number' ? String(Math.round(v * 1e4) / 1e4) : /[",\n]/.test(v) ? `"${v.replace(/"/g, '""')}"` : v;
17
+ export const renderCsv = (r) => [r.cols.map((c) => c.title), ...r.rows].map((row) => row.map(csvCell).join(',')).join('\n');
18
+ const round = (n) => Math.round(n * 1e4) / 1e4;
19
+ const pctOf = (part, whole) => (whole ? (part / whole) * 100 : 0);
20
+ const at = (t, i) => t.t[i] ?? 0;
21
+ const writes = (t) => at(t, T.write5m) + at(t, T.write1h);
22
+ const span = (t) => {
23
+ const days = [...t.days].sort();
24
+ return { first: days[0] ?? '', last: days[days.length - 1] ?? '' };
25
+ };
26
+ export function toJson(t) {
27
+ return {
28
+ cost: round(t.cost),
29
+ costBreakdown: { input: round(t.c[0]), cacheWrite: round(t.c[1]), cacheRead: round(t.c[2]), output: round(t.c[3]), webSearch: round(t.c[4]) },
30
+ inputTokens: at(t, T.input),
31
+ cacheWrite5mTokens: at(t, T.write5m),
32
+ cacheWrite1hTokens: at(t, T.write1h),
33
+ cacheReadTokens: at(t, T.read),
34
+ outputTokens: at(t, T.output),
35
+ thinkingTokens: at(t, T.thinking),
36
+ webSearches: at(t, T.webSearch),
37
+ calls: at(t, T.calls),
38
+ sessions: t.sessions.size,
39
+ avgContext: Math.round(avgContext(t.t)),
40
+ maxContext: at(t, T.maxContext),
41
+ };
42
+ }
43
+ export const PERIODS = ['daily', 'weekly', 'monthly'];
44
+ const periodKey = {
45
+ daily: (r) => r.d,
46
+ weekly: (r) => weekOf(r.d),
47
+ monthly: (r) => r.d.slice(0, 7),
48
+ };
49
+ const byKey = (m) => [...m].sort((a, b) => a[0].localeCompare(b[0]));
50
+ export function periodReport(rows, period, opts = {}) {
51
+ const c = L().col;
52
+ const nums = (t) => [at(t, T.input), at(t, T.output), writes(t), at(t, T.read), at(t, T.calls), t.cost];
53
+ const numCols = [col(c.input, 'tok'), col(c.output, 'tok'), col(c.cacheWrite, 'tok'), col(c.cacheRead, 'tok'), col(c.calls, 'int'), col(c.cost, 'usd')];
54
+ const label = col({ daily: c.date, weekly: c.week, monthly: c.month }[period], 'text');
55
+ const groups = byKey(groupBy(rows, periodKey[period]));
56
+ const models = (key) => byCost(groupBy(rows.filter((r) => periodKey[period](r) === key), modelLabel));
57
+ // CSV wants one self-contained row per period and model rather than indented sub-rows
58
+ if (opts.breakdown && opts.flat)
59
+ return {
60
+ cols: [label, col(c.model, 'text'), ...numCols],
61
+ rows: groups.flatMap(([key]) => models(key).map(([m, t]) => [key, m, ...nums(t)])),
62
+ };
63
+ const body = [];
64
+ const dim = new Set();
65
+ for (const [key, t] of groups) {
66
+ body.push([key, ...nums(t)]);
67
+ if (opts.breakdown)
68
+ for (const [m, mt] of models(key)) {
69
+ dim.add(body.length);
70
+ body.push([' └ ' + m, ...nums(mt)]);
71
+ }
72
+ }
73
+ return { cols: [label, ...numCols], rows: body, foot: [c.total, ...nums(totals(rows))], dim };
74
+ }
75
+ export function periodJson(rows, period, breakdown) {
76
+ return {
77
+ [period]: byKey(groupBy(rows, periodKey[period])).map(([key, t]) => ({
78
+ period: key,
79
+ ...toJson(t),
80
+ ...(breakdown && {
81
+ models: byCost(groupBy(rows.filter((r) => periodKey[period](r) === key), modelLabel)).map(([m, mt]) => ({ model: m, ...toJson(mt) })),
82
+ }),
83
+ })),
84
+ totals: toJson(totals(rows)),
85
+ };
86
+ }
87
+ export const DIMENSIONS = ['models', 'machines', 'projects', 'agents', 'skills', 'mcp', 'sessions'];
88
+ const none = () => L().dash.none;
89
+ // The main thread is "main" in data and filters, and a translated label on screen
90
+ const agentLabel = (key) => (key === 'main' ? L().dash.main : key);
91
+ const dimensionKey = {
92
+ models: modelLabel,
93
+ machines: (r) => r.machine,
94
+ projects: (r) => r.p,
95
+ agents: agentOf,
96
+ skills: (r) => r.k ?? none(),
97
+ mcp: (r) => r.x ?? none(),
98
+ sessions: (r) => r.s,
99
+ };
100
+ const singular = {
101
+ models: 'model', machines: 'machine', projects: 'project', agents: 'agent', skills: 'skill', mcp: 'mcp', sessions: 'session',
102
+ };
103
+ function dimensionGroups(rows, dimension, limit) {
104
+ // Legacy data has no session ids; lumped together it would pose as one huge session.
105
+ const source = dimension === 'sessions' ? rows.filter((r) => r.s !== UNKNOWN) : rows;
106
+ const all = byCost(groupBy(source, dimensionKey[dimension]));
107
+ return limit ? all.slice(0, limit) : all;
108
+ }
109
+ const sessionInfo = (rows, id) => {
110
+ const r = rows.find((x) => x.s === id);
111
+ return { project: r?.p ?? '', machine: r?.machine ?? '' };
112
+ };
113
+ export function dimensionReport(rows, dimension, limit) {
114
+ const c = L().col;
115
+ const groups = dimensionGroups(rows, dimension, limit);
116
+ if (dimension === 'sessions')
117
+ return {
118
+ cols: [col(c.session, 'text'), col(c.project, 'text'), col(c.machine, 'text'), col(c.first, 'text'), col(c.last, 'text'),
119
+ col(c.calls, 'int'), col(c.avgContext, 'tok'), col(c.cost, 'usd')],
120
+ rows: groups.map(([id, t]) => {
121
+ const { project, machine } = sessionInfo(rows, id);
122
+ const { first, last } = span(t);
123
+ return [id.slice(0, 8), project, machine, first, last, at(t, T.calls), avgContext(t.t), t.cost];
124
+ }),
125
+ };
126
+ const grand = totals(rows);
127
+ const name = { models: c.model, machines: c.machine, projects: c.project, agents: c.agent, skills: c.skill, mcp: c.mcp }[dimension];
128
+ const nums = (t) => [at(t, T.calls), avgContext(t.t), at(t, T.read), at(t, T.output), t.cost, pctOf(t.cost, grand.cost)];
129
+ return {
130
+ cols: [col(name, 'text'), col(c.calls, 'int'), col(c.avgContext, 'tok'), col(c.cacheRead, 'tok'), col(c.output, 'tok'), col(c.cost, 'usd'), col(c.share, 'pct')],
131
+ rows: groups.map(([key, t]) => [dimension === 'agents' ? agentLabel(key) : key, ...nums(t)]),
132
+ foot: [c.total, ...nums(grand)],
133
+ };
134
+ }
135
+ export function dimensionJson(rows, dimension, limit) {
136
+ return {
137
+ [dimension]: dimensionGroups(rows, dimension, limit).map(([key, t]) => ({
138
+ [singular[dimension]]: key,
139
+ ...(dimension === 'sessions' && { ...sessionInfo(rows, key), ...span(t) }),
140
+ ...toJson(t),
141
+ })),
142
+ totals: toJson(totals(rows)),
143
+ };
144
+ }
145
+ // ---------- summary
146
+ export function summary(rows) {
147
+ const L_ = L(), c = L_.col, grand = totals(rows);
148
+ const costTable = (title, groups) => ({
149
+ cols: [col(title, 'text'), col(c.cost, 'usd'), col(c.share, 'pct')],
150
+ rows: groups.map(([k, t]) => [k, t.cost, pctOf(t.cost, grand.cost)]),
151
+ });
152
+ const items = [
153
+ [L_.item.read, grand.c[2]], [L_.item.write, grand.c[1]], [L_.item.output, grand.c[3]], [L_.item.input, grand.c[0]], [L_.item.web, grand.c[4]],
154
+ ];
155
+ return [
156
+ paint('bold', fill(L_.total, { cost: usd(grand.cost), calls: int(at(grand, T.calls)), machines: grand.machines.size })),
157
+ renderTable({ cols: [col(c.month, 'text'), col(c.cost, 'usd')], rows: byKey(groupBy(rows, periodKey.monthly)).map(([k, t]) => [k, t.cost]) }),
158
+ renderTable(costTable(c.machine, byCost(groupBy(rows, dimensionKey.machines)))),
159
+ renderTable(costTable(c.model, byCost(groupBy(rows, modelLabel)))),
160
+ renderTable(costTable(c.project, byCost(groupBy(rows, dimensionKey.projects)).slice(0, 5))),
161
+ renderTable({
162
+ cols: [col(c.item, 'text'), col(c.cost, 'usd'), col(c.share, 'pct')],
163
+ rows: items.filter(([, v]) => v > 0).sort((a, b) => b[1] - a[1]).map(([k, v]) => [k, v, pctOf(v, grand.cost)]),
164
+ }),
165
+ ].join('\n\n');
166
+ }
167
+ export function summaryJson(rows) {
168
+ return {
169
+ totals: toJson(totals(rows)),
170
+ months: periodJson(rows, 'monthly').monthly,
171
+ machines: dimensionJson(rows, 'machines').machines,
172
+ models: dimensionJson(rows, 'models').models,
173
+ projects: dimensionJson(rows, 'projects').projects,
174
+ };
175
+ }
176
+ // ---------- insights
177
+ const weekdayName = (w) => new Intl.DateTimeFormat(lang() === 'tr' ? 'tr-TR' : 'en-US', { weekday: 'long', timeZone: 'UTC' }).format(new Date(Date.UTC(2026, 0, 4 + w))); // 2026-01-04 is a Sunday
178
+ function topBy(entries) {
179
+ let best;
180
+ for (const e of entries)
181
+ if (!best || e[1] > best[1])
182
+ best = e;
183
+ return best;
184
+ }
185
+ export function insights({ rows, hours, limits, table: prices, timezone }) {
186
+ const g = totals(rows), t = g.t;
187
+ const dates = [...g.days].sort();
188
+ const calendar = dates.length ? daysBetween(dates[0], dates[dates.length - 1]) : 0;
189
+ // Part of each row's cache reads that went to context beyond 200K tokens, assuming its calls sat at the row's average
190
+ const above200k = rows.reduce((acc, r) => {
191
+ const avg = avgContext(r.t);
192
+ return acc + (avg > 200_000 ? r.c[2] * (1 - 200_000 / avg) : 0);
193
+ }, 0);
194
+ const buckets = [T.ctx50k, T.ctx200k, T.ctx500k, T.ctxOver500k].map((i) => at(g, i));
195
+ const bigSessions = [...groupBy(rows.filter((r) => r.s !== UNKNOWN), (r) => r.s)].sort((a, b) => b[1].c[2] - a[1].c[2]).slice(0, 5);
196
+ const withKey = (key) => byCost(groupBy(rows.filter((r) => key(r) !== undefined), (r) => key(r)));
197
+ const models = byCost(groupBy(rows, modelLabel));
198
+ const baseModels = [...new Set(rows.map((r) => r.m))];
199
+ const whatIf = baseModels
200
+ .flatMap((m) => {
201
+ const cost = repriceAs(rows, prices, m);
202
+ return cost === undefined ? [] : [{ model: m, cost }];
203
+ })
204
+ .sort((a, b) => a.cost - b.cost);
205
+ const fast = rows.filter((r) => r.f).reduce((a, r) => a + r.cost, 0);
206
+ const weekdays = new Map();
207
+ for (const r of rows) {
208
+ const w = new Date(r.d + 'T00:00:00Z').getUTCDay();
209
+ weekdays.set(w, (weekdays.get(w) ?? 0) + r.cost);
210
+ }
211
+ const hourCost = new Map();
212
+ for (const h of hours ?? [])
213
+ hourCost.set(h.h, (hourCost.get(h.h) ?? 0) + h.cost);
214
+ const busiestDay = topBy(weekdays), busiestHour = topBy(hourCost);
215
+ const clock = localClock(timezone);
216
+ const hits = limits.filter((l) => l.status === 'rejected');
217
+ const hitTypes = new Map();
218
+ for (const l of hits)
219
+ hitTypes.set(l.type, (hitTypes.get(l.type) ?? 0) + 1);
220
+ const lastHit = hits.map((l) => l.ts).sort().pop();
221
+ const localTime = (iso) => {
222
+ const k = clock(iso);
223
+ return `${k.d} ${String(k.h).padStart(2, '0')}:${String(k.min).padStart(2, '0')}`;
224
+ };
225
+ const json = {
226
+ cost: round(g.cost),
227
+ calls: at(g, T.calls),
228
+ activeDays: dates.length,
229
+ calendarDays: calendar,
230
+ avgContext: Math.round(avgContext(t)),
231
+ maxContext: at(g, T.maxContext),
232
+ cacheHitRatio: round(cacheHitRatio(t)),
233
+ cacheReadShare: round(pctOf(g.c[2], g.cost) / 100),
234
+ cacheWriteShare: round(pctOf(g.c[1], g.cost) / 100),
235
+ oneHourWriteShare: round(writes(g) ? at(g, T.write1h) / writes(g) : 0),
236
+ contextBuckets: { under50k: buckets[0], from50kTo200k: buckets[1], from200kTo500k: buckets[2], over500k: buckets[3] },
237
+ cacheReadAbove200kEstimate: round(above200k),
238
+ largestContextSessions: bigSessions.map(([id, s]) => ({ session: id, ...sessionInfo(rows, id), calls: at(s, T.calls), avgContext: Math.round(avgContext(s.t)), cacheReadCost: round(s.c[2]), cost: round(s.cost) })),
239
+ agents: withKey(agentOf).map(([k, s]) => ({ agent: k, cost: round(s.cost), calls: at(s, T.calls) })),
240
+ skills: withKey((r) => r.k).map(([k, s]) => ({ skill: k, cost: round(s.cost), calls: at(s, T.calls) })),
241
+ mcp: withKey((r) => r.x).map(([k, s]) => ({ mcp: k, cost: round(s.cost), calls: at(s, T.calls) })),
242
+ effort: withKey((r) => r.e).map(([k, s]) => ({ effort: k, cost: round(s.cost), calls: at(s, T.calls) })),
243
+ models: models.map(([k, s]) => ({ model: k, cost: round(s.cost) })),
244
+ sameTokensOn: whatIf.map((w) => ({ model: w.model, cost: round(w.cost) })),
245
+ fastModeCost: round(fast),
246
+ thinkingShareOfOutput: round(at(g, T.output) ? at(g, T.thinking) / at(g, T.output) : 0),
247
+ busiestWeekday: busiestDay && { weekday: weekdayName(busiestDay[0]), cost: round(busiestDay[1]) },
248
+ busiestHour: busiestHour && { hour: busiestHour[0], cost: round(busiestHour[1]) },
249
+ rateLimitHits: { total: hits.length, byType: Object.fromEntries(hitTypes), last: lastHit },
250
+ };
251
+ const I = L().ins, c = L().col;
252
+ const pct = (v) => v.toFixed(1) + '%';
253
+ const out = [];
254
+ const section = (title, lines) => out.push(paint('bold', title) + '\n' + lines.join('\n'));
255
+ const kv = (pairs) => {
256
+ const w = Math.max(...pairs.map(([k]) => k.length));
257
+ return pairs.map(([k, v]) => ' ' + paint('dim', k.padEnd(w)) + ' ' + v);
258
+ };
259
+ const shareTable = (title, groups) => renderTable({ cols: [col(title, 'text'), col(c.calls, 'int'), col(c.cost, 'usd'), col(c.share, 'pct')], rows: groups.map(([k, s]) => [k, at(s, T.calls), s.cost, pctOf(s.cost, g.cost)]) });
260
+ section(I.overview, kv([
261
+ [I.cost, usd(g.cost)],
262
+ [I.calls, `${int(at(g, T.calls))} (${fill(I.perCall, { cost: usd(g.cost / (at(g, T.calls) || 1)) })})`],
263
+ [I.activeDays, fill(I.activeOf, { active: dates.length, calendar })],
264
+ [I.avgContext, fill(I.avgContextValue, { avg: tokens(avgContext(t)), max: tokens(at(g, T.maxContext)) })],
265
+ ]));
266
+ section(I.cache, kv([
267
+ [I.hitRatio, fill(I.hitRatioValue, { pct: pct(cacheHitRatio(t) * 100) })],
268
+ [I.readShare, fill(I.ofCost, { pct: pct(pctOf(g.c[2], g.cost)) })],
269
+ [I.writeShare, fill(I.oneHour, { pct: pct(pctOf(g.c[1], g.cost)), tier: pct(pctOf(at(g, T.write1h), writes(g))) })],
270
+ ]));
271
+ const bucketTotal = buckets.reduce((a, b) => a + b, 0);
272
+ if (bucketTotal)
273
+ section(I.contextSize, [
274
+ renderTable({ cols: [col(c.context, 'text'), col(c.calls, 'int'), col(c.share, 'pct')], rows: buckets.map((b, i) => [I.bucket[i], b, pctOf(b, bucketTotal)]) }),
275
+ fill(I.above200k, { cost: usd(above200k) }),
276
+ paint('dim', I.contextTip),
277
+ ]);
278
+ if (bigSessions.length)
279
+ section(I.bigSessions, [renderTable({
280
+ cols: [col(c.session, 'text'), col(c.project, 'text'), col(c.calls, 'int'), col(c.avgContext, 'tok'), col(c.readCost, 'usd'), col(c.cost, 'usd')],
281
+ rows: bigSessions.map(([id, s]) => [id.slice(0, 8), sessionInfo(rows, id).project, at(s, T.calls), avgContext(s.t), s.c[2], s.cost]),
282
+ })]);
283
+ section(I.agents, [shareTable(c.agent, withKey(agentOf).map(([k, s]) => [agentLabel(k), s]))]);
284
+ const skills = withKey((r) => r.k), mcps = withKey((r) => r.x), efforts = withKey((r) => r.e);
285
+ if (skills.length)
286
+ section(I.skills, [shareTable(c.skill, skills.slice(0, 5))]);
287
+ if (mcps.length)
288
+ section(I.mcp, [shareTable(c.mcp, mcps.slice(0, 5))]);
289
+ if (efforts.length)
290
+ section(I.effort, [shareTable(c.effort, efforts)]);
291
+ section(I.models, [
292
+ shareTable(c.model, models),
293
+ ...(whatIf.length > 1 ? whatIf.map((w) => fill(I.whatIf, { model: w.model, cost: usd(w.cost) })) : []),
294
+ ...kv([
295
+ ...(fast ? [[I.fast, `${usd(fast)} (${pct(pctOf(fast, g.cost))})`]] : []),
296
+ [I.thinking, fill(I.thinkingValue, { pct: pct(pctOf(at(g, T.thinking), at(g, T.output))) })],
297
+ ]),
298
+ ]);
299
+ const when = [];
300
+ if (busiestDay)
301
+ when.push([I.weekday, `${weekdayName(busiestDay[0])} (${usd(busiestDay[1])})`]);
302
+ if (busiestHour)
303
+ when.push([I.hour, `${String(busiestHour[0]).padStart(2, '0')}:00–${String((busiestHour[0] + 1) % 24).padStart(2, '0')}:00 (${usd(busiestHour[1])})`]);
304
+ if (when.length)
305
+ section(I.when, kv(when));
306
+ section(I.limits, [' ' + (hits.length
307
+ ? fill(I.limitsValue, { n: hits.length, types: [...hitTypes].map(([k, v]) => `${k}: ${v}`).join(', '), last: localTime(lastHit) })
308
+ : I.noLimits)]);
309
+ return { text: out.join('\n\n'), json };
310
+ }
311
+ // ---------- plan
312
+ // Limit events filtered to the rows' date range, by local date
313
+ export function limitsInRange(limits, timezone, since, until, machines) {
314
+ const clock = localClock(timezone);
315
+ return limits.filter((l) => {
316
+ const d = clock(l.ts).d;
317
+ return (!since || d >= since) && (!until || d <= until) && (!machines || machines(l.machine));
318
+ });
319
+ }
320
+ export function planReport(rows, limits, opts) {
321
+ const c = L().col, P = L().plan;
322
+ const dates = [...new Set(rows.map((r) => r.d))].sort();
323
+ const first = dates[0] ?? '', last = dates[dates.length - 1] ?? '';
324
+ const clock = localClock(opts.timezone);
325
+ const hitsIn = (month) => limits.filter((l) => l.status === 'rejected' && (!month || clock(l.ts).d.startsWith(month))).length;
326
+ const plans = Object.entries(PLANS);
327
+ const line = (label, days, cost, hits) => {
328
+ const perMonth = days ? (cost / days) * 30 : 0;
329
+ return [label, days, cost, perMonth, ...plans.map(([, price]) => perMonth / price), hits];
330
+ };
331
+ const months = byKey(groupBy(rows, (r) => r.d.slice(0, 7))).map(([m, t]) => {
332
+ // calendar days of the month that fall inside the recorded range; idle days count, days before the first record don't
333
+ const [y, mo] = m.split('-').map(Number);
334
+ const monthEnd = new Date(Date.UTC(y, mo, 0)).toISOString().slice(0, 10);
335
+ const from = m + '-01' > first ? m + '-01' : first;
336
+ const to = monthEnd < last ? monthEnd : last;
337
+ return line(m, daysBetween(from, to), t.cost, hitsIn(m));
338
+ });
339
+ const grand = totals(rows);
340
+ const foot = line(c.total, first ? daysBetween(first, last) : 0, grand.cost, hitsIn());
341
+ const planName = { pro: 'Pro', max5x: 'Max 5x', max20x: 'Max 20x' };
342
+ const report = {
343
+ cols: [col(c.month, 'text'), col(c.days, 'int'), col(c.cost, 'usd'), col(c.perMonth, 'usd'),
344
+ ...plans.map(([k, price]) => col(`${planName[k] ?? k} ($${price})`, 'x')), col(c.limitHits, 'int')],
345
+ rows: months,
346
+ foot,
347
+ };
348
+ const perMonth = foot[3];
349
+ const price = opts.plan ? PLANS[opts.plan] : undefined;
350
+ const notes = [
351
+ price ? fill(P.yours, { plan: planName[opts.plan] ?? opts.plan, price, multiple: (perMonth / price).toFixed(1) }) : P.noPlan,
352
+ paint('dim', P.note),
353
+ ];
354
+ const json = {
355
+ months: months.map((r) => ({ month: r[0], days: r[1], cost: round(r[2]), per30Days: round(r[3]), limitHits: r[r.length - 1] })),
356
+ total: { days: foot[1], cost: round(grand.cost), per30Days: round(perMonth), limitHits: foot[foot.length - 1] },
357
+ multiples: Object.fromEntries(plans.map(([k, p]) => [k, round(perMonth / p)])),
358
+ plan: opts.plan ?? null,
359
+ };
360
+ return { report, notes, json };
361
+ }
362
+ // ---------- 5-hour blocks
363
+ // Claude's usage limits reset in 5-hour windows that start with the first message. With hourly data a
364
+ // window starts at the top of its first active hour, and the next activity after it closes opens a new one.
365
+ export function blocksReport(hours, limits, opts) {
366
+ const c = L().col;
367
+ const clock = localClock(opts.timezone);
368
+ const index = (d, h) => Date.UTC(+d.slice(0, 4), +d.slice(5, 7) - 1, +d.slice(8, 10), h) / 36e5;
369
+ const label = (i) => new Date(i * 36e5).toISOString().slice(0, 13).replace('T', ' ') + ':00';
370
+ const byHour = new Map();
371
+ for (const h of hours) {
372
+ const k = index(h.d, h.h);
373
+ const acc = byHour.get(k) ?? { cost: 0, calls: 0, tokens: 0 };
374
+ acc.cost += h.cost;
375
+ acc.calls += h.t[T.calls] ?? 0;
376
+ acc.tokens += contextTokens(h.t) + (h.t[T.output] ?? 0);
377
+ byHour.set(k, acc);
378
+ }
379
+ const blocks = [];
380
+ for (const k of [...byHour.keys()].sort((a, b) => a - b)) {
381
+ let b = blocks[blocks.length - 1];
382
+ if (!b || k >= b.start + 5)
383
+ blocks.push((b = { start: k, cost: 0, calls: 0, tokens: 0, hits: 0 }));
384
+ const v = byHour.get(k);
385
+ b.cost += v.cost;
386
+ b.calls += v.calls;
387
+ b.tokens += v.tokens;
388
+ }
389
+ for (const l of limits) {
390
+ if (l.status !== 'rejected')
391
+ continue;
392
+ const k = clock(l.ts), i = index(k.d, k.h);
393
+ const b = blocks.find((x) => i >= x.start && i < x.start + 5);
394
+ if (b)
395
+ b.hits++;
396
+ }
397
+ const now = clock((opts.now ?? new Date()).toISOString());
398
+ const nowAt = index(now.d, now.h) + now.min / 60;
399
+ const shown = opts.limit ? blocks.slice(-opts.limit) : blocks;
400
+ return {
401
+ cols: [col(c.start, 'text'), col(c.end, 'text'), col(c.calls, 'int'), col(c.tokens, 'tok'), col(c.cost, 'usd'), col(c.limitHits, 'int'), col(c.state, 'text')],
402
+ rows: shown.map((b) => {
403
+ const left = b.start + 5 - nowAt;
404
+ const active = nowAt >= b.start && left > 0;
405
+ const state = active ? fill(L().blocks.active, { left: `${Math.floor(left)}h ${Math.round((left % 1) * 60)}m` }) : '';
406
+ return [label(b.start), label(b.start + 5), b.calls, b.tokens, b.cost, b.hits, state];
407
+ }),
408
+ foot: [c.total, '', shown.reduce((a, b) => a + b.calls, 0), shown.reduce((a, b) => a + b.tokens, 0), shown.reduce((a, b) => a + b.cost, 0), shown.reduce((a, b) => a + b.hits, 0), ''],
409
+ };
410
+ }
package/dist/scan.js ADDED
@@ -0,0 +1,178 @@
1
+ import { createHash } from 'node:crypto';
2
+ import fs from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import readline from 'node:readline';
6
+ import { normModel } from "./pricing.js";
7
+ import { CTX_BOUNDS, T, T_LEN, UNKNOWN } from "./types.js";
8
+ const zeros = () => new Array(T_LEN).fill(0);
9
+ // Snapshots are synced JSON; keep absent dimensions out of them instead of writing nulls.
10
+ function defined(o) {
11
+ for (const k of Object.keys(o))
12
+ if (o[k] === undefined)
13
+ delete o[k];
14
+ return o;
15
+ }
16
+ // The git repository a working directory belongs to, so a session that cd's into src/foo still
17
+ // counts for its repo. Missing directories (deleted since) resolve through their nearest existing
18
+ // parent; worktrees resolve to their main repository. Falls back to the directory itself.
19
+ export function projectRoot(cwd) {
20
+ // Walking up a relative path (or a Windows path read on macOS/Linux) would search wherever cc-cost runs from
21
+ if (!path.isAbsolute(cwd))
22
+ return cwd;
23
+ const home = os.homedir();
24
+ for (let dir = cwd;;) {
25
+ // a dotfiles repo in $HOME must not claim every project under it
26
+ if (dir !== home || dir === cwd) {
27
+ const git = path.join(dir, '.git');
28
+ try {
29
+ if (fs.statSync(git).isDirectory())
30
+ return dir;
31
+ const gitdir = fs.readFileSync(git, 'utf8').match(/^gitdir:\s*(.+)$/m)?.[1]?.trim();
32
+ if (!gitdir)
33
+ return dir;
34
+ const [main, worktree] = path.resolve(dir, gitdir).split(/[\\/]\.git[\\/]worktrees[\\/]/);
35
+ return worktree ? main : dir;
36
+ }
37
+ catch {
38
+ // no .git here, or the directory no longer exists
39
+ }
40
+ }
41
+ const up = path.dirname(dir);
42
+ if (up === dir)
43
+ return cwd;
44
+ dir = up;
45
+ }
46
+ }
47
+ function listJsonl(dir, out) {
48
+ for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
49
+ const p = path.join(dir, e.name);
50
+ if (e.isDirectory())
51
+ listJsonl(p, out);
52
+ else if (e.name.endsWith('.jsonl'))
53
+ out.push(p);
54
+ }
55
+ }
56
+ // ISO timestamp -> local date and hour in the given IANA zone
57
+ export function localClock(timezone) {
58
+ const fmt = new Intl.DateTimeFormat('en-CA', {
59
+ timeZone: timezone, year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', hourCycle: 'h23',
60
+ });
61
+ return (iso) => {
62
+ const p = {};
63
+ for (const part of fmt.formatToParts(new Date(iso)))
64
+ p[part.type] = part.value;
65
+ return { d: `${p.year}-${p.month}-${p.day}`, h: Number(p.hour), min: Number(p.minute) };
66
+ };
67
+ }
68
+ function addUsage(t, u) {
69
+ const cc = u.cache_creation ?? {};
70
+ // Older transcripts only have the combined counter; treat it as the 5m tier.
71
+ const w5 = cc.ephemeral_5m_input_tokens ?? (cc.ephemeral_1h_input_tokens ? 0 : u.cache_creation_input_tokens ?? 0);
72
+ const w1 = cc.ephemeral_1h_input_tokens ?? 0;
73
+ const input = u.input_tokens ?? 0, read = u.cache_read_input_tokens ?? 0;
74
+ t[T.input] += input;
75
+ t[T.write5m] += w5;
76
+ t[T.write1h] += w1;
77
+ t[T.read] += read;
78
+ t[T.output] += u.output_tokens ?? 0;
79
+ t[T.calls] += 1;
80
+ t[T.thinking] += u.output_tokens_details?.thinking_tokens ?? 0;
81
+ t[T.webSearch] += u.server_tool_use?.web_search_requests ?? 0;
82
+ t[T.webFetch] += u.server_tool_use?.web_fetch_requests ?? 0;
83
+ const context = input + w5 + w1 + read;
84
+ t[T.maxContext] = Math.max(t[T.maxContext], context);
85
+ const bucket = CTX_BOUNDS.findIndex((b) => context < b);
86
+ t[T.ctx50k + (bucket < 0 ? CTX_BOUNDS.length : bucket)] += 1;
87
+ }
88
+ export async function scan(dirs, opts) {
89
+ const files = [];
90
+ for (const dir of dirs)
91
+ listJsonl(path.join(dir, 'projects'), files);
92
+ const calls = new Map();
93
+ const limits = new Map();
94
+ let duplicates = 0;
95
+ for (const file of files) {
96
+ const rl = readline.createInterface({ input: fs.createReadStream(file), crlfDelay: Infinity });
97
+ for await (const line of rl) {
98
+ if (!line.includes('"usage"') && !line.includes('"quotaLimits"'))
99
+ continue;
100
+ let d;
101
+ try {
102
+ d = JSON.parse(line);
103
+ }
104
+ catch {
105
+ continue; // a line cut off by a crash or a concurrent write
106
+ }
107
+ const q = d.quotaLimits;
108
+ if (q && d.timestamp) {
109
+ // Retries inside one limit period share resetsAt; keep the first hit of each period.
110
+ const key = `${q.rateLimitType}|${q.status}|${q.resetsAt ?? d.timestamp}`;
111
+ const seen = limits.get(key);
112
+ if (!seen || d.timestamp < seen.ts)
113
+ limits.set(key, { ts: d.timestamp, type: String(q.rateLimitType), status: String(q.status), ...(typeof q.resetsAt === 'number' && { resetsAt: q.resetsAt }) });
114
+ }
115
+ const msg = d.message;
116
+ if (d.type !== 'assistant' || !msg?.usage || !msg.model || msg.model === '<synthetic>' || !d.timestamp)
117
+ continue;
118
+ // Resume and compaction rewrite the same message into several files; streaming writes one
119
+ // line per content block with output_tokens still growing. Keep the most complete copy.
120
+ const key = `${msg.id}|${d.requestId}`;
121
+ const prev = calls.get(key);
122
+ if (prev)
123
+ duplicates++;
124
+ if (prev && (prev.usage.output_tokens ?? 0) >= (msg.usage.output_tokens ?? 0))
125
+ continue;
126
+ calls.set(key, {
127
+ ts: d.timestamp,
128
+ session: d.sessionId ?? '',
129
+ cwd: d.cwd ?? '',
130
+ model: msg.model,
131
+ usage: msg.usage,
132
+ agent: d.isSidechain ? d.attributionAgent || 'subagent' : undefined,
133
+ skill: d.attributionSkill,
134
+ mcp: d.attributionMcpServer,
135
+ effort: d.effort,
136
+ entrypoint: d.entrypoint,
137
+ background: d.sessionKind === 'bg',
138
+ });
139
+ }
140
+ }
141
+ const at = localClock(opts.timezone);
142
+ const projects = new Map();
143
+ const project = (cwd) => {
144
+ let name = projects.get(cwd);
145
+ if (name === undefined) {
146
+ name = (cwd && projectRoot(cwd).split(/[\\/]/).filter(Boolean).pop()) || UNKNOWN;
147
+ if (opts.anonymize)
148
+ name = 'p-' + createHash('sha256').update(name).digest('hex').slice(0, 8);
149
+ projects.set(cwd, name);
150
+ }
151
+ return name;
152
+ };
153
+ const rows = new Map(), hourly = new Map();
154
+ const sessions = {};
155
+ for (const c of calls.values()) {
156
+ const { d, h } = at(c.ts);
157
+ const m = normModel(c.model), f = c.usage.speed === 'fast' ? 1 : undefined, p = project(c.cwd);
158
+ const bg = c.background ? 1 : undefined;
159
+ const rowKey = [d, p, c.session, m, f, c.agent, c.skill, c.mcp, c.effort, c.entrypoint, bg].join('\0');
160
+ let row = rows.get(rowKey);
161
+ if (!row) {
162
+ row = defined({ d, p, s: c.session, m, f, a: c.agent, k: c.skill, x: c.mcp, e: c.effort, ep: c.entrypoint, bg, t: zeros() });
163
+ rows.set(rowKey, row);
164
+ }
165
+ addUsage(row.t, c.usage);
166
+ const hourKey = `${d}|${h}|${m}|${f}`;
167
+ let hr = hourly.get(hourKey);
168
+ if (!hr)
169
+ hourly.set(hourKey, (hr = defined({ d, h, m, f, t: zeros() })));
170
+ addUsage(hr.t, c.usage);
171
+ const s = (sessions[c.session] ??= { p, first: c.ts, last: c.ts });
172
+ if (c.ts < s.first)
173
+ s.first = c.ts;
174
+ if (c.ts > s.last)
175
+ s.last = c.ts;
176
+ }
177
+ return { rows: [...rows.values()], hourly: [...hourly.values()], sessions, limits: [...limits.values()], files: files.length, duplicates };
178
+ }