@dropalltables/yacu 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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +52 -0
  3. package/dist/yacu.js +1782 -0
  4. package/package.json +62 -0
  5. package/src/data/jsonl.ts +81 -0
  6. package/src/data/load.ts +52 -0
  7. package/src/data/pricing.test.ts +22 -0
  8. package/src/data/pricing.ts +49 -0
  9. package/src/data/sources/claude.test.ts +40 -0
  10. package/src/data/sources/claude.ts +118 -0
  11. package/src/data/sources/codex.ts +73 -0
  12. package/src/data/sources/cursor.test.ts +16 -0
  13. package/src/data/sources/cursor.ts +83 -0
  14. package/src/data/sources/gemini.test.ts +27 -0
  15. package/src/data/sources/gemini.ts +112 -0
  16. package/src/data/sources/grok.ts +60 -0
  17. package/src/data/sources/opencode.ts +53 -0
  18. package/src/data/types.ts +23 -0
  19. package/src/domain/aggregate.test.ts +62 -0
  20. package/src/domain/aggregate.ts +143 -0
  21. package/src/domain/dates.ts +24 -0
  22. package/src/domain/types.ts +48 -0
  23. package/src/index.tsx +15 -0
  24. package/src/tui/App.tsx +170 -0
  25. package/src/tui/ThemeContext.tsx +12 -0
  26. package/src/tui/chart.test.ts +48 -0
  27. package/src/tui/chart.ts +130 -0
  28. package/src/tui/components/Breakdown.tsx +85 -0
  29. package/src/tui/components/Chart.test.tsx +67 -0
  30. package/src/tui/components/Chart.tsx +159 -0
  31. package/src/tui/components/Footer.tsx +11 -0
  32. package/src/tui/components/Header.tsx +52 -0
  33. package/src/tui/components/PointerButton.tsx +40 -0
  34. package/src/tui/components/ScanBoot.test.tsx +30 -0
  35. package/src/tui/components/ScanBoot.tsx +30 -0
  36. package/src/tui/components/Segmented.test.tsx +32 -0
  37. package/src/tui/components/Segmented.tsx +59 -0
  38. package/src/tui/components/Summary.tsx +67 -0
  39. package/src/tui/components/Totals.tsx +27 -0
  40. package/src/tui/format.ts +44 -0
  41. package/src/tui/theme.ts +99 -0
  42. package/src/tui/usePointer.ts +11 -0
  43. package/tsconfig.json +15 -0
