@@ -12,12 +12,16 @@ import path from "node:path";
1212import readline from "node:readline" ;
1313import type {
1414 NormalizedApiError ,
15+ NormalizedDiffStats ,
16+ NormalizedMessage ,
1517 NormalizedSession ,
1618 NormalizedTokenCounts ,
19+ NormalizedTokenRecord ,
1720 NormalizedToolResultError ,
1821 NormalizedToolUse ,
1922 NormalizedTurnDuration ,
2023} from "../types.js" ;
24+ import { truncateText , computeLineDelta , collectArtifacts } from "../parser-utils.js" ;
2125
2226/** Mirror the vendor's lenient timestamp handling: epoch number → ISO, string as-is. */
2327function isoTs ( ts : unknown ) : string | null {
@@ -72,6 +76,19 @@ export async function parseSessionFile(filePath: string): Promise<NormalizedSess
7276 const speeds = new Set < string > ( ) ;
7377 const inferenceGeos = new Set < string > ( ) ;
7478
79+ // CR-1: ordered messages
80+ const messages : NormalizedMessage [ ] = [ ] ;
81+ // CR-2: per-turn token time-series
82+ const tokenSeries : NormalizedTokenRecord [ ] = [ ] ;
83+ // CR-4: aggregate diff stats
84+ let totalAdded = 0 ;
85+ let totalRemoved = 0 ;
86+ const diffFiles = new Set < string > ( ) ;
87+ // CR-7: slash commands
88+ const slashCommands : Array < { name : string ; timestamp : string } > = [ ] ;
89+ // CR-3: map tool_use_id → index in toolUses for back-linking tool results
90+ const toolUseIdIndex = new Map < string , number > ( ) ;
91+
7592 try {
7693 for await ( const line of rl ) {
7794 if ( ! line . trim ( ) ) continue ;
@@ -142,6 +159,52 @@ export async function parseSessionFile(filePath: string): Promise<NormalizedSess
142159
143160 if ( entry . type === "user" ) {
144161 userMessageCount ++ ;
162+
163+ // CR-1: Build NormalizedMessage for user messages.
164+ const userMsg = asRecord ( entry . message ) ;
165+ const userContent = Array . isArray ( userMsg . content ) ? userMsg . content : [ ] ;
166+ const userTextParts : string [ ] = [ ] ;
167+ for ( const raw of userContent ) {
168+ const block = asRecord ( raw ) ;
169+ if ( block . type === "text" && typeof block . text === "string" ) {
170+ userTextParts . push ( block . text ) ;
171+ }
172+ // CR-3: Capture tool_result content and back-link to the originating tool_use.
173+ if ( block . type === "tool_result" && typeof block . tool_use_id === "string" ) {
174+ const resultContent = Array . isArray ( block . content ) ? block . content : [ ] ;
175+ const resultTextParts : string [ ] = [ ] ;
176+ for ( const rc of resultContent ) {
177+ const rcBlock = asRecord ( rc ) ;
178+ if ( typeof rcBlock . text === "string" ) resultTextParts . push ( rcBlock . text ) ;
179+ }
180+ // Also handle string content directly
181+ if ( typeof block . content === "string" ) resultTextParts . push ( block . content ) ;
182+ const resultText = resultTextParts . join ( "\n" ) ;
183+ const tuIdx = toolUseIdIndex . get ( block . tool_use_id as string ) ;
184+ if ( tuIdx !== undefined && toolUses [ tuIdx ] ) {
185+ toolUses [ tuIdx ] . output = truncateText ( resultText ) ;
186+ if ( block . is_error ) toolUses [ tuIdx ] . isError = true ;
187+ }
188+ }
189+ }
190+ const userTextJoined = userTextParts . join ( "\n" ) ;
191+ messages . push ( {
192+ role : "human" ,
193+ timestamp : isoTs ( entry . timestamp ) ,
194+ text : truncateText ( userTextJoined ) || null ,
195+ } ) ;
196+
197+ // CR-7: Scan user message text for <command-name> XML tags (slash commands).
198+ const cmdRe = / < c o m m a n d - n a m e > ( [ ^ < ] + ) < \/ c o m m a n d - n a m e > / g;
199+ let cmdMatch : RegExpExecArray | null ;
200+ const entryIso = isoTs ( entry . timestamp ) ;
201+ while ( ( cmdMatch = cmdRe . exec ( userTextJoined ) ) !== null ) {
202+ if ( entryIso ) {
203+ slashCommands . push ( { name : cmdMatch [ 1 ] . trim ( ) , timestamp : entryIso } ) ;
204+ }
205+ }
206+
207+ // Existing: toolUseResult error tracking (top-level shorthand).
145208 const toolUseResult = entry . toolUseResult ;
146209 if ( toolUseResult && typeof toolUseResult === "object" ) {
147210 const tur = toolUseResult as Record < string , unknown > ;
@@ -171,6 +234,18 @@ export async function parseSessionFile(filePath: string): Promise<NormalizedSess
171234 tokensByModel [ msgModel ] . output += num ( usage . output_tokens ) ;
172235 tokensByModel [ msgModel ] . cacheRead += num ( usage . cache_read_input_tokens ) ;
173236 tokensByModel [ msgModel ] . cacheWrite += num ( usage . cache_creation_input_tokens ) ;
237+
238+ // CR-2: Push per-turn token record for time-series.
239+ if ( iso ) {
240+ tokenSeries . push ( {
241+ timestamp : iso ,
242+ model : msgModel ,
243+ input : num ( usage . input_tokens ) ,
244+ output : num ( usage . output_tokens ) ,
245+ cacheRead : num ( usage . cache_read_input_tokens ) ,
246+ cacheWrite : num ( usage . cache_creation_input_tokens ) ,
247+ } ) ;
248+ }
174249 }
175250 if ( msg . usage ) {
176251 if ( typeof usage . service_tier === "string" ) serviceTiers . add ( usage . service_tier ) ;
@@ -183,17 +258,90 @@ export async function parseSessionFile(filePath: string): Promise<NormalizedSess
183258 }
184259 }
185260 const content = msg . content ;
261+ // CR-1: Collect text blocks for the assistant NormalizedMessage.
262+ const assistantTextParts : string [ ] = [ ] ;
186263 if ( Array . isArray ( content ) ) {
187264 for ( const raw of content ) {
188265 const block = asRecord ( raw ) ;
266+ if ( block . type === "text" && typeof block . text === "string" ) {
267+ assistantTextParts . push ( block . text ) ;
268+ }
189269 if ( block . type === "tool_use" && typeof block . name === "string" ) {
190- toolUses . push ( {
191- name : block . name ,
270+ const toolName = block . name as string ;
271+ const toolInput = block . input ?? null ;
272+ const tu : NormalizedToolUse = {
273+ name : toolName ,
192274 timestamp : iso || firstTimestamp ,
193- input : block . input ?? null ,
275+ input : toolInput ,
276+ } ;
277+
278+ // CR-8: Extract skill name from Skill tool.
279+ if ( toolName === "Skill" ) {
280+ const inp = asRecord ( toolInput ) ;
281+ if ( typeof inp . skill === "string" ) tu . skillName = inp . skill ;
282+ }
283+
284+ // CR-4: Compute diffDelta for Edit and Write tool uses.
285+ if ( toolName === "Edit" ) {
286+ const inp = asRecord ( toolInput ) ;
287+ const oldStr = typeof inp . old_string === "string" ? inp . old_string : null ;
288+ const newStr = typeof inp . new_string === "string" ? inp . new_string : null ;
289+ tu . diffDelta = computeLineDelta ( oldStr , newStr ) ;
290+ totalAdded += tu . diffDelta . add ;
291+ totalRemoved += tu . diffDelta . del ;
292+ if ( typeof inp . file_path === "string" ) diffFiles . add ( inp . file_path ) ;
293+ }
294+ if ( toolName === "Write" ) {
295+ const inp = asRecord ( toolInput ) ;
296+ const fileContent = typeof inp . content === "string" ? inp . content : "" ;
297+ const addLines = fileContent . split ( "\n" ) . length ;
298+ tu . diffDelta = { add : addLines , del : 0 } ;
299+ totalAdded += addLines ;
300+ if ( typeof inp . file_path === "string" ) diffFiles . add ( inp . file_path ) ;
301+ }
302+
303+ // CR-3: Track tool_use_id for back-linking tool results.
304+ if ( typeof block . id === "string" ) {
305+ toolUseIdIndex . set ( block . id as string , toolUses . length ) ;
306+ }
307+ toolUses . push ( tu ) ;
308+ }
309+ if ( block . type === "thinking" ) {
310+ thinkingBlockCount ++ ;
311+ // CR-1: Emit a NormalizedMessage for thinking blocks (text redacted).
312+ messages . push ( {
313+ role : "assistant" ,
314+ timestamp : iso ,
315+ text : null ,
316+ model : msgModel ,
317+ isThinking : true ,
194318 } ) ;
195319 }
196- if ( block . type === "thinking" ) thinkingBlockCount ++ ;
320+ }
321+ }
322+ // CR-1: Build main assistant NormalizedMessage.
323+ const assistantText = assistantTextParts . join ( "\n" ) ;
324+ messages . push ( {
325+ role : "assistant" ,
326+ timestamp : iso ,
327+ text : truncateText ( assistantText ) || null ,
328+ model : msgModel ,
329+ tokens : msg . usage
330+ ? {
331+ input : num ( usage . input_tokens ) ,
332+ output : num ( usage . output_tokens ) ,
333+ cacheRead : num ( usage . cache_read_input_tokens ) ,
334+ cacheWrite : num ( usage . cache_creation_input_tokens ) ,
335+ }
336+ : undefined ,
337+ } ) ;
338+
339+ // CR-7: Scan assistant text for <command-name> tags too.
340+ const cmdRe = / < c o m m a n d - n a m e > ( [ ^ < ] + ) < \/ c o m m a n d - n a m e > / g;
341+ let cmdMatch : RegExpExecArray | null ;
342+ while ( ( cmdMatch = cmdRe . exec ( assistantText ) ) !== null ) {
343+ if ( iso ) {
344+ slashCommands . push ( { name : cmdMatch [ 1 ] . trim ( ) , timestamp : iso } ) ;
197345 }
198346 }
199347 }
@@ -218,6 +366,15 @@ export async function parseSessionFile(filePath: string): Promise<NormalizedSess
218366 /* non-fatal */
219367 }
220368
369+ // CR-4: Build aggregate diffStats (null when no edits were made).
370+ const diffStats : NormalizedDiffStats | null =
371+ diffFiles . size > 0
372+ ? { filesChanged : diffFiles . size , linesAdded : totalAdded , linesRemoved : totalRemoved }
373+ : null ;
374+
375+ // CR-13: Collect artifact references from tool uses.
376+ const artifacts = collectArtifacts ( toolUses , cwd ) ;
377+
221378 return {
222379 sessionId,
223380 name : sessionName ,
@@ -247,5 +404,15 @@ export async function parseSessionFile(filePath: string): Promise<NormalizedSess
247404 speeds : [ ...speeds ] ,
248405 inference_geos : [ ...inferenceGeos ] ,
249406 } ,
407+ // CR-1: Ordered messages with text content.
408+ messages,
409+ // CR-2: Per-turn token time-series.
410+ tokenSeries,
411+ // CR-4: Aggregate diff stats.
412+ diffStats,
413+ // CR-7: Extracted slash commands.
414+ slashCommands,
415+ // CR-13: Structured artifact references.
416+ artifacts,
250417 } ;
251418}
0 commit comments