agentfit 0.1.8 → 0.1.10

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.
@@ -3,6 +3,7 @@
3
3
  import { createContext, useContext, useEffect, useState, useCallback, useMemo, type ReactNode } from 'react'
4
4
  import { toast } from 'sonner'
5
5
  import type { UsageData, SessionSummary, DailyUsage, ProjectSummary, OverviewStats } from '@/lib/parse-logs'
6
+ import { formatLocalDate } from '@/lib/format'
6
7
 
7
8
  export type AgentType = 'claude' | 'codex' | 'combined'
8
9
  export type TimeRange = '7d' | '30d' | '90d' | 'all'
@@ -58,6 +59,7 @@ function filterData(raw: UsageData | null, range: TimeRange, project: string): U
58
59
  const cutoff = new Date()
59
60
  cutoff.setDate(cutoff.getDate() - days)
60
61
  const cutoffISO = range === 'all' ? '' : cutoff.toISOString()
62
+ const cutoffDate = range === 'all' ? '' : formatLocalDate(cutoff)
61
63
 
62
64
  // Filter sessions by time range and project
63
65
  const sessions = raw.sessions.filter(s => {
@@ -66,14 +68,57 @@ function filterData(raw: UsageData | null, range: TimeRange, project: string): U
66
68
  return true
67
69
  })
68
70
 
69
- // Re-aggregate from filtered sessions
71
+ // Daily: preserve the server's per-message-derived rows (with
72
+ // modelBreakdowns intact, including haiku/sub-agent tokens) when the
73
+ // project filter is inactive — only trim by date. Re-aggregating from
74
+ // session rollups loses the per-(date, model) split, mis-buckets
75
+ // cross-midnight tokens, and can drift from ccusage. When a project
76
+ // filter IS active we have no choice but to rebuild from sessions, since
77
+ // per-message rows aren't tagged by project on the client.
78
+ let daily: DailyUsage[]
79
+ if (project === 'all') {
80
+ daily = raw.daily.filter(d => range === 'all' || d.date >= cutoffDate)
81
+ } else {
82
+ const dailyMap = new Map<string, DailyUsage>()
83
+ for (const s of sessions) {
84
+ const date = formatLocalDate(new Date(s.startTime))
85
+ if (!dailyMap.has(date)) {
86
+ dailyMap.set(date, {
87
+ date, sessions: 0, messages: 0,
88
+ userMessages: 0, assistantMessages: 0,
89
+ inputTokens: 0, outputTokens: 0,
90
+ cacheCreationTokens: 0, cacheReadTokens: 0,
91
+ totalTokens: 0, costUSD: 0, toolCalls: 0,
92
+ toolCallsDetail: {}, interruptions: 0, rateLimitErrors: 0,
93
+ modelBreakdowns: [],
94
+ })
95
+ }
96
+ const day = dailyMap.get(date)!
97
+ day.sessions++
98
+ day.messages += s.totalMessages
99
+ day.userMessages += s.userMessages
100
+ day.assistantMessages += s.assistantMessages
101
+ day.inputTokens += s.inputTokens
102
+ day.outputTokens += s.outputTokens
103
+ day.cacheCreationTokens += s.cacheCreationTokens
104
+ day.cacheReadTokens += s.cacheReadTokens
105
+ day.totalTokens += s.totalTokens
106
+ day.costUSD += s.costUSD
107
+ day.toolCalls += s.toolCallsTotal
108
+ day.interruptions += s.userInterruptions
109
+ day.rateLimitErrors += s.rateLimitErrors
110
+ for (const [tool, count] of Object.entries(s.toolCalls)) {
111
+ day.toolCallsDetail[tool] = (day.toolCallsDetail[tool] || 0) + count
112
+ }
113
+ }
114
+ daily = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date))
115
+ }
116
+
70
117
  const projectMap = new Map<string, ProjectSummary>()
71
- const dailyMap = new Map<string, DailyUsage>()
72
118
  const toolUsage: Record<string, number> = {}
73
119
  const models: Record<string, number> = {}
74
120
 
