@brimveyn/aimux 1.22.12 → 1.23.1

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 (66) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/settings-actions.ts +24 -13
  3. package/src/app-runtime/side-effects.ts +5 -0
  4. package/src/app-runtime/use-mouse-handlers.ts +2 -0
  5. package/src/app.tsx +6 -0
  6. package/src/index.tsx +6 -0
  7. package/src/input/keymap/help-entries.ts +1 -0
  8. package/src/input/modes/bridge.ts +2 -1
  9. package/src/input/modes/transitions.ts +7 -3
  10. package/src/input/modes/types.ts +2 -1
  11. package/src/restart-daemon.ts +5 -0
  12. package/src/services/ai-usage/projection.ts +44 -0
  13. package/src/services/aimux-counters/index.ts +87 -0
  14. package/src/services/aimux-counters/observe.ts +39 -0
  15. package/src/services/aimux-counters/store.ts +150 -0
  16. package/src/services/aimux-counters/summary.ts +78 -0
  17. package/src/services/usage-history/cost.ts +149 -0
  18. package/src/services/usage-history/insights.ts +314 -0
  19. package/src/services/usage-history/rollup.ts +78 -6
  20. package/src/services/usage-history/stats.ts +66 -35
  21. package/src/services/usage-history/store.ts +128 -9
  22. package/src/settings/search.ts +13 -9
  23. package/src/settings/sections/about.ts +1 -0
  24. package/src/settings/sections/appearance.ts +1 -0
  25. package/src/settings/sections/automation.ts +1 -0
  26. package/src/settings/sections/commands.ts +1 -0
  27. package/src/settings/sections/editor.ts +1 -0
  28. package/src/settings/sections/experimental.ts +1 -0
  29. package/src/settings/sections/git.ts +1 -0
  30. package/src/settings/sections/index.ts +23 -12
  31. package/src/settings/sections/integrations.ts +1 -0
  32. package/src/settings/sections/layout.ts +1 -0
  33. package/src/settings/sections/notifications.ts +1 -0
  34. package/src/settings/sections/setup.ts +1 -0
  35. package/src/settings/sections/status-bar.ts +1 -0
  36. package/src/settings/sections/workspace.ts +1 -0
  37. package/src/settings/types.ts +10 -0
  38. package/src/state/actions.ts +13 -3
  39. package/src/state/app-store.ts +12 -1
  40. package/src/state/reducers/modal-state.ts +12 -17
  41. package/src/state/reducers/settings-state.ts +29 -51
  42. package/src/state/reducers/stats-state.ts +56 -0
  43. package/src/state/stats-pages.ts +33 -0
  44. package/src/state/store.ts +5 -0
  45. package/src/state/types.ts +22 -7
  46. package/src/ui/components/layout/sidebar/project-list.tsx +32 -1
  47. package/src/ui/components/modals/app/quotas-modal.tsx +42 -0
  48. package/src/ui/components/overlays/ai-usage/ai-usage-indicator.tsx +4 -4
  49. package/src/ui/components/settings/row-value.tsx +22 -3
  50. package/src/ui/components/settings/settings-footer.tsx +170 -0
  51. package/src/ui/components/settings/settings-row.tsx +109 -33
  52. package/src/ui/components/settings/settings-search-bar.tsx +61 -0
  53. package/src/ui/components/settings/settings-view.tsx +163 -77
  54. package/src/ui/components/stats/aimux-page.tsx +245 -0
  55. package/src/ui/components/stats/chart.ts +157 -0
  56. package/src/ui/components/stats/day-facts.tsx +268 -0
  57. package/src/ui/components/stats/format.ts +98 -0
  58. package/src/ui/components/stats/heatmap.tsx +305 -0
  59. package/src/ui/components/stats/projects-page.tsx +293 -0
  60. package/src/ui/components/stats/quotas.tsx +210 -0
  61. package/src/ui/components/stats/shared.tsx +654 -0
  62. package/src/ui/components/stats/stats-view.tsx +153 -0
  63. package/src/ui/components/stats/usage-page.tsx +291 -0
  64. package/src/ui/components/stats/use-stats-data.ts +48 -0
  65. package/src/ui/root.tsx +7 -3
  66. package/src/ui/components/modals/app/ai-usage-modal.tsx +0 -520
@@ -0,0 +1,78 @@
1
+ import type { CounterDays, CounterKey, MaxCounterKey } from './store'
2
+
3
+ import { localDay } from '../usage-history/store'
4
+
5
+ /** Pure shaping of the stored counter days into what the aimux page renders. */
6
+
7
+ export interface CounterPeak {
8
+ day: string
9
+ value: number
10
+ }
11
+
12
+ export interface CounterSummary {
13
+ best: CounterPeak
14
+ today: number
15
+ total: number
16
+ /** Days carrying a non-zero value — the denominator for a daily average. */
17
+ days: number
18
+ }
19
+
20
+ export function summarizeCounter(
21
+ days: CounterDays,
22
+ key: CounterKey,
23
+ today: string
24
+ ): CounterSummary {
25
+ let best: CounterPeak = { day: '', value: 0 }
26
+ let dayCount = 0
27
+ let total = 0
28
+
29
+ for (const [date, day] of Object.entries(days)) {
30
+ const value = day[key] ?? 0
31
+ if (value <= 0) continue
32
+ dayCount += 1
33
+ total += value
34
+ if (value > best.value) best = { day: date, value }
35
+ }
36
+
37
+ return { best, days: dayCount, today: days[today]?.[key] ?? 0, total }
38
+ }
39
+
40
+ /**
41
+ * The largest value ever recorded for a max-merged counter.
42
+ *
43
+ * Not a sum: `longestRunMs` is the length of one uninterrupted aimux run, so the
44
+ * answer across days is the biggest of them, never their total.
45
+ */
46
+ export function peakOf(days: CounterDays, key: MaxCounterKey): CounterPeak {
47
+ let best: CounterPeak = { day: '', value: 0 }
48
+ for (const [date, day] of Object.entries(days)) {
49
+ const value = day[key] ?? 0
50
+ if (value > best.value) best = { day: date, value }
51
+ }
52
+ return best
53
+ }
54
+
55
+ /**
56
+ * One value per calendar day for the `count` days ending today, oldest first.
57
+ *
58
+ * The counters' twin of `lastDays` over the usage history, and unrecorded days
59
+ * are zero for the same reason: a day aimux never ran is a real zero, not a hole.
60
+ */
61
+ export function lastCounterDays(
62
+ days: CounterDays,
63
+ count: number,
64
+ today: Date,
65
+ key: CounterKey | MaxCounterKey
66
+ ): number[] {
67
+ return Array.from({ length: count }, (_, index) => {
68
+ const date = new Date(today)
69
+ date.setDate(date.getDate() - (count - 1 - index))
70
+ return days[localDay(date)]?.[key] ?? 0
71
+ })
72
+ }
73
+
74
+ export function sumOf(days: CounterDays, key: CounterKey): number {
75
+ let total = 0
76
+ for (const day of Object.values(days)) total += day[key] ?? 0
77
+ return total
78
+ }
@@ -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(line: string, seen: Set<string>, days: UsageDays): void {
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
- /** `history.jsonl`, the prompt log — the only source that survives transcript pruning. */
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: { timestamp?: number | string }
194
+ let entry: HistoryLine
154
195
  try {
155
- entry = JSON.parse(line) as { timestamp?: number | string }
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
- dayAt(days, localDay(new Date(ms))).prompts += 1
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