@@ -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 */
1919type 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