75
121
  for (const s of sessions) {
76
- // Projects
77
122
  if (!projectMap.has(s.project)) {
78
123
  projectMap.set(s.project, {
79
124
  name: s.project,
@@ -94,55 +139,27 @@ function filterData(raw: UsageData | null, range: TimeRange, project: string): U
94
139
  proj.totalDurationMinutes += s.durationMinutes
95
140
  for (const [tool, count] of Object.entries(s.toolCalls)) {
96
141
  proj.toolCalls[tool] = (proj.toolCalls[tool] || 0) + count
97
- }
98
-
99
- // Daily
100
- const date = s.startTime.slice(0, 10)
101
- if (!dailyMap.has(date)) {
102
- dailyMap.set(date, {
103
- date, sessions: 0, messages: 0,
104
- userMessages: 0, assistantMessages: 0,
105
- inputTokens: 0, outputTokens: 0,
106
- cacheCreationTokens: 0, cacheReadTokens: 0,
107
- totalTokens: 0, costUSD: 0, toolCalls: 0,
108
- toolCallsDetail: {}, interruptions: 0, rateLimitErrors: 0,
109
- // Client-side rebuild: per-(date, model) breakdown is not derivable
110
- // from session-level rows. The unfiltered case (project=all,
111
- // range=all) returns raw and preserves the server's breakdowns.
112
- modelBreakdowns: [],
113
- })
114
- }
115
- const day = dailyMap.get(date)!
116
- day.sessions++
117
- day.messages += s.totalMessages
118
- day.userMessages += s.userMessages
119
- day.assistantMessages += s.assistantMessages
120
- day.inputTokens += s.inputTokens
121
- day.outputTokens += s.outputTokens
122
- day.cacheCreationTokens += s.cacheCreationTokens
123
- day.cacheReadTokens += s.cacheReadTokens
124
- day.totalTokens += s.totalTokens
125
- day.costUSD += s.costUSD
126
- day.toolCalls += s.toolCallsTotal
127
- day.interruptions += s.userInterruptions
128
- day.rateLimitErrors += s.rateLimitErrors
129
- for (const [tool, count] of Object.entries(s.toolCalls)) {
130
- day.toolCallsDetail[tool] = (day.toolCallsDetail[tool] || 0) + count
131
- }
132
-
133
- // Tools
134
- for (const [tool, count] of Object.entries(s.toolCalls)) {
135
142
  toolUsage[tool] = (toolUsage[tool] || 0) + count
136
143
  }
137
-
138
- // Models — aggregate at message level
139
144
  for (const [m, count] of Object.entries(s.modelCounts || {})) {
140
145
  models[m] = (models[m] || 0) + count
141
146
  }
142
147
  }
143
148
 
144
149
  const projects = Array.from(projectMap.values()).sort((a, b) => b.totalCost - a.totalCost)
145
- const daily = Array.from(dailyMap.values()).sort((a, b) => a.date.localeCompare(b.date))
150
+
151
+ // Overview token/cost totals come from the daily rows so the overview
152
+ // and the daily breakdown agree (mirrors getUsageData on the server).
153
+ const tokenTotals = daily.reduce(
154
+ (acc, d) => ({
155
+ input: acc.input + d.inputTokens,
156
+ output: acc.output + d.outputTokens,
157
+ cc: acc.cc + d.cacheCreationTokens,
158
+ cr: acc.cr + d.cacheReadTokens,
159
+ cost: acc.cost + d.costUSD,
160
+ }),
161
+ { input: 0, output: 0, cc: 0, cr: 0, cost: 0 }
162
+ )
146
163
 
147
164
  const overview: OverviewStats = {
148
165
  totalSessions: sessions.length,
@@ -150,22 +167,22 @@ function filterData(raw: UsageData | null, range: TimeRange, project: string): U
150
167
  totalMessages: sessions.reduce((a, s) => a + s.totalMessages, 0),
151
168
  totalUserMessages: sessions.reduce((a, s) => a + s.userMessages, 0),
152
169
  totalAssistantMessages: sessions.reduce((a, s) => a + s.assistantMessages, 0),
153
- totalInputTokens: sessions.reduce((a, s) => a + s.inputTokens, 0),
154
- totalOutputTokens: sessions.reduce((a, s) => a + s.outputTokens, 0),
155
- totalCacheCreationTokens: sessions.reduce((a, s) => a + s.cacheCreationTokens, 0),
156
- totalCacheReadTokens: sessions.reduce((a, s) => a + s.cacheReadTokens, 0),
157
- totalTokens: sessions.reduce((a, s) => a + s.totalTokens, 0),
158
- totalCostUSD: sessions.reduce((a, s) => a + s.costUSD, 0),
170
+ totalInputTokens: tokenTotals.input,
171
+ totalOutputTokens: tokenTotals.output,
172
+ totalCacheCreationTokens: tokenTotals.cc,
173
+ totalCacheReadTokens: tokenTotals.cr,
174
+ totalTokens: tokenTotals.input + tokenTotals.output + tokenTotals.cc + tokenTotals.cr,
175
+ totalCostUSD: tokenTotals.cost,
159
176
  totalSystemPromptEdits: sessions.reduce((a, s) => a + (s.systemPromptEdits ?? 0), 0),
160
177
  totalDurationMinutes: sessions.reduce((a, s) => a + s.durationMinutes, 0),
161
178
  totalToolCalls: sessions.reduce((a, s) => a + s.toolCallsTotal, 0),
162
179
  totalApiErrors: sessions.reduce((a, s) => a + s.apiErrors, 0),
163
180
  totalRateLimitDays: (() => {
164
- const days = new Set<string>()
181
+ const rateDays = new Set<string>()
165
182
  for (const s of sessions) {
166
- if (s.rateLimitErrors > 0) days.add(s.startTime.slice(0, 10))
183
+ if (s.rateLimitErrors > 0) rateDays.add(formatLocalDate(new Date(s.startTime)))
167
184
  }
168
- return days.size
185
+ return rateDays.size
169
186
  })(),
170
187
  totalUserInterruptions: sessions.reduce((a, s) => a + s.userInterruptions, 0),
171
188
  models,
package/lib/format.ts CHANGED
@@ -24,3 +24,15 @@ export function formatDuration(minutes: number): string {
24
24
  export function formatNumber(n: number): string {
25
25
  return n.toLocaleString()
26
26
  }
27
+
28
+ // Local-timezone YYYY-MM-DD bucket. Matches ccusage's _date-utils.ts so daily
29
+ // totals agree to the cent — UTC slicing shifts cross-midnight tokens.
30
+ const LOCAL_DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
31
+ year: 'numeric',
32
+ month: '2-digit',
33
+ day: '2-digit',
34
+ })
35
+
36
+ export function formatLocalDate(d: Date): string {
37
+ return LOCAL_DATE_FORMATTER.format(d)
38
+ }
package/lib/pricing.ts CHANGED
@@ -28,7 +28,10 @@ export async function loadPricing(): Promise<Record<string, ModelPricing>> {
28
28
  if (pricingCache) return pricingCache
29
29
 
30
30
  try {
31
- const res = await fetch(LITELLM_PRICING_URL, { next: { revalidate: 86400 } })
31
+ // Bypass Next's data cache; we memoize the parsed result in
32
+ // `pricingCache` ourselves. Double-caching once let an empty body
33
+ // freeze every newly-listed model at cost=0 until process restart.
34
+ const res = await fetch(LITELLM_PRICING_URL, { cache: 'no-store' })
32
35
  const data = await res.json()
33
36
  const filtered: Record<string, ModelPricing> = {}
34
37
  for (const [key, value] of Object.entries(data)) {
@@ -55,6 +58,9 @@ export async function loadPricing(): Promise<Record<string, ModelPricing>> {
55
58
  : undefined,
56
59
  }
57
60
  }
61
+ // Refuse to memoize an empty map — that would be a transient fetch
62
+ // hiccup turning into a process-lifetime zero-cost lock.
63
+ if (Object.keys(filtered).length === 0) return getFallbackPricing()
58
64
  pricingCache = filtered
59
65
  return filtered
60
66
  } catch {
package/lib/sync.ts CHANGED
@@ -3,19 +3,11 @@ import path from 'path'
3
3
  import os from 'os'
4
4
  import { prisma } from './db'
5
5
  import { loadPricing, calculateCost, type ModelPricing, type Speed } from './pricing'
6
+ import { formatLocalDate } from './format'
6
7
 
7
8
  const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects')
8
9
  const IMAGES_DIR = path.resolve(process.cwd(), 'data', 'images')
9
10
 
10
- // Bucket per-message timestamps using the user's *local* timezone so daily
11
- // totals match ccusage (apps/ccusage/src/_date-utils.ts:43-48). en-CA yields
12
- // YYYY-MM-DD without zero-padding surprises.
13
- const DATE_FORMATTER = new Intl.DateTimeFormat('en-CA', {
14
- year: 'numeric',
15
- month: '2-digit',
16
- day: '2-digit',
17
- })
18
-
19
11
  interface LogEntry {
20
12
  type?: string
21
13
  uuid?: string
@@ -242,7 +234,7 @@ function parseSessionFile(
242
234
  model: currentModel,
243
235
  speed,
244
236
  timestamp: ts,
245
- date: DATE_FORMATTER.format(ts),
237
+ date: formatLocalDate(ts),
246
238
  inputTokens: inT,
247
239
  outputTokens: outT,
248
240
  cacheCreationTokens: ccT,
@@ -417,7 +409,7 @@ export async function syncLogs(): Promise<SyncResult> {
417
409
  if (isSubagent) {
418
410
  for (const m of parsed.messageUsages) {
419
411
  await prisma.$executeRaw`
420
- INSERT OR IGNORE INTO "MessageUsage" (
412
+ INSERT INTO "MessageUsage" (
421
413
  "id", "sessionId", "messageId", "requestId", "model", "speed",
422
414
  "timestamp", "date",
423
415
  "inputTokens", "outputTokens", "cacheCreationTokens", "cacheReadTokens",
@@ -429,6 +421,8 @@ export async function syncLogs(): Promise<SyncResult> {
429
421
  ${m.inputTokens}, ${m.outputTokens}, ${m.cacheCreationTokens}, ${m.cacheReadTokens},
430
422
  ${m.costUSD}, ${new Date().toISOString()}
431
423
  )
424
+ ON CONFLICT("messageId", "requestId") DO UPDATE SET
425
+ "costUSD" = MAX("MessageUsage"."costUSD", excluded."costUSD")
432
426
  `
433
427
  }
434
428
  result.sessionsAdded++
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentfit",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "Fitness tracker dashboard for AI coding agents (Claude Code, Codex). Visualize usage, cost, tokens, and productivity from local conversation logs.",
5
5
  "type": "module",
6
6
  "bin": {