@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.
- package/LICENSE +21 -0
- package/README.md +52 -0
- package/dist/yacu.js +1782 -0
- package/package.json +62 -0
- package/src/data/jsonl.ts +81 -0
- package/src/data/load.ts +52 -0
- package/src/data/pricing.test.ts +22 -0
- package/src/data/pricing.ts +49 -0
- package/src/data/sources/claude.test.ts +40 -0
- package/src/data/sources/claude.ts +118 -0
- package/src/data/sources/codex.ts +73 -0
- package/src/data/sources/cursor.test.ts +16 -0
- package/src/data/sources/cursor.ts +83 -0
- package/src/data/sources/gemini.test.ts +27 -0
- package/src/data/sources/gemini.ts +112 -0
- package/src/data/sources/grok.ts +60 -0
- package/src/data/sources/opencode.ts +53 -0
- package/src/data/types.ts +23 -0
- package/src/domain/aggregate.test.ts +62 -0
- package/src/domain/aggregate.ts +143 -0
- package/src/domain/dates.ts +24 -0
- package/src/domain/types.ts +48 -0
- package/src/index.tsx +15 -0
- package/src/tui/App.tsx +170 -0
- package/src/tui/ThemeContext.tsx +12 -0
- package/src/tui/chart.test.ts +48 -0
- package/src/tui/chart.ts +130 -0
- package/src/tui/components/Breakdown.tsx +85 -0
- package/src/tui/components/Chart.test.tsx +67 -0
- package/src/tui/components/Chart.tsx +159 -0
- package/src/tui/components/Footer.tsx +11 -0
- package/src/tui/components/Header.tsx +52 -0
- package/src/tui/components/PointerButton.tsx +40 -0
- package/src/tui/components/ScanBoot.test.tsx +30 -0
- package/src/tui/components/ScanBoot.tsx +30 -0
- package/src/tui/components/Segmented.test.tsx +32 -0
- package/src/tui/components/Segmented.tsx +59 -0
- package/src/tui/components/Summary.tsx +67 -0
- package/src/tui/components/Totals.tsx +27 -0
- package/src/tui/format.ts +44 -0
- package/src/tui/theme.ts +99 -0
- package/src/tui/usePointer.ts +11 -0
- 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"
|
package/src/data/load.ts
ADDED
|
@@ -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
|
+
})
|