agentfit 0.1.6 → 0.1.8

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/lib/sync.ts CHANGED
@@ -2,16 +2,28 @@ import fs from 'fs'
2
2
  import path from 'path'
3
3
  import os from 'os'
4
4
  import { prisma } from './db'
5
- import { loadPricing, calculateCost, type ModelPricing } from './pricing'
5
+ import { loadPricing, calculateCost, type ModelPricing, type Speed } from './pricing'
6
6
 
7
7
  const PROJECTS_DIR = path.join(os.homedir(), '.claude', 'projects')
8
8
  const IMAGES_DIR = path.resolve(process.cwd(), 'data', 'images')
9
9
 
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
+
10
19
  interface LogEntry {
11
20
  type?: string
12
21
  uuid?: string
13
22
  version?: string
23
+ requestId?: string
24
+ sessionId?: string
14
25
  message?: {
26
+ id?: string
15
27
  role?: string
16
28
  content?: unknown[]
17
29
  model?: string
@@ -20,11 +32,27 @@ interface LogEntry {
20
32
  output_tokens?: number
21
33
  cache_creation_input_tokens?: number
22
34
  cache_read_input_tokens?: number
35
+ speed?: Speed
23
36
  }
24
37
  }
25
38
  timestamp?: string
26
39
  }
27
40
 
