mirror of
https://github.com/AgentSeal/codeburn.git
synced 2026-08-22 15:05:16 +00:00
feat(export): subagentType and model on per-record exports; --by-agent follow-ups (#712)
Follow-ups to #710, completing issue #708's second ask plus the two review notes: - export: new records.csv with one row per call incl. subagentType and model; sessions.csv gains both fields APPENDED after all legacy columns so by-index consumers are unaffected; JSON fields additive; formula- injection escaping preserved - models --by-agent: the main-session bucket sentinel becomes '(main)' - agentType is free-form text, so a real subagent literally named 'main' no longer merges into the main bucket (test-pinned across all formats) - README: note that the --min-cost 0.01 default can hide sub-cent agent buckets; --min-cost 0 shows the complete agent inventory
This commit is contained in:
parent
6e66d34582
commit
a55251ce8f
5 changed files with 128 additions and 20 deletions
|
|
@ -447,7 +447,7 @@ Sync sends token counts, costs, models, and projects — never prompts or code.
|
|||
|---------|--------------|
|
||||
| `codeburn models` | Per-model token + cost table (last 30 days) |
|
||||
| `codeburn models --by-task` | Break each model into per-task-type rows |
|
||||
| `codeburn models --by-agent` | Break each model into per-agent rows (which agent drove which model's spend); Claude subagent transcripts only, other providers and main sessions bucket under "main" |
|
||||
| `codeburn models --by-agent` | Break each model into per-agent rows (which agent drove which model's spend); Claude subagent transcripts only, other providers and main sessions bucket under `(main)`. The default `--min-cost 0.01` hides sub-cent agent buckets; use `--min-cost 0` for a complete inventory. |
|
||||
| `codeburn models --top 10` | Only the 10 most expensive models |
|
||||
| `codeburn models --format markdown` | Emit a paste-friendly markdown table |
|
||||
| `codeburn models --task feature` | Filter to feature-development work |
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ function escCsv(s: string): string {
|
|||
return sanitized
|
||||
}
|
||||
|
||||
type Row = Record<string, string | number>
|
||||
type Row = Record<string, string | number | undefined>
|
||||
|
||||
function rowsToCsv(rows: Row[]): string {
|
||||
if (rows.length === 0) return ''
|
||||
|
|
@ -83,6 +83,35 @@ function buildDailyRows(projects: ProjectSummary[], period: string): Row[] {
|
|||
}))
|
||||
}
|
||||
|
||||
function buildRecordRows(projects: ProjectSummary[]): Row[] {
|
||||
const rows: Row[] = []
|
||||
for (const project of projects) {
|
||||
for (const session of project.sessions) {
|
||||
for (const turn of session.turns) {
|
||||
for (const call of turn.assistantCalls) {
|
||||
rows.push({
|
||||
project: project.projectPath,
|
||||
sessionId: session.sessionId,
|
||||
timestamp: call.timestamp || turn.timestamp || undefined,
|
||||
category: turn.category,
|
||||
provider: call.provider,
|
||||
subagentType: session.agentType?.trim() || undefined,
|
||||
model: call.model || undefined,
|
||||
inputTokens: call.usage.inputTokens,
|
||||
outputTokens: call.usage.outputTokens,
|
||||
reasoningTokens: call.usage.reasoningTokens,
|
||||
cacheWriteTokens: call.usage.cacheCreationInputTokens,
|
||||
cacheReadTokens: Math.max(call.usage.cacheReadInputTokens, call.usage.cachedInputTokens),
|
||||
cost: roundForActiveCurrency(convertCost(call.costUSD)),
|
||||
savings: roundForActiveCurrency(convertCost(call.savingsUSD ?? 0)),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
function buildActivityRows(projects: ProjectSummary[], period: string): Row[] {
|
||||
const catTotals: Record<string, { turns: number; cost: number }> = {}
|
||||
for (const project of projects) {
|
||||
|
|
@ -231,6 +260,9 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] {
|
|||
const rows: Row[] = []
|
||||
for (const p of projects) {
|
||||
for (const s of p.sessions) {
|
||||
const models = new Set(
|
||||
s.turns.flatMap(turn => turn.assistantCalls.map(call => call.model).filter(Boolean)),
|
||||
)
|
||||
rows.push({
|
||||
Project: p.projectPath,
|
||||
'Session ID': s.sessionId,
|
||||
|
|
@ -239,6 +271,8 @@ function buildSessionRows(projects: ProjectSummary[]): Row[] {
|
|||
[`Saved (${code})`]: roundForActiveCurrency(convertCost(s.totalSavingsUSD)),
|
||||
'API Calls': s.apiCalls,
|
||||
Turns: s.turns.length,
|
||||
subagentType: s.agentType?.trim() || undefined,
|
||||
model: models.size === 1 ? [...models][0] : undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -286,6 +320,7 @@ function buildReadme(periods: PeriodExport[]): string {
|
|||
' daily.csv Day-by-day breakdown, Period column distinguishes the window.',
|
||||
' activity.csv Time spent per task category (Coding, Debugging, Exploration, etc.).',
|
||||
' models.csv Spend per model with token totals and cache usage.',
|
||||
' records.csv One row per API call, including optional subagentType and model.',
|
||||
' projects.csv Spend per project folder for the selected detail period.',
|
||||
' sessions.csv One row per session for the selected detail period.',
|
||||
' tools.csv Tool invocations and share for the selected detail period.',
|
||||
|
|
@ -355,6 +390,7 @@ export async function exportCsv(periods: PeriodExport[], outputPath: string): Pr
|
|||
await writeFile(join(folder, 'daily.csv'), rowsToCsv(dailyRows), 'utf-8')
|
||||
await writeFile(join(folder, 'activity.csv'), rowsToCsv(activityRows), 'utf-8')
|
||||
await writeFile(join(folder, 'models.csv'), rowsToCsv(modelRows), 'utf-8')
|
||||
await writeFile(join(folder, 'records.csv'), rowsToCsv(buildRecordRows(thirtyDayProjects)), 'utf-8')
|
||||
await writeFile(join(folder, 'projects.csv'), rowsToCsv(buildProjectRows(thirtyDayProjects)), 'utf-8')
|
||||
await writeFile(join(folder, 'sessions.csv'), rowsToCsv(buildSessionRows(thirtyDayProjects)), 'utf-8')
|
||||
await writeFile(join(folder, 'tools.csv'), rowsToCsv(buildToolRows(thirtyDayProjects)), 'utf-8')
|
||||
|
|
@ -382,6 +418,7 @@ export async function exportJson(periods: PeriodExport[], outputPath: string): P
|
|||
})),
|
||||
projects: buildProjectRows(thirtyDayProjects),
|
||||
sessions: buildSessionRows(thirtyDayProjects),
|
||||
records: buildRecordRows(thirtyDayProjects),
|
||||
tools: buildToolRows(thirtyDayProjects),
|
||||
mcp: buildMcpRows(thirtyDayProjects),
|
||||
shellCommands: buildBashRows(thirtyDayProjects),
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ export type ModelReportRow = {
|
|||
modelDisplayName: string
|
||||
category: TaskCategory | null
|
||||
/// Set only in `byAgent` mode: the Claude subagent type this row's spend ran
|
||||
/// under (`general-purpose`, `Explore`, a workflow agent, …), or `'main'` for
|
||||
/// under (`general-purpose`, `Explore`, a workflow agent, …), or `'(main)'` for
|
||||
/// ordinary non-agent sessions and every non-Claude provider. `null` in the
|
||||
/// default and `byTask` views.
|
||||
agentType?: string | null
|
||||
|
|
@ -38,7 +38,7 @@ export type AggregateOptions = {
|
|||
byTask?: boolean
|
||||
/// One row per (provider, model, agent). Mutually exclusive with `byTask`;
|
||||
/// the caller enforces that. Non-Claude providers and ordinary sessions bucket
|
||||
/// under `'main'`.
|
||||
/// under `'(main)'`.
|
||||
byAgent?: boolean
|
||||
taskFilter?: TaskCategory
|
||||
topN?: number
|
||||
|
|
@ -81,7 +81,7 @@ function bucketKey(provider: string, model: string, category: TaskCategory | nul
|
|||
/// blank repeated provider/model cells. Group order follows total cost across
|
||||
/// that model; within each group, rows go by cost descending. The agent label
|
||||
/// comes from the subagent transcript's own `session.agentType`; ordinary
|
||||
/// sessions and every non-Claude provider bucket under `'main'`.
|
||||
/// sessions and every non-Claude provider bucket under `'(main)'`.
|
||||
export async function aggregateModels(projects: ProjectSummary[], opts: AggregateOptions = {}): Promise<ModelReportRow[]> {
|
||||
const buckets = new Map<string, Bucket>()
|
||||
const perModelCategoryCost = new Map<ModelKey, Map<CategoryKey, number>>()
|
||||
|
|
@ -96,9 +96,9 @@ export async function aggregateModels(projects: ProjectSummary[], opts: Aggregat
|
|||
const model = call.model || 'unknown'
|
||||
const category: TaskCategory | null = opts.byTask ? turn.category : null
|
||||
// Non-Claude sessions and ordinary main sessions have no agentType, so
|
||||
// their spend all lands under 'main'. That is correct: the report never
|
||||
// their spend all lands under '(main)'. That is correct: the report never
|
||||
// fabricates an agent for calls that were not driven by one.
|
||||
const agentType: string | null = opts.byAgent ? (session.agentType ?? 'main') : null
|
||||
const agentType: string | null = opts.byAgent ? (session.agentType ?? '(main)') : null
|
||||
const key = bucketKey(provider, model, category, agentType)
|
||||
let bucket = buckets.get(key)
|
||||
if (!bucket) {
|
||||
|
|
|
|||
|
|
@ -16,7 +16,7 @@ afterEach(async () => {
|
|||
await rm(tmpDir, { recursive: true, force: true })
|
||||
})
|
||||
|
||||
function makeProject(projectPath: string): ProjectSummary {
|
||||
function makeProject(projectPath: string, agentType?: string): ProjectSummary {
|
||||
return {
|
||||
project: projectPath,
|
||||
projectPath,
|
||||
|
|
@ -24,6 +24,7 @@ function makeProject(projectPath: string): ProjectSummary {
|
|||
{
|
||||
sessionId: 'sess-001',
|
||||
project: projectPath,
|
||||
agentType,
|
||||
firstTimestamp: '2026-04-14T10:00:00Z',
|
||||
lastTimestamp: '2026-04-14T10:01:00Z',
|
||||
totalCostUSD: 1.23,
|
||||
|
|
@ -57,6 +58,7 @@ function makeProject(projectPath: string): ProjectSummary {
|
|||
tools: ['Read'],
|
||||
mcpTools: [],
|
||||
skills: [],
|
||||
subagentTypes: [],
|
||||
hasAgentSpawn: false,
|
||||
hasPlanMode: false,
|
||||
speed: 'standard',
|
||||
|
|
@ -205,9 +207,57 @@ describe('exportCsv', () => {
|
|||
expect(mcp).toContain('Server,Calls,Share (%)')
|
||||
expect(mcp).toContain('node_repl,5,100')
|
||||
})
|
||||
|
||||
it('writes optional subagentType and model fields to per-call records.csv', async () => {
|
||||
const periods: PeriodExport[] = [{ label: '30 Days', projects: [makeProject('app', 'planner')] }]
|
||||
|
||||
const folder = await exportCsv(periods, join(tmpDir, 'records.csv'))
|
||||
const records = await readFile(join(folder, 'records.csv'), 'utf-8')
|
||||
const lines = records.trimEnd().split('\n')
|
||||
|
||||
expect(lines[0]).toContain('subagentType,model')
|
||||
expect(lines[1]).toContain('planner')
|
||||
expect(lines[1]).toContain("'+danger-model")
|
||||
})
|
||||
|
||||
it('adds optional subagentType and unambiguous model fields to sessions.csv', async () => {
|
||||
const periods: PeriodExport[] = [{ label: '30 Days', projects: [makeProject('app', 'planner')] }]
|
||||
|
||||
const folder = await exportCsv(periods, join(tmpDir, 'sessions.csv'))
|
||||
const sessions = await readFile(join(folder, 'sessions.csv'), 'utf-8')
|
||||
|
||||
expect(sessions.split('\n')[0]).toBe('Project,Session ID,Started At,Cost (USD),Saved (USD),API Calls,Turns,subagentType,model')
|
||||
expect(sessions.split('\n')[1]).toContain('planner')
|
||||
expect(sessions.split('\n')[1]).toContain("'+danger-model")
|
||||
})
|
||||
})
|
||||
|
||||
describe('exportJson', () => {
|
||||
it('adds per-call records with optional subagentType and model fields', async () => {
|
||||
const periods: PeriodExport[] = [{
|
||||
label: '30 Days',
|
||||
projects: [makeProject('agent-project', 'planner'), makeProject('main-project')],
|
||||
}]
|
||||
|
||||
const outputPath = join(tmpDir, 'records.json')
|
||||
const saved = await exportJson(periods, outputPath)
|
||||
const data = JSON.parse(await readFile(saved, 'utf-8'))
|
||||
|
||||
expect(data.records[0]).toMatchObject({
|
||||
project: 'agent-project',
|
||||
subagentType: 'planner',
|
||||
model: '+danger-model',
|
||||
inputTokens: 100,
|
||||
outputTokens: 50,
|
||||
cost: 1.23,
|
||||
})
|
||||
expect(data.records[1]).toMatchObject({ project: 'main-project', model: '+danger-model' })
|
||||
expect(data.records[1]).not.toHaveProperty('subagentType')
|
||||
expect(data.sessions[0]).toMatchObject({ subagentType: 'planner', model: '+danger-model' })
|
||||
expect(data.sessions[1]).toMatchObject({ model: '+danger-model' })
|
||||
expect(data.sessions[1]).not.toHaveProperty('subagentType')
|
||||
})
|
||||
|
||||
it('includes an mcp section with per-server usage', async () => {
|
||||
const project = makeProject('app')
|
||||
project.sessions[0]!.mcpBreakdown = { node_repl: { calls: 3 }, github: { calls: 1 } }
|
||||
|
|
|
|||
|
|
@ -260,9 +260,9 @@ describe('aggregateModels', () => {
|
|||
})
|
||||
|
||||
describe('aggregateModels byAgent', () => {
|
||||
// One project, four sessions: a planner agent on two models, a reviewer agent
|
||||
// sharing one of those models, an ordinary main session, and a non-Claude
|
||||
// provider session (no agentType).
|
||||
// One project: a planner agent on two models, a reviewer agent sharing one of
|
||||
// those models, a real agent named main, an ordinary main session, and a
|
||||
// non-Claude provider session (no agentType).
|
||||
function crossProject(): ProjectSummary {
|
||||
return projectFromSessions([
|
||||
makeAgentSession({ sessionId: 'a', agentType: 'planner', turns: [
|
||||
|
|
@ -272,18 +272,21 @@ describe('aggregateModels byAgent', () => {
|
|||
makeAgentSession({ sessionId: 'b', agentType: 'reviewer', turns: [
|
||||
makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 3.0, input: 40, output: 8 })]),
|
||||
] }),
|
||||
makeAgentSession({ sessionId: 'real-main', agentType: 'main', turns: [
|
||||
makeTurn('exploration', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 0.75, input: 25, output: 4 })]),
|
||||
] }),
|
||||
// no agentType -> ordinary main session
|
||||
makeAgentSession({ sessionId: 'c', turns: [
|
||||
makeTurn('feature', [makeCall({ provider: 'claude', model: 'claude-opus-4-8', costUSD: 1.0, input: 30, output: 5 })]),
|
||||
] }),
|
||||
// non-Claude provider, no agentType -> also 'main'
|
||||
// non-Claude provider, no agentType -> also '(main)'
|
||||
makeAgentSession({ sessionId: 'd', turns: [
|
||||
makeTurn('feature', [makeCall({ provider: 'codex', model: 'gpt-5', costUSD: 0.5, input: 20, output: 4 })]),
|
||||
] }),
|
||||
])
|
||||
}
|
||||
|
||||
it('crosses model x agent, labelling main/undefined and non-Claude sessions "main"', async () => {
|
||||
it('keeps a real agent named main distinct from the (main) sentinel across all formats', async () => {
|
||||
const rows = await aggregateModels([crossProject()], { byAgent: true })
|
||||
const byKey = Object.fromEntries(rows.map(r => [`${r.provider}:${r.model}:${r.agentType}`, r]))
|
||||
// one agent (planner) split across two models
|
||||
|
|
@ -291,13 +294,31 @@ describe('aggregateModels byAgent', () => {
|
|||
expect(byKey['claude:claude-sonnet-4-6:planner']!.costUSD).toBeCloseTo(2.0, 6)
|
||||
// two agents (planner + reviewer) on the same model
|
||||
expect(byKey['claude:claude-opus-4-8:reviewer']!.costUSD).toBeCloseTo(3.0, 6)
|
||||
// ordinary main session buckets under 'main'
|
||||
expect(byKey['claude:claude-opus-4-8:main']!.costUSD).toBeCloseTo(1.0, 6)
|
||||
// non-Claude provider (no agentType) also buckets under 'main'
|
||||
expect(byKey['codex:gpt-5:main']!.agentType).toBe('main')
|
||||
expect(byKey['codex:gpt-5:main']!.costUSD).toBeCloseTo(0.5, 6)
|
||||
// three distinct agent rows share claude-opus-4-8
|
||||
expect(rows.filter(r => r.model === 'claude-opus-4-8')).toHaveLength(3)
|
||||
// real agent named main and the ordinary-session sentinel remain separate
|
||||
expect(byKey['claude:claude-opus-4-8:main']!.costUSD).toBeCloseTo(0.75, 6)
|
||||
expect(byKey['claude:claude-opus-4-8:(main)']!.costUSD).toBeCloseTo(1.0, 6)
|
||||
// non-Claude provider (no agentType) also buckets under '(main)'
|
||||
expect(byKey['codex:gpt-5:(main)']!.agentType).toBe('(main)')
|
||||
expect(byKey['codex:gpt-5:(main)']!.costUSD).toBeCloseTo(0.5, 6)
|
||||
// four distinct agent rows share claude-opus-4-8
|
||||
const collisionRows = rows.filter(r => r.model === 'claude-opus-4-8')
|
||||
expect(collisionRows).toHaveLength(4)
|
||||
|
||||
const table = stripAnsi(renderTable(collisionRows, { byAgent: true, showTotals: false, terminalWidth: 200 }))
|
||||
expect(table.split('\n').some(line => /│\s*\(main\)\s*│/.test(line))).toBe(true)
|
||||
expect(table.split('\n').some(line => /│\s*main\s*│/.test(line))).toBe(true)
|
||||
|
||||
const markdown = renderMarkdown(collisionRows, { byAgent: true, showTotals: false })
|
||||
expect(markdown).toContain('| (main) |')
|
||||
expect(markdown).toContain('| main |')
|
||||
|
||||
const jsonAgents = (JSON.parse(renderJson(collisionRows)) as Array<{ agentType: string }>).map(row => row.agentType)
|
||||
expect(jsonAgents).toContain('(main)')
|
||||
expect(jsonAgents).toContain('main')
|
||||
|
||||
const csvAgents = renderCsv(collisionRows, { byAgent: true }).trimEnd().split('\n').slice(1).map(line => line.split(',')[2])
|
||||
expect(csvAgents).toContain('(main)')
|
||||
expect(csvAgents).toContain('main')
|
||||
})
|
||||
|
||||
it('groups rows by (provider, model) ordered by total model cost, agents by cost desc within a group', async () => {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue