oc-metricboard 0.1.5 → 0.1.7

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "oc-metricboard",
3
- "version": "0.1.5",
3
+ "version": "0.1.7",
4
4
  "description": "OpenCode TUI sidebar plugin showing real-time LLM metrics (TPS, TTFT, tokens) with sub-agent × model breakdown",
5
5
  "keywords": [
6
6
  "opencode",
@@ -4,6 +4,7 @@ import { currentRequest } from "./request-state"
4
4
  import { eventAggregateID, eventID, eventProperties, stringEventProperty } from "./event-bus"
5
5
  import type { EventHandlerContext } from "./collector-state"
6
6
  import { recordLiveTokens } from "./live-speed"
7
+ import { ensureTurn, recordTurnFirstToken } from "./turn-state"
7
8
 
8
9
  export interface AssistantProgressPart {
9
10
  readonly partID: string
@@ -26,6 +27,7 @@ function applyTokenDelta(
26
27
  if (state.turns.get(sessionID)?.finalizedSteps.has(messageID)) return
27
28
  const current = currentRequest({ state, actions, sessionID, messageID, now })
28
29
  if (current.firstTokenTime === null) current.firstTokenTime = now
30
+ recordTurnFirstToken(ensureTurn(state.turns, sessionID, now), now)
29
31
  if (deltaTokens > 0) {
30
32
  current.estimatedOutputTokens += deltaTokens
31
33
  current.lastDeltaTime = now
package/src/collector.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import type { TuiPluginApi } from "@opencode-ai/plugin/tui"
2
2
  import type { BarConfig, CacheReadCompleteness, MetricsAggregate, MetricsScope, ModelMetrics, RequestMetrics } from "./types"
3
- import { getDisplayInputTokens, getDisplayOutputTokens, getTtft } from "./metrics"
3
+ import { getDisplayInputTokens, getDisplayOutputTokens, gateTtft, getTtft } from "./metrics"
4
4
  import { registerEventHandlers } from "./event-handlers"
5
5
  import type { CollectorState } from "./collector-state"
6
6
  import type { MetricsEventApi } from "./event-bus"
@@ -352,7 +352,7 @@ export function createCollector(
352
352
 
353
353
  const cacheReadCompleteness: CacheReadCompleteness =
354
354
  exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial"
355
- const ttft = firstTokenTime === null ? null : Math.round(firstTokenTime - requestStartTime)
355
+ const ttft = firstTokenTime === null ? null : gateTtft(firstTokenTime - requestStartTime)
356
356
 
357
357
  // Live TPS for this model group
358
358
  let liveTps = 0
@@ -546,7 +546,15 @@ export function createCollector(
546
546
 
547
547
  // NEW: Build per-model breakdown for tree scope
548
548
  const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope) : []
549
- const ttft = foregroundRequest ? getTtft(foregroundRequest) : null
549
+ // Turn-level TTFT: opencode stamps step.started at the first token, so
550
+ // intra-turn steps cannot measure their own request start (they would
551
+ // collapse to ~0). Anchor TTFT to the turn's user message instead —
552
+ // stable across all steps within a turn, refreshed on each new turn.
553
+ // Falls back to the per-request measurement when no turn timing exists.
554
+ const turnTtft = foregroundTurn?.firstTokenTime != null
555
+ ? gateTtft(foregroundTurn.firstTokenTime - foregroundTurn.turnStartTime)
556
+ : null
557
+ const ttft = turnTtft ?? (foregroundRequest ? getTtft(foregroundRequest) : null)
550
558
 
551
559
  const result: MetricsAggregate = {
552
560
  sessionIDs: contributingSessionIDs.length > 0 ? contributingSessionIDs : [rootID],
package/src/metrics.ts CHANGED
@@ -81,7 +81,7 @@ export function aggregateRequestMetrics(
81
81
 
82
82
  const cacheReadCompleteness: CacheReadCompleteness =
83
83
  exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial"
84
- const ttft = firstTokenTime === null ? null : Math.round(firstTokenTime - requestStartTime)
84
+ const ttft = firstTokenTime === null ? null : gateTtft(firstTokenTime - requestStartTime)
85
85
  const sessionIDs = [...new Set(metrics.map((m) => m.sessionID))]
86
86
 
87
87
  return {
@@ -98,6 +98,7 @@ export function aggregateRequestMetrics(
98
98
  liveTps: null,
99
99
  isStreaming,
100
100
  isComplete,
101
+ modelBreakdown: [],
101
102
  }
102
103
  }
103
104
 
@@ -112,11 +113,18 @@ export function aggregateRequestMetrics(
112
113
  */
113
114
  export const MAX_TTFT_MS = 10 * 60 * 1000
114
115
 
116
+ /**
117
+ * Gate a raw TTFT value: non-finite, negative, or beyond MAX_TTFT_MS is not a
118
+ * meaningful TTFT and is reported as null (rendered "--").
119
+ */
120
+ export function gateTtft(rawMs: number): number | null {
121
+ if (!Number.isFinite(rawMs) || rawMs < 0 || rawMs > MAX_TTFT_MS) return null
122
+ return Math.round(rawMs)
123
+ }
124
+
115
125
  export function getTtft(m: RequestMetrics): number | null {
116
126
  if (m.firstTokenTime === null) return null
117
- const ttft = m.firstTokenTime - m.requestStartTime
118
- if (!Number.isFinite(ttft) || ttft < 0 || ttft > MAX_TTFT_MS) return null
119
- return Math.round(ttft)
127
+ return gateTtft(m.firstTokenTime - m.requestStartTime)
120
128
  }
121
129
 
122
130
  /**
@@ -38,13 +38,18 @@ export function currentRequest(input: CurrentRequestInput): RequestMetrics {
38
38
  ? startTurn(input.state.turns, input.sessionID, input.now)
39
39
  : ensureTurn(input.state.turns, input.sessionID, existing?.requestStartTime ?? input.now)
40
40
  if (existing && isDifferentMessage && !startsAfterCompletedTurn) retireRequestIntoTurn(turn, existing)
41
- // Keep the existing request's start time when continuing the same turn:
42
- // opencode 1.18.x stamps step.started lazily at the first token, so a fresh
43
- // `now` here would collapse TTFT to ~0. The user message's created time is
44
- // the correct "request submitted" anchor.
45
- const requestStartTime = startsAfterCompletedTurn
46
- ? input.now
47
- : (existing?.requestStartTime ?? input.now)
41
+ // Keep the existing request's start time ONLY while the existing request
42
+ // is still an un-streamed placeholder (opencode 1.18.x stamps
43
+ // step.started lazily at the first token, so the user message's created
44
+ // time is the correct "request submitted" anchor). Once the previous
45
+ // request has produced a first token it was a real generation, and a new
46
+ // messageID is a NEW step: it must NOT inherit the turn's original start
47
+ // time, otherwise TTFT absorbs every tool execution since the turn began
48
+ // (huge TTFT in long agentic sessions).
49
+ const inheritAnchor = !startsAfterCompletedTurn && (existing?.firstTokenTime ?? null) === null
50
+ const requestStartTime = inheritAnchor
51
+ ? (existing?.requestStartTime ?? input.now)
52
+ : input.now
48
53
  const current = createFreshMetrics(
49
54
  input.sessionID,
50
55
  input.messageID,
@@ -233,6 +233,11 @@ export async function hydrateSession(input: HydrateSessionInput): Promise<boolea
233
233
  input.now,
234
234
  ) ?? requestStartTime
235
235
  const turn = createTurnMetrics(input.sessionID, turnStartTime)
236
+ // Turn-level TTFT anchor: the first trailing assistant's createdTime is
237
+ // stamped at its first token, so turnStart → that instant is the hydrated
238
+ // turn's time-to-first-token. Fall back to the live request's first token.
239
+ turn.firstTokenTime = toPerformanceTime(trailingAssistants[0]?.createdTime, input.now)
240
+ ?? current.firstTokenTime
236
241
  for (const step of trailingAssistants) {
237
242
  if (!step.tokens || !hasPositiveAssistantTokens(step.tokens)) continue
238
243
  const stepStart = toPerformanceTime(step.createdTime, input.now) ?? turnStartTime
package/src/turn-state.ts CHANGED
@@ -5,6 +5,7 @@ export function createTurnMetrics(sessionID: string, now: number): TurnMetrics {
5
5
  return {
6
6
  sessionID,
7
7
  turnStartTime: now,
8
+ firstTokenTime: null,
8
9
  completeTime: null,
9
10
  finalizedOutputTokens: 0,
10
11
  finalizedSteps: new Map(),
@@ -83,3 +84,13 @@ export function completeTurn(turn: TurnMetrics, now: number): void {
83
84
  turn.isComplete = true
84
85
  turn.completeTime = now
85
86
  }
87
+
88
+ /**
89
+ * Record the turn's first token instant (once). opencode stamps step.started
90
+ * at the first token, so intra-turn steps cannot measure their own request
91
+ * start; the turn-level anchor (user message → first delta) is the only
92
+ * meaningful TTFT observable from the event stream.
93
+ */
94
+ export function recordTurnFirstToken(turn: TurnMetrics, now: number): void {
95
+ if (turn.firstTokenTime === null) turn.firstTokenTime = now
96
+ }
package/src/types.ts CHANGED
@@ -37,6 +37,7 @@ export interface FinalizedStepUsage {
37
37
  export interface TurnMetrics {
38
38
  sessionID: string
39
39
  turnStartTime: number
40
+ firstTokenTime: number | null // First token of the turn (user message → first delta)
40
41
  completeTime: number | null
41
42
  finalizedOutputTokens: number
42
43
  finalizedSteps: Map<string, FinalizedStepUsage>