codeburn/src/parser.ts
AgentSeal 00afed6930 v0.1.0 - initial release
Interactive TUI dashboard for Claude Code token observability.
13-category task classifier, per-project/model/tool breakdowns,
gradient bar charts, SwiftBar menu bar widget, CSV/JSON export.
2026-04-13 15:10:27 -07:00

314 lines
9.3 KiB
TypeScript

import { readdir, readFile, stat } from 'fs/promises'
import { basename, join } from 'path'
import { homedir } from 'os'
import { calculateCost, getShortModelName } from './models.js'
import type {
AssistantMessageContent,
ClassifiedTurn,
ContentBlock,
DateRange,
JournalEntry,
ParsedApiCall,
ParsedTurn,
ProjectSummary,
SessionSummary,
TokenUsage,
ToolUseBlock,
} from './types.js'
import { classifyTurn } from './classifier.js'
function getClaudeDir(): string {
return join(homedir(), '.claude')
}
function getProjectsDir(): string {
return join(getClaudeDir(), 'projects')
}
function unsanitizePath(dirName: string): string {
return dirName.replace(/-/g, '/')
}
function parseJsonlLine(line: string): JournalEntry | null {
try {
return JSON.parse(line) as JournalEntry
} catch {
return null
}
}
function extractToolNames(content: ContentBlock[]): string[] {
return content
.filter((b): b is ToolUseBlock => b.type === 'tool_use')
.map(b => b.name)
}
function extractMcpTools(tools: string[]): string[] {
return tools.filter(t => t.startsWith('mcp__'))
}
function extractCoreTools(tools: string[]): string[] {
return tools.filter(t => !t.startsWith('mcp__'))
}
function getUserMessageText(entry: JournalEntry): string {
if (!entry.message || entry.message.role !== 'user') return ''
const content = entry.message.content
if (typeof content === 'string') return content
if (Array.isArray(content)) {
return content
.filter((b): b is { type: 'text'; text: string } => b.type === 'text')
.map(b => b.text)
.join(' ')
}
return ''
}
function getMessageId(entry: JournalEntry): string | null {
if (entry.type !== 'assistant') return null
const msg = entry.message as AssistantMessageContent | undefined
return msg?.id ?? null
}
function parseApiCall(entry: JournalEntry): ParsedApiCall | null {
if (entry.type !== 'assistant') return null
const msg = entry.message as AssistantMessageContent | undefined
if (!msg?.usage || !msg?.model) return null
const usage = msg.usage
const tokens: TokenUsage = {
inputTokens: usage.input_tokens ?? 0,
outputTokens: usage.output_tokens ?? 0,
cacheCreationInputTokens: usage.cache_creation_input_tokens ?? 0,
cacheReadInputTokens: usage.cache_read_input_tokens ?? 0,
webSearchRequests: usage.server_tool_use?.web_search_requests ?? 0,
}
const tools = extractToolNames(msg.content ?? [])
const costUSD = calculateCost(
msg.model,
tokens.inputTokens,
tokens.outputTokens,
tokens.cacheCreationInputTokens,
tokens.cacheReadInputTokens,
tokens.webSearchRequests,
usage.speed ?? 'standard',
)
return {
model: msg.model,
usage: tokens,
costUSD,
tools,
mcpTools: extractMcpTools(tools),
hasAgentSpawn: tools.includes('Agent'),
hasPlanMode: tools.includes('EnterPlanMode'),
speed: usage.speed ?? 'standard',
timestamp: entry.timestamp ?? '',
}
}
function groupIntoTurns(entries: JournalEntry[], seenMsgIds: Set<string>): ParsedTurn[] {
const turns: ParsedTurn[] = []
let currentUserMessage = ''
let currentCalls: ParsedApiCall[] = []
let currentTimestamp = ''
let currentSessionId = ''
for (const entry of entries) {
if (entry.type === 'user') {
if (currentCalls.length > 0) {
turns.push({
userMessage: currentUserMessage,
assistantCalls: currentCalls,
timestamp: currentTimestamp,
sessionId: currentSessionId,
})
}
currentUserMessage = getUserMessageText(entry)
currentCalls = []
currentTimestamp = entry.timestamp ?? ''
currentSessionId = entry.sessionId ?? ''
} else if (entry.type === 'assistant') {
const msgId = getMessageId(entry)
if (msgId && seenMsgIds.has(msgId)) continue
if (msgId) seenMsgIds.add(msgId)
const call = parseApiCall(entry)
if (call) currentCalls.push(call)
}
}
if (currentCalls.length > 0) {
turns.push({
userMessage: currentUserMessage,
assistantCalls: currentCalls,
timestamp: currentTimestamp,
sessionId: currentSessionId,
})
}
return turns
}
function buildSessionSummary(
sessionId: string,
project: string,
turns: ClassifiedTurn[],
): SessionSummary {
const modelBreakdown: SessionSummary['modelBreakdown'] = {}
const toolBreakdown: SessionSummary['toolBreakdown'] = {}
const mcpBreakdown: SessionSummary['mcpBreakdown'] = {}
const categoryBreakdown: SessionSummary['categoryBreakdown'] = {} as SessionSummary['categoryBreakdown']
let totalCost = 0
let totalInput = 0
let totalOutput = 0
let totalCacheRead = 0
let totalCacheWrite = 0
let apiCalls = 0
let firstTs = ''
let lastTs = ''
for (const turn of turns) {
const turnCost = turn.assistantCalls.reduce((s, c) => s + c.costUSD, 0)
if (!categoryBreakdown[turn.category]) {
categoryBreakdown[turn.category] = { turns: 0, costUSD: 0 }
}
categoryBreakdown[turn.category].turns++
categoryBreakdown[turn.category].costUSD += turnCost
for (const call of turn.assistantCalls) {
totalCost += call.costUSD
totalInput += call.usage.inputTokens
totalOutput += call.usage.outputTokens
totalCacheRead += call.usage.cacheReadInputTokens
totalCacheWrite += call.usage.cacheCreationInputTokens
apiCalls++
const modelKey = getShortModelName(call.model)
if (!modelBreakdown[modelKey]) {
modelBreakdown[modelKey] = {
calls: 0,
costUSD: 0,
tokens: { inputTokens: 0, outputTokens: 0, cacheCreationInputTokens: 0, cacheReadInputTokens: 0, webSearchRequests: 0 },
}
}
modelBreakdown[modelKey].calls++
modelBreakdown[modelKey].costUSD += call.costUSD
modelBreakdown[modelKey].tokens.inputTokens += call.usage.inputTokens
modelBreakdown[modelKey].tokens.outputTokens += call.usage.outputTokens
modelBreakdown[modelKey].tokens.cacheReadInputTokens += call.usage.cacheReadInputTokens
modelBreakdown[modelKey].tokens.cacheCreationInputTokens += call.usage.cacheCreationInputTokens
for (const tool of extractCoreTools(call.tools)) {
toolBreakdown[tool] = toolBreakdown[tool] ?? { calls: 0 }
toolBreakdown[tool].calls++
}
for (const mcp of call.mcpTools) {
const server = mcp.split('__')[1] ?? mcp
mcpBreakdown[server] = mcpBreakdown[server] ?? { calls: 0 }
mcpBreakdown[server].calls++
}
if (!firstTs || call.timestamp < firstTs) firstTs = call.timestamp
if (!lastTs || call.timestamp > lastTs) lastTs = call.timestamp
}
}
return {
sessionId,
project,
firstTimestamp: firstTs || turns[0]?.timestamp || '',
lastTimestamp: lastTs || turns[turns.length - 1]?.timestamp || '',
totalCostUSD: totalCost,
totalInputTokens: totalInput,
totalOutputTokens: totalOutput,
totalCacheReadTokens: totalCacheRead,
totalCacheWriteTokens: totalCacheWrite,
apiCalls,
turns,
modelBreakdown,
toolBreakdown,
mcpBreakdown,
categoryBreakdown,
}
}
async function parseSessionFile(
filePath: string,
project: string,
seenMsgIds: Set<string>,
dateRange?: DateRange,
): Promise<SessionSummary | null> {
const content = await readFile(filePath, 'utf-8')
const lines = content.split('\n').filter(l => l.trim())
const entries: JournalEntry[] = []
for (const line of lines) {
const entry = parseJsonlLine(line)
if (entry) entries.push(entry)
}
if (entries.length === 0) return null
let filteredEntries = entries
if (dateRange) {
filteredEntries = entries.filter(e => {
if (!e.timestamp) return e.type === 'user'
const ts = new Date(e.timestamp)
return ts >= dateRange.start && ts <= dateRange.end
})
if (filteredEntries.length === 0) return null
}
const sessionId = basename(filePath, '.jsonl')
const turns = groupIntoTurns(filteredEntries, seenMsgIds)
const classified = turns.map(classifyTurn)
return buildSessionSummary(sessionId, project, classified)
}
export async function parseAllSessions(dateRange?: DateRange): Promise<ProjectSummary[]> {
const projectsDir = getProjectsDir()
const projects: ProjectSummary[] = []
const seenMsgIds = new Set<string>()
let projectDirs: string[]
try {
projectDirs = await readdir(projectsDir)
} catch {
return []
}
for (const dirName of projectDirs) {
const dirPath = join(projectsDir, dirName)
const dirStat = await stat(dirPath).catch(() => null)
if (!dirStat?.isDirectory()) continue
const files = await readdir(dirPath).catch(() => [])
const jsonlFiles = files.filter(f => f.endsWith('.jsonl') && !f.startsWith('agent-'))
const sessions: SessionSummary[] = []
for (const file of jsonlFiles) {
const session = await parseSessionFile(join(dirPath, file), dirName, seenMsgIds, dateRange)
if (session && session.apiCalls > 0) {
sessions.push(session)
}
}
if (sessions.length > 0) {
const totalCost = sessions.reduce((s, sess) => s + sess.totalCostUSD, 0)
const totalCalls = sessions.reduce((s, sess) => s + sess.apiCalls, 0)
projects.push({
project: dirName,
projectPath: unsanitizePath(dirName),
sessions,
totalCostUSD: totalCost,
totalApiCalls: totalCalls,
})
}
}
return projects.sort((a, b) => b.totalCostUSD - a.totalCostUSD)
}