41
+ interface MessageUsageRow {
42
+ sessionId: string
43
+ messageId: string
44
+ requestId: string
45
+ model: string
46
+ speed: Speed
47
+ timestamp: Date
48
+ date: string
49
+ inputTokens: number
50
+ outputTokens: number
51
+ cacheCreationTokens: number
52
+ cacheReadTokens: number
53
+ costUSD: number
54
+ }
55
+
28
56
  interface ImageInfo {
29
57
  messageId: string
30
58
  filename: string
@@ -38,6 +66,33 @@ function decodeProjectPath(dirName: string): string {
38
66
  return dirName.replace(/-/g, '/')
39
67
  }
40
68
 
69
+ interface WalkedFile {
70
+ absPath: string
71
+ isSubagent: boolean
72
+ }
73
+
74
+ function walkJsonl(dir: string, projectRoot: string): WalkedFile[] {
75
+ const out: WalkedFile[] = []
76
+ let entries: fs.Dirent[]
77
+ try {
78
+ entries = fs.readdirSync(dir, { withFileTypes: true })
79
+ } catch {
80
+ return out
81
+ }
82
+ for (const e of entries) {
83
+ const full = path.join(dir, e.name)
84
+ if (e.isDirectory()) {
85
+ out.push(...walkJsonl(full, projectRoot))
86
+ } else if (e.isFile() && e.name.endsWith('.jsonl')) {
87
+ // A file is "subagent" if it sits below the project root (not directly
88
+ // in it). ccusage uses **/*.jsonl for the same effect.
89
+ const isSubagent = path.dirname(full) !== projectRoot
90
+ out.push({ absPath: full, isSubagent })
91
+ }
92
+ }
93
+ return out
94
+ }
95
+
41
96
  function getProjectName(projectPath: string): string {
42
97
  // The decoded path may be wrong if the actual folder name contains dashes,
43
98
  // since decodeProjectPath replaces ALL dashes with slashes.
@@ -81,6 +136,7 @@ function parseSessionFile(
81
136
  const toolCalls: Record<string, number> = {}
82
137
  const images: ImageInfo[] = []
83
138
  const messageTimestamps: string[] = []
139
+ const messageUsages: MessageUsageRow[] = []
84
140
  let apiErrors = 0
85
141
  let rateLimitErrors = 0
86
142
  let userInterruptions = 0
@@ -158,12 +214,41 @@ function parseSessionFile(
158
214
 
159
215
  if (msg.usage) {
160
216
  const u = msg.usage
161
- inputTokens += u.input_tokens || 0
162
- outputTokens += u.output_tokens || 0
163
- cacheCreationTokens += u.cache_creation_input_tokens || 0
164
- cacheReadTokens += u.cache_read_input_tokens || 0
165
- if (currentModel) {
166
- costUSD += calculateCost(currentModel, u, allPricing)
217
+ const speed: Speed = u.speed === 'fast' ? 'fast' : 'standard'
218
+ const inT = u.input_tokens || 0
219
+ const outT = u.output_tokens || 0
220
+ const ccT = u.cache_creation_input_tokens || 0
221
+ const crT = u.cache_read_input_tokens || 0
222
+ const msgCost = currentModel ? calculateCost(currentModel, u, allPricing, speed) : 0
223
+
224
+ inputTokens += inT
225
+ outputTokens += outT
226
+ cacheCreationTokens += ccT
227
+ cacheReadTokens += crT
228
+ costUSD += msgCost
229
+
230
+ // Per-message row for daily aggregation. Dedup by (messageId, requestId)
231
+ // happens at insert time via the unique index. Mirrors ccusage's
232
+ // createUniqueHash (apps/ccusage/src/data-loader.ts:530-540).
233
+ if (currentModel && msg.id && entry.requestId && entry.timestamp) {
234
+ const ts = new Date(entry.timestamp)
235
+ messageUsages.push({
236
+ // Subagent files have filename basename "agent-<hash>" but carry
237
+ // the parent session's sessionId on every line — use that so
238
+ // sub-agent token usage rolls up to the parent Session row.
239
+ sessionId: entry.sessionId || sessionId,
240
+ messageId: msg.id,
241
+ requestId: entry.requestId,
242
+ model: currentModel,
243
+ speed,
244
+ timestamp: ts,
245
+ date: DATE_FORMATTER.format(ts),
246
+ inputTokens: inT,
247
+ outputTokens: outT,
248
+ cacheCreationTokens: ccT,
249
+ cacheReadTokens: crT,
250
+ costUSD: msgCost,
251
+ })
167
252
  }
168
253
  }
169
254
  }
@@ -263,6 +348,7 @@ function parseSessionFile(
263
348
  systemPromptEdits,
264
349
  cliVersion: cliVersion || 'unknown',
265
350
  images,
351
+ messageUsages,
266
352
  }
267
353
  }
268
354
 
@@ -293,10 +379,10 @@ export async function syncLogs(): Promise<SyncResult> {
293
379
  fs.mkdirSync(IMAGES_DIR, { recursive: true })
294
380
  }
295
381
 
296
- // Get all existing sessionIds to skip
297
- const existing = await prisma.session.findMany({ select: { sessionId: true } })
298
- const existingIds = new Set(existing.map((s) => s.sessionId))
299
-
382
+ // We always re-read every JSONL — Claude Code appends to the same file
383
+ // throughout a long session, so a one-shot import would freeze the partial
384
+ // state. Idempotency comes from the (messageId, requestId) unique index on
385
+ // MessageUsage and from upserting Session by sessionId.
300
386
  const projectDirs = fs.readdirSync(PROJECTS_DIR).filter((d) => {
301
387
  try {
302
388
  return fs.statSync(path.join(PROJECTS_DIR, d)).isDirectory()
@@ -310,75 +396,119 @@ export async function syncLogs(): Promise<SyncResult> {
310
396
  const projectName = getProjectName(projectPath)
311
397
  const dirPath = path.join(PROJECTS_DIR, dir)
312
398
 
313
- let jsonlFiles: string[]
314
- try {
315
- jsonlFiles = fs.readdirSync(dirPath).filter((f) => f.endsWith('.jsonl'))
316
- } catch {
317
- continue
318
- }
399
+ // Recurse for *.jsonl. Top-level files are standalone conversations
400
+ // (filename = sessionId). Files under <session>/subagents/ carry the
401
+ // parent's sessionId on each line; we only mine them for MessageUsage.
402
+ const jsonlFiles = walkJsonl(dirPath, dirPath)
319
403
 
320
- for (const file of jsonlFiles) {
404
+ for (const { absPath, isSubagent } of jsonlFiles) {
321
405
  result.filesProcessed++
322
- const sessionId = path.basename(file, '.jsonl')
323
-
324
- if (existingIds.has(sessionId)) {
325
- result.sessionsSkipped++
326
- continue
327
- }
406
+ const sessionId = path.basename(absPath, '.jsonl')
328
407
 
329
408
  try {
330
- const parsed = parseSessionFile(path.join(dirPath, file), sessionId, allPricing)
409
+ const parsed = parseSessionFile(absPath, sessionId, allPricing)
331
410
  if (!parsed) {
332
411
  result.sessionsSkipped++
333
412
  continue
334
413
  }
335
414
 
336
- await prisma.session.create({
337
- data: {
338
- sessionId,
339
- project: projectName,
340
- projectPath,
341
- startTime: new Date(parsed.startTime),
342
- endTime: new Date(parsed.endTime),
343
- durationMinutes: parsed.durationMinutes,
344
- userMessages: parsed.userMessages,
345
- assistantMessages: parsed.assistantMessages,
346
- totalMessages: parsed.totalMessages,
347
- inputTokens: parsed.inputTokens,
348
- outputTokens: parsed.outputTokens,
349
- cacheCreationTokens: parsed.cacheCreationTokens,
350
- cacheReadTokens: parsed.cacheReadTokens,
351
- totalTokens: parsed.totalTokens,
352
- costUSD: parsed.costUSD,
353
- model: parsed.model,
354
- toolCallsTotal: parsed.toolCallsTotal,
355
- toolCallsJson: JSON.stringify(parsed.toolCalls),
356
- skillCallsJson: JSON.stringify(parsed.skillCalls),
357
- messageTimestamps: JSON.stringify(parsed.messageTimestamps),
358
- apiErrors: parsed.apiErrors,
359
- rateLimitErrors: parsed.rateLimitErrors,
360
- userInterruptions: parsed.userInterruptions,
361
- permissionModesJson: JSON.stringify(parsed.permissionModes),
362
- systemPromptEdits: parsed.systemPromptEdits,
363
- cliVersion: parsed.cliVersion,
364
- modelCountsJson: JSON.stringify(parsed.modelCounts),
365
- },
415
+ // Subagent files contribute MessageUsage only — the conversation
416
+ // itself is owned by the top-level JSONL.
417
+ if (isSubagent) {
418
+ for (const m of parsed.messageUsages) {
419
+ await prisma.$executeRaw`
420
+ INSERT OR IGNORE INTO "MessageUsage" (
421
+ "id", "sessionId", "messageId", "requestId", "model", "speed",
422
+ "timestamp", "date",
423
+ "inputTokens", "outputTokens", "cacheCreationTokens", "cacheReadTokens",
424
+ "costUSD", "createdAt"
425
+ ) VALUES (
426
+ ${`mu_${m.messageId}_${m.requestId}`}, ${m.sessionId}, ${m.messageId}, ${m.requestId},
427
+ ${m.model}, ${m.speed},
428
+ ${m.timestamp.toISOString()}, ${m.date},
429
+ ${m.inputTokens}, ${m.outputTokens}, ${m.cacheCreationTokens}, ${m.cacheReadTokens},
430
+ ${m.costUSD}, ${new Date().toISOString()}
431
+ )
432
+ `
433
+ }
434
+ result.sessionsAdded++
435
+ continue
436
+ }
437
+
438
+ const sessionData = {
439
+ project: projectName,
440
+ projectPath,
441
+ startTime: new Date(parsed.startTime),
442
+ endTime: new Date(parsed.endTime),
443
+ durationMinutes: parsed.durationMinutes,
444
+ userMessages: parsed.userMessages,
445
+ assistantMessages: parsed.assistantMessages,
446
+ totalMessages: parsed.totalMessages,
447
+ inputTokens: parsed.inputTokens,
448
+ outputTokens: parsed.outputTokens,
449
+ cacheCreationTokens: parsed.cacheCreationTokens,
450
+ cacheReadTokens: parsed.cacheReadTokens,
451
+ totalTokens: parsed.totalTokens,
452
+ costUSD: parsed.costUSD,
453
+ model: parsed.model,
454
+ toolCallsTotal: parsed.toolCallsTotal,
455
+ toolCallsJson: JSON.stringify(parsed.toolCalls),
456
+ skillCallsJson: JSON.stringify(parsed.skillCalls),
457
+ messageTimestamps: JSON.stringify(parsed.messageTimestamps),
458
+ apiErrors: parsed.apiErrors,
459
+ rateLimitErrors: parsed.rateLimitErrors,
460
+ userInterruptions: parsed.userInterruptions,
461
+ permissionModesJson: JSON.stringify(parsed.permissionModes),
462
+ systemPromptEdits: parsed.systemPromptEdits,
463
+ cliVersion: parsed.cliVersion,
464
+ modelCountsJson: JSON.stringify(parsed.modelCounts),
465
+ }
466
+
467
+ await prisma.session.upsert({
468
+ where: { sessionId },
469
+ create: { sessionId, ...sessionData },
470
+ update: sessionData,
366
471
  })
367
472
 
473
+ // Per-message usage rows. Unique index on (messageId, requestId) makes
474
+ // this idempotent across re-syncs and dedupes forks/resumes that copy
475
+ // prior turns into new JSONLs. Use SQLite's INSERT OR IGNORE because
476
+ // Prisma createMany on libsql doesn't expose skipDuplicates.
477
+ for (const m of parsed.messageUsages) {
478
+ await prisma.$executeRaw`
479
+ INSERT OR IGNORE INTO "MessageUsage" (
480
+ "id", "sessionId", "messageId", "requestId", "model", "speed",
481
+ "timestamp", "date",
482
+ "inputTokens", "outputTokens", "cacheCreationTokens", "cacheReadTokens",
483
+ "costUSD", "createdAt"
484
+ ) VALUES (
485
+ ${`mu_${m.messageId}_${m.requestId}`}, ${m.sessionId}, ${m.messageId}, ${m.requestId},
486
+ ${m.model}, ${m.speed},
487
+ ${m.timestamp.toISOString()}, ${m.date},
488
+ ${m.inputTokens}, ${m.outputTokens}, ${m.cacheCreationTokens}, ${m.cacheReadTokens},
489
+ ${m.costUSD}, ${new Date().toISOString()}
490
+ )
491
+ `
492
+ }
493
+
368
494
  // Insert image records
369
495
  for (const img of parsed.images) {
370
- await prisma.image.create({
371
- data: {
372
- sessionId,
373
- messageId: img.messageId,
374
- filename: img.filename,
375
- mediaType: img.mediaType,
376
- sizeBytes: img.sizeBytes,
377
- timestamp: new Date(img.timestamp),
378
- role: img.role,
379
- },
380
- })
381
- result.imagesExtracted++
496
+ try {
497
+ await prisma.image.create({
498
+ data: {
499
+ sessionId,
500
+ messageId: img.messageId,
501
+ filename: img.filename,
502
+ mediaType: img.mediaType,
503
+ sizeBytes: img.sizeBytes,
504
+ timestamp: new Date(img.timestamp),
505
+ role: img.role,
506
+ },
507
+ })
508
+ result.imagesExtracted++
509
+ } catch {
510
+ // Unique constraint — already imported on a prior sync.
511
+ }
382
512
  }
383
513
 
384
514
  result.sessionsAdded++
@@ -388,6 +518,21 @@ export async function syncLogs(): Promise<SyncResult> {
388
518
  }
389
519
  }
390
520
 
521
+ // Roll up sub-agent contributions into the parent Session row so per-session
522
+ // tokens/cost reflect total work (top-level + subagents).
523
+ await prisma.$executeRaw`
524
+ UPDATE "Session" SET
525
+ "inputTokens" = COALESCE((SELECT SUM("inputTokens") FROM "MessageUsage" mu WHERE mu."sessionId" = "Session"."sessionId"), "inputTokens"),
526
+ "outputTokens" = COALESCE((SELECT SUM("outputTokens") FROM "MessageUsage" mu WHERE mu."sessionId" = "Session"."sessionId"), "outputTokens"),
527
+ "cacheCreationTokens" = COALESCE((SELECT SUM("cacheCreationTokens") FROM "MessageUsage" mu WHERE mu."sessionId" = "Session"."sessionId"), "cacheCreationTokens"),
528
+ "cacheReadTokens" = COALESCE((SELECT SUM("cacheReadTokens") FROM "MessageUsage" mu WHERE mu."sessionId" = "Session"."sessionId"), "cacheReadTokens"),
529
+ "costUSD" = COALESCE((SELECT SUM("costUSD") FROM "MessageUsage" mu WHERE mu."sessionId" = "Session"."sessionId"), "costUSD"),
530
+ "totalTokens" = COALESCE((
531
+ SELECT SUM("inputTokens" + "outputTokens" + "cacheCreationTokens" + "cacheReadTokens")
532
+ FROM "MessageUsage" mu WHERE mu."sessionId" = "Session"."sessionId"
533
+ ), "totalTokens")
534
+ `
535
+
391
536
  // Log the sync
392
537
  await prisma.syncLog.create({
393
538
  data: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentfit",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
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": {
@@ -15,7 +15,7 @@
15
15
  "usage-tracker"
16
16
  ],
17
17
  "author": "Harry Wang",
18
- "license": "MIT",
18
+ "license": "AGPL-3.0",
19
19
  "repository": {
20
20
  "type": "git",
21
21
  "url": "https://github.com/harrywang/agentfit.git"
@@ -51,6 +51,7 @@
51
51
  "clsx": "^2.1.1",
52
52
  "dagre": "^0.8.5",
53
53
  "date-fns": "^4.1.0",
54
+ "electron-updater": "^6.8.3",
54
55
  "embla-carousel-react": "^8.6.0",
55
56
  "lucide-react": "^1.7.0",
56
57
  "next": "16.1.7",
@@ -0,0 +1,29 @@
1
+ -- CreateTable
2
+ CREATE TABLE "MessageUsage" (
3
+ "id" TEXT NOT NULL PRIMARY KEY,
4
+ "sessionId" TEXT NOT NULL,
5
+ "messageId" TEXT NOT NULL,
6
+ "requestId" TEXT NOT NULL,
7
+ "model" TEXT NOT NULL,
8
+ "speed" TEXT NOT NULL DEFAULT 'standard',
9
+ "timestamp" DATETIME NOT NULL,
10
+ "date" TEXT NOT NULL,
11
+ "inputTokens" INTEGER NOT NULL,
12
+ "outputTokens" INTEGER NOT NULL,
13
+ "cacheCreationTokens" INTEGER NOT NULL,
14
+ "cacheReadTokens" INTEGER NOT NULL,
15
+ "costUSD" REAL NOT NULL,
16
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
17
+ );
18
+
19
+ -- CreateIndex
20
+ CREATE INDEX "MessageUsage_date_idx" ON "MessageUsage"("date");
21
+
22
+ -- CreateIndex
23
+ CREATE INDEX "MessageUsage_sessionId_idx" ON "MessageUsage"("sessionId");
24
+
25
+ -- CreateIndex
26
+ CREATE INDEX "MessageUsage_model_idx" ON "MessageUsage"("model");
27
+
28
+ -- CreateIndex
29
+ CREATE UNIQUE INDEX "MessageUsage_messageId_requestId_key" ON "MessageUsage"("messageId", "requestId");
@@ -42,6 +42,28 @@ model Session {
42
42
  @@index([startTime])
43
43
  }
44
44
 
45
+ model MessageUsage {
46
+ id String @id @default(cuid())
47
+ sessionId String
48
+ messageId String
49
+ requestId String
50
+ model String
51
+ speed String @default("standard") // "standard" | "fast"
52
+ timestamp DateTime
53
+ date String // YYYY-MM-DD bucket of timestamp (local date)
54
+ inputTokens Int
55
+ outputTokens Int
56
+ cacheCreationTokens Int
57
+ cacheReadTokens Int
58
+ costUSD Float
59
+ createdAt DateTime @default(now())
60
+
61
+ @@unique([messageId, requestId])
62
+ @@index([date])
63
+ @@index([sessionId])
64
+ @@index([model])
65
+ }
66
+
45
67
  model Image {
46
68
  id String @id @default(cuid())
47
69
  sessionId String
package/prisma/schema.sql CHANGED
@@ -31,6 +31,24 @@ CREATE TABLE IF NOT EXISTS "Session" (
31
31
  "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
32
32
  );
33
33
 
34
+ -- CreateTable
35
+ CREATE TABLE IF NOT EXISTS "MessageUsage" (
36
+ "id" TEXT NOT NULL PRIMARY KEY,
37
+ "sessionId" TEXT NOT NULL,
38
+ "messageId" TEXT NOT NULL,
39
+ "requestId" TEXT NOT NULL,
40
+ "model" TEXT NOT NULL,
41
+ "speed" TEXT NOT NULL DEFAULT 'standard',
42
+ "timestamp" DATETIME NOT NULL,
43
+ "date" TEXT NOT NULL,
44
+ "inputTokens" INTEGER NOT NULL,
45
+ "outputTokens" INTEGER NOT NULL,
46
+ "cacheCreationTokens" INTEGER NOT NULL,
47
+ "cacheReadTokens" INTEGER NOT NULL,
48
+ "costUSD" REAL NOT NULL,
49
+ "createdAt" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP
50
+ );
51
+
34
52
  -- CreateTable
35
53
  CREATE TABLE IF NOT EXISTS "Image" (
36
54
  "id" TEXT NOT NULL PRIMARY KEY,
@@ -85,6 +103,18 @@ CREATE INDEX IF NOT EXISTS "Session_project_idx" ON "Session"("project");
85
103
  -- CreateIndex
86
104
  CREATE INDEX IF NOT EXISTS "Session_startTime_idx" ON "Session"("startTime");
87
105
 
106
+ -- CreateIndex
107
+ CREATE UNIQUE INDEX IF NOT EXISTS "MessageUsage_messageId_requestId_key" ON "MessageUsage"("messageId", "requestId");
108
+
109
+ -- CreateIndex
110
+ CREATE INDEX IF NOT EXISTS "MessageUsage_date_idx" ON "MessageUsage"("date");
111
+
112
+ -- CreateIndex
113
+ CREATE INDEX IF NOT EXISTS "MessageUsage_sessionId_idx" ON "MessageUsage"("sessionId");
114
+
115
+ -- CreateIndex
116
+ CREATE INDEX IF NOT EXISTS "MessageUsage_model_idx" ON "MessageUsage"("model");
117
+
88
118
  -- CreateIndex
89
119
  CREATE INDEX IF NOT EXISTS "Image_sessionId_idx" ON "Image"("sessionId");
90
120