pi-open-tui 0.2.14 → 0.2.16

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.
@@ -1,298 +1,303 @@
1
- import type {
2
- AgentSettledEvent,
3
- AgentStartEvent,
4
- MessageEndEvent,
5
- MessageStartEvent,
6
- MessageUpdateEvent,
7
- ToolExecutionStartEvent,
8
- TurnEndEvent,
9
- TurnStartEvent,
10
- Theme,
11
- } from "@earendil-works/pi-coding-agent";
12
- import type { IconMode, TelemetryConfig } from "./config.ts";
13
- import { resolveGlyphs } from "./icons.ts";
14
- import { fmtTokens, formatDuration } from "./utils.ts";
15
-
16
- const STALL_THRESHOLD_MS = 1000;
17
-
18
- type TelemetryEvent =
19
- | AgentStartEvent
20
- | AgentSettledEvent
21
- | TurnStartEvent
22
- | MessageStartEvent
23
- | MessageUpdateEvent
24
- | MessageEndEvent
25
- | ToolExecutionStartEvent
26
- | TurnEndEvent;
27
- type AgentMessage = MessageStartEvent["message"];
28
- type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
29
-
30
- interface MessageTiming {
31
- lastUpdateMs: number;
32
- firstOutputMs: number | null;
33
- inStall: boolean;
34
- }
35
-
36
- interface TurnTiming {
37
- startMs: number;
38
- firstTokenMs: number | null;
39
- currentMessage: MessageTiming | null;
40
- messages: AssistantMessage[];
41
- generationMs: number;
42
- stallMs: number;
43
- stallCount: number;
44
- }
45
-
46
- export interface TurnTelemetry {
47
- tps: number | null;
48
- ttftMs: number;
49
- totalMs: number;
50
- inputTokens: number;
51
- outputTokens: number;
52
- stallMs: number;
53
- stallCount: number;
54
- rateUsdPerMTokens: number | null;
55
- generationMs: number;
56
- totalTokens: number;
57
- costUsd: number;
58
- measurementMs: number | null;
59
- }
60
-
61
- function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
62
- return message.role === "assistant";
63
- }
64
-
65
- function round(value: number, decimals: number): number {
66
- const factor = 10 ** decimals;
67
- return Math.round(value * factor) / factor;
68
- }
69
-
70
- export class TurnTelemetryTracker {
71
- private readonly now: () => number;
72
- private turn: TurnTiming | undefined;
73
- private agentStartMs: number | null = null;
74
- private agentTurns: TurnTelemetry[] = [];
75
-
76
- constructor(now: () => number = () => performance.now()) {
77
- this.now = now;
78
- }
79
-
80
- handle(event: TelemetryEvent): TurnTelemetry | undefined {
81
- switch (event.type) {
82
- case "agent_start":
83
- if (this.agentStartMs === null) {
84
- this.agentStartMs = this.now();
85
- this.agentTurns = [];
86
- }
87
- return;
88
- case "agent_settled":
89
- return this.endAgent();
90
- case "turn_start":
91
- this.startTurn();
92
- return;
93
- case "message_start":
94
- this.startMessage(event.message);
95
- return;
96
- case "message_update":
97
- this.updateMessage(event);
98
- return;
99
- case "message_end":
100
- this.endMessage(event.message);
101
- return;
102
- case "tool_execution_start":
103
- return;
104
- case "turn_end":
105
- return this.endTurnAndCollect();
106
- }
107
- }
108
-
109
- private startTurn(): void {
110
- this.turn = {
111
- startMs: this.now(),
112
- firstTokenMs: null,
113
- currentMessage: null,
114
- messages: [],
115
- generationMs: 0,
116
- stallMs: 0,
117
- stallCount: 0,
118
- };
119
- }
120
-
121
- private startMessage(message: AgentMessage): void {
122
- if (!this.turn || !isAssistantMessage(message)) return;
123
- const now = this.now();
124
- this.turn.currentMessage = {
125
- lastUpdateMs: now,
126
- firstOutputMs: null,
127
- inStall: false,
128
- };
129
- }
130
-
131
- private updateMessage(event: MessageUpdateEvent): void {
132
- const turn = this.turn;
133
- const current = turn?.currentMessage;
134
- const streamEvent = event.assistantMessageEvent;
135
- if (
136
- streamEvent.type !== "text_delta" &&
137
- streamEvent.type !== "thinking_delta" &&
138
- streamEvent.type !== "toolcall_delta"
139
- ) return;
140
- if (streamEvent.delta.length === 0) return;
141
- const message = event.message;
142
- if (!turn || !current || !isAssistantMessage(message)) return;
143
-
144
- const now = this.now();
145
- if (current.firstOutputMs === null) {
146
- current.firstOutputMs = now;
147
- turn.firstTokenMs ??= now;
148
- current.lastUpdateMs = now;
149
- return;
150
- }
151
-
152
- const gap = now - current.lastUpdateMs;
153
- if (gap >= STALL_THRESHOLD_MS) {
154
- if (!current.inStall) turn.stallCount++;
155
- current.inStall = true;
156
- turn.stallMs += gap;
157
- } else {
158
- current.inStall = false;
159
- }
160
- current.lastUpdateMs = now;
161
- }
162
-
163
- private endMessage(message: AgentMessage): void {
164
- const turn = this.turn;
165
- if (!turn || !isAssistantMessage(message)) return;
166
-
167
- const current = turn.currentMessage;
168
- if (current) {
169
- const endMs = this.now();
170
- turn.generationMs = endMs - turn.startMs;
171
- if (current.firstOutputMs === null && message.usage.output > 0) {
172
- turn.firstTokenMs ??= endMs;
173
- }
174
- turn.currentMessage = null;
175
- }
176
- turn.messages.push(message);
177
- }
178
-
179
- private endTurnAndCollect(): TurnTelemetry | undefined {
180
- const telemetry = this.endTurn();
181
- if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
182
- return telemetry;
183
- }
184
-
185
- private endTurn(): TurnTelemetry | undefined {
186
- const turn = this.turn;
187
- this.turn = undefined;
188
- if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
189
-
190
- const endMs = this.now();
191
- let inputTokens = 0;
192
- let outputTokens = 0;
193
- let totalTokens = 0;
194
- let costUsd = 0;
195
- for (const message of turn.messages) {
196
- inputTokens += message.usage.input;
197
- outputTokens += message.usage.output;
198
- totalTokens += message.usage.totalTokens;
199
- costUsd += message.usage.cost.total;
200
- }
201
- if (![inputTokens, outputTokens, totalTokens, costUsd].every(Number.isFinite)) {
202
- throw new Error("Invalid assistant usage in turn telemetry");
203
- }
204
-
205
- const measurementMs = outputTokens > 0 && turn.generationMs > 0 ? turn.generationMs : null;
206
- const tps = measurementMs === null
207
- ? null
208
- : round(outputTokens / (measurementMs / 1000), 1);
209
- const validCost = Number.isFinite(costUsd) && costUsd > 0;
210
- const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
211
- return {
212
- tps,
213
- ttftMs: turn.firstTokenMs - turn.startMs,
214
- totalMs: endMs - turn.startMs,
215
- inputTokens,
216
- outputTokens,
217
- stallMs: turn.stallMs,
218
- stallCount: turn.stallCount,
219
- rateUsdPerMTokens: validCost && validTokens
220
- ? round(costUsd / (totalTokens / 1_000_000), 2)
221
- : null,
222
- generationMs: turn.generationMs,
223
- totalTokens,
224
- costUsd: validCost ? costUsd : 0,
225
- measurementMs,
226
- };
227
- }
228
-
229
- private endAgent(): TurnTelemetry | undefined {
230
- const startMs = this.agentStartMs;
231
- const turns = this.agentTurns;
232
- this.agentStartMs = null;
233
- this.agentTurns = [];
234
- if (startMs === null || turns.length === 0) return;
235
-
236
- const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
237
- const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
238
- const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
239
- const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
240
- const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
241
- const stallCount = turns.reduce((sum, turn) => sum + turn.stallCount, 0);
242
- const generationMs = turns.reduce((sum, turn) => sum + turn.generationMs, 0);
243
- const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
244
- const tps = measurementMs === null
245
- ? null
246
- : round(outputTokens / (measurementMs / 1000), 1);
247
- const validRate = costUsd > 0 && totalTokens > 0;
248
- return {
249
- tps,
250
- ttftMs: turns[0]!.ttftMs,
251
- totalMs: this.now() - startMs,
252
- inputTokens,
253
- outputTokens,
254
- stallMs,
255
- stallCount,
256
- rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
257
- generationMs,
258
- totalTokens,
259
- costUsd,
260
- measurementMs,
261
- };
262
- }
263
- }
264
-
265
- function formatTurnDuration(ms: number): string {
266
- return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
267
- }
268
-
269
- export function formatTurnTelemetry(
270
- telemetry: TurnTelemetry,
271
- theme: Theme,
272
- config: TelemetryConfig,
273
- iconMode: IconMode,
274
- ): string {
275
- const glyphs = resolveGlyphs(iconMode);
276
- const parts: string[] = [];
277
- if (config.tps) {
278
- const value = telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
279
- parts.push(theme.fg(telemetry.tps === null ? "muted" : "accent", `${glyphs.speed} TPS ${value}`));
280
- }
281
- if (config.ttft) {
282
- parts.push(theme.fg("text", `${glyphs.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`));
283
- }
284
- if (config.duration) {
285
- parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
286
- }
287
- if (config.tokens) {
288
- parts.push(theme.fg("accent", `${glyphs.input} ${fmtTokens(telemetry.inputTokens)}`));
289
- parts.push(theme.fg("success", `${glyphs.output} ${fmtTokens(telemetry.outputTokens)}`));
290
- }
291
- if (config.stalls && telemetry.stallMs > 0) {
292
- parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
293
- }
294
- if (config.cost && telemetry.rateUsdPerMTokens !== null) {
295
- parts.push(theme.fg("warning", `${glyphs.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`));
296
- }
297
- return parts.join(` ${theme.fg("dim", "|")} `);
298
- }
1
+ import type {
2
+ AgentSettledEvent,
3
+ AgentStartEvent,
4
+ MessageEndEvent,
5
+ MessageStartEvent,
6
+ MessageUpdateEvent,
7
+ ToolExecutionStartEvent,
8
+ TurnEndEvent,
9
+ TurnStartEvent,
10
+ Theme,
11
+ } from "@earendil-works/pi-coding-agent";
12
+ import type { IconMode, TelemetryConfig } from "./config.ts";
13
+ import { resolveGlyphs } from "./icons.ts";
14
+ import { finiteOrZero, fmtTokens, formatDuration, formatInputBreakdown } from "./utils.ts";
15
+
16
+ const STALL_THRESHOLD_MS = 1000;
17
+
18
+ type TelemetryEvent =
19
+ | AgentStartEvent
20
+ | AgentSettledEvent
21
+ | TurnStartEvent
22
+ | MessageStartEvent
23
+ | MessageUpdateEvent
24
+ | MessageEndEvent
25
+ | ToolExecutionStartEvent
26
+ | TurnEndEvent;
27
+ type AgentMessage = MessageStartEvent["message"];
28
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
29
+
30
+ interface MessageTiming {
31
+ lastUpdateMs: number;
32
+ firstOutputMs: number | null;
33
+ inStall: boolean;
34
+ }
35
+
36
+ interface TurnTiming {
37
+ startMs: number;
38
+ firstTokenMs: number | null;
39
+ currentMessage: MessageTiming | null;
40
+ messages: AssistantMessage[];
41
+ generationMs: number;
42
+ stallMs: number;
43
+ stallCount: number;
44
+ }
45
+
46
+ export interface TurnTelemetry {
47
+ tps: number | null;
48
+ ttftMs: number;
49
+ totalMs: number;
50
+ inputTokens: number;
51
+ outputTokens: number;
52
+ cacheReadTokens: number;
53
+ stallMs: number;
54
+ stallCount: number;
55
+ rateUsdPerMTokens: number | null;
56
+ generationMs: number;
57
+ totalTokens: number;
58
+ costUsd: number;
59
+ measurementMs: number | null;
60
+ }
61
+
62
+ function isAssistantMessage(message: AgentMessage): message is AssistantMessage {
63
+ return message.role === "assistant";
64
+ }
65
+
66
+ function round(value: number, decimals: number): number {
67
+ const factor = 10 ** decimals;
68
+ return Math.round(value * factor) / factor;
69
+ }
70
+
71
+ export class TurnTelemetryTracker {
72
+ private readonly now: () => number;
73
+ private turn: TurnTiming | undefined;
74
+ private agentStartMs: number | null = null;
75
+ private agentTurns: TurnTelemetry[] = [];
76
+
77
+ constructor(now: () => number = () => performance.now()) {
78
+ this.now = now;
79
+ }
80
+
81
+ handle(event: TelemetryEvent): TurnTelemetry | undefined {
82
+ switch (event.type) {
83
+ case "agent_start":
84
+ if (this.agentStartMs === null) {
85
+ this.agentStartMs = this.now();
86
+ this.agentTurns = [];
87
+ }
88
+ return;
89
+ case "agent_settled":
90
+ return this.endAgent();
91
+ case "turn_start":
92
+ this.startTurn();
93
+ return;
94
+ case "message_start":
95
+ this.startMessage(event.message);
96
+ return;
97
+ case "message_update":
98
+ this.updateMessage(event);
99
+ return;
100
+ case "message_end":
101
+ this.endMessage(event.message);
102
+ return;
103
+ case "tool_execution_start":
104
+ return;
105
+ case "turn_end":
106
+ return this.endTurnAndCollect();
107
+ }
108
+ }
109
+
110
+ private startTurn(): void {
111
+ this.turn = {
112
+ startMs: this.now(),
113
+ firstTokenMs: null,
114
+ currentMessage: null,
115
+ messages: [],
116
+ generationMs: 0,
117
+ stallMs: 0,
118
+ stallCount: 0,
119
+ };
120
+ }
121
+
122
+ private startMessage(message: AgentMessage): void {
123
+ if (!this.turn || !isAssistantMessage(message)) return;
124
+ const now = this.now();
125
+ this.turn.currentMessage = {
126
+ lastUpdateMs: now,
127
+ firstOutputMs: null,
128
+ inStall: false,
129
+ };
130
+ }
131
+
132
+ private updateMessage(event: MessageUpdateEvent): void {
133
+ const turn = this.turn;
134
+ const current = turn?.currentMessage;
135
+ const streamEvent = event.assistantMessageEvent;
136
+ if (
137
+ streamEvent.type !== "text_delta" &&
138
+ streamEvent.type !== "thinking_delta" &&
139
+ streamEvent.type !== "toolcall_delta"
140
+ ) return;
141
+ if (streamEvent.delta.length === 0) return;
142
+ const message = event.message;
143
+ if (!turn || !current || !isAssistantMessage(message)) return;
144
+
145
+ const now = this.now();
146
+ if (current.firstOutputMs === null) {
147
+ current.firstOutputMs = now;
148
+ turn.firstTokenMs ??= now;
149
+ current.lastUpdateMs = now;
150
+ return;
151
+ }
152
+
153
+ const gap = now - current.lastUpdateMs;
154
+ if (gap >= STALL_THRESHOLD_MS) {
155
+ if (!current.inStall) turn.stallCount++;
156
+ current.inStall = true;
157
+ turn.stallMs += gap;
158
+ } else {
159
+ current.inStall = false;
160
+ }
161
+ current.lastUpdateMs = now;
162
+ }
163
+
164
+ private endMessage(message: AgentMessage): void {
165
+ const turn = this.turn;
166
+ if (!turn || !isAssistantMessage(message)) return;
167
+
168
+ const current = turn.currentMessage;
169
+ if (current) {
170
+ const endMs = this.now();
171
+ turn.generationMs = endMs - turn.startMs;
172
+ if (current.firstOutputMs === null && finiteOrZero(message.usage?.output) > 0) {
173
+ turn.firstTokenMs ??= endMs;
174
+ }
175
+ turn.currentMessage = null;
176
+ }
177
+ turn.messages.push(message);
178
+ }
179
+
180
+ private endTurnAndCollect(): TurnTelemetry | undefined {
181
+ const telemetry = this.endTurn();
182
+ if (telemetry && this.agentStartMs !== null) this.agentTurns.push(telemetry);
183
+ return telemetry;
184
+ }
185
+
186
+ private endTurn(): TurnTelemetry | undefined {
187
+ const turn = this.turn;
188
+ this.turn = undefined;
189
+ if (!turn || turn.firstTokenMs === null || turn.messages.length === 0) return;
190
+
191
+ const endMs = this.now();
192
+ let inputTokens = 0;
193
+ let outputTokens = 0;
194
+ let cacheReadTokens = 0;
195
+ let totalTokens = 0;
196
+ let costUsd = 0;
197
+ for (const message of turn.messages) {
198
+ // match /session's "uncached" total: cacheWrite is fresh, near-full-price
199
+ // content; only cacheRead is discounted repeat content.
200
+ inputTokens += finiteOrZero(message.usage?.input) + finiteOrZero(message.usage?.cacheWrite);
201
+ outputTokens += finiteOrZero(message.usage?.output);
202
+ cacheReadTokens += finiteOrZero(message.usage?.cacheRead);
203
+ totalTokens += finiteOrZero(message.usage?.totalTokens);
204
+ costUsd += finiteOrZero(message.usage?.cost?.total);
205
+ }
206
+
207
+ const measurementMs = outputTokens > 0 && turn.generationMs > 0 ? turn.generationMs : null;
208
+ const tps = measurementMs === null
209
+ ? null
210
+ : round(outputTokens / (measurementMs / 1000), 1);
211
+ const validCost = Number.isFinite(costUsd) && costUsd > 0;
212
+ const validTokens = Number.isFinite(totalTokens) && totalTokens > 0;
213
+ return {
214
+ tps,
215
+ ttftMs: turn.firstTokenMs - turn.startMs,
216
+ totalMs: endMs - turn.startMs,
217
+ inputTokens,
218
+ outputTokens,
219
+ cacheReadTokens,
220
+ stallMs: turn.stallMs,
221
+ stallCount: turn.stallCount,
222
+ rateUsdPerMTokens: validCost && validTokens
223
+ ? round(costUsd / (totalTokens / 1_000_000), 2)
224
+ : null,
225
+ generationMs: turn.generationMs,
226
+ totalTokens,
227
+ costUsd: validCost ? costUsd : 0,
228
+ measurementMs,
229
+ };
230
+ }
231
+
232
+ private endAgent(): TurnTelemetry | undefined {
233
+ const startMs = this.agentStartMs;
234
+ const turns = this.agentTurns;
235
+ this.agentStartMs = null;
236
+ this.agentTurns = [];
237
+ if (startMs === null || turns.length === 0) return;
238
+
239
+ const outputTokens = turns.reduce((sum, turn) => sum + turn.outputTokens, 0);
240
+ const inputTokens = turns.reduce((sum, turn) => sum + turn.inputTokens, 0);
241
+ const cacheReadTokens = turns.reduce((sum, turn) => sum + turn.cacheReadTokens, 0);
242
+ const totalTokens = turns.reduce((sum, turn) => sum + turn.totalTokens, 0);
243
+ const costUsd = turns.reduce((sum, turn) => sum + turn.costUsd, 0);
244
+ const stallMs = turns.reduce((sum, turn) => sum + turn.stallMs, 0);
245
+ const stallCount = turns.reduce((sum, turn) => sum + turn.stallCount, 0);
246
+ const generationMs = turns.reduce((sum, turn) => sum + turn.generationMs, 0);
247
+ const measurementMs = outputTokens > 0 && generationMs > 0 ? generationMs : null;
248
+ const tps = measurementMs === null
249
+ ? null
250
+ : round(outputTokens / (measurementMs / 1000), 1);
251
+ const validRate = costUsd > 0 && totalTokens > 0;
252
+ return {
253
+ tps,
254
+ ttftMs: turns[0]!.ttftMs,
255
+ totalMs: this.now() - startMs,
256
+ inputTokens,
257
+ outputTokens,
258
+ cacheReadTokens,
259
+ stallMs,
260
+ stallCount,
261
+ rateUsdPerMTokens: validRate ? round(costUsd / (totalTokens / 1_000_000), 2) : null,
262
+ generationMs,
263
+ totalTokens,
264
+ costUsd,
265
+ measurementMs,
266
+ };
267
+ }
268
+ }
269
+
270
+ function formatTurnDuration(ms: number): string {
271
+ return ms < 60_000 ? `${(ms / 1000).toFixed(1)}s` : formatDuration(ms);
272
+ }
273
+
274
+ export function formatTurnTelemetry(
275
+ telemetry: TurnTelemetry,
276
+ theme: Theme,
277
+ config: TelemetryConfig,
278
+ iconMode: IconMode,
279
+ ): string {
280
+ const glyphs = resolveGlyphs(iconMode);
281
+ const parts: string[] = [];
282
+ if (config.tps) {
283
+ const value = telemetry.tps === null ? "—" : `${telemetry.tps.toFixed(1)} tok/s`;
284
+ parts.push(theme.fg(telemetry.tps === null ? "muted" : "accent", `${glyphs.speed} TPS ${value}`));
285
+ }
286
+ if (config.ttft) {
287
+ parts.push(theme.fg("text", `${glyphs.latency} TTFT ${formatTurnDuration(telemetry.ttftMs)}`));
288
+ }
289
+ if (config.duration) {
290
+ parts.push(theme.fg("success", `${glyphs.done} ${formatTurnDuration(telemetry.totalMs)}`));
291
+ }
292
+ if (config.tokens) {
293
+ parts.push(theme.fg("accent", `${glyphs.input} ${formatInputBreakdown(telemetry.inputTokens, telemetry.cacheReadTokens)}`));
294
+ parts.push(theme.fg("success", `${glyphs.output} ${fmtTokens(telemetry.outputTokens)}`));
295
+ }
296
+ if (config.stalls && telemetry.stallMs > 0) {
297
+ parts.push(theme.fg("warning", `${glyphs.stall} stall ${telemetry.stallCount}x / ${formatTurnDuration(telemetry.stallMs)}`));
298
+ }
299
+ if (config.cost && telemetry.rateUsdPerMTokens !== null) {
300
+ parts.push(theme.fg("warning", `${glyphs.cost} $${telemetry.rateUsdPerMTokens.toFixed(2)}/M`));
301
+ }
302
+ return parts.join(` ${theme.fg("dim", "|")} `);
303
+ }