@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.
@@ -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
- * Output tokens are counted from the assistant message *content* (text +
11
- * thinking blocks) via a lightweight word/char heuristic, NOT from
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
- * The heuristic intentionally over-counts slightly (whitespace + punctuation
25
- * are treated as tokens). That is acceptable for a live speed indicator and
26
- * keeps the estimate in the right order of magnitude (within ~10-20% of the
27
- * provider's reported output tokens for typical English/code output).
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
- /** Message index in the session (sequential, assistant-only) */
33
- messageIndex: number;
34
- /** Estimated output tokens produced so far for this message */
35
- outputTokens: number;
36
- /** When generation started (Date.now(), ms) */
37
- startedAt: number;
38
- /** When generation completed (Date.now(), ms), 0 if still generating */
39
- completedAt: number;
40
- /** Computed TPS for this message (final, once completed) */
41
- tps: number;
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
- * Estimate the number of tokens in a string.
46
- *
47
- * Uses a cheap word + punctuation split. Good enough for a live TPS display;
48
- * we deliberately avoid pulling in a full BPE tokenizer for performance and
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
- if (!text) return 0;
53
- // Count word-ish tokens and standalone punctuation.
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
- * Estimate output tokens from an assistant message by summing text and
64
- * thinking content lengths. Tool-call argument JSON is also counted since
65
- * it represents model output.
66
- *
67
- * Accepts the raw message shape from pi's session entries (loosely typed).
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
- if (!message || typeof message !== "object") return 0;
71
- const m = message as Record<string, unknown>;
72
- if (m.role !== "assistant") return 0;
73
- const content = m.content;
74
- if (!Array.isArray(content)) return 0;
75
-
76
- let total = 0;
77
- for (const block of content) {
78
- if (!block || typeof block !== "object") continue;
79
- const b = block as Record<string, unknown>;
80
- const type = b.type;
81
- if (type === "text" && typeof b.text === "string") {
82
- total += estimateTokens(b.text);
83
- } else if (type === "thinking" && typeof b.thinking === "string") {
84
- total += estimateTokens(b.thinking);
85
- } else if (type === "tool_use" || type === "toolcall") {
86
- // Count tool name + serialized arguments as model output.
87
- const name = typeof b.name === "string" ? b.name : "";
88
- const input = b.input ?? b.arguments;
89
- let inputStr: string;
90
- try {
91
- inputStr = typeof input === "string" ? input : JSON.stringify(input ?? "");
92
- } catch {
93
- inputStr = "";
94
- }
95
- total += estimateTokens(name) + estimateTokens(inputStr);
96
- }
97
- }
98
- return total;
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
- * Usage: Call `onMessageUpdate()` on every tick (e.g. 1s interval) with the
105
- * current assistant message state. The tracker records generation start/stop
106
- * per message and computes live TPS from elapsed wall-clock time.
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
- /** Per-message records (one per assistant message, in order) */
110
- private records: MessageTpsRecord[] = [];
111
-
112
- /** Total estimated output tokens across all messages */
113
- private totalOutput = 0;
114
-
115
- /**
116
- * Update with the latest message data from the session.
117
- * Call this on every tick (e.g. 1s interval) with the current state.
118
- *
119
- * @param messageIndex - Index of the assistant message (0-based, sequential)
120
- * @param message - The assistant message object (content used to count tokens)
121
- * @param hasStopReason - Whether this message has completed (has stopReason)
122
- */
123
- onMessageUpdate(messageIndex: number, message: unknown, hasStopReason: boolean): void {
124
- const now = Date.now();
125
- const outputTokens = estimateOutputTokens(message);
126
-
127
- // New message — create a record
128
- if (messageIndex >= this.records.length) {
129
- // Fill gaps if indices jump (shouldn't normally happen)
130
- while (this.records.length < messageIndex) {
131
- this.records.push({
132
- messageIndex: this.records.length,
133
- outputTokens: 0,
134
- startedAt: 0,
135
- completedAt: 0,
136
- tps: 0,
137
- });
138
- }
139
-
140
- if (hasStopReason && outputTokens > 0) {
141
- // Fast message: already completed on first sighting.
142
- // We never observed it streaming, so we can't measure real duration.
143
- // Fall back to a conservative estimate instead of inventing a 1s
144
- // window (which previously produced inflated TPS values).
145
- const estimatedDuration = Math.max(0.5, outputTokens / 60);
146
- const tps = outputTokens / estimatedDuration;
147
- this.records.push({
148
- messageIndex,
149
- outputTokens,
150
- startedAt: now - estimatedDuration * 1000,
151
- completedAt: now,
152
- tps,
153
- });
154
- this.totalOutput += outputTokens;
155
- } else {
156
- // Just started — mark start time
157
- this.records.push({
158
- messageIndex,
159
- outputTokens,
160
- startedAt: now,
161
- completedAt: 0,
162
- tps: 0,
163
- });
164
- }
165
- return;
166
- }
167
-
168
- // Update existing message
169
- const record = this.records[messageIndex];
170
- if (!record) return;
171
-
172
- if (record.completedAt === 0) {
173
- // Still generating — update token count (live TPS computed on demand)
174
- record.outputTokens = outputTokens;
175
-
176
- if (hasStopReason) {
177
- // Message just completed
178
- record.completedAt = now;
179
- const durationSec = (record.completedAt - record.startedAt) / 1000;
180
- record.tps = durationSec > 0 ? record.outputTokens / durationSec : 0;
181
- this.totalOutput += record.outputTokens;
182
- }
183
- }
184
- }
185
-
186
- /**
187
- * Get the live TPS from the currently generating message.
188
- * Returns the instantaneous rate based on tokens generated so far
189
- * in the current message divided by elapsed wall-clock time.
190
- */
191
- getLiveTps(): number {
192
- // Find the last record that's still generating
193
- for (let i = this.records.length - 1; i >= 0; i--) {
194
- const record = this.records[i];
195
- if (record.completedAt === 0 && record.startedAt > 0) {
196
- // Currently generating
197
- const elapsedSec = (Date.now() - record.startedAt) / 1000;
198
- if (elapsedSec <= 0) return 0;
199
- return record.outputTokens / elapsedSec;
200
- }
201
- }
202
- // No active generation — return the last completed message's TPS
203
- if (this.records.length > 0) {
204
- const last = this.records[this.records.length - 1];
205
- return last.tps;
206
- }
207
- return 0;
208
- }
209
-
210
- /**
211
- * Get the session average TPS, excluding idle/tool-execution time.
212
- * Computed as total output tokens / total generation time.
213
- */
214
- getSessionAvgTps(): number {
215
- let totalTokens = 0;
216
- let totalDurationSec = 0;
217
-
218
- for (const record of this.records) {
219
- if (record.completedAt > 0 && record.startedAt > 0) {
220
- totalTokens += record.outputTokens;
221
- totalDurationSec += (record.completedAt - record.startedAt) / 1000;
222
- }
223
- }
224
-
225
- // Include currently generating message in average
226
- for (let i = this.records.length - 1; i >= 0; i--) {
227
- if (this.records[i].completedAt === 0 && this.records[i].startedAt > 0) {
228
- totalTokens += this.records[i].outputTokens;
229
- totalDurationSec += (Date.now() - this.records[i].startedAt) / 1000;
230
- break;
231
- }
232
- }
233
-
234
- if (totalDurationSec <= 0) return 0;
235
- return totalTokens / totalDurationSec;
236
- }
237
-
238
- /**
239
- * Whether the model is currently streaming tokens.
240
- * True if the latest message has started but not completed.
241
- */
242
- isStreaming(): boolean {
243
- if (this.records.length === 0) return false;
244
- const last = this.records[this.records.length - 1];
245
- return last.startedAt > 0 && last.completedAt === 0;
246
- }
247
-
248
- /**
249
- * Get total output tokens for the session (estimated).
250
- */
251
- getTotalOutput(): number {
252
- // Include tokens from incomplete messages too
253
- let total = this.totalOutput;
254
- for (const record of this.records) {
255
- if (record.completedAt === 0 && record.startedAt > 0) {
256
- total += record.outputTokens;
257
- }
258
- }
259
- return total;
260
- }
261
-
262
- /**
263
- * Reset the tracker (e.g., on session shutdown).
264
- */
265
- reset(): void {
266
- this.records = [];
267
- this.totalOutput = 0;
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 */