oc-metricboard 0.1.4

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.
@@ -0,0 +1,345 @@
1
+ import { applyAssistantDelta, applyAssistantPartDelta, applyAssistantProgress } from "./assistant-progress"
2
+ import { createFreshMetrics, estimateTokens } from "./metrics"
3
+ import {
4
+ parseAssistantMessage,
5
+ parseAssistantProgressPart,
6
+ parseAssistantStepEnded,
7
+ parseAssistantStepStarted,
8
+ parseSessionInfo,
9
+ parseTextPart,
10
+ parseUserMessage,
11
+ } from "./event-shapes"
12
+ import { applyAssistantTokens, applySessionModel, hasPositiveAssistantTokens, mergeAssistantModel } from "./request-updates"
13
+ import { completeRequest, currentRequest } from "./request-state"
14
+ import { eventAggregateID, eventID, eventProperties, eventProperty, statusType, stringEventProperty } from "./event-bus"
15
+ import type { EventHandlerContext } from "./collector-state"
16
+ import { clearLiveSpeed, resetLiveSpeed } from "./live-speed"
17
+ import { ensureTurn, retireRequestIntoTurn, startTurn } from "./turn-state"
18
+
19
+ export function registerEventHandlers(ctx: EventHandlerContext): Array<() => void> {
20
+ const { api, config, log, state, actions } = ctx
21
+ const disposers: Array<() => void> = []
22
+
23
+ function recordSession(value: unknown, defaultParentWhenMissing: boolean): void {
24
+ const info = parseSessionInfo(value)
25
+ if (!info) return
26
+ if (info.hasParentID) {
27
+ state.sessionTree.setParent(info.id, info.parentID)
28
+ } else if (defaultParentWhenMissing) {
29
+ state.sessionTree.setParent(info.id, null)
30
+ }
31
+ }
32
+
33
+ function on(type: string, handler: (event: unknown) => void): void {
34
+ for (const eventType of [type, `${type}.1`, `${type}.2`]) {
35
+ disposers.push(api.event.on(eventType, (event) => {
36
+ const id = eventID(event)
37
+ const properties = eventProperties(event)
38
+ let serialized = ""
39
+ try {
40
+ serialized = JSON.stringify(properties)
41
+ } catch {
42
+ serialized = ""
43
+ }
44
+ const key = id && eventAggregateID(event) ? `${type}:${id}:${serialized}` : ""
45
+ if (key && state.seenEventKeys.has(key)) return
46
+ if (key) {
47
+ state.seenEventKeys.add(key)
48
+ state.seenEventOrder.push(key)
49
+ if (state.seenEventOrder.length > 4096) {
50
+ const oldest = state.seenEventOrder.shift()
51
+ if (oldest) state.seenEventKeys.delete(oldest)
52
+ }
53
+ }
54
+ handler(event)
55
+ }))
56
+ }
57
+ }
58
+
59
+ function addAlias(left: string, right: string): void {
60
+ if (left.length === 0 || right.length === 0 || left === right) return
61
+ const leftAliases = state.sessionAliases.get(left) ?? new Set<string>()
62
+ leftAliases.add(right)
63
+ state.sessionAliases.set(left, leftAliases)
64
+ const rightAliases = state.sessionAliases.get(right) ?? new Set<string>()
65
+ rightAliases.add(left)
66
+ state.sessionAliases.set(right, rightAliases)
67
+ }
68
+
69
+ function recordEventAlias(event: unknown, sessionID: string): void {
70
+ addAlias(eventAggregateID(event), sessionID)
71
+ }
72
+
73
+ function clearPartEstimates(sessionID: string): void {
74
+ for (const key of state.partTokenEstimates.keys()) {
75
+ if (key.startsWith(`${sessionID}:`)) state.partTokenEstimates.delete(key)
76
+ }
77
+ }
78
+
79
+ // ── session.created ──
80
+ on("session.created", (event) => {
81
+ recordSession(eventProperty(event, "info"), true)
82
+ actions.notify()
83
+ })
84
+
85
+ // ── session.updated ──
86
+ on("session.updated", (event) => {
87
+ recordSession(eventProperty(event, "info"), false)
88
+ actions.notify()
89
+ })
90
+
91
+ // ── session.status ──
92
+ on("session.status", (event) => {
93
+ const sessionID = stringEventProperty(event, "sessionID")
94
+ if (sessionID.length === 0) return
95
+ recordEventAlias(event, sessionID)
96
+ const type = statusType(event)
97
+ log(`session.status: ${sessionID} -> ${type}`)
98
+
99
+ if (type === "busy") {
100
+ const now = performance.now()
101
+ actions.startSessionTiming(sessionID, now)
102
+ actions.clearHoldTimer(sessionID)
103
+ const existingTurn = state.turns.get(sessionID)
104
+ if (!existingTurn || existingTurn.isComplete) startTurn(state.turns, sessionID, now)
105
+ const existing = state.requests.get(sessionID)
106
+ if (existing && existing.isComplete) {
107
+ const prev = existing
108
+ state.requests.set(sessionID, createFreshMetrics(sessionID, "", prev.modelID, prev.providerID, now))
109
+ clearLiveSpeed(state.liveSpeeds, sessionID)
110
+ state.lastRequestSessionID = sessionID
111
+ log(`new turn after idle: session=${sessionID}`)
112
+ actions.notify()
113
+ } else if (!existing) {
114
+ state.requests.set(sessionID, createFreshMetrics(sessionID, "", "", "", now))
115
+ state.lastRequestSessionID = sessionID
116
+ actions.notify()
117
+ }
118
+ }
119
+
120
+ if (type === "idle") {
121
+ completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log })
122
+ }
123
+ })
124
+
125
+ on("session.idle", (event) => {
126
+ const sessionID = stringEventProperty(event, "sessionID")
127
+ if (sessionID.length === 0) return
128
+ recordEventAlias(event, sessionID)
129
+ completeRequest({ state, actions, sessionID, now: performance.now(), holdDurationMs: config.holdDurationMs, log })
130
+ })
131
+
132
+ // ── message.part.delta ──
133
+ on("message.part.delta", (event) => {
134
+ const sessionID = stringEventProperty(event, "sessionID")
135
+ const messageID = stringEventProperty(event, "messageID")
136
+ const delta = stringEventProperty(event, "delta")
137
+ const field = stringEventProperty(event, "field")
138
+ const partID = stringEventProperty(event, "partID") || `${messageID}:stream`
139
+ if (sessionID.length === 0 || messageID.length === 0) return
140
+ recordEventAlias(event, sessionID)
141
+ if (field !== "text") return
142
+ actions.startSessionTiming(sessionID, performance.now())
143
+
144
+ const now = performance.now()
145
+ ensureTurn(state.turns, sessionID, now)
146
+ applyAssistantPartDelta(ctx, sessionID, messageID, partID, delta, now)
147
+ })
148
+
149
+ on("session.next.step.started", (event) => {
150
+ const step = parseAssistantStepStarted(eventProperties(event), eventID(event))
151
+ if (!step) return
152
+ recordEventAlias(event, step.sessionID)
153
+ const now = performance.now()
154
+ actions.startSessionTiming(step.sessionID, now)
155
+ const existingTurn = state.turns.get(step.sessionID)
156
+ if (!existingTurn || existingTurn.isComplete) startTurn(state.turns, step.sessionID, now)
157
+ state.assistantMessageIds.set(step.sessionID, step.messageID)
158
+ clearPartEstimates(step.sessionID)
159
+ if (step.modelID || step.providerID) {
160
+ state.sessionModels.set(step.sessionID, { modelID: step.modelID, providerID: step.providerID })
161
+ }
162
+ const current = currentRequest({ state, actions, sessionID: step.sessionID, messageID: step.messageID, now })
163
+ resetLiveSpeed(state.liveSpeeds, step.sessionID, step.messageID)
164
+ applySessionModel(current, state.sessionModels.get(step.sessionID))
165
+ actions.notify()
166
+ })
167
+
168
+ on("session.next.text.delta", (event) => applyAssistantDelta(ctx, event))
169
+
170
+ on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event))
171
+
172
+ on("session.next.step.ended", (event) => {
173
+ const sessionID = stringEventProperty(event, "sessionID")
174
+ const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event)
175
+ const step = parseAssistantStepEnded(eventProperties(event), fallbackMessageID)
176
+ if (!step) return
177
+ recordEventAlias(event, step.sessionID)
178
+ const now = performance.now()
179
+ actions.startSessionTiming(step.sessionID, now)
180
+ const turn = ensureTurn(state.turns, step.sessionID, now)
181
+ const existing = state.requests.get(step.sessionID)
182
+ const isCurrent = existing?.messageID === step.messageID
183
+ if (turn.finalizedSteps.get(step.messageID)?.exact) {
184
+ log(`duplicate finalized step ignored: session=${step.sessionID} message=${step.messageID}`)
185
+ return
186
+ }
187
+ const shouldStore = !existing
188
+ || existing.messageID.length === 0
189
+ || Boolean(state.userMessageIds.get(step.sessionID)?.has(existing.messageID))
190
+ const current = isCurrent
191
+ ? existing
192
+ : createFreshMetrics(step.sessionID, step.messageID, existing?.modelID ?? "", existing?.providerID ?? "", now)
193
+ if (shouldStore) {
194
+ state.requests.set(step.sessionID, current)
195
+ state.lastRequestSessionID = step.sessionID
196
+ }
197
+ if (applyAssistantTokens(current, step.tokens)) {
198
+ log(`exact: in=${step.tokens.input} out=${step.tokens.output} cr=${step.tokens.cacheRead} cw=${step.tokens.cacheWrite}`)
199
+ } else {
200
+ log("assistant tokens all zero, skipping overwrite")
201
+ }
202
+ current.lastDeltaTime = now
203
+ current.isStreaming = false
204
+ retireRequestIntoTurn(turn, current, isCurrent || shouldStore)
205
+ if (state.liveSpeeds.get(step.sessionID)?.messageID === step.messageID) {
206
+ clearLiveSpeed(state.liveSpeeds, step.sessionID)
207
+ }
208
+ actions.notify()
209
+ })
210
+
211
+ // ── message.part.updated ──
212
+ on("message.part.updated", (event) => {
213
+ const sessionID = stringEventProperty(event, "sessionID")
214
+ if (sessionID.length === 0) return
215
+ recordEventAlias(event, sessionID)
216
+ const rawPart = eventProperty(event, "part")
217
+ const part = parseTextPart(rawPart)
218
+ const now = performance.now()
219
+ const msgIds = state.userMessageIds.get(sessionID)
220
+ if (part && msgIds?.has(part.messageID)) {
221
+ const current = state.requests.get(sessionID)
222
+ if (current) {
223
+ if (!current.isComplete) actions.startSessionTiming(sessionID, now)
224
+ current.estimatedInputTokens = estimateTokens(part.text, config.estimationRatio)
225
+ log(`estimated input tokens: ${current.estimatedInputTokens} (textLen=${part.text.length})`)
226
+ actions.notify()
227
+ }
228
+ return
229
+ }
230
+
231
+ const assistantPart = parseAssistantProgressPart(rawPart)
232
+ if (assistantPart) {
233
+ applyAssistantProgress(ctx, sessionID, assistantPart, now)
234
+ }
235
+ })
236
+
237
+ // ── message.updated ──
238
+ on("message.updated", (event) => {
239
+ const sessionID = stringEventProperty(event, "sessionID")
240
+ if (sessionID.length === 0) return
241
+ recordEventAlias(event, sessionID)
242
+ const info = eventProperty(event, "info")
243
+
244
+ const assistant = parseAssistantMessage(info)
245
+ if (assistant) {
246
+ const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant)
247
+ if (nextModel) state.sessionModels.set(sessionID, nextModel)
248
+
249
+ const now = performance.now()
250
+ const tokens = assistant.tokens
251
+ const existing = state.requests.get(sessionID)
252
+ const turn = ensureTurn(state.turns, sessionID, now)
253
+ const hasPositive = hasPositiveAssistantTokens(tokens)
254
+ const isCurrent = existing?.messageID === assistant.messageID
255
+ const shouldUseAssistantMessage = assistant.messageID.length > 0 && (hasPositive || isCurrent)
256
+ const current = shouldUseAssistantMessage
257
+ ? isCurrent
258
+ ? existing
259
+ : turn.finalizedSteps.has(assistant.messageID)
260
+ ? createFreshMetrics(sessionID, assistant.messageID, assistant.modelID, assistant.providerID, now)
261
+ : currentRequest({ state, actions, sessionID, messageID: assistant.messageID, now })
262
+ : existing
263
+ if (current) {
264
+ if (!current.isComplete) actions.startSessionTiming(sessionID, now)
265
+ if (tokens) {
266
+ if (applyAssistantTokens(current, tokens)) {
267
+ log(`exact: in=${tokens.input} out=${tokens.output} cr=${tokens.cacheRead} cw=${tokens.cacheWrite}`)
268
+ retireRequestIntoTurn(turn, current, isCurrent || state.requests.get(sessionID) === current)
269
+ } else {
270
+ log(`assistant tokens all zero, skipping overwrite`)
271
+ }
272
+ }
273
+ applySessionModel(current, state.sessionModels.get(sessionID))
274
+ if (assistant.completed && state.liveSpeeds.get(sessionID)?.messageID === assistant.messageID) {
275
+ clearLiveSpeed(state.liveSpeeds, sessionID)
276
+ }
277
+ actions.notify()
278
+ }
279
+ }
280
+
281
+ const user = parseUserMessage(info)
282
+ if (user) {
283
+ const userMsgID = user.messageID
284
+ const current = state.requests.get(sessionID)
285
+ if (current && current.messageID === userMsgID) {
286
+ return
287
+ }
288
+ if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
289
+ return
290
+ }
291
+ const now = performance.now()
292
+ const previous = state.requests.get(sessionID)
293
+ const previousTurn = state.turns.get(sessionID)
294
+ if (previous && previousTurn) retireRequestIntoTurn(previousTurn, previous)
295
+ startTurn(state.turns, sessionID, now)
296
+ clearLiveSpeed(state.liveSpeeds, sessionID)
297
+ clearPartEstimates(sessionID)
298
+ const fresh = createFreshMetrics(sessionID, userMsgID, "", "", now)
299
+ actions.startSessionTiming(sessionID, fresh.requestStartTime)
300
+ if (userMsgID) {
301
+ let msgIds = state.userMessageIds.get(sessionID)
302
+ if (!msgIds) {
303
+ msgIds = new Set()
304
+ state.userMessageIds.set(sessionID, msgIds)
305
+ }
306
+ msgIds.add(userMsgID)
307
+ }
308
+ log(`user msg: id=${userMsgID}`)
309
+ applySessionModel(fresh, state.sessionModels.get(sessionID))
310
+ state.requests.set(sessionID, fresh)
311
+ state.lastRequestSessionID = sessionID
312
+ actions.notify()
313
+ }
314
+ })
315
+
316
+ // ── session.deleted ──
317
+ on("session.deleted", (event) => {
318
+ const sessionID = stringEventProperty(event, "sessionID")
319
+ if (sessionID.length === 0) return
320
+ state.sessionTree.deleteSession(sessionID)
321
+ state.sessionModels.delete(sessionID)
322
+ state.sessionTimings.delete(sessionID)
323
+ state.turns.delete(sessionID)
324
+ state.liveSpeeds.delete(sessionID)
325
+ state.userMessageIds.delete(sessionID)
326
+ state.assistantMessageIds.delete(sessionID)
327
+ state.sessionAliases.delete(sessionID)
328
+ for (const aliases of state.sessionAliases.values()) {
329
+ aliases.delete(sessionID)
330
+ }
331
+ for (const key of state.partTokenEstimates.keys()) {
332
+ if (key.startsWith(`${sessionID}:`)) state.partTokenEstimates.delete(key)
333
+ }
334
+ actions.clearHoldTimer(sessionID)
335
+ if (state.requests.has(sessionID)) {
336
+ state.requests.delete(sessionID)
337
+ if (state.lastRequestSessionID === sessionID) {
338
+ state.lastRequestSessionID = [...state.requests.keys()].at(-1) ?? null
339
+ }
340
+ actions.notify()
341
+ }
342
+ })
343
+
344
+ return disposers
345
+ }
@@ -0,0 +1,198 @@
1
+ export interface SessionInfoEvent {
2
+ readonly id: string
3
+ readonly parentID: string | null
4
+ readonly hasParentID: boolean
5
+ }
6
+
7
+ export interface TextPartEvent {
8
+ readonly partID: string
9
+ readonly messageID: string
10
+ readonly text: string
11
+ }
12
+
13
+ export interface AssistantTokenUpdate {
14
+ readonly hasAny: boolean
15
+ readonly hasCacheRead: boolean
16
+ readonly hasCacheWrite: boolean
17
+ readonly input: number
18
+ readonly output: number
19
+ readonly reasoning: number
20
+ readonly cacheRead: number
21
+ readonly cacheWrite: number
22
+ }
23
+
24
+ export interface AssistantMessageEvent {
25
+ readonly messageID: string
26
+ readonly modelID: string
27
+ readonly providerID: string
28
+ readonly tokens: AssistantTokenUpdate | null
29
+ readonly completed: boolean
30
+ readonly createdTime: number | null
31
+ readonly completedTime: number | null
32
+ }
33
+
34
+ export interface UserMessageEvent {
35
+ readonly messageID: string
36
+ readonly createdTime: number | null
37
+ }
38
+
39
+ export interface AssistantStepStartedEvent {
40
+ readonly sessionID: string
41
+ readonly messageID: string
42
+ readonly modelID: string
43
+ readonly providerID: string
44
+ }
45
+
46
+ export interface AssistantTextDeltaEvent {
47
+ readonly sessionID: string
48
+ readonly messageID: string
49
+ readonly partID: string
50
+ readonly delta: string
51
+ }
52
+
53
+ export interface AssistantStepEndedEvent {
54
+ readonly sessionID: string
55
+ readonly messageID: string
56
+ readonly tokens: AssistantTokenUpdate
57
+ }
58
+
59
+ function isRecord(value: unknown): value is Record<string, unknown> {
60
+ return typeof value === "object" && value !== null && !Array.isArray(value)
61
+ }
62
+
63
+ function hasOwn(record: Record<string, unknown>, key: string): boolean {
64
+ return Object.prototype.hasOwnProperty.call(record, key)
65
+ }
66
+
67
+ function stringOrEmpty(value: unknown): string {
68
+ return typeof value === "string" ? value : ""
69
+ }
70
+
71
+ function nonNegativeNumber(value: unknown): number {
72
+ return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0
73
+ }
74
+
75
+ function timestampOrNull(value: unknown): number | null {
76
+ return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null
77
+ }
78
+
79
+ export function parseSessionInfo(value: unknown): SessionInfoEvent | null {
80
+ if (!isRecord(value)) return null
81
+ const id = stringOrEmpty(value.id)
82
+ if (id.length === 0) return null
83
+ const hasParentID = hasOwn(value, "parentID")
84
+ return {
85
+ id,
86
+ parentID: typeof value.parentID === "string" ? value.parentID : null,
87
+ hasParentID,
88
+ }
89
+ }
90
+
91
+ export function parseTextPart(value: unknown): TextPartEvent | null {
92
+ if (!isRecord(value)) return null
93
+ if (value.type !== "text") return null
94
+ const partID = stringOrEmpty(value.id)
95
+ const messageID = stringOrEmpty(value.messageID)
96
+ const text = stringOrEmpty(value.text)
97
+ if (partID.length === 0 || messageID.length === 0 || text.length === 0) return null
98
+ return { partID, messageID, text }
99
+ }
100
+
101
+ export function parseAssistantProgressPart(value: unknown): TextPartEvent | null {
102
+ if (!isRecord(value)) return null
103
+ if (value.type !== "text" && value.type !== "reasoning") return null
104
+ const partID = stringOrEmpty(value.id)
105
+ const messageID = stringOrEmpty(value.messageID)
106
+ const text = stringOrEmpty(value.text)
107
+ if (partID.length === 0 || messageID.length === 0 || text.length === 0) return null
108
+ return { partID, messageID, text }
109
+ }
110
+
111
+ function parseAssistantTokens(value: unknown): AssistantTokenUpdate | null {
112
+ if (!isRecord(value)) return null
113
+ const cache = isRecord(value.cache) ? value.cache : null
114
+ const hasInput = hasOwn(value, "input")
115
+ const hasOutput = hasOwn(value, "output")
116
+ const hasReasoning = hasOwn(value, "reasoning")
117
+ const hasCacheRead = cache !== null && hasOwn(cache, "read")
118
+ const hasCacheWrite = cache !== null && hasOwn(cache, "write")
119
+
120
+ return {
121
+ hasAny: hasInput || hasOutput || hasReasoning || hasCacheRead || hasCacheWrite,
122
+ hasCacheRead,
123
+ hasCacheWrite,
124
+ input: nonNegativeNumber(value.input),
125
+ output: nonNegativeNumber(value.output),
126
+ reasoning: nonNegativeNumber(value.reasoning),
127
+ cacheRead: nonNegativeNumber(cache?.read),
128
+ cacheWrite: nonNegativeNumber(cache?.write),
129
+ }
130
+ }
131
+
132
+ export function parseAssistantMessage(value: unknown): AssistantMessageEvent | null {
133
+ if (!isRecord(value)) return null
134
+ const time = isRecord(value.time) ? value.time : null
135
+ const createdTime = timestampOrNull(time?.created)
136
+ const completedTime = timestampOrNull(time?.completed)
137
+ if (value.role === "assistant") {
138
+ return {
139
+ messageID: stringOrEmpty(value.id),
140
+ modelID: stringOrEmpty(value.modelID),
141
+ providerID: stringOrEmpty(value.providerID),
142
+ tokens: parseAssistantTokens(value.tokens),
143
+ completed: completedTime !== null,
144
+ createdTime,
145
+ completedTime,
146
+ }
147
+ }
148
+ if (value.type !== "assistant") return null
149
+ const model = isRecord(value.model) ? value.model : null
150
+ return {
151
+ messageID: stringOrEmpty(value.id),
152
+ modelID: stringOrEmpty(model?.id),
153
+ providerID: stringOrEmpty(model?.providerID),
154
+ tokens: parseAssistantTokens(value.tokens),
155
+ completed: completedTime !== null,
156
+ createdTime,
157
+ completedTime,
158
+ }
159
+ }
160
+
161
+ export function parseUserMessage(value: unknown): UserMessageEvent | null {
162
+ if (!isRecord(value) || value.role !== "user") return null
163
+ const time = isRecord(value.time) ? value.time : null
164
+ return { messageID: stringOrEmpty(value.id), createdTime: timestampOrNull(time?.created) }
165
+ }
166
+
167
+ export function parseAssistantStepStarted(value: unknown, fallbackMessageID = ""): AssistantStepStartedEvent | null {
168
+ if (!isRecord(value)) return null
169
+ const sessionID = stringOrEmpty(value.sessionID)
170
+ const messageID = stringOrEmpty(value.assistantMessageID) || fallbackMessageID
171
+ const model = isRecord(value.model) ? value.model : null
172
+ if (sessionID.length === 0 || messageID.length === 0) return null
173
+ return {
174
+ sessionID,
175
+ messageID,
176
+ modelID: stringOrEmpty(model?.id),
177
+ providerID: stringOrEmpty(model?.providerID),
178
+ }
179
+ }
180
+
181
+ export function parseAssistantTextDelta(value: unknown, fallbackMessageID = ""): AssistantTextDeltaEvent | null {
182
+ if (!isRecord(value)) return null
183
+ const sessionID = stringOrEmpty(value.sessionID)
184
+ const messageID = stringOrEmpty(value.assistantMessageID) || fallbackMessageID
185
+ const partID = stringOrEmpty(value.textID) || stringOrEmpty(value.reasoningID) || `${messageID}:stream`
186
+ const delta = stringOrEmpty(value.delta)
187
+ if (sessionID.length === 0 || messageID.length === 0 || partID.length === 0 || delta.length === 0) return null
188
+ return { sessionID, messageID, partID, delta }
189
+ }
190
+
191
+ export function parseAssistantStepEnded(value: unknown, fallbackMessageID = ""): AssistantStepEndedEvent | null {
192
+ if (!isRecord(value)) return null
193
+ const sessionID = stringOrEmpty(value.sessionID)
194
+ const messageID = stringOrEmpty(value.assistantMessageID) || fallbackMessageID
195
+ const tokens = parseAssistantTokens(value.tokens)
196
+ if (sessionID.length === 0 || messageID.length === 0 || tokens === null) return null
197
+ return { sessionID, messageID, tokens }
198
+ }
@@ -0,0 +1,79 @@
1
+ import type { LiveSpeedState } from "./types"
2
+
3
+ export const LIVE_SPEED_WINDOW_MS = 3000
4
+ export const LIVE_SPEED_STALE_MS = 2000
5
+ export const LIVE_SPEED_MAX_SAMPLES = 256
6
+ // Minimum interval between distinct samples. History replay during session
7
+ // reopen delivers many deltas in a few milliseconds; merging those into one
8
+ // sample prevents the rolling window from reporting absurd TPS values.
9
+ export const LIVE_SPEED_MIN_SAMPLE_INTERVAL_MS = 20
10
+
11
+ export function resetLiveSpeed(
12
+ states: Map<string, LiveSpeedState>,
13
+ sessionID: string,
14
+ messageID: string,
15
+ ): void {
16
+ states.set(sessionID, {
17
+ messageID,
18
+ cumulativeTokens: 0,
19
+ samples: [],
20
+ })
21
+ }
22
+
23
+ export function clearLiveSpeed(states: Map<string, LiveSpeedState>, sessionID: string): void {
24
+ states.delete(sessionID)
25
+ }
26
+
27
+ export function recordLiveTokens(
28
+ states: Map<string, LiveSpeedState>,
29
+ sessionID: string,
30
+ messageID: string,
31
+ deltaTokens: number,
32
+ now: number,
33
+ ): void {
34
+ if (!Number.isFinite(deltaTokens) || deltaTokens <= 0) return
35
+
36
+ let state = states.get(sessionID)
37
+ if (!state || state.messageID !== messageID) {
38
+ state = { messageID, cumulativeTokens: 0, samples: [] }
39
+ states.set(sessionID, state)
40
+ }
41
+
42
+ state.cumulativeTokens += deltaTokens
43
+
44
+ // Merge into the most recent sample when deltas arrive faster than the
45
+ // minimum interval (e.g. history replay bursts). This keeps the windowed
46
+ // TPS honest without discarding the token counts themselves.
47
+ const latest = state.samples.at(-1)
48
+ if (latest !== undefined && now - latest.at < LIVE_SPEED_MIN_SAMPLE_INTERVAL_MS) {
49
+ latest.cumulativeTokens = state.cumulativeTokens
50
+ } else {
51
+ state.samples.push({ at: now, cumulativeTokens: state.cumulativeTokens })
52
+ }
53
+
54
+ const cutoff = now - LIVE_SPEED_WINDOW_MS
55
+ while (state.samples.length > 2 && state.samples[1]!.at < cutoff) {
56
+ state.samples.shift()
57
+ }
58
+ while (state.samples.length > LIVE_SPEED_MAX_SAMPLES) state.samples.shift()
59
+ }
60
+
61
+ export function getLiveTps(state: LiveSpeedState | undefined, now: number): number | null {
62
+ if (!state || state.samples.length < 2) return null
63
+ const latest = state.samples.at(-1)!
64
+ if (now - latest.at >= LIVE_SPEED_STALE_MS) return null
65
+
66
+ const cutoff = latest.at - LIVE_SPEED_WINDOW_MS
67
+ let baseline = state.samples[0]!
68
+ for (const sample of state.samples) {
69
+ if (sample.at >= cutoff) {
70
+ baseline = sample
71
+ break
72
+ }
73
+ }
74
+
75
+ const elapsedMs = latest.at - baseline.at
76
+ const tokens = latest.cumulativeTokens - baseline.cumulativeTokens
77
+ if (elapsedMs <= 0 || tokens <= 0) return null
78
+ return Math.round((tokens / (elapsedMs / 1000)) * 10) / 10
79
+ }
package/src/logger.ts ADDED
@@ -0,0 +1,36 @@
1
+ // src/logger.ts
2
+ import { appendFileSync, existsSync, mkdirSync } from "node:fs"
3
+ import { join } from "node:path"
4
+ import { homedir } from "node:os"
5
+ import { getConfig } from "./config"
6
+
7
+ let logFile: string | null = null
8
+
9
+ function getLogDir(): string {
10
+ if (process.platform === "win32") {
11
+ return join(process.env.LOCALAPPDATA || join(homedir(), "AppData", "Local"), "opentui", "opencode-metrics", "logs")
12
+ }
13
+ // macOS & Linux: XDG_DATA_HOME or ~/.local/share
14
+ const dataHome = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share")
15
+ return join(dataHome, "opentui", "opencode-metrics", "logs")
16
+ }
17
+
18
+ function getLogFile(): string {
19
+ if (logFile) return logFile
20
+ const dir = getLogDir()
21
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
22
+ logFile = join(dir, "opencode-metrics.log")
23
+ return logFile
24
+ }
25
+
26
+ export function log(msg: string): void {
27
+ const config = getConfig()
28
+ if (!config.enableLogging) return
29
+ try {
30
+ appendFileSync(getLogFile(), `[${new Date().toISOString()}] ${msg}\n`)
31
+ } catch (error) {
32
+ if (process.env.OPENCODE_METRICS_DEBUG === "1") {
33
+ console.warn("opencode-metrics: failed to write log", error)
34
+ }
35
+ }
36
+ }