transcript.ts
1 /** 2 * Transcript parsing utilities for Qwen Code JSONL files. 3 * 4 * Qwen Code writes chat records to 5 * `~/.qwen/projects/<project-id>/chats/<sessionId>.jsonl`. Records have 6 * `uuid`/`parentUuid` for tree traversal but are also emitted in 7 * chronological order, so we walk them sequentially for span creation and 8 * use the tree only for message history reconstruction. 9 */ 10 11 import { readFileSync } from 'node:fs'; 12 import type { 13 ChatRecord, 14 FunctionCall, 15 FunctionCallPart, 16 FunctionResponsePart, 17 GeminiMessage, 18 GeminiPart, 19 TextPart, 20 UsageMetadata, 21 } from './types.js'; 22 23 export const NANOSECONDS_PER_MS = 1e6; 24 25 /** Read and parse a Qwen Code JSONL transcript file. */ 26 export function readTranscript(path: string): ChatRecord[] { 27 const content = readFileSync(path, 'utf-8'); 28 return content 29 .split('\n') 30 .filter((line) => line.trim()) 31 .map((line) => JSON.parse(line) as ChatRecord); 32 } 33 34 /** Parse an ISO timestamp string to nanoseconds since Unix epoch. */ 35 export function parseTimestampToNs(timestamp: string | undefined | null): number | null { 36 if (!timestamp) { 37 return null; 38 } 39 try { 40 const ms = new Date(timestamp).getTime(); 41 if (isNaN(ms)) { 42 return null; 43 } 44 return ms * NANOSECONDS_PER_MS; 45 } catch { 46 return null; 47 } 48 } 49 50 /** 51 * Narrow a GeminiPart union member. The runtime shape is discriminated by 52 * the presence of `text` / `functionCall` / `functionResponse` keys. 53 */ 54 export function isTextPart(part: GeminiPart): part is TextPart { 55 return typeof (part as TextPart).text === 'string'; 56 } 57 58 export function isFunctionCallPart(part: GeminiPart): part is FunctionCallPart { 59 return (part as FunctionCallPart).functionCall != null; 60 } 61 62 export function isFunctionResponsePart(part: GeminiPart): part is FunctionResponsePart { 63 return (part as FunctionResponsePart).functionResponse != null; 64 } 65 66 /** 67 * Extract user-facing text from a record's message. Internal reasoning 68 * (`thought: true` text parts) is excluded — those should not appear as 69 * assistant content in the Chat view. 70 * 71 * If `includeThoughts` is true, thought parts are included (used only by 72 * request-preview fallbacks where there's nothing else to show). 73 */ 74 export function getMessageText(record: ChatRecord, includeThoughts = false): string { 75 const msg = record.message; 76 if (typeof msg === 'string') { 77 return msg; 78 } 79 if (!isGeminiMessage(msg)) { 80 return ''; 81 } 82 return msg.parts 83 .filter(isTextPart) 84 .filter((p) => includeThoughts || !p.thought) 85 .map((p) => p.text) 86 .join('\n'); 87 } 88 89 /** Return the functionCall parts embedded in an assistant record's message. */ 90 export function getFunctionCalls(record: ChatRecord): FunctionCall[] { 91 const msg = record.message; 92 if (!isGeminiMessage(msg)) { 93 return []; 94 } 95 return msg.parts.filter(isFunctionCallPart).map((p) => p.functionCall); 96 } 97 98 function isGeminiMessage(msg: unknown): msg is GeminiMessage { 99 return ( 100 typeof msg === 'object' && 101 msg != null && 102 'parts' in msg && 103 Array.isArray((msg as GeminiMessage).parts) 104 ); 105 } 106 107 /** 108 * Return the records belonging to the last turn (from the last user record 109 * through end-of-file), in chronological order. 110 */ 111 export function getLastTurnRecords(records: ChatRecord[]): ChatRecord[] { 112 for (let i = records.length - 1; i >= 0; i--) { 113 if (records[i].type === 'user' && getMessageText(records[i]).trim()) { 114 return records.slice(i); 115 } 116 } 117 return []; 118 } 119 120 /** Build a lookup from tool call_id to its `tool_result` record. */ 121 export function buildToolResultMap(records: ChatRecord[]): Map<string, ChatRecord> { 122 const byCallId = new Map<string, ChatRecord>(); 123 for (const record of records) { 124 if (record.type === 'tool_result' && record.toolCallResult?.callId) { 125 byCallId.set(record.toolCallResult.callId, record); 126 } 127 } 128 return byCallId; 129 } 130 131 /** Extract structured token usage from a record's usageMetadata, if any. */ 132 export function getTokenUsage( 133 metadata: UsageMetadata | undefined, 134 ): { input: number; output: number; total: number } | null { 135 if (!metadata) { 136 return null; 137 } 138 const input = metadata.promptTokenCount ?? metadata.input_tokens ?? 0; 139 const output = metadata.candidatesTokenCount ?? metadata.output_tokens ?? 0; 140 const total = metadata.totalTokenCount ?? input + output; 141 if (input === 0 && output === 0) { 142 return null; 143 } 144 return { input, output, total }; 145 } 146 147 /** 148 * Normalize a `resultDisplay` value (which can be a plain string or a 149 * structured object) into a string suitable for a TOOL span output or a 150 * tool message's content. 151 */ 152 export function formatResultDisplay(display: unknown): string { 153 if (display == null) { 154 return ''; 155 } 156 if (typeof display === 'string') { 157 return display; 158 } 159 return JSON.stringify(display); 160 } 161 162 /** 163 * Return a string rendering of a tool_result record's output. Prefers 164 * `toolCallResult.resultDisplay` (the user-facing rendering) and falls back 165 * to the raw `functionResponse.response` payload embedded in `message.parts` 166 * when `resultDisplay` is omitted. Returns an empty string if neither is 167 * available. 168 */ 169 export function getToolOutput(record: ChatRecord): string { 170 const display = record.toolCallResult?.resultDisplay; 171 if (display != null) { 172 return formatResultDisplay(display); 173 } 174 const msg = record.message; 175 if (isGeminiMessage(msg)) { 176 for (const part of msg.parts) { 177 if (isFunctionResponsePart(part) && part.functionResponse.response != null) { 178 return formatResultDisplay(part.functionResponse.response); 179 } 180 } 181 } 182 return ''; 183 }