package/package.json ADDED
@@ -0,0 +1,62 @@
1
+ {
2
+ "name": "@dropalltables/yacu",
3
+ "version": "0.1.0",
4
+ "description": "Local coding-agent usage dashboard for the terminal",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/dropalltables/yacu.git"
8
+ },
9
+ "bugs": {
10
+ "url": "https://github.com/dropalltables/yacu/issues"
11
+ },
12
+ "homepage": "https://github.com/dropalltables/yacu#readme",
13
+ "type": "module",
14
+ "bin": {
15
+ "yacu": "dist/yacu.js"
16
+ },
17
+ "files": [
18
+ "dist",
19
+ "src",
20
+ "tsconfig.json",
21
+ "LICENSE",
22
+ "README.md"
23
+ ],
24
+ "scripts": {
25
+ "start": "bun src/index.tsx",
26
+ "build": "bun build src/index.tsx --target=bun --packages=external --outfile=dist/yacu.js",
27
+ "typecheck": "tsc --noEmit",
28
+ "test": "bun test",
29
+ "check": "bun test && tsc --noEmit",
30
+ "prepack": "bun run check && bun run build"
31
+ },
32
+ "engines": {
33
+ "bun": ">=1.4.0"
34
+ },
35
+ "keywords": [
36
+ "ai",
37
+ "coding-agent",
38
+ "opentui",
39
+ "terminal",
40
+ "tokens",
41
+ "tui",
42
+ "usage"
43
+ ],
44
+ "license": "MIT",
45
+ "author": "Natey Hecht",
46
+ "packageManager": "bun@1.4.0",
47
+ "publishConfig": {
48
+ "access": "public"
49
+ },
50
+ "dependencies": {
51
+ "@opentui/core": "^0.5.9",
52
+ "@opentui/react": "^0.5.9",
53
+ "gpt-tokenizer": "4.0.0",
54
+ "react": "^19.2.8",
55
+ "tokentally": "^0.1.4"
56
+ },
57
+ "devDependencies": {
58
+ "@types/bun": "^1.4.0",
59
+ "@types/react": "^19.2.18",
60
+ "typescript": "^7.0.2"
61
+ }
62
+ }
@@ -0,0 +1,81 @@
1
+ export async function forEachJsonLine(
2
+ path: string,
3
+ callback: (value: unknown) => void | Promise<void>,
4
+ ): Promise<void> {
5
+ const reader = Bun.file(path).stream().getReader()
6
+ const decoder = new TextDecoder()
7
+ let pending = ""
8
+
9
+ while (true) {
10
+ const { done, value } = await reader.read()
11
+ if (done) break
12
+ pending += decoder.decode(value, { stream: true })
13
+ const lines = pending.split("\n")
14
+ pending = lines.pop() ?? ""
15
+ for (const line of lines) await parseLine(line, callback)
16
+ }
17
+
18
+ pending += decoder.decode()
19
+ await parseLine(pending, callback)
20
+ }
21
+
22
+ async function parseLine(
23
+ line: string,
24
+ callback: (value: unknown) => void | Promise<void>,
25
+ ): Promise<void> {
26
+ if (line.trim() === "") return
27
+ try {
28
+ await callback(JSON.parse(line))
29
+ } catch {
30
+ return
31
+ }
32
+ }
33
+
34
+ export async function globFiles(
35
+ root: string,
36
+ pattern: string,
37
+ options: { includeSymlinks?: boolean } = {},
38
+ ): Promise<string[]> {
39
+ if (!(await Bun.file(root).exists()) && !(await isDirectory(root))) return []
40
+ const files: string[] = []
41
+ const glob = new Bun.Glob(pattern)
42
+ for await (const file of glob.scan({
43
+ cwd: root,
44
+ absolute: true,
45
+ onlyFiles: options.includeSymlinks !== true,
46
+ })) files.push(file)
47
+ if (options.includeSymlinks === true) {
48
+ const existingFiles = await Promise.all(files.map(async (file) => {
49
+ try {
50
+ return (await stat(file)).isFile() ? file : null
51
+ } catch {
52
+ return null
53
+ }
54
+ }))
55
+ return existingFiles.filter((file): file is string => file != null).sort()
56
+ }
57
+ return files.sort()
58
+ }
59
+
60
+ async function isDirectory(path: string): Promise<boolean> {
61
+ try {
62
+ return (await stat(path)).isDirectory()
63
+ } catch {
64
+ return false
65
+ }
66
+ }
67
+
68
+ export function asObject(value: unknown): Record<string, unknown> | null {
69
+ return typeof value === "object" && value != null && !Array.isArray(value)
70
+ ? (value as Record<string, unknown>)
71
+ : null
72
+ }
73
+
74
+ export function numberValue(value: unknown): number {
75
+ return typeof value === "number" && Number.isFinite(value) ? value : 0
76
+ }
77
+
78
+ export function stringValue(value: unknown): string | null {
79
+ return typeof value === "string" && value !== "" ? value : null
80
+ }
81
+ import { stat } from "node:fs/promises"
@@ -0,0 +1,52 @@
1
+ import { SOURCE_META, type SourceId, type UsageDataset } from "../domain/types"
2
+ import { loadClaudeUsage } from "./sources/claude"
3
+ import { loadCodexUsage } from "./sources/codex"
4
+ import { loadCursorUsage } from "./sources/cursor"
5
+ import { loadGeminiUsage } from "./sources/gemini"
6
+ import { loadGrokUsage } from "./sources/grok"
7
+ import { loadOpenCodeUsage } from "./sources/opencode"
8
+ import type { ScanProgressHandler, SourceLoadResult } from "./types"
9
+
10
+ type SourceLoader = {
11
+ id: SourceId
12
+ load: () => Promise<SourceLoadResult>
13
+ }
14
+
15
+ const SOURCES: SourceLoader[] = [
16
+ { id: "claude", load: loadClaudeUsage },
17
+ { id: "codex", load: loadCodexUsage },
18
+ { id: "cursor", load: loadCursorUsage },
19
+ { id: "gemini", load: loadGeminiUsage },
20
+ { id: "opencode", load: loadOpenCodeUsage },
21
+ { id: "grok", load: loadGrokUsage },
22
+ ]
23
+
24
+ export async function loadUsageDataset(onProgress?: ScanProgressHandler): Promise<UsageDataset> {
25
+ let completed = 0
26
+ const results = await Promise.all(SOURCES.map(async (source) => {
27
+ const common = { source: source.id, label: SOURCE_META[source.id].label, total: SOURCES.length }
28
+ onProgress?.({ ...common, status: "scanning", completed })
29
+ try {
30
+ const value = await source.load()
31
+ completed += 1
32
+ onProgress?.({
33
+ ...common,
34
+ status: "done",
35
+ completed,
36
+ files: value.files,
37
+ records: value.records.length,
38
+ sessions: value.sessions.length,
39
+ })
40
+ return { value, error: null }
41
+ } catch (cause) {
42
+ completed += 1
43
+ const error = cause instanceof Error ? cause.message : String(cause)
44
+ onProgress?.({ ...common, status: "error", completed, error })
45
+ return { value: null, error }
46
+ }
47
+ }))
48
+ const records = results.flatMap((result) => result.value?.records ?? [])
49
+ const sessions = results.flatMap((result) => result.value?.sessions ?? [])
50
+ const errors = results.flatMap((result) => result.error == null ? [] : [result.error])
51
+ return { records, sessions, errors, scannedAt: new Date() }
52
+ }
@@ -0,0 +1,22 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { estimateCacheSavings, estimateCost, resolvePricing } from "./pricing"
3
+
4
+ describe("pricing", () => {
5
+ test("matches supported model families", () => {
6
+ expect(resolvePricing("claude-opus-4-1")).not.toBeNull()
7
+ expect(resolvePricing("gpt-5-codex")).not.toBeNull()
8
+ expect(resolvePricing("unknown-local-model")).toBeNull()
9
+ })
10
+
11
+ test("estimates cost and cache savings", () => {
12
+ const cost = estimateCost("gpt-5-codex", {
13
+ inputTokens: 1_000_000,
14
+ outputTokens: 0,
15
+ cacheCreationTokens: 0,
16
+ cacheReadTokens: 0,
17
+ })
18
+
19
+ expect(cost).toBeCloseTo(1.75)
20
+ expect(estimateCacheSavings("gpt-5-codex", 1_000_000)).toBeCloseTo(1.575)
21
+ })
22
+ })
@@ -0,0 +1,49 @@
1
+ import {
2
+ estimateUsdCost,
3
+ normalizeTokenUsage,
4
+ pricingFromUsdPerMillion,
5
+ type Pricing,
6
+ } from "tokentally"
7
+
8
+ export type TokenParts = {
9
+ inputTokens: number
10
+ outputTokens: number
11
+ cacheCreationTokens: number
12
+ cacheReadTokens: number
13
+ }
14
+
15
+ const PRICING: Array<[RegExp, Pricing]> = [
16
+ [/claude.*opus/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 5, outputUsdPerMillion: 25, cachedInputUsdPerMillion: 0.5, cacheCreationInputUsdPerMillion: 6.25 })],
17
+ [/claude.*sonnet/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 3, outputUsdPerMillion: 15, cachedInputUsdPerMillion: 0.3, cacheCreationInputUsdPerMillion: 3.75 })],
18
+ [/claude.*haiku/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1, outputUsdPerMillion: 5, cachedInputUsdPerMillion: 0.1, cacheCreationInputUsdPerMillion: 1.25 })],
19
+ [/(^|\/)gpt-5|codex/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.75, outputUsdPerMillion: 14, cachedInputUsdPerMillion: 0.175 })],
20
+ [/grok|composer/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.2 })],
21
+ [/deepseek/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.27, outputUsdPerMillion: 1.1, cachedInputUsdPerMillion: 0.07 })],
22
+ [/gemini-3\.1-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 2, outputUsdPerMillion: 12, cachedInputUsdPerMillion: 0.2 })],
23
+ [/gemini-2\.5-pro/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.25, outputUsdPerMillion: 10, cachedInputUsdPerMillion: 0.125 })],
24
+ [/gemini-3\.[67]-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.75, outputUsdPerMillion: 3.75, cachedInputUsdPerMillion: 0.075 })],
25
+ [/gemini-3\.5-flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 1.5, outputUsdPerMillion: 9, cachedInputUsdPerMillion: 0.15 })],
26
+ [/gemini-3\.1-flash-lite/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.25, outputUsdPerMillion: 1.5, cachedInputUsdPerMillion: 0.025 })],
27
+ [/gemini.*flash/i, pricingFromUsdPerMillion({ inputUsdPerMillion: 0.3, outputUsdPerMillion: 2.5, cachedInputUsdPerMillion: 0.03 })],
28
+ ]
29
+
30
+ export function resolvePricing(model: string): Pricing | null {
31
+ return PRICING.find(([pattern]) => pattern.test(model))?.[1] ?? null
32
+ }
33
+
34
+ export function estimateCost(model: string, tokens: TokenParts): number {
35
+ const usage = normalizeTokenUsage({
36
+ inputTokens: tokens.inputTokens,
37
+ outputTokens: tokens.outputTokens,
38
+ cachedInputTokens: tokens.cacheReadTokens,
39
+ cacheCreationInputTokens: tokens.cacheCreationTokens,
40
+ })
41
+ return estimateUsdCost({ usage, pricing: resolvePricing(model) })?.totalUsd ?? 0
42
+ }
43
+
44
+ export function estimateCacheSavings(model: string, cachedReadTokens: number): number {
45
+ const pricing = resolvePricing(model)
46
+ if (pricing == null) return 0
47
+ const cachedRate = pricing.cachedInputUsdPerToken ?? pricing.inputUsdPerToken
48
+ return Math.max(0, cachedReadTokens * (pricing.inputUsdPerToken - cachedRate))
49
+ }
@@ -0,0 +1,40 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseClaudeUsageEntry } from "./claude"
3
+
4
+ describe("parseClaudeUsageEntry", () => {
5
+ test("reads Claude token usage without transcript content", () => {
6
+ expect(parseClaudeUsageEntry({
7
+ timestamp: "2026-08-29T12:00:00.000Z",
8
+ sessionId: "session-1",
9
+ requestId: "request-1",
10
+ message: {
11
+ id: "message-1",
12
+ model: "claude-opus-5",
13
+ usage: {
14
+ input_tokens: 10,
15
+ output_tokens: 20,
16
+ cache_creation_input_tokens: 30,
17
+ cache_read_input_tokens: 40,
18
+ },
19
+ },
20
+ })).toEqual({
21
+ timestamp: "2026-08-29T12:00:00.000Z",
22
+ sessionId: "session-1",
23
+ requestId: "request-1",
24
+ messageId: "message-1",
25
+ model: "claude-opus-5",
26
+ inputTokens: 10,
27
+ outputTokens: 20,
28
+ cacheCreationTokens: 30,
29
+ cacheReadTokens: 40,
30
+ })
31
+ })
32
+
33
+ test("rejects rows without required counters or a valid timestamp", () => {
34
+ expect(parseClaudeUsageEntry({ timestamp: "invalid", message: { usage: {} } })).toBeNull()
35
+ expect(parseClaudeUsageEntry({
36
+ timestamp: "2026-08-29T12:00:00.000Z",
37
+ message: { usage: { input_tokens: 1 } },
38
+ })).toBeNull()
39
+ })
40
+ })
@@ -0,0 +1,118 @@
1
+ import { homedir } from "node:os"
2
+ import { join, resolve } from "node:path"
3
+ import { localDate } from "../../domain/dates"
4
+ import type { UsageRecord, UsageSession } from "../../domain/types"
5
+ import { estimateCacheSavings, estimateCost } from "../pricing"
6
+ import { asObject, forEachJsonLine, globFiles, numberValue, stringValue } from "../jsonl"
7
+ import type { SourceLoadResult } from "../types"
8
+
9
+ type ClaudeEntry = {
10
+ timestamp: string
11
+ sessionId: string | null
12
+ model: string
13
+ messageId: string | null
14
+ requestId: string | null
15
+ inputTokens: number
16
+ outputTokens: number
17
+ cacheCreationTokens: number
18
+ cacheReadTokens: number
19
+ }
20
+
21
+ export async function loadClaudeUsage(): Promise<SourceLoadResult> {
22
+ const files = [...new Set((await Promise.all(
23
+ claudeRoots().map((root) => globFiles(
24
+ join(root, "projects"),
25
+ "**/*.jsonl",
26
+ { includeSymlinks: true },
27
+ )),
28
+ )).flat())]
29
+ const records: UsageRecord[] = []
30
+ const sessions = new Map<string, UsageSession>()
31
+ const processed = new Set<string>()
32
+
33
+ for (const path of files) {
34
+ await forEachJsonLine(path, (value) => {
35
+ const entry = parseClaudeUsageEntry(value)
36
+ if (entry == null) return
37
+
38
+ const uniqueId = entry.messageId != null && entry.requestId != null
39
+ ? `${entry.messageId}:${entry.requestId}`
40
+ : null
41
+ if (uniqueId != null && processed.has(uniqueId)) return
42
+ if (uniqueId != null) processed.add(uniqueId)
43
+
44
+ const date = localDate(entry.timestamp)
45
+ const sessionId = entry.sessionId == null ? undefined : `claude:${entry.sessionId}`
46
+ const tokens = {
47
+ inputTokens: entry.inputTokens,
48
+ outputTokens: entry.outputTokens,
49
+ cacheCreationTokens: entry.cacheCreationTokens,
50
+ cacheReadTokens: entry.cacheReadTokens,
51
+ }
52
+ if (sumTokens(tokens) === 0 || entry.model === "<synthetic>") return
53
+
54
+ records.push({
55
+ date,
56
+ source: "claude",
57
+ model: entry.model,
58
+ sessionId,
59
+ ...tokens,
60
+ costUsd: estimateCost(entry.model, tokens),
61
+ cacheSavingsUsd: estimateCacheSavings(entry.model, entry.cacheReadTokens),
62
+ })
63
+
64
+ if (sessionId != null) {
65
+ const existing = sessions.get(sessionId)
66
+ if (existing == null || existing.date < date) {
67
+ sessions.set(sessionId, { id: sessionId, source: "claude", date })
68
+ }
69
+ }
70
+ })
71
+ }
72
+
73
+ return { records, sessions: [...sessions.values()], files: files.length }
74
+ }
75
+
76
+ export function parseClaudeUsageEntry(value: unknown): ClaudeEntry | null {
77
+ const row = asObject(value)
78
+ const message = asObject(row?.message)
79
+ const usage = asObject(message?.usage)
80
+ const timestamp = stringValue(row?.timestamp)
81
+ const inputTokens = finiteNumber(usage?.input_tokens)
82
+ const outputTokens = finiteNumber(usage?.output_tokens)
83
+
84
+ if (
85
+ timestamp == null
86
+ || Number.isNaN(new Date(timestamp).getTime())
87
+ || inputTokens == null
88
+ || outputTokens == null
89
+ ) return null
90
+
91
+ return {
92
+ timestamp,
93
+ sessionId: stringValue(row?.sessionId),
94
+ model: stringValue(message?.model) ?? "unknown",
95
+ messageId: stringValue(message?.id),
96
+ requestId: stringValue(row?.requestId),
97
+ inputTokens,
98
+ outputTokens,
99
+ cacheCreationTokens: numberValue(usage?.cache_creation_input_tokens),
100
+ cacheReadTokens: numberValue(usage?.cache_read_input_tokens),
101
+ }
102
+ }
103
+
104
+ function claudeRoots(): string[] {
105
+ const configured = process.env.CLAUDE_CONFIG_DIR?.trim()
106
+ if (configured != null && configured !== "") {
107
+ return [...new Set(configured.split(",").map((path) => resolve(path.trim())).filter(Boolean))]
108
+ }
109
+ return [join(homedir(), ".config", "claude"), join(homedir(), ".claude")]
110
+ }
111
+
112
+ function finiteNumber(value: unknown): number | null {
113
+ return typeof value === "number" && Number.isFinite(value) ? value : null
114
+ }
115
+
116
+ function sumTokens(tokens: Pick<UsageRecord, "inputTokens" | "outputTokens" | "cacheCreationTokens" | "cacheReadTokens">): number {
117
+ return tokens.inputTokens + tokens.outputTokens + tokens.cacheCreationTokens + tokens.cacheReadTokens
118
+ }
@@ -0,0 +1,73 @@
1
+ import { homedir } from "node:os"
2
+ import { join } from "node:path"
3
+ import { localDate } from "../../domain/dates"
4
+ import type { UsageRecord, UsageSession } from "../../domain/types"
5
+ import { estimateCacheSavings, estimateCost } from "../pricing"
6
+ import { asObject, forEachJsonLine, globFiles, numberValue, stringValue } from "../jsonl"
7
+ import type { SourceLoadResult } from "../types"
8
+
9
+ export async function loadCodexUsage(): Promise<SourceLoadResult> {
10
+ const root = join(process.env.CODEX_HOME ?? join(homedir(), ".codex"), "sessions")
11
+ const files = await globFiles(root, "**/*.jsonl")
12
+ const records: UsageRecord[] = []
13
+ const sessions = new Map<string, UsageSession>()
14
+
15
+ for (const path of files) {
16
+ let sessionId = path
17
+ let model = "codex"
18
+ let skipSession = false
19
+ let previousTotal = ""
20
+
21
+ await forEachJsonLine(path, (unknownValue) => {
22
+ const value = asObject(unknownValue)
23
+ const payload = asObject(value?.payload)
24
+ const type = stringValue(value?.type)
25
+
26
+ if (type === "session_meta") {
27
+ sessionId = stringValue(payload?.id) ?? sessionId
28
+ const timestamp = stringValue(payload?.timestamp) ?? stringValue(value?.timestamp)
29
+ const threadSource = payload?.thread_source
30
+ skipSession = typeof threadSource !== "string" && JSON.stringify(threadSource).toLowerCase().includes("sub")
31
+ previousTotal = ""
32
+ if (timestamp != null && !skipSession) {
33
+ sessions.set(sessionId, { id: `codex:${sessionId}`, source: "codex", date: localDate(timestamp) })
34
+ }
35
+ return
36
+ }
37
+
38
+ if (type === "turn_context") {
39
+ model = stringValue(payload?.model) ?? model
40
+ return
41
+ }
42
+
43
+ if (skipSession || type !== "event_msg" || payload?.type !== "token_count") return
44
+ const info = asObject(payload.info)
45
+ const last = asObject(info?.last_token_usage)
46
+ const total = asObject(info?.total_token_usage)
47
+ if (last == null) return
48
+
49
+ const signature = JSON.stringify(total)
50
+ if (signature === previousTotal) return
51
+ previousTotal = signature
52
+
53
+ const cached = numberValue(last.cached_input_tokens)
54
+ const rawInput = numberValue(last.input_tokens)
55
+ const input = Math.max(0, rawInput - cached)
56
+ const output = numberValue(last.output_tokens)
57
+ if (input + cached + output === 0) return
58
+ const timestamp = stringValue(value?.timestamp) ?? new Date().toISOString()
59
+ const tokens = { inputTokens: input, outputTokens: output, cacheCreationTokens: 0, cacheReadTokens: cached }
60
+ records.push({
61
+ date: localDate(timestamp),
62
+ source: "codex",
63
+ model,
64
+ sessionId: `codex:${sessionId}`,
65
+ ...tokens,
66
+ costUsd: estimateCost(model, tokens),
67
+ cacheSavingsUsd: estimateCacheSavings(model, cached),
68
+ })
69
+ })
70
+ }
71
+
72
+ return { records, sessions: [...sessions.values()], files: files.length }
73
+ }
@@ -0,0 +1,16 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { countContent } from "./cursor"
3
+
4
+ describe("countContent", () => {
5
+ test("counts text and tool calls from Cursor transcript parts", () => {
6
+ const text = countContent([{ type: "text", text: "hello world" }])
7
+ const tool = countContent([{ type: "tool_use", name: "Read", input: { path: "a.ts" } }])
8
+
9
+ expect(text).toBeGreaterThan(0)
10
+ expect(tool).toBeGreaterThan(0)
11
+ })
12
+
13
+ test("ignores unknown transcript parts", () => {
14
+ expect(countContent([{ type: "image", data: "ignored" }])).toBe(0)
15
+ })
16
+ })
@@ -0,0 +1,83 @@
1
+ import { stat } from "node:fs/promises"
2
+ import { homedir } from "node:os"
3
+ import { basename, dirname, join } from "node:path"
4
+ import { countTokens } from "gpt-tokenizer/encoding/o200k_base"
5
+ import { localDate } from "../../domain/dates"
6
+ import type { UsageRecord, UsageSession } from "../../domain/types"
7
+ import { estimateCost } from "../pricing"
8
+ import { asObject, forEachJsonLine, globFiles, stringValue } from "../jsonl"
9
+ import type { SourceLoadResult } from "../types"
10
+
11
+ type TranscriptCount = {
12
+ inputTokens: number
13
+ outputTokens: number
14
+ }
15
+
16
+ export async function loadCursorUsage(): Promise<SourceLoadResult> {
17
+ const root = process.env.CURSOR_CONFIG_DIR ?? join(homedir(), ".cursor")
18
+ const files = await globFiles(join(root, "projects"), "**/agent-transcripts/**/*.jsonl")
19
+ const fallbackModel = await readConfiguredModel(join(root, "cli-config.json"))
20
+ const records: UsageRecord[] = []
21
+ const sessions: UsageSession[] = []
22
+
23
+ for (const path of files) {
24
+ const counts: TranscriptCount = { inputTokens: 0, outputTokens: 0 }
25
+ let model = fallbackModel
26
+ await forEachJsonLine(path, (unknownValue) => {
27
+ const value = asObject(unknownValue)
28
+ const message = asObject(value?.message) ?? value
29
+ const role = stringValue(value?.role) ?? stringValue(message?.role)
30
+ model = stringValue(value?.model) ?? stringValue(message?.model) ?? model
31
+ const tokens = countContent(message?.content)
32
+ if (role === "user") counts.inputTokens += tokens
33
+ if (role === "assistant") counts.outputTokens += tokens
34
+ })
35
+
36
+ if (counts.inputTokens + counts.outputTokens === 0) continue
37
+ const timestamp = (await stat(path)).mtime
38
+ const date = localDate(timestamp)
39
+ const rawSessionId = basename(dirname(path))
40
+ const sessionId = `cursor:${rawSessionId}`
41
+ const tokens = { ...counts, cacheCreationTokens: 0, cacheReadTokens: 0 }
42
+ records.push({
43
+ date,
44
+ source: "cursor",
45
+ model,
46
+ sessionId,
47
+ ...tokens,
48
+ costUsd: estimateCost(model, tokens),
49
+ cacheSavingsUsd: 0,
50
+ })
51
+ sessions.push({ id: sessionId, source: "cursor", date })
52
+ }
53
+
54
+ return { records, sessions, files: files.length }
55
+ }
56
+
57
+ export function countContent(value: unknown): number {
58
+ if (typeof value === "string") return countTokens(value)
59
+ if (!Array.isArray(value)) return 0
60
+ return value.reduce((total, item) => {
61
+ const part = asObject(item)
62
+ if (part == null) return total
63
+ const text = stringValue(part.text)
64
+ if (text != null) return total + countTokens(text)
65
+ if (part.type === "tool_use") return total + countTokens(JSON.stringify({ name: part.name, input: part.input }))
66
+ return total
67
+ }, 0)
68
+ }
69
+
70
+ async function readConfiguredModel(path: string): Promise<string> {
71
+ try {
72
+ const config = asObject(await Bun.file(path).json())
73
+ const model = asObject(config?.model)
74
+ const selected = asObject(config?.selectedModel)
75
+ return stringValue(model?.modelId)
76
+ ?? stringValue(model?.id)
77
+ ?? stringValue(selected?.modelId)
78
+ ?? stringValue(selected?.id)
79
+ ?? "cursor-agent"
80
+ } catch {
81
+ return "cursor-agent"
82
+ }
83
+ }
@@ -0,0 +1,27 @@
1
+ import { describe, expect, test } from "bun:test"
2
+ import { parseGeminiConversation } from "./gemini"
3
+
4
+ describe("parseGeminiConversation", () => {
5
+ test("parses complete JSON session files", () => {
6
+ const parsed = parseGeminiConversation(JSON.stringify({
7
+ sessionId: "one",
8
+ startTime: "2026-08-28T10:00:00Z",
9
+ messages: [{ id: "m1", type: "gemini", tokens: { input: 10, output: 2, cached: 3, total: 12 } }],
10
+ }))
11
+
12
+ expect(parsed?.sessionId).toBe("one")
13
+ expect(parsed?.messages).toHaveLength(1)
14
+ })
15
+
16
+ test("applies JSONL rewinds", () => {
17
+ const parsed = parseGeminiConversation([
18
+ JSON.stringify({ sessionId: "two", startTime: "2026-08-28T10:00:00Z" }),
19
+ JSON.stringify({ id: "m1", type: "gemini", tokens: { input: 10 } }),
20
+ JSON.stringify({ id: "m2", type: "gemini", tokens: { input: 20 } }),
21
+ JSON.stringify({ $rewindTo: "m1" }),
22
+ JSON.stringify({ id: "m3", type: "gemini", tokens: { input: 30 } }),
23
+ ].join("\n"))
24
+
25
+ expect(parsed?.messages.map((message) => message.id)).toEqual(["m1", "m3"])
26
+ })
27
+ })