( sessionId: string, messages: Message[], opts: AnalyzeOptions, )
| 117 | |
| 118 | /** Run the analysis. Pure function — no I/O. */ |
| 119 | export function analyzeMessages( |
| 120 | sessionId: string, |
| 121 | messages: Message[], |
| 122 | opts: AnalyzeOptions, |
| 123 | ): TokenReport { |
| 124 | const turns = groupIntoTurns(messages); |
| 125 | const breakdowns: TurnBreakdown[] = []; |
| 126 | |
| 127 | // Per-tool aggregation |
| 128 | const toolStats = new Map<string, { calls: number; tokens: number }>(); |
| 129 | // Per-file aggregation (best-effort: matches tool calls whose args contain a `path`/`file_path`) |
| 130 | const fileStats = new Map<string, { reads: number; tokens: number }>(); |
| 131 | |
| 132 | for (let i = 0; i < turns.length; i++) { |
| 133 | const turn = turns[i]!; |
| 134 | let userTok = 0; |
| 135 | let assistantTok = 0; |
| 136 | let resultTok = 0; |
| 137 | |
| 138 | for (const m of turn) { |
| 139 | if (m.role === 'user') { |
| 140 | userTok += estimateTokens(m.content); |
| 141 | } else if (m.role === 'assistant') { |
| 142 | assistantTok += estimateTokens(m.content); |
| 143 | if (m.tool_calls) { |
| 144 | assistantTok += estimateTokensJson(m.tool_calls); |
| 145 | // Track per-tool / per-file usage from the EMITTED calls (results come in `tool` messages) |
| 146 | for (const tc of m.tool_calls) { |
| 147 | const toolName = tc.function?.name ?? '(unknown)'; |
| 148 | const entry = toolStats.get(toolName) ?? { calls: 0, tokens: 0 }; |
| 149 | entry.calls += 1; |
| 150 | toolStats.set(toolName, entry); |
| 151 | // Extract file path heuristically |
| 152 | try { |
| 153 | const args = JSON.parse(tc.function?.arguments ?? '{}'); |
| 154 | const p = (args?.path ?? args?.file_path ?? args?.filename) as string | undefined; |
| 155 | if (typeof p === 'string' && p.length < 256) { |
| 156 | const e = fileStats.get(p) ?? { reads: 0, tokens: 0 }; |
| 157 | e.reads += 1; |
| 158 | fileStats.set(p, e); |
| 159 | } |
| 160 | } catch (err) { |
| 161 | // Unparseable tool-call args mean this call's file/path attribution is |
| 162 | // skipped — log so the resulting undercount in diagnostics is traceable. |
| 163 | logger.debug('token-analyzer: failed to parse tool-call arguments', { tool: toolName, err }); |
| 164 | } |
| 165 | } |
| 166 | } |
| 167 | } else if (m.role === 'tool') { |
| 168 | const tokens = estimateTokens(m.content); |
| 169 | resultTok += tokens; |
| 170 | // Attribute these tokens to the tool that produced them (lookup by tool_call_id is complex, |
| 171 | // but `name` is set on tool messages when the agent records them). |
| 172 | if ((m as any).name) { |
| 173 | const t = (m as any).name as string; |
| 174 | const entry = toolStats.get(t) ?? { calls: 0, tokens: 0 }; |
| 175 | entry.tokens += tokens; |
| 176 | toolStats.set(t, entry); |
no test coverage detected