@brimveyn/aimux 1.22.12 → 1.23.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/app-runtime/side-effects.ts +5 -0
- package/src/app-runtime/use-mouse-handlers.ts +2 -0
- package/src/app.tsx +6 -0
- package/src/index.tsx +6 -0
- package/src/input/keymap/help-entries.ts +1 -0
- package/src/input/modes/bridge.ts +2 -1
- package/src/input/modes/transitions.ts +7 -3
- package/src/input/modes/types.ts +2 -1
- package/src/restart-daemon.ts +5 -0
- package/src/services/ai-usage/projection.ts +44 -0
- package/src/services/aimux-counters/index.ts +87 -0
- package/src/services/aimux-counters/observe.ts +39 -0
- package/src/services/aimux-counters/store.ts +150 -0
- package/src/services/aimux-counters/summary.ts +78 -0
- package/src/services/usage-history/cost.ts +149 -0
- package/src/services/usage-history/insights.ts +314 -0
- package/src/services/usage-history/rollup.ts +78 -6
- package/src/services/usage-history/stats.ts +66 -35
- package/src/services/usage-history/store.ts +128 -9
- package/src/settings/sections/about.ts +1 -0
- package/src/settings/sections/appearance.ts +1 -0
- package/src/settings/sections/automation.ts +1 -0
- package/src/settings/sections/commands.ts +1 -0
- package/src/settings/sections/editor.ts +1 -0
- package/src/settings/sections/experimental.ts +1 -0
- package/src/settings/sections/git.ts +1 -0
- package/src/settings/sections/integrations.ts +1 -0
- package/src/settings/sections/layout.ts +1 -0
- package/src/settings/sections/notifications.ts +1 -0
- package/src/settings/sections/setup.ts +1 -0
- package/src/settings/sections/status-bar.ts +1 -0
- package/src/settings/sections/workspace.ts +1 -0
- package/src/settings/types.ts +10 -0
- package/src/state/actions.ts +12 -1
- package/src/state/app-store.ts +12 -1
- package/src/state/reducers/modal-state.ts +12 -17
- package/src/state/reducers/stats-state.ts +56 -0
- package/src/state/stats-pages.ts +33 -0
- package/src/state/store.ts +5 -0
- package/src/state/types.ts +17 -4
- package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
- package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
- package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
- package/src/ui/components/settings/settings-view.tsx +6 -3
- package/src/ui/components/stats/aimux-page.tsx +245 -0
- package/src/ui/components/stats/chart.ts +157 -0
- package/src/ui/components/stats/day-facts.tsx +268 -0
- package/src/ui/components/stats/format.ts +98 -0
- package/src/ui/components/stats/heatmap.tsx +305 -0
- package/src/ui/components/stats/projects-page.tsx +293 -0
- package/src/ui/components/stats/quotas.tsx +210 -0
- package/src/ui/components/stats/shared.tsx +645 -0
- package/src/ui/components/stats/stats-view.tsx +153 -0
- package/src/ui/components/stats/usage-page.tsx +291 -0
- package/src/ui/components/stats/use-stats-data.ts +48 -0
- package/src/ui/root.tsx +7 -3
- package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import type { UsageDay, UsageDays, UsageTokens } from './store'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Notional cost of the recorded tokens.
|
|
5
|
+
*
|
|
6
|
+
* Nothing here is a charge. Claude Code and Codex run on subscriptions, not
|
|
7
|
+
* metered API billing, so these numbers answer one question only: what would
|
|
8
|
+
* this usage have cost at API list price. Every surface that renders them says
|
|
9
|
+
* "estimated" for that reason.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
export interface ModelRate {
|
|
13
|
+
/** USD per million input tokens. */
|
|
14
|
+
input: number
|
|
15
|
+
/** USD per million output tokens. */
|
|
16
|
+
output: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MILLION = 1_000_000
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* List prices, matched by id prefix so dated snapshots
|
|
23
|
+
* (`claude-sonnet-4-5-20250929`) hit the same row as the alias. Longest prefix
|
|
24
|
+
* wins, so `claude-opus-4-6` is not shadowed by a shorter `claude-opus` entry —
|
|
25
|
+
* there deliberately isn't one. A model whose price is not published here is
|
|
26
|
+
* left out of the total rather than guessed at.
|
|
27
|
+
*/
|
|
28
|
+
const RATES: [prefix: string, rate: ModelRate][] = [
|
|
29
|
+
['claude-fable-5', { input: 10, output: 50 }],
|
|
30
|
+
['claude-mythos-5', { input: 10, output: 50 }],
|
|
31
|
+
['claude-opus-5', { input: 5, output: 25 }],
|
|
32
|
+
['claude-opus-4-8', { input: 5, output: 25 }],
|
|
33
|
+
['claude-opus-4-7', { input: 5, output: 25 }],
|
|
34
|
+
['claude-opus-4-6', { input: 5, output: 25 }],
|
|
35
|
+
['claude-sonnet-5', { input: 3, output: 15 }],
|
|
36
|
+
['claude-sonnet-4-6', { input: 3, output: 15 }],
|
|
37
|
+
['claude-haiku-4-5', { input: 1, output: 5 }],
|
|
38
|
+
]
|
|
39
|
+
|
|
40
|
+
/** Cache reads bill at a tenth of the input rate; writes at 1.25x (the 5-minute TTL). */
|
|
41
|
+
const CACHE_READ_MULTIPLIER = 0.1
|
|
42
|
+
const CACHE_WRITE_MULTIPLIER = 1.25
|
|
43
|
+
|
|
44
|
+
export function rateFor(model: string): ModelRate | null {
|
|
45
|
+
let best: ModelRate | null = null
|
|
46
|
+
let bestLength = 0
|
|
47
|
+
for (const [prefix, rate] of RATES) {
|
|
48
|
+
if (model.startsWith(prefix) && prefix.length > bestLength) {
|
|
49
|
+
best = rate
|
|
50
|
+
bestLength = prefix.length
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return best
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function costOf(tokens: UsageTokens, rate: ModelRate): number {
|
|
57
|
+
return (
|
|
58
|
+
(tokens.input * rate.input +
|
|
59
|
+
tokens.output * rate.output +
|
|
60
|
+
tokens.cacheRead * rate.input * CACHE_READ_MULTIPLIER +
|
|
61
|
+
tokens.cacheWrite * rate.input * CACHE_WRITE_MULTIPLIER) /
|
|
62
|
+
MILLION
|
|
63
|
+
)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** What the cache reads would have cost at the full input rate, minus what they did cost. */
|
|
67
|
+
export function savedByCache(tokens: UsageTokens, rate: ModelRate): number {
|
|
68
|
+
return (tokens.cacheRead * rate.input * (1 - CACHE_READ_MULTIPLIER)) / MILLION
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function scale(tokens: UsageTokens, factor: number): UsageTokens {
|
|
72
|
+
return {
|
|
73
|
+
cacheRead: tokens.cacheRead * factor,
|
|
74
|
+
cacheWrite: tokens.cacheWrite * factor,
|
|
75
|
+
input: tokens.input * factor,
|
|
76
|
+
output: tokens.output * factor,
|
|
77
|
+
total: tokens.total * factor,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export interface CostBreakdown {
|
|
82
|
+
saved: number
|
|
83
|
+
total: number
|
|
84
|
+
/**
|
|
85
|
+
* Tokens `total` does not cover, so a page can say so instead of presenting a
|
|
86
|
+
* partial sum as the whole bill. Two ways in: a model with no published rate
|
|
87
|
+
* (every Codex model, today), and tokens no model claimed at all.
|
|
88
|
+
*/
|
|
89
|
+
unpricedTokens: number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function emptyBreakdown(): CostBreakdown {
|
|
93
|
+
return { saved: 0, total: 0, unpricedTokens: 0 }
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* A day's cost, split across its models in proportion to their token share.
|
|
98
|
+
*
|
|
99
|
+
* ponytail: the store keeps one total per model, not an input/output/cache split
|
|
100
|
+
* per model, so a day's overall split is applied to each model's share. Exact
|
|
101
|
+
* whenever a day used one model — the common case — and off only when a day
|
|
102
|
+
* mixes tiers whose input/output ratios also differ. Storing `UsageTokens` per
|
|
103
|
+
* model would make it exact, at the cost of a wider file and another migration;
|
|
104
|
+
* do that if the estimate is ever visibly wrong.
|
|
105
|
+
*/
|
|
106
|
+
export function dayCost(day: UsageDay): CostBreakdown {
|
|
107
|
+
const result = emptyBreakdown()
|
|
108
|
+
const { total } = day.tokens
|
|
109
|
+
if (total <= 0) return result
|
|
110
|
+
|
|
111
|
+
let attributed = 0
|
|
112
|
+
for (const [model, modelTotal] of Object.entries(day.models)) {
|
|
113
|
+
attributed += modelTotal
|
|
114
|
+
const rate = rateFor(model)
|
|
115
|
+
if (rate === null) {
|
|
116
|
+
result.unpricedTokens += modelTotal
|
|
117
|
+
continue
|
|
118
|
+
}
|
|
119
|
+
const share = scale(day.tokens, modelTotal / total)
|
|
120
|
+
result.total += costOf(share, rate)
|
|
121
|
+
result.saved += savedByCache(share, rate)
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Tokens no model claimed: a Codex transcript whose `model` line never
|
|
125
|
+
// appeared, or a day whose attribution was pruned out from under its totals.
|
|
126
|
+
// Unpriced for the same reason a missing rate is — nothing here knows what
|
|
127
|
+
// they cost — and counting them is what keeps the loop above from silently
|
|
128
|
+
// dropping a day's tokens out of the accounting altogether.
|
|
129
|
+
result.unpricedTokens += Math.max(0, total - attributed)
|
|
130
|
+
|
|
131
|
+
return result
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
export function totalCost(days: UsageDays): CostBreakdown {
|
|
135
|
+
const result = emptyBreakdown()
|
|
136
|
+
for (const day of Object.values(days)) {
|
|
137
|
+
const cost = dayCost(day)
|
|
138
|
+
result.saved += cost.saved
|
|
139
|
+
result.total += cost.total
|
|
140
|
+
result.unpricedTokens += cost.unpricedTokens
|
|
141
|
+
}
|
|
142
|
+
return result
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export function formatUsd(amount: number): string {
|
|
146
|
+
if (amount >= 1000) return `$${Math.round(amount).toLocaleString('en-US').replaceAll(',', ' ')}`
|
|
147
|
+
if (amount >= 10) return `$${amount.toFixed(0)}`
|
|
148
|
+
return `$${amount.toFixed(2)}`
|
|
149
|
+
}
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import {
|
|
2
|
+
HOURS_IN_DAY,
|
|
3
|
+
localDay,
|
|
4
|
+
type UsageDay,
|
|
5
|
+
type UsageDays,
|
|
6
|
+
type UsageMean,
|
|
7
|
+
type UsageSession,
|
|
8
|
+
} from './store'
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Derivations over the stored days, for the Activity / Projects / Records pages.
|
|
12
|
+
*
|
|
13
|
+
* Separate from `stats.ts`, which shapes the heatmap grid: that file is calendar
|
|
14
|
+
* geometry, this one is arithmetic over what the days contain. Everything here is
|
|
15
|
+
* pure and takes `UsageDays`, so it is testable without touching disk.
|
|
16
|
+
*
|
|
17
|
+
* Days recorded before the v2 rollup carry the new fields as empty rather than
|
|
18
|
+
* zeroed. Every function below reports a `days` count alongside its result so a
|
|
19
|
+
* page can say "no data" instead of rendering an empty span as a measurement.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const MINUTES_IN_DAY = 24 * 60
|
|
23
|
+
const MS_PER_DAY = 86_400_000
|
|
24
|
+
|
|
25
|
+
export function parseDayKey(key: string): Date {
|
|
26
|
+
const [year, month, day] = key.split('-').map(Number)
|
|
27
|
+
return new Date(year ?? 0, (month ?? 1) - 1, day ?? 1)
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Whole days apart, via `Date.UTC` on the calendar parts — a DST span is not a whole number of 24h periods. */
|
|
31
|
+
export function daysBetween(from: Date, to: Date): number {
|
|
32
|
+
const utcOf = (date: Date): number =>
|
|
33
|
+
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate())
|
|
34
|
+
return Math.round((utcOf(to) - utcOf(from)) / MS_PER_DAY)
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* One value per calendar day for the `count` days ending today, oldest first —
|
|
39
|
+
* the series a daily bar chart draws. Unrecorded days are zero, not skipped:
|
|
40
|
+
* the x-axis is time, and time does not pause on a day off.
|
|
41
|
+
*/
|
|
42
|
+
export function lastDays(
|
|
43
|
+
days: UsageDays,
|
|
44
|
+
count: number,
|
|
45
|
+
today: Date,
|
|
46
|
+
of: (day: UsageDay) => number
|
|
47
|
+
): number[] {
|
|
48
|
+
return Array.from({ length: count }, (_, index) => {
|
|
49
|
+
const date = new Date(today)
|
|
50
|
+
date.setDate(date.getDate() - (count - 1 - index))
|
|
51
|
+
const day = days[localDay(date)]
|
|
52
|
+
return day === undefined ? 0 : of(day)
|
|
53
|
+
})
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function promptDayKeys(days: UsageDays): string[] {
|
|
57
|
+
return Object.entries(days)
|
|
58
|
+
.filter(([, day]) => day.prompts > 0)
|
|
59
|
+
.map(([key]) => key)
|
|
60
|
+
.sort()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ─── Activity ────────────────────────────────────────────────────────────────
|
|
64
|
+
|
|
65
|
+
export interface StreakInfo {
|
|
66
|
+
current: number
|
|
67
|
+
longest: number
|
|
68
|
+
/** Largest run of consecutive prompt-less days between two active ones. */
|
|
69
|
+
longestGapDays: number
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* A streak that ends yesterday is still current: the day is not over, and
|
|
74
|
+
* breaking it at midnight would report every morning as a reset.
|
|
75
|
+
*/
|
|
76
|
+
export function streaks(days: UsageDays, today: Date): StreakInfo {
|
|
77
|
+
const keys = promptDayKeys(days)
|
|
78
|
+
if (keys.length === 0) return { current: 0, longest: 0, longestGapDays: 0 }
|
|
79
|
+
|
|
80
|
+
let longest = 1
|
|
81
|
+
let longestGapDays = 0
|
|
82
|
+
let run = 1
|
|
83
|
+
for (let index = 1; index < keys.length; index++) {
|
|
84
|
+
const gap = daysBetween(parseDayKey(keys[index - 1] ?? ''), parseDayKey(keys[index] ?? ''))
|
|
85
|
+
if (gap === 1) {
|
|
86
|
+
run += 1
|
|
87
|
+
if (run > longest) longest = run
|
|
88
|
+
} else {
|
|
89
|
+
if (gap - 1 > longestGapDays) longestGapDays = gap - 1
|
|
90
|
+
run = 1
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const last = parseDayKey(keys.at(-1) ?? '')
|
|
95
|
+
const sinceLast = daysBetween(last, today)
|
|
96
|
+
// Only a run that reaches today or yesterday is still going. `run` is the
|
|
97
|
+
// length of the final run because the loop never resets it after the last gap.
|
|
98
|
+
const current = sinceLast <= 1 ? run : 0
|
|
99
|
+
|
|
100
|
+
return { current, longest, longestGapDays }
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 24 buckets of prompts, summed across every day. */
|
|
104
|
+
export function hourTotals(days: UsageDays): number[] {
|
|
105
|
+
const totals = Array.from({ length: HOURS_IN_DAY }, () => 0)
|
|
106
|
+
for (const day of Object.values(days)) {
|
|
107
|
+
for (let hour = 0; hour < HOURS_IN_DAY; hour++) {
|
|
108
|
+
totals[hour] = (totals[hour] ?? 0) + (day.hours[hour] ?? 0)
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
return totals
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* 7 buckets of prompts, Sunday first — the order `buildHeatmap` draws its rows
|
|
116
|
+
* in, so the bars and the calendar on the same page mean the same thing by
|
|
117
|
+
* "the top row".
|
|
118
|
+
*/
|
|
119
|
+
export function weekdayTotals(days: UsageDays): number[] {
|
|
120
|
+
const totals = Array.from({ length: 7 }, () => 0)
|
|
121
|
+
for (const [key, day] of Object.entries(days)) {
|
|
122
|
+
if (day.prompts === 0) continue
|
|
123
|
+
const index = parseDayKey(key).getDay()
|
|
124
|
+
totals[index] = (totals[index] ?? 0) + day.prompts
|
|
125
|
+
}
|
|
126
|
+
return totals
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export interface TypicalDay {
|
|
130
|
+
/** Days that contributed — 0 means the pages must not render the times. */
|
|
131
|
+
days: number
|
|
132
|
+
endMinutes: number
|
|
133
|
+
startMinutes: number
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The average local clock time of the day's first and last prompt.
|
|
138
|
+
*
|
|
139
|
+
* Read off the session spans rather than the hour buckets: those place the edges
|
|
140
|
+
* of a day to the hour, and `09:00` where the truth is `09:12` reads as a
|
|
141
|
+
* rounded guess rather than a measurement.
|
|
142
|
+
*/
|
|
143
|
+
export function typicalDay(days: UsageDays): TypicalDay {
|
|
144
|
+
let startSum = 0
|
|
145
|
+
let endSum = 0
|
|
146
|
+
let counted = 0
|
|
147
|
+
|
|
148
|
+
for (const day of Object.values(days)) {
|
|
149
|
+
let first = Infinity
|
|
150
|
+
let last = -Infinity
|
|
151
|
+
for (const session of Object.values(day.sessions)) {
|
|
152
|
+
if (session.first < first) first = session.first
|
|
153
|
+
if (session.last > last) last = session.last
|
|
154
|
+
}
|
|
155
|
+
if (!Number.isFinite(first) || !Number.isFinite(last)) continue
|
|
156
|
+
|
|
157
|
+
const firstAt = new Date(first)
|
|
158
|
+
const lastAt = new Date(last)
|
|
159
|
+
startSum += firstAt.getHours() * 60 + firstAt.getMinutes()
|
|
160
|
+
endSum += lastAt.getHours() * 60 + lastAt.getMinutes()
|
|
161
|
+
counted += 1
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (counted === 0) return { days: 0, endMinutes: 0, startMinutes: 0 }
|
|
165
|
+
return {
|
|
166
|
+
days: counted,
|
|
167
|
+
endMinutes: Math.round(endSum / counted) % MINUTES_IN_DAY,
|
|
168
|
+
startMinutes: Math.round(startSum / counted) % MINUTES_IN_DAY,
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ─── Projects and sessions ───────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
export interface Ranked {
|
|
175
|
+
entries: [string, number][]
|
|
176
|
+
total: number
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export function projectTotals(days: UsageDays, limit = 6): Ranked {
|
|
180
|
+
const totals: Record<string, number> = {}
|
|
181
|
+
for (const day of Object.values(days)) {
|
|
182
|
+
for (const [path, count] of Object.entries(day.projects)) {
|
|
183
|
+
totals[path] = (totals[path] ?? 0) + count
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const entries = Object.entries(totals).sort((left, right) => right[1] - left[1])
|
|
187
|
+
let total = 0
|
|
188
|
+
for (const [, value] of entries) total += value
|
|
189
|
+
return { entries: entries.slice(0, limit), total }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export interface SessionStats {
|
|
193
|
+
count: number
|
|
194
|
+
/** Day key of the longest session, for the Records page. */
|
|
195
|
+
longestDay: string
|
|
196
|
+
longestMs: number
|
|
197
|
+
medianMs: number
|
|
198
|
+
/** Days that carried any session, the denominator for sessions-per-day. */
|
|
199
|
+
days: number
|
|
200
|
+
/** Sessions holding exactly one prompt — they last ~0 and would otherwise look like a bug. */
|
|
201
|
+
singlePrompt: number
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
export function sessionStats(days: UsageDays): SessionStats {
|
|
205
|
+
const durations: number[] = []
|
|
206
|
+
let count = 0
|
|
207
|
+
let longestMs = 0
|
|
208
|
+
let longestDay = ''
|
|
209
|
+
let dayCount = 0
|
|
210
|
+
let singlePrompt = 0
|
|
211
|
+
|
|
212
|
+
for (const [key, day] of Object.entries(days)) {
|
|
213
|
+
const sessions: UsageSession[] = Object.values(day.sessions)
|
|
214
|
+
if (sessions.length === 0) continue
|
|
215
|
+
dayCount += 1
|
|
216
|
+
for (const session of sessions) {
|
|
217
|
+
count += 1
|
|
218
|
+
if (session.prompts <= 1) singlePrompt += 1
|
|
219
|
+
const duration = Math.max(0, session.last - session.first)
|
|
220
|
+
durations.push(duration)
|
|
221
|
+
if (duration > longestMs) {
|
|
222
|
+
longestMs = duration
|
|
223
|
+
longestDay = key
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
durations.sort((left, right) => left - right)
|
|
229
|
+
// Every session is kept, so this is a true median — unlike the averages
|
|
230
|
+
// elsewhere in this file, which are reconstructed from a running sum.
|
|
231
|
+
const medianMs = durations.length === 0 ? 0 : (durations[Math.floor(durations.length / 2)] ?? 0)
|
|
232
|
+
|
|
233
|
+
return { count, days: dayCount, longestDay, longestMs, medianMs, singlePrompt }
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Upper bounds in minutes. The last bucket is everything above the final bound,
|
|
238
|
+
* so the array the histogram draws is one longer than this one.
|
|
239
|
+
*/
|
|
240
|
+
const SESSION_BOUNDS = [5, 15, 30, 60, 120] as const
|
|
241
|
+
|
|
242
|
+
/** One label per bucket, for the bar rows the Projects page draws. */
|
|
243
|
+
export const SESSION_BUCKETS = [
|
|
244
|
+
'under 5 min',
|
|
245
|
+
'5 to 15 min',
|
|
246
|
+
'15 to 30 min',
|
|
247
|
+
'30 to 60 min',
|
|
248
|
+
'1 to 2 hours',
|
|
249
|
+
'over 2 hours',
|
|
250
|
+
] as const
|
|
251
|
+
|
|
252
|
+
/**
|
|
253
|
+
* How many sessions fell in each length bucket.
|
|
254
|
+
*
|
|
255
|
+
* A median and a longest say where the middle and the edge are but not the
|
|
256
|
+
* shape, and the shape is the interesting part: a day of one long session and a
|
|
257
|
+
* day of twenty two-minute ones have the same total and nothing else in common.
|
|
258
|
+
*/
|
|
259
|
+
export function sessionLengths(days: UsageDays): number[] {
|
|
260
|
+
const buckets = Array.from({ length: SESSION_BUCKETS.length }, () => 0)
|
|
261
|
+
for (const day of Object.values(days)) {
|
|
262
|
+
for (const session of Object.values(day.sessions)) {
|
|
263
|
+
const minutes = Math.max(0, session.last - session.first) / 60_000
|
|
264
|
+
const found = SESSION_BOUNDS.findIndex((bound) => minutes < bound)
|
|
265
|
+
const index = found === -1 ? buckets.length - 1 : found
|
|
266
|
+
buckets[index] = (buckets[index] ?? 0) + 1
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
return buckets
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ─── Means ───────────────────────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
export function totalMean(days: UsageDays, pick: (day: UsageDay) => UsageMean): UsageMean {
|
|
275
|
+
let count = 0
|
|
276
|
+
let max = 0
|
|
277
|
+
let sum = 0
|
|
278
|
+
for (const day of Object.values(days)) {
|
|
279
|
+
const mean = pick(day)
|
|
280
|
+
count += mean.count
|
|
281
|
+
sum += mean.sum
|
|
282
|
+
if (mean.max > max) max = mean.max
|
|
283
|
+
}
|
|
284
|
+
return { count, max, sum }
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
export function meanOf(mean: UsageMean): number {
|
|
288
|
+
return mean.count === 0 ? 0 : mean.sum / mean.count
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
// ─── Records ─────────────────────────────────────────────────────────────────
|
|
292
|
+
|
|
293
|
+
export interface DayRecord {
|
|
294
|
+
day: string
|
|
295
|
+
value: number
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
export function peakDay(days: UsageDays, pick: (day: UsageDay) => number): DayRecord {
|
|
299
|
+
let best: DayRecord = { day: '', value: 0 }
|
|
300
|
+
for (const [key, day] of Object.entries(days)) {
|
|
301
|
+
const value = pick(day)
|
|
302
|
+
if (value > best.value) best = { day: key, value }
|
|
303
|
+
}
|
|
304
|
+
return best
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/** Prompts sent between 02:00 and 04:59 local — the buckets, not a session span. */
|
|
308
|
+
export function lateNightPrompts(days: UsageDays): number {
|
|
309
|
+
let total = 0
|
|
310
|
+
for (const day of Object.values(days)) {
|
|
311
|
+
for (let hour = 2; hour <= 4; hour++) total += day.hours[hour] ?? 0
|
|
312
|
+
}
|
|
313
|
+
return total
|
|
314
|
+
}
|
|
@@ -94,8 +94,24 @@ interface TranscriptLine {
|
|
|
94
94
|
timestamp?: string
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
+
export interface TranscriptFileState {
|
|
98
|
+
/** ms epoch of the previous billed assistant message in this file, for the turn gap. */
|
|
99
|
+
previousMs: number | null
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Anything longer than this is the user walking away, not the model working.
|
|
104
|
+
* Included, the median turn becomes a measure of lunch breaks.
|
|
105
|
+
*/
|
|
106
|
+
const MAX_TURN_GAP_MS = 5 * 60 * 1000
|
|
107
|
+
|
|
97
108
|
/** `seen` spans the whole run: resume, fork and compaction re-emit the same billed request across files. */
|
|
98
|
-
export function consumeTranscriptLine(
|
|
109
|
+
export function consumeTranscriptLine(
|
|
110
|
+
line: string,
|
|
111
|
+
seen: Set<string>,
|
|
112
|
+
days: UsageDays,
|
|
113
|
+
state: TranscriptFileState
|
|
114
|
+
): void {
|
|
99
115
|
// ~23k of ~126k lines carry usage; the rest hold base64 thinking blobs. A
|
|
100
116
|
// substring test beats parsing them.
|
|
101
117
|
if (!line.includes('"output_tokens"')) return
|
|
@@ -144,15 +160,40 @@ export function consumeTranscriptLine(line: string, seen: Set<string>, days: Usa
|
|
|
144
160
|
if (model != null && model !== '') bump(day.models, model, total)
|
|
145
161
|
const branch = entry.gitBranch
|
|
146
162
|
if (branch != null && branch !== '') bump(day.branches, branch, total)
|
|
163
|
+
|
|
164
|
+
// The gap to the previous billed message in this file. Not prompt-to-response
|
|
165
|
+
// latency: the user turns in a transcript are mostly tool results, so isolating
|
|
166
|
+
// a real prompt would mean parsing lines this rollup deliberately skips. What
|
|
167
|
+
// this measures is the pace of the work cycle, and it is labelled as such.
|
|
168
|
+
const { previousMs } = state
|
|
169
|
+
if (previousMs !== null && ms > previousMs && ms - previousMs <= MAX_TURN_GAP_MS) {
|
|
170
|
+
const gap = ms - previousMs
|
|
171
|
+
day.turnMs.sum += gap
|
|
172
|
+
day.turnMs.count += 1
|
|
173
|
+
day.turnMs.max = Math.max(day.turnMs.max, gap)
|
|
174
|
+
}
|
|
175
|
+
state.previousMs = ms
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
interface HistoryLine {
|
|
179
|
+
display?: string
|
|
180
|
+
project?: string
|
|
181
|
+
sessionId?: string
|
|
182
|
+
timestamp?: number | string
|
|
147
183
|
}
|
|
148
184
|
|
|
149
|
-
/**
|
|
185
|
+
/**
|
|
186
|
+
* `history.jsonl`, the prompt log — the only source that survives transcript
|
|
187
|
+
* pruning, and the source of everything on the Activity and Projects pages. Each
|
|
188
|
+
* line carries the prompt text, its project path and its session id, not just a
|
|
189
|
+
* timestamp, and the file stays small where the transcripts run to ~90 MB.
|
|
190
|
+
*/
|
|
150
191
|
export function consumeHistoryLine(line: string, days: UsageDays): void {
|
|
151
192
|
if (!line.includes('"timestamp"')) return
|
|
152
193
|
|
|
153
|
-
let entry:
|
|
194
|
+
let entry: HistoryLine
|
|
154
195
|
try {
|
|
155
|
-
entry = JSON.parse(line) as
|
|
196
|
+
entry = JSON.parse(line) as HistoryLine
|
|
156
197
|
} catch {
|
|
157
198
|
return
|
|
158
199
|
}
|
|
@@ -162,7 +203,36 @@ export function consumeHistoryLine(line: string, days: UsageDays): void {
|
|
|
162
203
|
const ms = Number(entry.timestamp)
|
|
163
204
|
if (!Number.isFinite(ms) || ms <= 0) return
|
|
164
205
|
|
|
165
|
-
|
|
206
|
+
const at = new Date(ms)
|
|
207
|
+
const day = dayAt(days, localDay(at))
|
|
208
|
+
day.prompts += 1
|
|
209
|
+
|
|
210
|
+
const hour = at.getHours()
|
|
211
|
+
day.hours[hour] = (day.hours[hour] ?? 0) + 1
|
|
212
|
+
|
|
213
|
+
const { project } = entry
|
|
214
|
+
if (project != null && project !== '') bump(day.projects, project, 1)
|
|
215
|
+
|
|
216
|
+
const { display } = entry
|
|
217
|
+
if (display != null) {
|
|
218
|
+
day.promptChars.sum += display.length
|
|
219
|
+
day.promptChars.count += 1
|
|
220
|
+
day.promptChars.max = Math.max(day.promptChars.max, display.length)
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const { sessionId } = entry
|
|
224
|
+
if (sessionId != null && sessionId !== '') {
|
|
225
|
+
const session = day.sessions[sessionId]
|
|
226
|
+
if (session === undefined) {
|
|
227
|
+
day.sessions[sessionId] = { first: ms, last: ms, prompts: 1 }
|
|
228
|
+
} else {
|
|
229
|
+
// A session can span midnight, and each half is recorded against its own
|
|
230
|
+
// day — so `first` is the first prompt of this session *on this day*.
|
|
231
|
+
session.first = Math.min(session.first, ms)
|
|
232
|
+
session.last = Math.max(session.last, ms)
|
|
233
|
+
session.prompts += 1
|
|
234
|
+
}
|
|
235
|
+
}
|
|
166
236
|
}
|
|
167
237
|
|
|
168
238
|
interface CodexLine {
|
|
@@ -224,8 +294,10 @@ async function collectClaude(): Promise<UsageDays> {
|
|
|
224
294
|
const root = claudeHome()
|
|
225
295
|
|
|
226
296
|
await forEachFile(findJsonl(join(root, 'projects')), async (path) => {
|
|
297
|
+
// Per file: a turn gap only means anything inside one transcript.
|
|
298
|
+
const state: TranscriptFileState = { previousMs: null }
|
|
227
299
|
await readJsonlLines(path, (line) => {
|
|
228
|
-
consumeTranscriptLine(line, seen, days)
|
|
300
|
+
consumeTranscriptLine(line, seen, days, state)
|
|
229
301
|
})
|
|
230
302
|
})
|
|
231
303
|
|