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.
- package/README.md +137 -0
- package/package.json +92 -0
- package/src/assistant-progress.ts +99 -0
- package/src/badge-contrast.ts +62 -0
- package/src/collector-state.ts +36 -0
- package/src/collector.ts +615 -0
- package/src/components/SidebarMetrics.tsx +331 -0
- package/src/components/StatRow.tsx +83 -0
- package/src/config.ts +59 -0
- package/src/event-bus.ts +61 -0
- package/src/event-handlers.ts +345 -0
- package/src/event-shapes.ts +198 -0
- package/src/live-speed.ts +79 -0
- package/src/logger.ts +36 -0
- package/src/metrics.ts +243 -0
- package/src/request-state.ts +100 -0
- package/src/request-updates.ts +40 -0
- package/src/scripts/init-tui-preferences.ts +107 -0
- package/src/session-hydration.ts +278 -0
- package/src/session-timing.ts +78 -0
- package/src/session-tree.ts +43 -0
- package/src/tui-preferences.ts +203 -0
- package/src/tui-prefs-io.ts +159 -0
- package/src/tui.tsx +60 -0
- package/src/turn-state.ts +85 -0
- package/src/types.ts +138 -0
|
@@ -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
|
+
}
|