claude_stream_viewer 1.0.16 → 1.0.18
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +38 -10
- package/demo_vhs/claude_stream_viewer.gif +0 -0
- package/dist/cli.js +46 -364
- package/dist/cli.js.map +1 -1
- package/dist/libs/colors.d.ts +15 -0
- package/dist/libs/colors.d.ts.map +1 -0
- package/dist/libs/colors.js +16 -0
- package/dist/libs/colors.js.map +1 -0
- package/dist/libs/terminal_output.d.ts +8 -0
- package/dist/libs/terminal_output.d.ts.map +1 -0
- package/dist/libs/terminal_output.js +13 -0
- package/dist/libs/terminal_output.js.map +1 -0
- package/dist/log_viewer/log_renderer.d.ts +28 -0
- package/dist/log_viewer/log_renderer.d.ts.map +1 -0
- package/dist/log_viewer/log_renderer.js +163 -0
- package/dist/log_viewer/log_renderer.js.map +1 -0
- package/dist/log_viewer/tool_input_formatter.d.ts +31 -0
- package/dist/log_viewer/tool_input_formatter.d.ts.map +1 -0
- package/dist/log_viewer/tool_input_formatter.js +190 -0
- package/dist/log_viewer/tool_input_formatter.js.map +1 -0
- package/dist/stats/stats_collector.d.ts +28 -0
- package/dist/stats/stats_collector.d.ts.map +1 -0
- package/dist/stats/stats_collector.js +165 -0
- package/dist/stats/stats_collector.js.map +1 -0
- package/dist/stats/stats_renderer.d.ts +13 -0
- package/dist/stats/stats_renderer.d.ts.map +1 -0
- package/dist/stats/stats_renderer.js +193 -0
- package/dist/stats/stats_renderer.js.map +1 -0
- package/dist/types/claude_event.d.ts +75 -0
- package/dist/types/claude_event.d.ts.map +1 -0
- package/dist/types/claude_event.js +2 -0
- package/dist/types/claude_event.js.map +1 -0
- package/dist/types/stats_report.d.ts +68 -0
- package/dist/types/stats_report.d.ts.map +1 -0
- package/dist/types/stats_report.js +2 -0
- package/dist/types/stats_report.js.map +1 -0
- package/package.json +4 -5
- package/src/cli.ts +53 -409
- package/src/libs/colors.ts +16 -0
- package/src/libs/terminal_output.ts +14 -0
- package/src/log_viewer/log_renderer.ts +160 -0
- package/src/log_viewer/tool_input_formatter.ts +183 -0
- package/src/stats/stats_collector.ts +175 -0
- package/src/stats/stats_renderer.ts +185 -0
- package/src/types/claude_event.ts +74 -0
- package/src/types/stats_report.ts +66 -0
- package/dist/claude-stream-viewer.d.ts +0 -3
- package/dist/claude-stream-viewer.d.ts.map +0 -1
- package/dist/claude-stream-viewer.js +0 -100
- package/dist/claude-stream-viewer.js.map +0 -1
- package/dist/claude_stream_viewer copy.d.ts +0 -3
- package/dist/claude_stream_viewer copy.d.ts.map +0 -1
- package/dist/claude_stream_viewer copy.js +0 -228
- package/dist/claude_stream_viewer copy.js.map +0 -1
- package/dist/claude_stream_viewer.d.ts +0 -3
- package/dist/claude_stream_viewer.d.ts.map +0 -1
- package/dist/claude_stream_viewer.js +0 -155
- package/dist/claude_stream_viewer.js.map +0 -1
- package/src/claude_stream_viewer copy.ts +0 -287
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { colors } from '../libs/colors.js';
|
|
2
|
+
import { TerminalOutput } from '../libs/terminal_output.js';
|
|
3
|
+
import { ToolInputFormatter } from './tool_input_formatter.js';
|
|
4
|
+
import type { ClaudeEvent, ContentBlock, ToolInput } from '../types/claude_event.js';
|
|
5
|
+
|
|
6
|
+
/** Tool results longer than this many lines are truncated in the log. */
|
|
7
|
+
const MAX_TOOL_RESULT_LINES = 40;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Renders consolidated stream events to stdout as a colorized, human-readable
|
|
11
|
+
* log.
|
|
12
|
+
*
|
|
13
|
+
* Holds per-stream state (a `tool_use_id` → tool-name map used to label tool
|
|
14
|
+
* results), so a fresh instance should be created per stream.
|
|
15
|
+
*/
|
|
16
|
+
export class LogRenderer {
|
|
17
|
+
private toolNamesById = new Map<string, string>();
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Renders a single event to stdout. `stream_event` envelopes are skipped and
|
|
21
|
+
* unrecognized event types are dumped as raw JSON under a generic header.
|
|
22
|
+
*/
|
|
23
|
+
render(event: ClaudeEvent) {
|
|
24
|
+
const type = event.type;
|
|
25
|
+
if (type === 'stream_event') return;
|
|
26
|
+
if (type === 'system') return this.renderSystem(event);
|
|
27
|
+
if (type === 'assistant') return this.renderAssistant(event);
|
|
28
|
+
if (type === 'user') return this.renderUser(event);
|
|
29
|
+
if (type === 'rate_limit_event') return this.renderRateLimit(event);
|
|
30
|
+
if (type === 'result') return this.renderResult(event);
|
|
31
|
+
this.printHeader(`${type ?? 'unknown'}`);
|
|
32
|
+
this.printJSON(event);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
private printHeader(label: string) {
|
|
36
|
+
console.log(TerminalOutput.header(label));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private printJSON(obj: unknown) {
|
|
40
|
+
console.log(TerminalOutput.json(obj));
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private renderSystem(event: ClaudeEvent) {
|
|
44
|
+
if (event.subtype !== 'init') {
|
|
45
|
+
this.printHeader(`SYSTEM: ${event.subtype ?? 'unknown'}`);
|
|
46
|
+
this.printJSON(event);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
this.printHeader('SESSION');
|
|
50
|
+
const sessionPrefix = event.session_id !== undefined ? event.session_id.slice(0, 8) : 'unknown';
|
|
51
|
+
const toolsCount = event.tools !== undefined ? event.tools.length : 0;
|
|
52
|
+
console.log(colors.system(`model: ${event.model ?? 'unknown'}`));
|
|
53
|
+
console.log(colors.system(`cwd: ${event.cwd ?? 'unknown'}`));
|
|
54
|
+
console.log(colors.system(`tools: ${toolsCount}`));
|
|
55
|
+
console.log(colors.system(`session: ${sessionPrefix}`));
|
|
56
|
+
if (event.claude_code_version !== undefined) {
|
|
57
|
+
console.log(colors.system(`claude-code: v${event.claude_code_version}`));
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
private renderAssistant(event: ClaudeEvent) {
|
|
62
|
+
const content = event.message?.content;
|
|
63
|
+
if (content === undefined) return;
|
|
64
|
+
for (const block of content) {
|
|
65
|
+
this.renderAssistantBlock(block);
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
private renderAssistantBlock(block: ContentBlock) {
|
|
70
|
+
if (block.type === 'thinking') {
|
|
71
|
+
console.log(colors.system('\n--- thinking ---'));
|
|
72
|
+
console.log(colors.thinking(block.thinking ?? ''));
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
if (block.type === 'text') {
|
|
76
|
+
console.log(colors.text(`\n${block.text ?? ''}`));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
79
|
+
if (block.type === 'tool_use') {
|
|
80
|
+
this.renderToolUse(block);
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
console.log(colors.system(`\n[assistant block: ${block.type ?? 'unknown'}]`));
|
|
84
|
+
this.printJSON(block);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
private renderToolUse(block: ContentBlock) {
|
|
88
|
+
const name = block.name ?? 'tool';
|
|
89
|
+
if (block.id !== undefined) {
|
|
90
|
+
this.toolNamesById.set(block.id, name);
|
|
91
|
+
}
|
|
92
|
+
console.log(colors.tool(`\n→ ${name}`));
|
|
93
|
+
|
|
94
|
+
const rawInput = block.input;
|
|
95
|
+
const input: ToolInput = (rawInput !== null && typeof rawInput === 'object')
|
|
96
|
+
? rawInput as ToolInput
|
|
97
|
+
: {};
|
|
98
|
+
const bodyLines = ToolInputFormatter.format(name, input);
|
|
99
|
+
for (const line of bodyLines) {
|
|
100
|
+
console.log(` ${line}`);
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
private renderUser(event: ClaudeEvent) {
|
|
105
|
+
const content = event.message?.content;
|
|
106
|
+
if (content === undefined) return;
|
|
107
|
+
for (const block of content) {
|
|
108
|
+
if (block.type === 'tool_result') {
|
|
109
|
+
this.renderToolResult(block);
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
console.log(colors.system(`\n[user block: ${block.type ?? 'unknown'}]`));
|
|
113
|
+
this.printJSON(block);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private renderToolResult(block: ContentBlock) {
|
|
118
|
+
const toolName = block.tool_use_id !== undefined
|
|
119
|
+
? this.toolNamesById.get(block.tool_use_id) ?? block.tool_use_id.slice(0, 12)
|
|
120
|
+
: 'unknown';
|
|
121
|
+
const isError = block.is_error === true;
|
|
122
|
+
const label = isError ? `← ${toolName} ERROR` : `← ${toolName} result`;
|
|
123
|
+
console.log(colors.tool(`\n${label}`));
|
|
124
|
+
|
|
125
|
+
const text = LogRenderer.toolResultToText(block.content);
|
|
126
|
+
const lines = text.split('\n');
|
|
127
|
+
const colorFn = isError ? colors.error : colors.text;
|
|
128
|
+
if (lines.length <= MAX_TOOL_RESULT_LINES) {
|
|
129
|
+
console.log(colorFn(text));
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const head = lines.slice(0, MAX_TOOL_RESULT_LINES).join('\n');
|
|
133
|
+
const moreCount = lines.length - MAX_TOOL_RESULT_LINES;
|
|
134
|
+
console.log(colorFn(head));
|
|
135
|
+
console.log(colors.system(`… (truncated, ${moreCount} more lines)`));
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
private static toolResultToText(content: ContentBlock['content']): string {
|
|
139
|
+
if (content === undefined) return '';
|
|
140
|
+
if (typeof content === 'string') return content;
|
|
141
|
+
return content.map((b) => b.text ?? '').join('');
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
private renderRateLimit(event: ClaudeEvent) {
|
|
145
|
+
const status = event.rate_limit_info?.status ?? 'unknown';
|
|
146
|
+
console.log(colors.system(`\n[rate_limit] ${status}`));
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
private renderResult(event: ClaudeEvent) {
|
|
150
|
+
this.printHeader('RESULT');
|
|
151
|
+
if (event.terminal_reason !== undefined) console.log(colors.system(`terminal_reason: ${event.terminal_reason}`));
|
|
152
|
+
if (event.stop_reason !== undefined) console.log(colors.system(`stop_reason: ${event.stop_reason}`));
|
|
153
|
+
if (event.is_error === true) console.log(colors.error('is_error: true'));
|
|
154
|
+
const denials = event.permission_denials;
|
|
155
|
+
if (denials !== undefined && denials.length > 0) console.log(colors.system(`permission_denials: ${denials.length}`));
|
|
156
|
+
if (event.num_turns !== undefined) console.log(colors.system(`turns: ${event.num_turns}`));
|
|
157
|
+
if (event.duration_ms !== undefined) console.log(colors.system(`duration: ${(event.duration_ms / 1000).toFixed(2)}s`));
|
|
158
|
+
if (event.total_cost_usd !== undefined) console.log(colors.system(`cost: $${event.total_cost_usd.toFixed(4)}`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { colors } from '../libs/colors.js';
|
|
2
|
+
import type { ToolInput } from '../types/claude_event.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Renders a `tool_use` block's input into compact, human-readable lines — one
|
|
6
|
+
* dedicated formatter per known tool (Bash, Read, Grep, …).
|
|
7
|
+
*/
|
|
8
|
+
export class ToolInputFormatter {
|
|
9
|
+
/**
|
|
10
|
+
* Formats a tool's input into display lines, dispatched by tool name.
|
|
11
|
+
* Unrecognized tools fall back to a pretty-printed JSON dump.
|
|
12
|
+
*
|
|
13
|
+
* @param name Tool name from the `tool_use` block.
|
|
14
|
+
* @param input Raw tool input object.
|
|
15
|
+
* @returns Colorized lines to print beneath the tool header.
|
|
16
|
+
*/
|
|
17
|
+
static format(name: string, input: ToolInput): string[] {
|
|
18
|
+
if (name === 'Bash') return ToolInputFormatter.formatBashInput(input);
|
|
19
|
+
if (name === 'Read') return ToolInputFormatter.formatReadInput(input);
|
|
20
|
+
if (name === 'Write') return ToolInputFormatter.formatWriteInput(input);
|
|
21
|
+
if (name === 'Edit') return ToolInputFormatter.formatEditInput(input);
|
|
22
|
+
if (name === 'Grep') return ToolInputFormatter.formatGrepInput(input);
|
|
23
|
+
if (name === 'Glob') return ToolInputFormatter.formatGlobInput(input);
|
|
24
|
+
if (name === 'TodoWrite') return ToolInputFormatter.formatTodoWriteInput(input);
|
|
25
|
+
if (name === 'WebFetch') return ToolInputFormatter.formatWebFetchInput(input);
|
|
26
|
+
if (name === 'WebSearch') return ToolInputFormatter.formatWebSearchInput(input);
|
|
27
|
+
if (name === 'Task' || name === 'Agent') return ToolInputFormatter.formatTaskInput(input);
|
|
28
|
+
return ToolInputFormatter.formatFallbackInput(input);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
private static formatBashInput(input: ToolInput): string[] {
|
|
32
|
+
const command = ToolInputFormatter.readString(input, 'command') ?? '';
|
|
33
|
+
const description = ToolInputFormatter.readString(input, 'description');
|
|
34
|
+
const lines: string[] = [];
|
|
35
|
+
const cmdLines = command.split('\n');
|
|
36
|
+
lines.push(colors.text(`$ ${cmdLines[0] ?? ''}`));
|
|
37
|
+
for (let i = 1; i < cmdLines.length; i++) {
|
|
38
|
+
lines.push(colors.text(cmdLines[i]));
|
|
39
|
+
}
|
|
40
|
+
if (description !== undefined) {
|
|
41
|
+
lines.push(colors.system(`# ${description}`));
|
|
42
|
+
}
|
|
43
|
+
return lines;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
private static formatReadInput(input: ToolInput): string[] {
|
|
47
|
+
const filePath = ToolInputFormatter.readString(input, 'file_path') ?? '';
|
|
48
|
+
const offset = ToolInputFormatter.readNumber(input, 'offset');
|
|
49
|
+
const limit = ToolInputFormatter.readNumber(input, 'limit');
|
|
50
|
+
let suffix = '';
|
|
51
|
+
if (offset !== undefined && limit !== undefined) {
|
|
52
|
+
suffix = `:${offset}-${offset + limit - 1}`;
|
|
53
|
+
} else if (offset !== undefined) {
|
|
54
|
+
suffix = `:${offset}+`;
|
|
55
|
+
}
|
|
56
|
+
return [colors.text(`${filePath}${suffix}`)];
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private static formatWriteInput(input: ToolInput): string[] {
|
|
60
|
+
const filePath = ToolInputFormatter.readString(input, 'file_path') ?? '';
|
|
61
|
+
return [colors.text(filePath)];
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private static formatEditInput(input: ToolInput): string[] {
|
|
65
|
+
const filePath = ToolInputFormatter.readString(input, 'file_path') ?? '';
|
|
66
|
+
const lines = [colors.text(filePath)];
|
|
67
|
+
if (ToolInputFormatter.readBoolean(input, 'replace_all') === true) {
|
|
68
|
+
lines.push(colors.system('replace_all=true'));
|
|
69
|
+
}
|
|
70
|
+
return lines;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private static formatGrepInput(input: ToolInput): string[] {
|
|
74
|
+
const pattern = ToolInputFormatter.readString(input, 'pattern') ?? '';
|
|
75
|
+
const path = ToolInputFormatter.readString(input, 'path');
|
|
76
|
+
const head = path !== undefined ? `"${pattern}" in ${path}` : `"${pattern}"`;
|
|
77
|
+
const flags: string[] = [];
|
|
78
|
+
const outputMode = ToolInputFormatter.readString(input, 'output_mode');
|
|
79
|
+
if (outputMode !== undefined) flags.push(`output_mode=${outputMode}`);
|
|
80
|
+
if (ToolInputFormatter.readBoolean(input, '-n') === true) flags.push('-n');
|
|
81
|
+
if (ToolInputFormatter.readBoolean(input, '-i') === true) flags.push('-i');
|
|
82
|
+
const headLimit = ToolInputFormatter.readNumber(input, 'head_limit');
|
|
83
|
+
if (headLimit !== undefined) flags.push(`head_limit=${headLimit}`);
|
|
84
|
+
const glob = ToolInputFormatter.readString(input, 'glob');
|
|
85
|
+
if (glob !== undefined) flags.push(`glob=${glob}`);
|
|
86
|
+
const fileType = ToolInputFormatter.readString(input, 'type');
|
|
87
|
+
if (fileType !== undefined) flags.push(`type=${fileType}`);
|
|
88
|
+
if (flags.length === 0) {
|
|
89
|
+
return [colors.text(head)];
|
|
90
|
+
}
|
|
91
|
+
return [`${colors.text(head)} ${colors.system(`(${flags.join(', ')})`)}`];
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
private static formatGlobInput(input: ToolInput): string[] {
|
|
95
|
+
const pattern = ToolInputFormatter.readString(input, 'pattern') ?? '';
|
|
96
|
+
const path = ToolInputFormatter.readString(input, 'path');
|
|
97
|
+
const text = path !== undefined ? `${pattern} in ${path}` : pattern;
|
|
98
|
+
return [colors.text(text)];
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
private static formatTodoWriteInput(input: ToolInput): string[] {
|
|
102
|
+
const todos = input['todos'];
|
|
103
|
+
if (Array.isArray(todos) === false) {
|
|
104
|
+
return [colors.system('(no todos)')];
|
|
105
|
+
}
|
|
106
|
+
return todos.map((todo) => {
|
|
107
|
+
if (typeof todo !== 'object' || todo === null) {
|
|
108
|
+
return colors.text(String(todo));
|
|
109
|
+
}
|
|
110
|
+
const t = todo as Record<string, unknown>;
|
|
111
|
+
const status = typeof t['status'] === 'string' ? t['status'] : '?';
|
|
112
|
+
const content = typeof t['content'] === 'string' ? t['content'] : '';
|
|
113
|
+
let mark = '?';
|
|
114
|
+
if (status === 'pending') mark = ' ';
|
|
115
|
+
else if (status === 'in_progress') mark = '~';
|
|
116
|
+
else if (status === 'completed') mark = 'x';
|
|
117
|
+
return colors.text(`[${mark}] ${content}`);
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
private static formatWebFetchInput(input: ToolInput): string[] {
|
|
122
|
+
const url = ToolInputFormatter.readString(input, 'url') ?? '';
|
|
123
|
+
const prompt = ToolInputFormatter.readString(input, 'prompt');
|
|
124
|
+
const lines = [colors.text(url)];
|
|
125
|
+
if (prompt !== undefined) {
|
|
126
|
+
lines.push(colors.system(`prompt: ${prompt}`));
|
|
127
|
+
}
|
|
128
|
+
return lines;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
private static formatWebSearchInput(input: ToolInput): string[] {
|
|
132
|
+
const query = ToolInputFormatter.readString(input, 'query') ?? '';
|
|
133
|
+
const flags: string[] = [];
|
|
134
|
+
const allowed = input['allowed_domains'];
|
|
135
|
+
if (Array.isArray(allowed) && allowed.length > 0) {
|
|
136
|
+
flags.push(`allowed_domains=[${allowed.map((d) => String(d)).join(', ')}]`);
|
|
137
|
+
}
|
|
138
|
+
const blocked = input['blocked_domains'];
|
|
139
|
+
if (Array.isArray(blocked) && blocked.length > 0) {
|
|
140
|
+
flags.push(`blocked_domains=[${blocked.map((d) => String(d)).join(', ')}]`);
|
|
141
|
+
}
|
|
142
|
+
const head = colors.text(`"${query}"`);
|
|
143
|
+
if (flags.length === 0) return [head];
|
|
144
|
+
return [`${head} ${colors.system(`(${flags.join(', ')})`)}`];
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private static formatTaskInput(input: ToolInput): string[] {
|
|
148
|
+
const description = ToolInputFormatter.readString(input, 'description') ?? '';
|
|
149
|
+
const subagentType = ToolInputFormatter.readString(input, 'subagent_type') ?? 'agent';
|
|
150
|
+
const prompt = ToolInputFormatter.readString(input, 'prompt');
|
|
151
|
+
const lines = [colors.text(`[${subagentType}] ${description}`)];
|
|
152
|
+
if (prompt !== undefined) {
|
|
153
|
+
const PROMPT_PREVIEW_LIMIT = 200;
|
|
154
|
+
if (prompt.length <= PROMPT_PREVIEW_LIMIT) {
|
|
155
|
+
lines.push(colors.system(`prompt: ${prompt}`));
|
|
156
|
+
} else {
|
|
157
|
+
const head = prompt.slice(0, PROMPT_PREVIEW_LIMIT);
|
|
158
|
+
lines.push(colors.system(`prompt: ${head}… (${prompt.length} chars)`));
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
return lines;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
private static formatFallbackInput(input: ToolInput): string[] {
|
|
165
|
+
const json = JSON.stringify(input, null, 2);
|
|
166
|
+
return json.split('\n').map((line) => colors.json(line));
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private static readString(input: ToolInput, key: string): string | undefined {
|
|
170
|
+
const value = input[key];
|
|
171
|
+
return typeof value === 'string' ? value : undefined;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private static readNumber(input: ToolInput, key: string): number | undefined {
|
|
175
|
+
const value = input[key];
|
|
176
|
+
return typeof value === 'number' ? value : undefined;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private static readBoolean(input: ToolInput, key: string): boolean | undefined {
|
|
180
|
+
const value = input[key];
|
|
181
|
+
return typeof value === 'boolean' ? value : undefined;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { ClaudeEvent, ModelUsage } from '../types/claude_event.js';
|
|
2
|
+
import type { ModelTokens, StatsReport } from '../types/stats_report.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Accumulates per-stream statistics — tool-call counts, tool-result errors, and
|
|
6
|
+
* peak prompt size — and captures the `result` event, then assembles a
|
|
7
|
+
* {@link StatsReport}. A fresh instance should be created per stream.
|
|
8
|
+
*/
|
|
9
|
+
export class StatsCollector {
|
|
10
|
+
private toolUseCounts = new Map<string, number>();
|
|
11
|
+
private toolResultCount = 0;
|
|
12
|
+
private toolResultErrorCount = 0;
|
|
13
|
+
private peakPromptTokens = 0;
|
|
14
|
+
private resultEvent: ClaudeEvent | undefined;
|
|
15
|
+
|
|
16
|
+
/** Feeds one event into the accumulators; call for every event in the stream. */
|
|
17
|
+
track(event: ClaudeEvent) {
|
|
18
|
+
const type = event.type;
|
|
19
|
+
if (type === 'assistant') {
|
|
20
|
+
this.trackPromptSize(event);
|
|
21
|
+
this.trackToolUses(event);
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
if (type === 'user') {
|
|
25
|
+
this.trackToolResults(event);
|
|
26
|
+
return;
|
|
27
|
+
}
|
|
28
|
+
if (type === 'result') {
|
|
29
|
+
this.resultEvent = event;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private trackPromptSize(event: ClaudeEvent) {
|
|
34
|
+
const usage = event.message?.usage;
|
|
35
|
+
if (usage === undefined) return;
|
|
36
|
+
const prompt = (usage.input_tokens ?? 0) + (usage.cache_read_input_tokens ?? 0) + (usage.cache_creation_input_tokens ?? 0);
|
|
37
|
+
if (prompt > this.peakPromptTokens) {
|
|
38
|
+
this.peakPromptTokens = prompt;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private trackToolUses(event: ClaudeEvent) {
|
|
43
|
+
const content = event.message?.content;
|
|
44
|
+
if (content === undefined) return;
|
|
45
|
+
for (const block of content) {
|
|
46
|
+
if (block.type !== 'tool_use') {
|
|
47
|
+
continue;
|
|
48
|
+
}
|
|
49
|
+
const name = block.name ?? 'tool';
|
|
50
|
+
this.toolUseCounts.set(name, (this.toolUseCounts.get(name) ?? 0) + 1);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
private trackToolResults(event: ClaudeEvent) {
|
|
55
|
+
const content = event.message?.content;
|
|
56
|
+
if (content === undefined) return;
|
|
57
|
+
for (const block of content) {
|
|
58
|
+
if (block.type !== 'tool_result') {
|
|
59
|
+
continue;
|
|
60
|
+
}
|
|
61
|
+
this.toolResultCount += 1;
|
|
62
|
+
if (block.is_error === true) {
|
|
63
|
+
this.toolResultErrorCount += 1;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Builds the final {@link StatsReport} from the captured `result` event and
|
|
70
|
+
* the tracked accumulators. Call once, after the stream closes.
|
|
71
|
+
*/
|
|
72
|
+
buildReport(): StatsReport {
|
|
73
|
+
const event = this.resultEvent;
|
|
74
|
+
const usage = event?.usage;
|
|
75
|
+
|
|
76
|
+
const result = {
|
|
77
|
+
terminalReason: event?.terminal_reason,
|
|
78
|
+
stopReason: event?.stop_reason,
|
|
79
|
+
isError: event?.is_error === true,
|
|
80
|
+
numTurns: event?.num_turns,
|
|
81
|
+
permissionDenials: event?.permission_denials?.length ?? 0,
|
|
82
|
+
durationMs: event?.duration_ms,
|
|
83
|
+
totalCostUsd: event?.total_cost_usd,
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
let latency: StatsReport['latency'];
|
|
87
|
+
if (event?.duration_ms !== undefined) {
|
|
88
|
+
const wallMs = event.duration_ms;
|
|
89
|
+
const apiMs = event.duration_api_ms;
|
|
90
|
+
const localMs = apiMs !== undefined ? Math.max(0, wallMs - apiMs) : undefined;
|
|
91
|
+
const output = usage?.output_tokens;
|
|
92
|
+
const throughputTokPerS = (apiMs !== undefined && apiMs > 0 && output !== undefined && output > 0)
|
|
93
|
+
? Math.round(output / (apiMs / 1000))
|
|
94
|
+
: undefined;
|
|
95
|
+
latency = { wallMs, apiMs, localMs, throughputTokPerS };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
let tokens: StatsReport['tokens'];
|
|
99
|
+
if (usage !== undefined) {
|
|
100
|
+
const input = usage.input_tokens ?? 0;
|
|
101
|
+
const output = usage.output_tokens ?? 0;
|
|
102
|
+
const cacheRead = usage.cache_read_input_tokens ?? 0;
|
|
103
|
+
const cacheCreate = usage.cache_creation_input_tokens ?? 0;
|
|
104
|
+
const total = input + output + cacheRead + cacheCreate;
|
|
105
|
+
const promptTotal = input + cacheRead + cacheCreate;
|
|
106
|
+
const cacheHitPct = promptTotal > 0 ? StatsCollector.round1((cacheRead / promptTotal) * 100) : 0;
|
|
107
|
+
tokens = {
|
|
108
|
+
input,
|
|
109
|
+
output,
|
|
110
|
+
cacheRead,
|
|
111
|
+
cacheCreate,
|
|
112
|
+
total,
|
|
113
|
+
cacheHitPct,
|
|
114
|
+
byModel: StatsCollector.buildByModel(event?.modelUsage),
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const contextWindow = StatsCollector.maxContextWindow(event?.modelUsage);
|
|
119
|
+
const context = {
|
|
120
|
+
peakPromptTokens: this.peakPromptTokens,
|
|
121
|
+
contextWindow: contextWindow > 0 ? contextWindow : undefined,
|
|
122
|
+
peakPct: contextWindow > 0 ? StatsCollector.round1((this.peakPromptTokens / contextWindow) * 100) : undefined,
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
const byTool = [...this.toolUseCounts.entries()]
|
|
126
|
+
.sort((a, b) => b[1] - a[1])
|
|
127
|
+
.map(([name, count]) => ({ name, count }));
|
|
128
|
+
const totalCalls = byTool.reduce((sum, entry) => sum + entry.count, 0);
|
|
129
|
+
const errorPct = this.toolResultCount > 0 ? StatsCollector.round1((this.toolResultErrorCount / this.toolResultCount) * 100) : 0;
|
|
130
|
+
const tools = {
|
|
131
|
+
totalCalls,
|
|
132
|
+
byTool,
|
|
133
|
+
resultCount: this.toolResultCount,
|
|
134
|
+
errorCount: this.toolResultErrorCount,
|
|
135
|
+
errorPct,
|
|
136
|
+
};
|
|
137
|
+
|
|
138
|
+
return {
|
|
139
|
+
truncated: this.resultEvent === undefined,
|
|
140
|
+
result,
|
|
141
|
+
latency,
|
|
142
|
+
tokens,
|
|
143
|
+
context,
|
|
144
|
+
tools,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
private static buildByModel(modelUsage: Record<string, ModelUsage> | undefined): ModelTokens[] {
|
|
149
|
+
if (modelUsage === undefined) return [];
|
|
150
|
+
return Object.entries(modelUsage).map(([model, usage]) => ({
|
|
151
|
+
model,
|
|
152
|
+
input: usage.inputTokens ?? 0,
|
|
153
|
+
output: usage.outputTokens ?? 0,
|
|
154
|
+
cacheRead: usage.cacheReadInputTokens ?? 0,
|
|
155
|
+
cacheCreate: usage.cacheCreationInputTokens ?? 0,
|
|
156
|
+
costUsd: usage.costUSD,
|
|
157
|
+
}));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
private static maxContextWindow(modelUsage: Record<string, ModelUsage> | undefined): number {
|
|
161
|
+
if (modelUsage === undefined) return 0;
|
|
162
|
+
let max = 0;
|
|
163
|
+
for (const usage of Object.values(modelUsage)) {
|
|
164
|
+
const contextWindow = usage.contextWindow ?? 0;
|
|
165
|
+
if (contextWindow > max) {
|
|
166
|
+
max = contextWindow;
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
return max;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
private static round1(value: number): number {
|
|
173
|
+
return Math.round(value * 10) / 10;
|
|
174
|
+
}
|
|
175
|
+
}
|