Skip to content

Commit 25a773e

Browse files
committed
feat(build): implement session statistics tracking
- Add MODEL_PRICING table with token costs for Claude and GPT models - Implement estimateCost() to calculate operation costs based on model - Add createSessionStats() to initialize session tracking - Add StatsCallback type for event-driven stat updates - Enhance handleEvent() to track tool calls, tokens, and file modifications - Add getStats() and resetStats() to Builder class - Add handleStatsUpdate() to aggregate stats from event stream - Fix extractText() to handle undefined/null parts safely Signed-off-by: leocavalcante <leo@cavalcante.dev>
1 parent a415801 commit 25a773e

File tree

1 file changed

+146
-7
lines changed

1 file changed

+146
-7
lines changed

src/build.ts

Lines changed: 146 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@ import {
1313
generatePlanPrompt,
1414
generateTaskPrompt,
1515
} from "./plan.ts"
16-
import type { BuildResult, Config } from "./types.ts"
16+
import type { BuildResult, Config, SessionStats } from "./types.ts"
1717

1818
/** Type for the OpenCode client */
1919
type Client = Awaited<ReturnType<typeof createOpencode>>["client"]
@@ -33,7 +33,10 @@ export type Part = TextPart | { type: string; [key: string]: unknown }
3333
/**
3434
* Extract text content from message parts
3535
*/
36-
export function extractText(parts: Part[]): string {
36+
export function extractText(parts: Part[] | undefined | null): string {
37+
if (!parts || !Array.isArray(parts)) {
38+
return ""
39+
}
3740
return parts
3841
.filter((p): p is TextPart => p.type === "text" && typeof p.text === "string")
3942
.map((p) => p.text)
@@ -62,6 +65,67 @@ export interface EventLogger {
6265
step(action: string, detail?: string): void
6366
}
6467

68+
/**
69+
* Model pricing per million tokens (approximate, as of 2024)
70+
* Format: { input: $/1M tokens, output: $/1M tokens }
71+
*/
72+
const MODEL_PRICING: Record<string, { input: number; output: number }> = {
73+
// Claude models
74+
"claude-opus-4": { input: 15.0, output: 75.0 },
75+
"claude-sonnet-4": { input: 3.0, output: 15.0 },
76+
"claude-3-5-sonnet": { input: 3.0, output: 15.0 },
77+
"claude-3-opus": { input: 15.0, output: 75.0 },
78+
"claude-3-sonnet": { input: 3.0, output: 15.0 },
79+
"claude-3-haiku": { input: 0.25, output: 1.25 },
80+
// GPT models
81+
"gpt-4o": { input: 2.5, output: 10.0 },
82+
"gpt-4o-mini": { input: 0.15, output: 0.6 },
83+
"gpt-4-turbo": { input: 10.0, output: 30.0 },
84+
"gpt-4": { input: 30.0, output: 60.0 },
85+
"gpt-3.5-turbo": { input: 0.5, output: 1.5 },
86+
// Default fallback for unknown models
87+
default: { input: 3.0, output: 15.0 },
88+
}
89+
90+
/**
91+
* Estimate cost based on model and token usage
92+
*/
93+
export function estimateCost(model: string, inputTokens: number, outputTokens: number): number {
94+
// Extract model ID from provider/model format
95+
const modelId = model.includes("/") ? model.split("/")[1] : model
96+
97+
// Find matching pricing (check if model name contains any of our known models)
98+
let pricing = MODEL_PRICING.default
99+
if (modelId) {
100+
for (const [key, value] of Object.entries(MODEL_PRICING)) {
101+
if (key !== "default" && modelId.toLowerCase().includes(key.toLowerCase())) {
102+
pricing = value
103+
break
104+
}
105+
}
106+
}
107+
108+
// Calculate cost (pricing is per million tokens)
109+
const inputCost = (inputTokens / 1_000_000) * (pricing?.input ?? 3.0)
110+
const outputCost = (outputTokens / 1_000_000) * (pricing?.output ?? 15.0)
111+
112+
return inputCost + outputCost
113+
}
114+
115+
/**
116+
* Create initial session stats
117+
*/
118+
export function createSessionStats(): SessionStats {
119+
return {
120+
toolCalls: 0,
121+
inputTokens: 0,
122+
outputTokens: 0,
123+
costUsd: 0,
124+
filesModified: [],
125+
startTime: Date.now(),
126+
}
127+
}
128+
65129
/**
66130
* Extract contextual information from tool input for display
67131
*/
@@ -103,10 +167,22 @@ const IMPORTANT_EVENTS = new Set([
103167
"file.deleted",
104168
])
105169

170+
/** Callback for tracking session stats from events */
171+
export type StatsCallback = (update: {
172+
toolCall?: boolean
173+
inputTokens?: number
174+
outputTokens?: number
175+
fileModified?: string
176+
}) => void
177+
106178
/**
107179
* Handle a single event from the server stream
108180
*/
109-
export function handleEvent(event: ServerEvent, logger: EventLogger): void {
181+
export function handleEvent(
182+
event: ServerEvent,
183+
logger: EventLogger,
184+
onStats?: StatsCallback,
185+
): void {
110186
const { type, properties } = event
111187

112188
switch (type) {
@@ -127,6 +203,8 @@ export function handleEvent(event: ServerEvent, logger: EventLogger): void {
127203
logger.stopSpinner()
128204
logger.streamEnd()
129205
logger.toolCall(name, input)
206+
// Track tool call in stats
207+
onStats?.({ toolCall: true })
130208
// Build spinner message with context from tool input
131209
const context = extractToolContext(input)
132210
const spinnerMsg = context ? `Running ${name}: ${context}...` : `Running ${name}...`
@@ -159,6 +237,8 @@ export function handleEvent(event: ServerEvent, logger: EventLogger): void {
159237
const usage = properties?.usage as { input?: number; output?: number } | undefined
160238
if (usage?.input !== undefined && usage?.output !== undefined) {
161239
logger.tokens(usage.input, usage.output)
240+
// Track token usage in stats
241+
onStats?.({ inputTokens: usage.input, outputTokens: usage.output })
162242
}
163243
break
164244
}
@@ -176,6 +256,7 @@ export function handleEvent(event: ServerEvent, logger: EventLogger): void {
176256
const filePath = properties?.path || properties?.filePath
177257
if (typeof filePath === "string") {
178258
logger.fileChange("Edited", filePath)
259+
onStats?.({ fileModified: filePath })
179260
}
180261
break
181262
}
@@ -184,6 +265,7 @@ export function handleEvent(event: ServerEvent, logger: EventLogger): void {
184265
const filePath = properties?.path || properties?.filePath
185266
if (typeof filePath === "string") {
186267
logger.fileChange("Created", filePath)
268+
onStats?.({ fileModified: filePath })
187269
}
188270
break
189271
}
@@ -192,6 +274,7 @@ export function handleEvent(event: ServerEvent, logger: EventLogger): void {
192274
const filePath = properties?.path || properties?.filePath
193275
if (typeof filePath === "string") {
194276
logger.fileChange("Deleted", filePath)
277+
onStats?.({ fileModified: filePath })
195278
}
196279
break
197280
}
@@ -236,12 +319,57 @@ export class Builder {
236319
private config: Config
237320
private logger: Logger
238321
private eventStreamAbort?: AbortController
322+
private currentStats: SessionStats = createSessionStats()
239323

240324
constructor(config: Config, logger: Logger) {
241325
this.config = config
242326
this.logger = logger
243327
}
244328

329+
/**
330+
* Get current session stats
331+
*/
332+
getStats(): SessionStats {
333+
return { ...this.currentStats }
334+
}
335+
336+
/**
337+
* Reset session stats for a new operation
338+
*/
339+
resetStats(): void {
340+
this.currentStats = createSessionStats()
341+
}
342+
343+
/**
344+
* Handle stats update from event processing
345+
*/
346+
private handleStatsUpdate(update: {
347+
toolCall?: boolean
348+
inputTokens?: number
349+
outputTokens?: number
350+
fileModified?: string
351+
}): void {
352+
if (update.toolCall) {
353+
this.currentStats.toolCalls++
354+
}
355+
if (update.inputTokens !== undefined) {
356+
this.currentStats.inputTokens += update.inputTokens
357+
}
358+
if (update.outputTokens !== undefined) {
359+
this.currentStats.outputTokens += update.outputTokens
360+
}
361+
if (update.fileModified && !this.currentStats.filesModified.includes(update.fileModified)) {
362+
this.currentStats.filesModified.push(update.fileModified)
363+
}
364+
365+
// Update cost estimate
366+
this.currentStats.costUsd = estimateCost(
367+
this.config.buildModel,
368+
this.currentStats.inputTokens,
369+
this.currentStats.outputTokens,
370+
)
371+
}
372+
245373
/**
246374
* Initialize the OpenCode SDK server and client
247375
*/
@@ -304,8 +432,13 @@ export class Builder {
304432
this.logger.phase("Building", `Task ${taskNum}/${totalTasks}`)
305433
this.logger.say(` ${task}`)
306434

435+
// Ensure we have a valid session before building
307436
if (!this.sessionId) {
308-
return { success: false, error: "No active session" }
437+
try {
438+
await this.ensureSession(cycle, `Cycle ${cycle} - Recovery`)
439+
} catch (err) {
440+
return { success: false, error: `Failed to create session: ${err}` }
441+
}
309442
}
310443

311444
const prompt = generateTaskPrompt(task, cycle, taskNum, totalTasks)
@@ -324,8 +457,9 @@ export class Builder {
324457
async runEval(cycle: number, planContent: string): Promise<string> {
325458
this.logger.phase("Evaluating", `Cycle ${cycle}`)
326459

460+
// Ensure we have a valid session before evaluating
327461
if (!this.sessionId) {
328-
throw new Error("No active session")
462+
await this.ensureSession(cycle, `Cycle ${cycle} - Eval Recovery`)
329463
}
330464

331465
const prompt = generateEvalPrompt(cycle, planContent)
@@ -435,7 +569,12 @@ export class Builder {
435569
throw new Error("No response from OpenCode")
436570
}
437571

438-
return extractText(result.data.parts as Part[])
572+
const text = extractText(result.data.parts as Part[] | undefined)
573+
if (!text) {
574+
throw new Error("Empty response from OpenCode")
575+
}
576+
577+
return text
439578
}
440579

441580
/**
@@ -464,7 +603,7 @@ export class Builder {
464603
for await (const event of stream) {
465604
if (this.eventStreamAbort?.signal.aborted) break
466605

467-
handleEvent(event, this.logger)
606+
handleEvent(event, this.logger, (update) => this.handleStatsUpdate(update))
468607
}
469608
} catch (err) {
470609
// Stream ended or errored

0 commit comments

Comments
 (0)