@pi-unipi/footer 2.10.2 → 2.12.0
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 +20 -3
- package/package.json +2 -2
- package/src/commands.ts +9 -15
- package/src/config.ts +2 -0
- package/src/glance-editor.ts +285 -0
- package/src/help.ts +1 -1
- package/src/index.ts +360 -27
- package/src/presets.ts +16 -1
- package/src/rendering/icons.ts +8 -0
- package/src/rendering/theme.ts +2 -0
- package/src/segments/core.ts +114 -7
- package/src/tps-tracker.ts +529 -240
- package/src/tui/settings-tui.ts +18 -2
- package/src/types.ts +4 -0
package/src/tps-tracker.ts
CHANGED
|
@@ -2,270 +2,559 @@
|
|
|
2
2
|
* @pi-unipi/footer — TPS (Tokens Per Second) tracker
|
|
3
3
|
*
|
|
4
4
|
* Per-message TPS calculation for live generation rate display.
|
|
5
|
-
* Tracks individual assistant messages with start/stop timestamps
|
|
6
|
-
* to measure generation rate excluding idle/tool-execution time.
|
|
7
5
|
*
|
|
8
|
-
* ## Token counting
|
|
6
|
+
* ## Token counting (anchored, harness-style)
|
|
9
7
|
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
* `usage.output`. Rationale:
|
|
13
|
-
* - `usage.output` is only populated by the provider at stream end (or in
|
|
14
|
-
* a final `message_delta`), so it is 0 throughout generation and useless
|
|
15
|
-
* for a live rate.
|
|
16
|
-
* - Counting the streamed text ourselves gives a real, monotonically
|
|
17
|
-
* increasing numerator that reflects how much the model has actually
|
|
18
|
-
* produced so far.
|
|
19
|
-
* - This matches what users perceive as "tokens per second" (visible
|
|
20
|
-
* output rate) and avoids the previous bug where the displayed TPS was
|
|
21
|
-
* always inflated to 90+ t/s (caused by dividing a post-hoc `usage.output`
|
|
22
|
-
* total by a too-short, incorrectly measured elapsed window).
|
|
8
|
+
* Token sources, best-first — following the deepseek-harness token-meter
|
|
9
|
+
* principle of never fabricating when an exact count exists:
|
|
23
10
|
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
26
|
-
*
|
|
27
|
-
*
|
|
11
|
+
* 1. Provider-anchored (`anchored`): the exact `usage.output` reported by
|
|
12
|
+
* the provider at stream end. Truth; replaces any estimate.
|
|
13
|
+
* 2. Density estimate: chars/4 heuristic (deepseek-harness fixed-density
|
|
14
|
+
* model) over accumulated text/thinking/tool-arg content. Works during
|
|
15
|
+
* streaming where `usage.output` is still 0. Much cheaper and more
|
|
16
|
+
* accurate than word counting — especially for CJK, where word regexes
|
|
17
|
+
* collapse entire paragraphs into one "word" while chars/4 stays within
|
|
18
|
+
* ~2x for typical Chinese output.
|
|
19
|
+
*
|
|
20
|
+
* While streaming, tokens = density estimate of what has arrived so far.
|
|
21
|
+
* At completion, tokens = provider usage.output (anchor wins). If the
|
|
22
|
+
* provider reports nothing usable, the estimate stands.
|
|
23
|
+
*
|
|
24
|
+
* ## Timing contract (user requirement)
|
|
25
|
+
*
|
|
26
|
+
* Durations measure ONLY output generation: first content → stream end.
|
|
27
|
+
* Tool-execution time, queueing before the first token, and idle time
|
|
28
|
+
* between messages are all excluded:
|
|
29
|
+
*
|
|
30
|
+
* - startedAt = arrival of the FIRST streamed delta (not message_start;
|
|
31
|
+
* excludes TTFT + request overhead), or the provider message timestamp
|
|
32
|
+
* when a message is only ever seen completed.
|
|
33
|
+
* - completedAt = stream end (message_end / stopReason sighting).
|
|
34
|
+
*
|
|
35
|
+
* Live TPS = estimated tokens so far ÷ (now − startedAt).
|
|
36
|
+
* Session AVG = Σ anchored/estimated output ÷ Σ per-message generation times.
|
|
28
37
|
*/
|
|
29
38
|
|
|
39
|
+
/** Deepseek-harness fixed text-density estimate. */
|
|
40
|
+
const CHARS_PER_TOKEN = 4;
|
|
41
|
+
|
|
30
42
|
/** Per-message TPS record */
|
|
31
43
|
interface MessageTpsRecord {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
44
|
+
/** Branch-local assistant message index */
|
|
45
|
+
messageIndex: number;
|
|
46
|
+
/**
|
|
47
|
+
* Tokens used for rates. Estimate while streaming; the exact provider
|
|
48
|
+
* usage.output once completed (0 if the provider reported none).
|
|
49
|
+
*/
|
|
50
|
+
tokens: number;
|
|
51
|
+
/** Density-estimate fallback when the provider reports no usage. */
|
|
52
|
+
estimatedTokens: number;
|
|
53
|
+
/** True once `tokens` holds the exact provider-reported output. */
|
|
54
|
+
anchored: boolean;
|
|
55
|
+
/** Agent turn start (ms) — when generation was requested. 0 if unknown. */
|
|
56
|
+
requestAt: number;
|
|
57
|
+
/** When OUTPUT GENERATION started (ms). First non-empty delta. */
|
|
58
|
+
startedAt: number;
|
|
59
|
+
/** When generation completed (ms), 0 if still generating. */
|
|
60
|
+
completedAt: number;
|
|
61
|
+
/** Final TPS for this message (once completed). */
|
|
62
|
+
tps: number;
|
|
42
63
|
}
|
|
43
64
|
|
|
44
65
|
/**
|
|
45
|
-
*
|
|
46
|
-
*
|
|
47
|
-
*
|
|
48
|
-
*
|
|
49
|
-
* footprint reasons.
|
|
66
|
+
* Fixed-density token estimate: chars/4 with structural allowance.
|
|
67
|
+
* Same constant family as deepseek-harness dsh-token-meter's estimator.
|
|
68
|
+
* Unicode-safe: String.length counts UTF-16 units; CJK BMP chars count 1
|
|
69
|
+
* unit each which slightly overprices them toward correctness.
|
|
50
70
|
*/
|
|
51
71
|
function estimateTokens(text: string): number {
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
// \p{L} / \p{N} keep this Unicode-aware for non-ASCII content.
|
|
55
|
-
const wordish = text.match(/[\p{L}\p{N}]+/gu);
|
|
56
|
-
const punct = text.match(/[^\s\p{L}\p{N}]+/gu);
|
|
57
|
-
const wordCount = wordish ? wordish.length : 0;
|
|
58
|
-
const punctCount = punct ? punct.length : 0;
|
|
59
|
-
return wordCount + punctCount;
|
|
72
|
+
if (!text) return 0;
|
|
73
|
+
return Math.ceil(text.length / CHARS_PER_TOKEN);
|
|
60
74
|
}
|
|
61
75
|
|
|
62
76
|
/**
|
|
63
|
-
*
|
|
64
|
-
*
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
77
|
+
* Accumulate the density estimate for one streaming delta.
|
|
78
|
+
* Returns the running total length contributed for O(1)-per-delta updates.
|
|
79
|
+
*/
|
|
80
|
+
function deltaContribution(deltaText: string): number {
|
|
81
|
+
return estimateTokens(deltaText);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Extract the assistant message timestamp (provider-side start), if present.
|
|
86
|
+
*/
|
|
87
|
+
function messageTimestamp(message: unknown): number {
|
|
88
|
+
const m = message as Record<string, unknown> | undefined;
|
|
89
|
+
if (!m || typeof m !== "object") return 0;
|
|
90
|
+
return typeof m.timestamp === "number" ? m.timestamp : 0;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Exact provider-reported output tokens for a completed assistant message,
|
|
95
|
+
* or 0 when absent/error (fall back to the density estimate then).
|
|
96
|
+
*/
|
|
97
|
+
function providerOutputTokens(message: unknown): number {
|
|
98
|
+
const m = message as Record<string, unknown> | undefined;
|
|
99
|
+
if (!m || typeof m !== "object") return 0;
|
|
100
|
+
const usage = m.usage as Record<string, unknown> | undefined;
|
|
101
|
+
const output = usage?.output;
|
|
102
|
+
return typeof output === "number" && output > 0 ? output : 0;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Estimate output tokens from message content via the fixed-density model.
|
|
107
|
+
* Counts text, thinking, and tool-call arguments (all model output).
|
|
68
108
|
*/
|
|
69
109
|
function estimateOutputTokens(message: unknown): number {
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
110
|
+
if (!message || typeof message !== "object") return 0;
|
|
111
|
+
const m = message as Record<string, unknown>;
|
|
112
|
+
if (m.role !== "assistant") return 0;
|
|
113
|
+
const content = m.content;
|
|
114
|
+
if (!Array.isArray(content)) return 0;
|
|
115
|
+
|
|
116
|
+
let chars = 0;
|
|
117
|
+
for (const block of content) {
|
|
118
|
+
if (!block || typeof block !== "object") continue;
|
|
119
|
+
const b = block as Record<string, unknown>;
|
|
120
|
+
switch (b.type) {
|
|
121
|
+
case "text":
|
|
122
|
+
chars += typeof b.text === "string" ? b.text.length : 0;
|
|
123
|
+
break;
|
|
124
|
+
case "thinking":
|
|
125
|
+
chars += typeof b.thinking === "string" ? b.thinking.length : 0;
|
|
126
|
+
break;
|
|
127
|
+
case "tool_use":
|
|
128
|
+
case "toolcall": {
|
|
129
|
+
let inputStr: string;
|
|
130
|
+
try {
|
|
131
|
+
const input = b.input ?? b.arguments;
|
|
132
|
+
inputStr =
|
|
133
|
+
typeof input === "string"
|
|
134
|
+
? input
|
|
135
|
+
: JSON.stringify(input ?? "");
|
|
136
|
+
} catch {
|
|
137
|
+
inputStr = "";
|
|
138
|
+
}
|
|
139
|
+
chars += inputStr.length;
|
|
140
|
+
break;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return estimateTokens(String(chars)); // ceil(chars/4) via single call
|
|
99
145
|
}
|
|
100
146
|
|
|
101
147
|
/**
|
|
102
148
|
* Tracks per-message TPS and computes live/session metrics.
|
|
103
149
|
*
|
|
104
|
-
*
|
|
105
|
-
*
|
|
106
|
-
*
|
|
150
|
+
* Writers (both keyed by branch-local assistant index — see index.ts):
|
|
151
|
+
* - Streaming hooks: onMessageStart / onStreamingDelta / onMessageEnd
|
|
152
|
+
* - 1s reconciliation scan: onMessageUpdate (snapshot; timing source of
|
|
153
|
+
* last resort — it stamps startedAt from the persisted record only when
|
|
154
|
+
* nothing better exists, so a pure-scan environment still works but a
|
|
155
|
+
* streaming-aware one always measures output-only windows).
|
|
107
156
|
*/
|
|
108
157
|
export class TpsTracker {
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
158
|
+
private records: MessageTpsRecord[] = [];
|
|
159
|
+
|
|
160
|
+
/** Total output tokens across COMPLETED messages (anchored when possible). */
|
|
161
|
+
private totalOutput = 0;
|
|
162
|
+
|
|
163
|
+
// ── TTFT aggregation (deepseek-harness session-stats semantics) ────────
|
|
164
|
+
/** Summed (firstDelta − turnStart) over completed messages with both bounds. */
|
|
165
|
+
private ttftMs = 0;
|
|
166
|
+
/** Messages that contributed a measurable TTFT sample. */
|
|
167
|
+
private ttftSteps = 0;
|
|
168
|
+
/** TTFT samples recorded by live hooks (as opposed to branch seeds). */
|
|
169
|
+
private ttftHookSamples = 0;
|
|
170
|
+
|
|
171
|
+
// ── Session strip stats (harness session-stats: turns/steps/wall/tool) ──
|
|
172
|
+
private turns = 0;
|
|
173
|
+
/** Completed assistant messages (= steps with output). */
|
|
174
|
+
private steps = 0;
|
|
175
|
+
/** Turn-start → agent_settled-ish wall time accumulated per turn (open now). */
|
|
176
|
+
private turnStartAt = 0;
|
|
177
|
+
/** Summed wall time of completed turns. */
|
|
178
|
+
private llmMs = 0;
|
|
179
|
+
/** tool execution start → end pairs matched by index. */
|
|
180
|
+
private pendingTools = new Map<string, number>();
|
|
181
|
+
private toolMs = 0;
|
|
182
|
+
|
|
183
|
+
/** Most recent turn_start timestamp — stamped onto records as requestAt. */
|
|
184
|
+
private lastTurnStart = 0;
|
|
185
|
+
|
|
186
|
+
/** Pending char-count used to keep density estimates incremental. */
|
|
187
|
+
private pendingChars = new Map<number, number>();
|
|
188
|
+
|
|
189
|
+
// ── Streaming-hook API ────────────────────────────────────────────────
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Assistant message begins streaming. Creates the record but does NOT
|
|
193
|
+
* start the clock — that happens at the first delta (output-only timing).
|
|
194
|
+
*/
|
|
195
|
+
onMessageStart(messageIndex: number): void {
|
|
196
|
+
if (this.records[messageIndex]) return; // re-start of known message
|
|
197
|
+
while (this.records.length < messageIndex) {
|
|
198
|
+
this.records.push({
|
|
199
|
+
messageIndex: this.records.length,
|
|
200
|
+
tokens: 0,
|
|
201
|
+
estimatedTokens: 0,
|
|
202
|
+
anchored: false,
|
|
203
|
+
requestAt: this.lastTurnStart,
|
|
204
|
+
startedAt: 0,
|
|
205
|
+
completedAt: 0,
|
|
206
|
+
tps: 0,
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
this.records.push({
|
|
210
|
+
messageIndex,
|
|
211
|
+
tokens: 0,
|
|
212
|
+
estimatedTokens: 0,
|
|
213
|
+
anchored: false,
|
|
214
|
+
// turn_start precedes message_start in pi's event order, so a fresh
|
|
215
|
+
// record inherits the current open-turn start as its TTFT bound.
|
|
216
|
+
requestAt: this.lastTurnStart,
|
|
217
|
+
startedAt: 0,
|
|
218
|
+
completedAt: 0,
|
|
219
|
+
tps: 0,
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The request boundary for this assistant message — pi's turn_start.
|
|
225
|
+
* Timing authority stays with streaming hooks; this only stamps the TTFT
|
|
226
|
+
* start bound and never touches startedAt.
|
|
227
|
+
*/
|
|
228
|
+
onTurnStart(timestamp?: number): void {
|
|
229
|
+
const ts = typeof timestamp === "number" && timestamp > 0 ? timestamp : Date.now();
|
|
230
|
+
this.lastTurnStart = ts;
|
|
231
|
+
if (this.turnStartAt === 0) {
|
|
232
|
+
this.turns += 1;
|
|
233
|
+
this.turnStartAt = ts;
|
|
234
|
+
}
|
|
235
|
+
const pending = this.records.find(r => r.completedAt === 0);
|
|
236
|
+
if (!pending || pending.requestAt !== 0) return;
|
|
237
|
+
pending.requestAt = ts;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** One streamed delta arrived. Starts the clock on the FIRST NON-EMPTY delta. */
|
|
241
|
+
onStreamingDelta(messageIndex: number, deltaText: string): void {
|
|
242
|
+
const record = this.records[messageIndex];
|
|
243
|
+
if (!record || record.completedAt !== 0) return;
|
|
244
|
+
if (!deltaText) return;
|
|
245
|
+
if (record.startedAt === 0) {
|
|
246
|
+
record.startedAt = Date.now();
|
|
247
|
+
// Harness rule: record one TTFT sample per step ONLY when both
|
|
248
|
+
// bounds exist; steps missing either drop out of the average.
|
|
249
|
+
if (record.requestAt > 0 && record.requestAt <= record.startedAt) {
|
|
250
|
+
this.ttftMs += Math.max(0, record.startedAt - record.requestAt);
|
|
251
|
+
this.ttftSteps += 1;
|
|
252
|
+
this.ttftHookSamples += 1;
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
record.estimatedTokens += deltaContribution(deltaText);
|
|
256
|
+
record.tokens = record.estimatedTokens;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/** Assistant stream finished. Anchors to provider usage.output. */
|
|
260
|
+
onMessageEnd(messageIndex: number, finalMessage?: unknown): void {
|
|
261
|
+
if (!this.records[messageIndex]) this.onMessageStart(messageIndex);
|
|
262
|
+
const record = this.records[messageIndex];
|
|
263
|
+
if (!record || record.completedAt !== 0) return;
|
|
264
|
+
record.completedAt = Date.now();
|
|
265
|
+
|
|
266
|
+
const providerOut = providerOutputTokens(finalMessage);
|
|
267
|
+
if (providerOut > 0) {
|
|
268
|
+
record.tokens = providerOut;
|
|
269
|
+
record.anchored = true;
|
|
270
|
+
} else if (finalMessage && !record.startedAt) {
|
|
271
|
+
record.estimatedTokens = Math.max(
|
|
272
|
+
record.estimatedTokens,
|
|
273
|
+
estimateOutputTokens(finalMessage),
|
|
274
|
+
);
|
|
275
|
+
record.tokens = record.estimatedTokens;
|
|
276
|
+
}
|
|
277
|
+
if (!record.startedAt) {
|
|
278
|
+
// Never saw a delta (ultra-fast or scan-only): fall back to the
|
|
279
|
+
// provider message timestamp so the window is still output-ish.
|
|
280
|
+
record.startedAt =
|
|
281
|
+
messageTimestamp(finalMessage) || record.completedAt - 500;
|
|
282
|
+
} const durationSec = Math.max(
|
|
283
|
+
(record.completedAt - record.startedAt) / 1000,
|
|
284
|
+
0.05,
|
|
285
|
+
);
|
|
286
|
+
record.tps = record.tokens > 0 ? record.tokens / durationSec : 0;
|
|
287
|
+
this.totalOutput += record.tokens;
|
|
288
|
+
this.pendingChars.delete(messageIndex);
|
|
289
|
+
this.steps += 1;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// ── Tool timing (harness session-stats: call → result pairs) ────────────
|
|
293
|
+
|
|
294
|
+
onToolCallStart(callId: string): void {
|
|
295
|
+
if (callId && !this.pendingTools.has(callId)) this.pendingTools.set(callId, Date.now());
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
onToolCallEnd(callId: string): void {
|
|
299
|
+
const started = this.pendingTools.get(callId);
|
|
300
|
+
if (started === undefined) return;
|
|
301
|
+
this.pendingTools.delete(callId);
|
|
302
|
+
this.toolMs += Math.max(0, Date.now() - started);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
/**
|
|
306
|
+
* Branch-derived tool-time fallback for restart-proof stats: pairs each
|
|
307
|
+
* assistant tool_use call with its toolResult timestamp (matched by
|
|
308
|
+
* callId) from the session branch. Monotonic — the live-hook sum always
|
|
309
|
+
* wins because it measures wall clock, while timestamps under-measure by
|
|
310
|
+
* excluding queueing… but after a restart hooks have nothing, so seeds.
|
|
311
|
+
*/
|
|
312
|
+
syncToolMs(toolMs: number): void {
|
|
313
|
+
if (toolMs > this.toolMs) this.toolMs = toolMs;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Close the currently open turn, accumulating its wall time. */
|
|
317
|
+
onTurnEnd(): void {
|
|
318
|
+
if (this.turnStartAt > 0) {
|
|
319
|
+
this.llmMs += Math.max(0, Date.now() - this.turnStartAt);
|
|
320
|
+
this.turnStartAt = 0;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// ── Branch-derived session stats (scan fallback) ─────────────────────
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Derive turns / steps from the session branch. Used by the 1s scan so
|
|
328
|
+
* the strip works even when turn hooks are unavailable (extensions loaded
|
|
329
|
+
* late, steering/queued flows bypassing them, or providers whose streams
|
|
330
|
+
* never surface them to us).
|
|
331
|
+
*
|
|
332
|
+
* A turn = one user message; a step = one completed assistant message.
|
|
333
|
+
* Monotonic: values never decrease across scans (branch grows).
|
|
334
|
+
*/
|
|
335
|
+
syncBranchStats(userCount: number, assistantCount: number): void {
|
|
336
|
+
if (userCount > this.turns) this.turns = userCount;
|
|
337
|
+
if (assistantCount > this.steps) this.steps = assistantCount;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* Wall-time fallback from persisted timestamps when hook-based llmMs is
|
|
342
|
+
* empty: first assistant timestamp → last assistant timestamp on branch.
|
|
343
|
+
*/
|
|
344
|
+
syncWallMs(wallMs: number): void {
|
|
345
|
+
if (wallMs > this.llmMs) this.llmMs = wallMs;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* TTFT fallback for environments where turn hooks never fire (streaming
|
|
350
|
+
* deltas absent too — e.g. scan-only reconciliation): use the PREVIOUS
|
|
351
|
+
* assistant message's timestamp as the request bound and the message's own
|
|
352
|
+
* timestamp as first-output. Approximation; only sampled when no hook
|
|
353
|
+
* samples exist yet (hook data always wins).
|
|
354
|
+
*/
|
|
355
|
+
seedTtftFallback(previousAssistantTs: number, assistantTs: number, index: number): void {
|
|
356
|
+
if (this.ttftHookSamples > 0) return; // hooks produced real samples
|
|
357
|
+
const record = this.records[index];
|
|
358
|
+
if (!record || record.completedAt === 0 || record.requestAt > 0) return;
|
|
359
|
+
// One seed per record — the scan repeats every second.
|
|
360
|
+
const flagged = record as unknown as { ttftSeeded?: boolean };
|
|
361
|
+
if (flagged.ttftSeeded) return;
|
|
362
|
+
if (previousAssistantTs <= 0 || assistantTs <= previousAssistantTs) return;
|
|
363
|
+
flagged.ttftSeeded = true;
|
|
364
|
+
// Approximate window: prev assistant ts → this assistant ts, clamped so
|
|
365
|
+
// idle gaps can't poison the average.
|
|
366
|
+
this.ttftMs += Math.min(assistantTs - previousAssistantTs, 30_000);
|
|
367
|
+
this.ttftSteps += 1;
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
// ── Reconciliation-scan API ───────────────────────────────────────────
|
|
371
|
+
|
|
372
|
+
/**
|
|
373
|
+
* Snapshot update from the 1s branch scan or message_update events.
|
|
374
|
+
*
|
|
375
|
+
* For in-flight messages this supplies an optional full-message density
|
|
376
|
+
* estimate (self-correction). For records already complete it is a no-op.
|
|
377
|
+
* Timing authority remains the streaming hooks; the scan may only set
|
|
378
|
+
* startedAt for never-seen messages via onMessageEnd's fallback logic.
|
|
379
|
+
*/
|
|
380
|
+
onMessageUpdate(messageIndex: number, message: unknown, hasStopReason: boolean): void {
|
|
381
|
+
const now = Date.now();
|
|
382
|
+
const existing = this.records[messageIndex];
|
|
383
|
+
|
|
384
|
+
if (!existing) {
|
|
385
|
+
// First sighting by the scan. Create the record; do not fabricate
|
|
386
|
+
// start times here — wait for end-of-stream info.
|
|
387
|
+
while (this.records.length < messageIndex) {
|
|
388
|
+
this.records.push({
|
|
389
|
+
messageIndex: this.records.length,
|
|
390
|
+
tokens: 0,
|
|
391
|
+
estimatedTokens: 0,
|
|
392
|
+
anchored: false,
|
|
393
|
+
requestAt: this.lastTurnStart,
|
|
394
|
+
startedAt: 0,
|
|
395
|
+
completedAt: 0,
|
|
396
|
+
tps: 0,
|
|
397
|
+
});
|
|
398
|
+
}
|
|
399
|
+
if (hasStopReason) {
|
|
400
|
+
// Scan-only fast path: already done when first seen.
|
|
401
|
+
this.onMessageStart(messageIndex);
|
|
402
|
+
this.onMessageEnd(messageIndex, message);
|
|
403
|
+
} else {
|
|
404
|
+
this.onMessageStart(messageIndex);
|
|
405
|
+
}
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
if (existing.completedAt !== 0) return; // immutable once done
|
|
410
|
+
|
|
411
|
+
// In-flight snapshot: refine the density estimate (max-wins vs deltas).
|
|
412
|
+
const est = estimateOutputTokens(message);
|
|
413
|
+
if (est > existing.estimatedTokens) {
|
|
414
|
+
existing.estimatedTokens = est;
|
|
415
|
+
existing.tokens = est;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
if (hasStopReason) this.onMessageEnd(messageIndex, message);
|
|
419
|
+
else void now; // now unused unless we later need it here
|
|
420
|
+
}
|
|
421
|
+
|
|
422
|
+
// ── Metrics ───────────────────────────────────────────────────────────
|
|
423
|
+
|
|
424
|
+
/** Live TPS from the currently generating message (output-only window). */
|
|
425
|
+
getLiveTps(): number {
|
|
426
|
+
for (let i = this.records.length - 1; i >= 0; i--) {
|
|
427
|
+
const r = this.records[i];
|
|
428
|
+
if (r.completedAt === 0 && r.startedAt > 0) {
|
|
429
|
+
const elapsedSec = (Date.now() - r.startedAt) / 1000;
|
|
430
|
+
if (elapsedSec <= 0) return 0;
|
|
431
|
+
return r.tokens / elapsedSec;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
// Idle: last completed message
|
|
435
|
+
if (this.records.length > 0) return this.records[this.records.length - 1].tps;
|
|
436
|
+
return 0;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
/** Session average TPS across completed + current generation windows. */
|
|
440
|
+
/**
|
|
441
|
+
* Honest per-record duration override from branch order: a persisted
|
|
442
|
+
* assistant message ended by the time the NEXT entry arrived. Feeding
|
|
443
|
+
* these durations replaces first-scan-sighting reconstruction (which made
|
|
444
|
+
* every historical message look 10 min long → tok/s ≈ 0).
|
|
445
|
+
*/
|
|
446
|
+
syncRecordDurations(durations: Map<number, number>): void {
|
|
447
|
+
for (const [idx, durMs] of durations) {
|
|
448
|
+
const r = this.records[idx];
|
|
449
|
+
if (!r || r.completedAt === 0 || !r.startedAt) continue;
|
|
450
|
+
const sec = Math.max(0.05, Math.min(durMs / 1000, TpsTracker.MAX_RECORD_DURATION_SEC));
|
|
451
|
+
// Recompute this record's tps contribution lazily via tokens/duration
|
|
452
|
+
r.tps = sec > 0 ? r.tokens / sec : r.tps;
|
|
453
|
+
(r as unknown as { forcedDurationSec?: number }).forcedDurationSec = sec;
|
|
454
|
+
}
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** Session average TPS across completed + current generation windows.
|
|
458
|
+
*
|
|
459
|
+
* Scan-reconciled OLD messages (after restart/reload) can yield absurd
|
|
460
|
+
* durations: startedAt comes from the provider timestamp, completedAt
|
|
461
|
+
* from 'first time our scanner saw it' = now — i.e. minutes/hours for a
|
|
462
|
+
* message that generated in seconds. Each record's duration is therefore
|
|
463
|
+
* clamped to MAX_RECORD_DURATION_SEC before averaging; syncRecordDurations
|
|
464
|
+
* supersedes the clamp with real next-entry deltas when available.
|
|
465
|
+
*/
|
|
466
|
+
private static readonly MAX_RECORD_DURATION_SEC = 600;
|
|
467
|
+
|
|
468
|
+
getSessionAvgTps(): number {
|
|
469
|
+
const cap = TpsTracker.MAX_RECORD_DURATION_SEC;
|
|
470
|
+
let totalTokens = 0;
|
|
471
|
+
let totalDurationSec = 0;
|
|
472
|
+
for (const r of this.records) {
|
|
473
|
+
if (r.completedAt > 0 && r.startedAt > 0) {
|
|
474
|
+
totalTokens += r.tokens;
|
|
475
|
+
// Branch-derived duration wins; fallback reconstructs from
|
|
476
|
+
// startedAt→completedAt clamped at the cap.
|
|
477
|
+
const forced = (r as unknown as { forcedDurationSec?: number }).forcedDurationSec;
|
|
478
|
+
totalDurationSec += forced ?? Math.min((r.completedAt - r.startedAt) / 1000, cap);
|
|
479
|
+
} else if (r.completedAt === 0 && r.startedAt > 0) {
|
|
480
|
+
totalTokens += r.tokens;
|
|
481
|
+
totalDurationSec += Math.min((Date.now() - r.startedAt) / 1000, cap);
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
if (totalDurationSec <= 0) return 0;
|
|
485
|
+
return totalTokens / totalDurationSec;
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
isStreaming(): boolean {
|
|
489
|
+
for (let i = this.records.length - 1; i >= 0; i--) {
|
|
490
|
+
const r = this.records[i];
|
|
491
|
+
if (r.completedAt === 0 && r.startedAt > 0) return true;
|
|
492
|
+
if (r.completedAt > 0) return false;
|
|
493
|
+
}
|
|
494
|
+
return false;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
/** Total output tokens for the session (anchored per message). */
|
|
498
|
+
getTotalOutput(): number {
|
|
499
|
+
let total = this.totalOutput;
|
|
500
|
+
for (const r of this.records) {
|
|
501
|
+
if (r.completedAt === 0 && r.startedAt > 0) total += r.tokens;
|
|
502
|
+
}
|
|
503
|
+
return total;
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* Average time-to-first-token in ms (harness semantics: per-step samples
|
|
508
|
+
* summed over completed messages where BOTH turn-start and first-delta
|
|
509
|
+
* were observed; unbounded steps drop out instead of skewing).
|
|
510
|
+
* Returns null when no complete sample exists.
|
|
511
|
+
*/
|
|
512
|
+
getAvgTtftMs(): number | null {
|
|
513
|
+
return this.ttftSteps > 0 ? Math.round(this.ttftMs / this.ttftSteps) : null;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/** Number of TTFT samples recorded (diagnostics). */
|
|
517
|
+
getTtftSamples(): number {
|
|
518
|
+
return this.ttftSteps;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// ── Session strip stats accessors ─────────────────────────────────────
|
|
522
|
+
|
|
523
|
+
getTurnCount(): number {
|
|
524
|
+
return this.turns;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
getStepCount(): number {
|
|
528
|
+
return this.steps;
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/** Accumulated agent wall time across completed + open turn, ms. */
|
|
532
|
+
getSessionLlmMs(): number {
|
|
533
|
+
let ms = this.llmMs;
|
|
534
|
+
if (this.turnStartAt > 0) ms += Math.max(0, Date.now() - this.turnStartAt);
|
|
535
|
+
return ms;
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Accumulated tool-execution wall time, ms. */
|
|
539
|
+
getToolMs(): number {
|
|
540
|
+
return this.toolMs;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
reset(): void {
|
|
544
|
+
this.records = [];
|
|
545
|
+
this.totalOutput = 0;
|
|
546
|
+
this.pendingChars.clear();
|
|
547
|
+
this.ttftMs = 0;
|
|
548
|
+
this.ttftSteps = 0;
|
|
549
|
+
this.ttftHookSamples = 0;
|
|
550
|
+
this.turns = 0;
|
|
551
|
+
this.steps = 0;
|
|
552
|
+
this.turnStartAt = 0;
|
|
553
|
+
this.llmMs = 0;
|
|
554
|
+
this.pendingTools.clear();
|
|
555
|
+
this.toolMs = 0;
|
|
556
|
+
this.lastTurnStart = 0;
|
|
557
|
+
}
|
|
269
558
|
}
|
|
270
559
|
|
|
271
560
|
/** Singleton TPS tracker instance */
|