oc-metricboard 0.1.22 → 0.1.24
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/dist/tui.js +2694 -55
- package/package.json +4 -2
- package/dist/assistant-progress.js +0 -66
- package/dist/badge-contrast.js +0 -50
- package/dist/collector-state.js +0 -1
- package/dist/collector.js +0 -621
- package/dist/components/SidebarMetrics.js +0 -377
- package/dist/components/StatRow.js +0 -77
- package/dist/components/ui-element.js +0 -40
- package/dist/config.js +0 -57
- package/dist/event-bus.js +0 -91
- package/dist/event-handlers.js +0 -363
- package/dist/event-shapes.js +0 -144
- package/dist/live-speed.js +0 -63
- package/dist/logger.js +0 -36
- package/dist/metrics.js +0 -247
- package/dist/request-state.js +0 -68
- package/dist/request-updates.js +0 -29
- package/dist/session-hydration.js +0 -282
- package/dist/session-timing.js +0 -59
- package/dist/session-tree.js +0 -33
- package/dist/tui-preferences.js +0 -142
- package/dist/tui-prefs-io.js +0 -153
- package/dist/tui-v2.js +0 -298
- package/dist/turn-state.js +0 -157
- package/dist/types.js +0 -18
package/dist/tui.js
CHANGED
|
@@ -1,61 +1,2700 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
1
|
+
// src/metrics.ts
|
|
2
|
+
function estimateTokens(text, ratio) {
|
|
3
|
+
if (!text) return 0;
|
|
4
|
+
const safeRatio = Number.isFinite(ratio) && ratio > 0 ? ratio : 4;
|
|
5
|
+
let tokens = 0;
|
|
6
|
+
for (const char of text) {
|
|
7
|
+
const isCjk = /\p{Script=Han}|\p{Script=Hiragana}|\p{Script=Katakana}|\p{Script=Hangul}/u.test(char);
|
|
8
|
+
tokens += isCjk ? 0.6 : 1 / safeRatio;
|
|
9
|
+
}
|
|
10
|
+
return Math.max(0, tokens);
|
|
11
|
+
}
|
|
12
|
+
function getDisplayOutputTokens(m) {
|
|
13
|
+
if (m.hasExactTokens) {
|
|
14
|
+
return Math.max(0, m.exactOutputTokens + m.exactReasoningTokens);
|
|
15
|
+
}
|
|
16
|
+
return Math.max(0, m.estimatedOutputTokens);
|
|
17
|
+
}
|
|
18
|
+
function getDisplayInputTokens(m) {
|
|
19
|
+
if (m.hasExactTokens) {
|
|
20
|
+
return Math.max(0, m.exactInputTokens + m.exactCacheReadTokens + m.exactCacheWriteTokens);
|
|
21
|
+
}
|
|
22
|
+
return Math.max(0, m.estimatedInputTokens);
|
|
23
|
+
}
|
|
24
|
+
function formatCacheRead(n, completeness) {
|
|
25
|
+
if (completeness === "unknown") return "\u2014";
|
|
26
|
+
return `${formatTokens(n)}${completeness === "partial" ? "+" : ""}`;
|
|
27
|
+
}
|
|
28
|
+
var MAX_TTFT_MS = 10 * 60 * 1e3;
|
|
29
|
+
function gateTtft(rawMs) {
|
|
30
|
+
if (!Number.isFinite(rawMs) || rawMs < 0 || rawMs > MAX_TTFT_MS) return null;
|
|
31
|
+
return Math.round(rawMs);
|
|
32
|
+
}
|
|
33
|
+
function getTtft(m) {
|
|
34
|
+
if (m.firstTokenTime === null) return null;
|
|
35
|
+
return gateTtft(m.firstTokenTime - m.requestStartTime);
|
|
36
|
+
}
|
|
37
|
+
var MAX_CREDIBLE_TPS = 300;
|
|
38
|
+
function credibleTps(tokens, windowMs) {
|
|
39
|
+
if (!(tokens > 0) || !(windowMs > 0)) return null;
|
|
40
|
+
const tps = Math.round(tokens / (windowMs / 1e3) * 10) / 10;
|
|
41
|
+
if (!Number.isFinite(tps) || tps < 0 || tps > MAX_CREDIBLE_TPS) return null;
|
|
42
|
+
return tps;
|
|
43
|
+
}
|
|
44
|
+
function formatTokens(n) {
|
|
45
|
+
if (n < 1e3) return String(Math.round(n));
|
|
46
|
+
return (n / 1e3).toFixed(1) + "K";
|
|
47
|
+
}
|
|
48
|
+
function formatDuration(ms) {
|
|
49
|
+
if (ms < 1e3) return `${Math.round(ms)}ms`;
|
|
50
|
+
if (ms < 6e4) return `${(ms / 1e3).toFixed(1)}s`;
|
|
51
|
+
const minutes = Math.floor(ms / 6e4);
|
|
52
|
+
const seconds = Math.round(ms % 6e4 / 1e3);
|
|
53
|
+
return `${minutes}m${seconds}s`;
|
|
54
|
+
}
|
|
55
|
+
function formatElapsed(ms) {
|
|
56
|
+
ms = Math.max(0, ms);
|
|
57
|
+
if (ms < 6e4) return `${Math.floor(ms / 1e3)}s`;
|
|
58
|
+
const minutes = Math.floor(ms / 6e4);
|
|
59
|
+
const seconds = Math.floor(ms % 6e4 / 1e3);
|
|
60
|
+
return `${minutes}m${seconds}s`;
|
|
61
|
+
}
|
|
62
|
+
function createFreshMetrics(sessionID, messageID, modelID, providerID, now) {
|
|
63
|
+
return {
|
|
64
|
+
sessionID,
|
|
65
|
+
messageID,
|
|
66
|
+
modelID,
|
|
67
|
+
providerID,
|
|
68
|
+
requestStartTime: now,
|
|
69
|
+
firstTokenTime: null,
|
|
70
|
+
lastDeltaTime: null,
|
|
71
|
+
completeTime: null,
|
|
72
|
+
estimatedInputTokens: 0,
|
|
73
|
+
estimatedOutputTokens: 0,
|
|
74
|
+
exactInputTokens: 0,
|
|
75
|
+
exactOutputTokens: 0,
|
|
76
|
+
exactCacheReadTokens: 0,
|
|
77
|
+
exactCacheWriteTokens: 0,
|
|
78
|
+
exactReasoningTokens: 0,
|
|
79
|
+
hasExactTokens: false,
|
|
80
|
+
hasExactCacheReadTokens: false,
|
|
81
|
+
hasExactCacheWriteTokens: false,
|
|
82
|
+
isStreaming: false,
|
|
83
|
+
isComplete: false
|
|
84
|
+
};
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// src/event-shapes.ts
|
|
88
|
+
function isRecord(value) {
|
|
89
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
90
|
+
}
|
|
91
|
+
function hasOwn(record, key) {
|
|
92
|
+
return Object.prototype.hasOwnProperty.call(record, key);
|
|
93
|
+
}
|
|
94
|
+
function stringOrEmpty(value) {
|
|
95
|
+
return typeof value === "string" ? value : "";
|
|
96
|
+
}
|
|
97
|
+
function nonNegativeNumber(value) {
|
|
98
|
+
return typeof value === "number" && Number.isFinite(value) ? Math.max(0, value) : 0;
|
|
99
|
+
}
|
|
100
|
+
function timestampOrNull(value) {
|
|
101
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
102
|
+
}
|
|
103
|
+
function parseSessionInfo(value) {
|
|
104
|
+
if (!isRecord(value)) return null;
|
|
105
|
+
const id = stringOrEmpty(value.id);
|
|
106
|
+
if (id.length === 0) return null;
|
|
107
|
+
const hasParentID = hasOwn(value, "parentID");
|
|
108
|
+
return {
|
|
109
|
+
id,
|
|
110
|
+
parentID: typeof value.parentID === "string" ? value.parentID : null,
|
|
111
|
+
hasParentID
|
|
112
|
+
};
|
|
113
|
+
}
|
|
114
|
+
function parseTextPart(value) {
|
|
115
|
+
if (!isRecord(value)) return null;
|
|
116
|
+
if (value.type !== "text") return null;
|
|
117
|
+
const partID = stringOrEmpty(value.id);
|
|
118
|
+
const messageID = stringOrEmpty(value.messageID);
|
|
119
|
+
const text = stringOrEmpty(value.text);
|
|
120
|
+
if (partID.length === 0 || messageID.length === 0 || text.length === 0) return null;
|
|
121
|
+
return { partID, messageID, text };
|
|
122
|
+
}
|
|
123
|
+
function parseAssistantProgressPart(value) {
|
|
124
|
+
if (!isRecord(value)) return null;
|
|
125
|
+
if (value.type !== "text" && value.type !== "reasoning") return null;
|
|
126
|
+
const partID = stringOrEmpty(value.id);
|
|
127
|
+
const messageID = stringOrEmpty(value.messageID);
|
|
128
|
+
const text = stringOrEmpty(value.text);
|
|
129
|
+
if (partID.length === 0 || messageID.length === 0 || text.length === 0) return null;
|
|
130
|
+
return { partID, messageID, text };
|
|
131
|
+
}
|
|
132
|
+
function parseAssistantTokens(value) {
|
|
133
|
+
if (!isRecord(value)) return null;
|
|
134
|
+
const cache = isRecord(value.cache) ? value.cache : null;
|
|
135
|
+
const hasInput = hasOwn(value, "input");
|
|
136
|
+
const hasOutput = hasOwn(value, "output");
|
|
137
|
+
const hasReasoning = hasOwn(value, "reasoning");
|
|
138
|
+
const hasCacheRead = cache !== null && hasOwn(cache, "read");
|
|
139
|
+
const hasCacheWrite = cache !== null && hasOwn(cache, "write");
|
|
140
|
+
return {
|
|
141
|
+
hasAny: hasInput || hasOutput || hasReasoning || hasCacheRead || hasCacheWrite,
|
|
142
|
+
hasCacheRead,
|
|
143
|
+
hasCacheWrite,
|
|
144
|
+
input: nonNegativeNumber(value.input),
|
|
145
|
+
output: nonNegativeNumber(value.output),
|
|
146
|
+
reasoning: nonNegativeNumber(value.reasoning),
|
|
147
|
+
cacheRead: nonNegativeNumber(cache?.read),
|
|
148
|
+
cacheWrite: nonNegativeNumber(cache?.write)
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
function parseAssistantMessage(value) {
|
|
152
|
+
if (!isRecord(value)) return null;
|
|
153
|
+
const time = isRecord(value.time) ? value.time : null;
|
|
154
|
+
const createdTime = timestampOrNull(time?.created);
|
|
155
|
+
const completedTime = timestampOrNull(time?.completed);
|
|
156
|
+
if (value.role === "assistant") {
|
|
157
|
+
return {
|
|
158
|
+
messageID: stringOrEmpty(value.id),
|
|
159
|
+
modelID: stringOrEmpty(value.modelID),
|
|
160
|
+
providerID: stringOrEmpty(value.providerID),
|
|
161
|
+
tokens: parseAssistantTokens(value.tokens),
|
|
162
|
+
completed: completedTime !== null,
|
|
163
|
+
createdTime,
|
|
164
|
+
completedTime
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
if (value.type !== "assistant") return null;
|
|
168
|
+
const model = isRecord(value.model) ? value.model : null;
|
|
169
|
+
return {
|
|
170
|
+
messageID: stringOrEmpty(value.id),
|
|
171
|
+
modelID: stringOrEmpty(model?.id),
|
|
172
|
+
providerID: stringOrEmpty(model?.providerID),
|
|
173
|
+
tokens: parseAssistantTokens(value.tokens),
|
|
174
|
+
completed: completedTime !== null,
|
|
175
|
+
createdTime,
|
|
176
|
+
completedTime
|
|
177
|
+
};
|
|
178
|
+
}
|
|
179
|
+
function parseUserMessage(value) {
|
|
180
|
+
if (!isRecord(value) || value.role !== "user") return null;
|
|
181
|
+
const time = isRecord(value.time) ? value.time : null;
|
|
182
|
+
return { messageID: stringOrEmpty(value.id), createdTime: timestampOrNull(time?.created) };
|
|
183
|
+
}
|
|
184
|
+
function parseAssistantStepStarted(value, fallbackMessageID = "") {
|
|
185
|
+
if (!isRecord(value)) return null;
|
|
186
|
+
const sessionID = stringOrEmpty(value.sessionID);
|
|
187
|
+
const messageID = stringOrEmpty(value.assistantMessageID) || fallbackMessageID;
|
|
188
|
+
const model = isRecord(value.model) ? value.model : null;
|
|
189
|
+
if (sessionID.length === 0 || messageID.length === 0) return null;
|
|
190
|
+
return {
|
|
191
|
+
sessionID,
|
|
192
|
+
messageID,
|
|
193
|
+
modelID: stringOrEmpty(model?.id),
|
|
194
|
+
providerID: stringOrEmpty(model?.providerID)
|
|
195
|
+
};
|
|
196
|
+
}
|
|
197
|
+
function parseAssistantTextDelta(value, fallbackMessageID = "") {
|
|
198
|
+
if (!isRecord(value)) return null;
|
|
199
|
+
const sessionID = stringOrEmpty(value.sessionID);
|
|
200
|
+
const messageID = stringOrEmpty(value.assistantMessageID) || fallbackMessageID;
|
|
201
|
+
const partID = stringOrEmpty(value.textID) || stringOrEmpty(value.reasoningID) || `${messageID}:stream`;
|
|
202
|
+
const delta = stringOrEmpty(value.delta);
|
|
203
|
+
if (sessionID.length === 0 || messageID.length === 0 || partID.length === 0 || delta.length === 0) return null;
|
|
204
|
+
return { sessionID, messageID, partID, delta };
|
|
205
|
+
}
|
|
206
|
+
function parseAssistantStepEnded(value, fallbackMessageID = "") {
|
|
207
|
+
if (!isRecord(value)) return null;
|
|
208
|
+
const sessionID = stringOrEmpty(value.sessionID);
|
|
209
|
+
const messageID = stringOrEmpty(value.assistantMessageID) || fallbackMessageID;
|
|
210
|
+
const tokens = parseAssistantTokens(value.tokens);
|
|
211
|
+
if (sessionID.length === 0 || messageID.length === 0 || tokens === null) return null;
|
|
212
|
+
return { sessionID, messageID, tokens };
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// src/request-updates.ts
|
|
216
|
+
function mergeAssistantModel(existing, message) {
|
|
217
|
+
const modelID = message.modelID || existing?.modelID || "";
|
|
218
|
+
const providerID = message.providerID || existing?.providerID || "";
|
|
219
|
+
return modelID || providerID ? { modelID, providerID } : null;
|
|
220
|
+
}
|
|
221
|
+
function applySessionModel(metrics, model) {
|
|
222
|
+
if (!model) return;
|
|
223
|
+
metrics.modelID = model.modelID;
|
|
224
|
+
metrics.providerID = model.providerID;
|
|
225
|
+
}
|
|
226
|
+
function hasPositiveAssistantTokens(tokens) {
|
|
227
|
+
if (!tokens?.hasAny) return false;
|
|
228
|
+
return tokens.input + tokens.output + tokens.reasoning + tokens.cacheRead + tokens.cacheWrite > 0;
|
|
229
|
+
}
|
|
230
|
+
function applyAssistantTokens(metrics, tokens) {
|
|
231
|
+
if (!hasPositiveAssistantTokens(tokens)) return false;
|
|
232
|
+
metrics.exactInputTokens = tokens.input;
|
|
233
|
+
metrics.exactOutputTokens = tokens.output;
|
|
234
|
+
metrics.exactReasoningTokens = tokens.reasoning;
|
|
235
|
+
metrics.exactCacheReadTokens = tokens.cacheRead;
|
|
236
|
+
metrics.exactCacheWriteTokens = tokens.cacheWrite;
|
|
237
|
+
metrics.hasExactTokens = true;
|
|
238
|
+
metrics.hasExactCacheReadTokens = tokens.hasCacheRead;
|
|
239
|
+
metrics.hasExactCacheWriteTokens = tokens.hasCacheWrite;
|
|
240
|
+
return true;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// src/live-speed.ts
|
|
244
|
+
var LIVE_SPEED_WINDOW_MS = 3e3;
|
|
245
|
+
var LIVE_SPEED_STALE_MS = 2e3;
|
|
246
|
+
var LIVE_SPEED_MAX_SAMPLES = 256;
|
|
247
|
+
var LIVE_SPEED_MIN_SAMPLE_INTERVAL_MS = 20;
|
|
248
|
+
function resetLiveSpeed(states, sessionID, messageID) {
|
|
249
|
+
states.set(sessionID, {
|
|
250
|
+
messageID,
|
|
251
|
+
cumulativeTokens: 0,
|
|
252
|
+
samples: []
|
|
253
|
+
});
|
|
254
|
+
}
|
|
255
|
+
function clearLiveSpeed(states, sessionID) {
|
|
256
|
+
states.delete(sessionID);
|
|
257
|
+
}
|
|
258
|
+
function recordLiveTokens(states, sessionID, messageID, deltaTokens, now) {
|
|
259
|
+
if (!Number.isFinite(deltaTokens) || deltaTokens <= 0) return;
|
|
260
|
+
let state = states.get(sessionID);
|
|
261
|
+
if (!state || state.messageID !== messageID) {
|
|
262
|
+
state = { messageID, cumulativeTokens: 0, samples: [] };
|
|
263
|
+
states.set(sessionID, state);
|
|
264
|
+
}
|
|
265
|
+
state.cumulativeTokens += deltaTokens;
|
|
266
|
+
const latest = state.samples.at(-1);
|
|
267
|
+
if (latest !== void 0 && now - latest.at < LIVE_SPEED_MIN_SAMPLE_INTERVAL_MS) {
|
|
268
|
+
latest.cumulativeTokens = state.cumulativeTokens;
|
|
269
|
+
} else {
|
|
270
|
+
state.samples.push({ at: now, cumulativeTokens: state.cumulativeTokens });
|
|
271
|
+
}
|
|
272
|
+
const cutoff = now - LIVE_SPEED_WINDOW_MS;
|
|
273
|
+
while (state.samples.length > 2 && state.samples[1].at < cutoff) {
|
|
274
|
+
state.samples.shift();
|
|
275
|
+
}
|
|
276
|
+
while (state.samples.length > LIVE_SPEED_MAX_SAMPLES) state.samples.shift();
|
|
277
|
+
}
|
|
278
|
+
function getLiveTps(state, now) {
|
|
279
|
+
if (!state || state.samples.length < 2) return null;
|
|
280
|
+
const latest = state.samples.at(-1);
|
|
281
|
+
if (now - latest.at >= LIVE_SPEED_STALE_MS) return null;
|
|
282
|
+
const cutoff = latest.at - LIVE_SPEED_WINDOW_MS;
|
|
283
|
+
let baseline = state.samples[0];
|
|
284
|
+
for (const sample of state.samples) {
|
|
285
|
+
if (sample.at >= cutoff) {
|
|
286
|
+
baseline = sample;
|
|
287
|
+
break;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
const elapsedMs = latest.at - baseline.at;
|
|
291
|
+
const tokens = latest.cumulativeTokens - baseline.cumulativeTokens;
|
|
292
|
+
if (elapsedMs <= 0 || tokens <= 0) return null;
|
|
293
|
+
return Math.round(tokens / (elapsedMs / 1e3) * 10) / 10;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// src/turn-state.ts
|
|
297
|
+
function createTurnMetrics(sessionID, now) {
|
|
298
|
+
return {
|
|
299
|
+
sessionID,
|
|
300
|
+
turnStartTime: now,
|
|
301
|
+
firstTokenTime: null,
|
|
302
|
+
completeTime: null,
|
|
303
|
+
finalizedOutputTokens: 0,
|
|
304
|
+
finalizedSteps: /* @__PURE__ */ new Map(),
|
|
305
|
+
stickyContextTokens: 0,
|
|
306
|
+
stickyContextUpdatedAt: Number.NEGATIVE_INFINITY,
|
|
307
|
+
hasStickyContextTokens: false,
|
|
308
|
+
stickyCacheReadTokens: 0,
|
|
309
|
+
hasStickyCacheReadTokens: false,
|
|
310
|
+
toolIntervals: [],
|
|
311
|
+
isComplete: false
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
function ensureTurn(turns, sessionID, now) {
|
|
315
|
+
const existing = turns.get(sessionID);
|
|
316
|
+
if (existing) return existing;
|
|
317
|
+
const turn = createTurnMetrics(sessionID, now);
|
|
318
|
+
turns.set(sessionID, turn);
|
|
319
|
+
return turn;
|
|
320
|
+
}
|
|
321
|
+
function startTurn(turns, sessionID, now) {
|
|
322
|
+
const turn = createTurnMetrics(sessionID, now);
|
|
323
|
+
turns.set(sessionID, turn);
|
|
324
|
+
return turn;
|
|
325
|
+
}
|
|
326
|
+
function retireRequestIntoTurn(turn, request, updateSticky = true) {
|
|
327
|
+
if (!request.messageID) return false;
|
|
328
|
+
const outputTokens = getDisplayOutputTokens(request);
|
|
329
|
+
const exact = request.hasExactTokens;
|
|
330
|
+
const previous = turn.finalizedSteps.get(request.messageID);
|
|
331
|
+
if (!previous) {
|
|
332
|
+
turn.finalizedSteps.set(request.messageID, { outputTokens, exact });
|
|
333
|
+
turn.finalizedOutputTokens += outputTokens;
|
|
334
|
+
} else if (!previous.exact && exact) {
|
|
335
|
+
turn.finalizedOutputTokens += outputTokens - previous.outputTokens;
|
|
336
|
+
turn.finalizedSteps.set(request.messageID, { outputTokens, exact: true });
|
|
337
|
+
} else {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
if (updateSticky && exact && request.requestStartTime >= turn.stickyContextUpdatedAt) {
|
|
341
|
+
turn.stickyContextTokens = getDisplayInputTokens(request);
|
|
342
|
+
turn.stickyContextUpdatedAt = request.requestStartTime;
|
|
343
|
+
turn.hasStickyContextTokens = true;
|
|
344
|
+
turn.stickyCacheReadTokens = Math.max(0, request.exactCacheReadTokens);
|
|
345
|
+
turn.hasStickyCacheReadTokens = request.hasExactCacheReadTokens;
|
|
346
|
+
}
|
|
347
|
+
return true;
|
|
348
|
+
}
|
|
349
|
+
function liveRequestOutput(turn, request) {
|
|
350
|
+
if (!request?.messageID || turn.finalizedSteps.has(request.messageID)) return 0;
|
|
351
|
+
return getDisplayOutputTokens(request);
|
|
352
|
+
}
|
|
353
|
+
function turnInputTokens(turn, request) {
|
|
354
|
+
if (turn.hasStickyContextTokens) return turn.stickyContextTokens;
|
|
355
|
+
return request ? getDisplayInputTokens(request) : 0;
|
|
356
|
+
}
|
|
357
|
+
function completeTurn(turn, now) {
|
|
358
|
+
turn.isComplete = true;
|
|
359
|
+
turn.completeTime = now;
|
|
360
|
+
}
|
|
361
|
+
function recordTurnFirstToken(turn, now) {
|
|
362
|
+
if (turn.firstTokenTime === null) turn.firstTokenTime = now;
|
|
363
|
+
}
|
|
364
|
+
function recordToolCalled(turns, sessionID, toolID, now) {
|
|
365
|
+
const turn = ensureTurn(turns, sessionID, now);
|
|
366
|
+
turn.toolIntervals.push({ id: toolID, start: now, end: null });
|
|
367
|
+
}
|
|
368
|
+
function recordToolSettled(turns, sessionID, toolID, now) {
|
|
369
|
+
const turn = turns.get(sessionID);
|
|
370
|
+
if (!turn) return;
|
|
371
|
+
if (toolID) {
|
|
372
|
+
const open = turn.toolIntervals.find((interval) => interval.id === toolID && interval.end === null);
|
|
373
|
+
if (open) {
|
|
374
|
+
open.end = Math.max(now, open.start);
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
for (let i = turn.toolIntervals.length - 1; i >= 0; i--) {
|
|
379
|
+
if (turn.toolIntervals[i].end === null) {
|
|
380
|
+
turn.toolIntervals[i].end = Math.max(now, turn.toolIntervals[i].start);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
function toolOverlapMs(turn, from, to) {
|
|
386
|
+
if (!turn || to <= from) return 0;
|
|
387
|
+
const spans = [];
|
|
388
|
+
for (const interval of turn.toolIntervals) {
|
|
389
|
+
const start = Math.max(interval.start, from);
|
|
390
|
+
const end = Math.min(interval.end ?? to, to);
|
|
391
|
+
if (end > start) spans.push({ start, end });
|
|
392
|
+
}
|
|
393
|
+
spans.sort((a, b) => a.start - b.start);
|
|
394
|
+
let total = 0;
|
|
395
|
+
let cursor = null;
|
|
396
|
+
let cursorEnd = 0;
|
|
397
|
+
for (const span of spans) {
|
|
398
|
+
if (cursor === null || span.start > cursorEnd) {
|
|
399
|
+
if (cursor !== null) total += cursorEnd - cursor;
|
|
400
|
+
cursor = span.start;
|
|
401
|
+
cursorEnd = span.end;
|
|
402
|
+
} else if (span.end > cursorEnd) {
|
|
403
|
+
cursorEnd = span.end;
|
|
404
|
+
}
|
|
405
|
+
}
|
|
406
|
+
if (cursor !== null) total += cursorEnd - cursor;
|
|
407
|
+
return total;
|
|
408
|
+
}
|
|
409
|
+
function getTurnTtft(turn, fallback = null) {
|
|
410
|
+
if (turn?.firstTokenTime != null) {
|
|
411
|
+
const gated = gateTtft(turn.firstTokenTime - turn.turnStartTime);
|
|
412
|
+
if (gated !== null) return gated;
|
|
413
|
+
}
|
|
414
|
+
return fallback;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// src/request-state.ts
|
|
418
|
+
function currentRequest(input) {
|
|
419
|
+
const existing = input.state.requests.get(input.sessionID);
|
|
420
|
+
const isDifferentMessage = existing?.messageID.length ? existing.messageID !== input.messageID : false;
|
|
421
|
+
if (!existing || isDifferentMessage) {
|
|
422
|
+
const priorTurn = input.state.turns.get(input.sessionID);
|
|
423
|
+
const startsAfterCompletedTurn = Boolean(priorTurn?.isComplete && isDifferentMessage);
|
|
424
|
+
const turn = startsAfterCompletedTurn ? startTurn(input.state.turns, input.sessionID, input.now) : ensureTurn(input.state.turns, input.sessionID, existing?.requestStartTime ?? input.now);
|
|
425
|
+
if (existing && isDifferentMessage && !startsAfterCompletedTurn) retireRequestIntoTurn(turn, existing);
|
|
426
|
+
const inheritAnchor = !startsAfterCompletedTurn && (existing?.firstTokenTime ?? null) === null;
|
|
427
|
+
const requestStartTime = inheritAnchor ? existing?.requestStartTime ?? input.now : input.now;
|
|
428
|
+
const current = createFreshMetrics(
|
|
429
|
+
input.sessionID,
|
|
430
|
+
input.messageID,
|
|
431
|
+
existing?.modelID ?? "",
|
|
432
|
+
existing?.providerID ?? "",
|
|
433
|
+
requestStartTime
|
|
434
|
+
);
|
|
435
|
+
applySessionModel(current, input.state.sessionModels.get(input.sessionID));
|
|
436
|
+
input.state.requests.set(input.sessionID, current);
|
|
437
|
+
resetLiveSpeed(input.state.liveSpeeds, input.sessionID, input.messageID);
|
|
438
|
+
input.state.lastRequestSessionID = input.sessionID;
|
|
439
|
+
input.actions.clearHoldTimer(input.sessionID);
|
|
440
|
+
return current;
|
|
441
|
+
}
|
|
442
|
+
if (existing.messageID.length === 0) {
|
|
443
|
+
existing.messageID = input.messageID;
|
|
444
|
+
}
|
|
445
|
+
input.state.lastRequestSessionID = input.sessionID;
|
|
446
|
+
return existing;
|
|
447
|
+
}
|
|
448
|
+
function completeRequest(input) {
|
|
449
|
+
const current = input.state.requests.get(input.sessionID);
|
|
450
|
+
input.actions.stopSessionTiming(input.sessionID, input.now);
|
|
451
|
+
const turn = input.state.turns.get(input.sessionID);
|
|
452
|
+
if (current && turn) retireRequestIntoTurn(turn, current);
|
|
453
|
+
if (turn) completeTurn(turn, input.now);
|
|
454
|
+
clearLiveSpeed(input.state.liveSpeeds, input.sessionID);
|
|
455
|
+
if (!current) return;
|
|
456
|
+
current.isStreaming = false;
|
|
457
|
+
current.isComplete = true;
|
|
458
|
+
current.completeTime = input.now;
|
|
459
|
+
input.log(`request complete: session=${input.sessionID}`);
|
|
460
|
+
input.actions.notify();
|
|
461
|
+
if (input.holdDurationMs <= 0) return;
|
|
462
|
+
input.actions.clearHoldTimer(input.sessionID);
|
|
463
|
+
input.state.holdTimers.set(input.sessionID, setTimeout(() => {
|
|
464
|
+
input.state.holdTimers.delete(input.sessionID);
|
|
465
|
+
if (input.state.requests.get(input.sessionID)?.sessionID === input.sessionID) {
|
|
466
|
+
input.state.requests.delete(input.sessionID);
|
|
467
|
+
input.actions.notify();
|
|
468
|
+
}
|
|
469
|
+
}, input.holdDurationMs));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// src/event-bus.ts
|
|
473
|
+
function isRecord2(value) {
|
|
474
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
475
|
+
}
|
|
476
|
+
function syncEvent(event) {
|
|
477
|
+
if (!isRecord2(event)) return null;
|
|
478
|
+
const sync = event.syncEvent;
|
|
479
|
+
return isRecord2(sync) ? sync : null;
|
|
480
|
+
}
|
|
481
|
+
function eventProperties(event) {
|
|
482
|
+
if (!isRecord2(event)) return null;
|
|
483
|
+
if (Object.prototype.hasOwnProperty.call(event, "properties")) return event.properties;
|
|
484
|
+
if (Object.prototype.hasOwnProperty.call(event, "data")) return event.data;
|
|
485
|
+
const sync = syncEvent(event);
|
|
486
|
+
if (sync && Object.prototype.hasOwnProperty.call(sync, "data")) return sync.data;
|
|
487
|
+
return null;
|
|
488
|
+
}
|
|
489
|
+
function eventID(event) {
|
|
490
|
+
if (!isRecord2(event)) return "";
|
|
491
|
+
const sync = syncEvent(event);
|
|
492
|
+
if (sync && typeof sync.id === "string") return sync.id;
|
|
493
|
+
return typeof event.id === "string" ? event.id : "";
|
|
494
|
+
}
|
|
495
|
+
function eventAggregateID(event) {
|
|
496
|
+
const sync = syncEvent(event);
|
|
497
|
+
if (sync && typeof sync.aggregateID === "string") return sync.aggregateID;
|
|
498
|
+
if (!isRecord2(event) || !isRecord2(event.durable)) return "";
|
|
499
|
+
return typeof event.durable.aggregateID === "string" ? event.durable.aggregateID : "";
|
|
500
|
+
}
|
|
501
|
+
function eventProperty(event, key) {
|
|
502
|
+
const properties = eventProperties(event);
|
|
503
|
+
if (!isRecord2(properties) || !Object.prototype.hasOwnProperty.call(properties, key)) return null;
|
|
504
|
+
return properties[key];
|
|
505
|
+
}
|
|
506
|
+
function stringEventProperty(event, key) {
|
|
507
|
+
const value = eventProperty(event, key);
|
|
508
|
+
return typeof value === "string" ? value : "";
|
|
509
|
+
}
|
|
510
|
+
function statusType(event) {
|
|
511
|
+
const status = eventProperty(event, "status");
|
|
512
|
+
if (!isRecord2(status)) return "";
|
|
513
|
+
return typeof status.type === "string" ? status.type : "";
|
|
514
|
+
}
|
|
515
|
+
function eventCreatedWallTime(event) {
|
|
516
|
+
if (!isRecord2(event)) return null;
|
|
517
|
+
const created = event.created;
|
|
518
|
+
if (typeof created !== "number" || !Number.isFinite(created) || created <= 0) return null;
|
|
519
|
+
if (Math.abs(created - Date.now()) > 864e5) return null;
|
|
520
|
+
return created;
|
|
521
|
+
}
|
|
522
|
+
function eventPerfTime(event, now) {
|
|
523
|
+
const wall = eventCreatedWallTime(event);
|
|
524
|
+
if (wall === null) return now;
|
|
525
|
+
return now - Math.max(0, Date.now() - wall);
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
// src/assistant-progress.ts
|
|
529
|
+
function partEstimateKey(sessionID, messageID, partID) {
|
|
530
|
+
return `${sessionID}:${messageID}:${partID}`;
|
|
531
|
+
}
|
|
532
|
+
function applyTokenDelta(ctx, sessionID, messageID, deltaTokens, now) {
|
|
533
|
+
const { actions, state } = ctx;
|
|
534
|
+
if (state.turns.get(sessionID)?.finalizedSteps.has(messageID)) return;
|
|
535
|
+
const current = currentRequest({ state, actions, sessionID, messageID, now });
|
|
536
|
+
if (current.firstTokenTime === null) current.firstTokenTime = now;
|
|
537
|
+
recordTurnFirstToken(ensureTurn(state.turns, sessionID, now), now);
|
|
538
|
+
if (deltaTokens > 0) {
|
|
539
|
+
current.estimatedOutputTokens += deltaTokens;
|
|
540
|
+
current.lastDeltaTime = now;
|
|
541
|
+
recordLiveTokens(state.liveSpeeds, sessionID, messageID, deltaTokens, now);
|
|
542
|
+
}
|
|
543
|
+
current.isStreaming = true;
|
|
544
|
+
actions.notify();
|
|
545
|
+
}
|
|
546
|
+
function applyAssistantText(ctx, sessionID, messageID, partID, text, now) {
|
|
547
|
+
const { actions, config, state } = ctx;
|
|
548
|
+
const key = partEstimateKey(sessionID, messageID, partID);
|
|
549
|
+
const nextTokens = estimateTokens(text, config.estimationRatio);
|
|
550
|
+
const previousTokens = state.partTokenEstimates.get(key) ?? 0;
|
|
551
|
+
const deltaTokens = Math.max(0, nextTokens - previousTokens);
|
|
552
|
+
state.partTokenEstimates.set(key, Math.max(previousTokens, nextTokens));
|
|
553
|
+
applyTokenDelta(ctx, sessionID, messageID, deltaTokens, now);
|
|
554
|
+
}
|
|
555
|
+
function applyAssistantProgress(ctx, sessionID, part, now) {
|
|
556
|
+
applyAssistantText(ctx, sessionID, part.messageID, part.partID, part.text, now);
|
|
557
|
+
}
|
|
558
|
+
function applyAssistantDelta(ctx, event) {
|
|
559
|
+
const { actions, state } = ctx;
|
|
560
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
561
|
+
const fallbackMessageID = sessionID.length > 0 ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event);
|
|
562
|
+
const text = parseAssistantTextDelta(eventProperties(event), fallbackMessageID);
|
|
563
|
+
if (!text) return;
|
|
564
|
+
const aggregateID = eventAggregateID(event);
|
|
565
|
+
if (aggregateID.length > 0 && aggregateID !== text.sessionID) {
|
|
566
|
+
const aggregateAliases = state.sessionAliases.get(aggregateID) ?? /* @__PURE__ */ new Set();
|
|
567
|
+
aggregateAliases.add(text.sessionID);
|
|
568
|
+
state.sessionAliases.set(aggregateID, aggregateAliases);
|
|
569
|
+
const sessionAliases = state.sessionAliases.get(text.sessionID) ?? /* @__PURE__ */ new Set();
|
|
570
|
+
sessionAliases.add(aggregateID);
|
|
571
|
+
state.sessionAliases.set(text.sessionID, sessionAliases);
|
|
572
|
+
}
|
|
573
|
+
const now = eventPerfTime(event, performance.now());
|
|
574
|
+
actions.startSessionTiming(text.sessionID, now);
|
|
575
|
+
const key = partEstimateKey(text.sessionID, text.messageID, text.partID);
|
|
576
|
+
const deltaTokens = estimateTokens(text.delta, ctx.config.estimationRatio);
|
|
577
|
+
state.partTokenEstimates.set(key, (state.partTokenEstimates.get(key) ?? 0) + deltaTokens);
|
|
578
|
+
applyTokenDelta(ctx, text.sessionID, text.messageID, deltaTokens, now);
|
|
579
|
+
}
|
|
580
|
+
function applyAssistantPartDelta(ctx, sessionID, messageID, partID, delta, now) {
|
|
581
|
+
const key = partEstimateKey(sessionID, messageID, partID);
|
|
582
|
+
const deltaTokens = estimateTokens(delta, ctx.config.estimationRatio);
|
|
583
|
+
ctx.state.partTokenEstimates.set(key, (ctx.state.partTokenEstimates.get(key) ?? 0) + deltaTokens);
|
|
584
|
+
applyTokenDelta(ctx, sessionID, messageID, deltaTokens, now);
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
// src/event-handlers.ts
|
|
588
|
+
function registerEventHandlers(ctx) {
|
|
589
|
+
const { api, config, log: log2, state, actions } = ctx;
|
|
590
|
+
const disposers = [];
|
|
591
|
+
function recordSession(value, defaultParentWhenMissing) {
|
|
592
|
+
const info = parseSessionInfo(value);
|
|
593
|
+
if (!info) return;
|
|
594
|
+
if (info.hasParentID) {
|
|
595
|
+
state.sessionTree.setParent(info.id, info.parentID);
|
|
596
|
+
} else if (defaultParentWhenMissing) {
|
|
597
|
+
state.sessionTree.setParent(info.id, null);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
function on(type, handler) {
|
|
601
|
+
for (const eventType of [type, `${type}.1`, `${type}.2`]) {
|
|
602
|
+
disposers.push(api.event.on(eventType, (event) => {
|
|
603
|
+
const id = eventID(event);
|
|
604
|
+
const properties = eventProperties(event);
|
|
605
|
+
let serialized = "";
|
|
606
|
+
try {
|
|
607
|
+
serialized = JSON.stringify(properties);
|
|
608
|
+
} catch {
|
|
609
|
+
serialized = "";
|
|
610
|
+
}
|
|
611
|
+
const key = id && eventAggregateID(event) ? `${type}:${id}:${serialized}` : "";
|
|
612
|
+
if (key && state.seenEventKeys.has(key)) return;
|
|
613
|
+
if (key) {
|
|
614
|
+
state.seenEventKeys.add(key);
|
|
615
|
+
state.seenEventOrder.push(key);
|
|
616
|
+
if (state.seenEventOrder.length > 4096) {
|
|
617
|
+
const oldest = state.seenEventOrder.shift();
|
|
618
|
+
if (oldest) state.seenEventKeys.delete(oldest);
|
|
619
|
+
}
|
|
620
|
+
}
|
|
621
|
+
handler(event);
|
|
622
|
+
}));
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
function addAlias(left, right) {
|
|
626
|
+
if (left.length === 0 || right.length === 0 || left === right) return;
|
|
627
|
+
const leftAliases = state.sessionAliases.get(left) ?? /* @__PURE__ */ new Set();
|
|
628
|
+
leftAliases.add(right);
|
|
629
|
+
state.sessionAliases.set(left, leftAliases);
|
|
630
|
+
const rightAliases = state.sessionAliases.get(right) ?? /* @__PURE__ */ new Set();
|
|
631
|
+
rightAliases.add(left);
|
|
632
|
+
state.sessionAliases.set(right, rightAliases);
|
|
633
|
+
}
|
|
634
|
+
function recordEventAlias(event, sessionID) {
|
|
635
|
+
addAlias(eventAggregateID(event), sessionID);
|
|
636
|
+
}
|
|
637
|
+
function clearPartEstimates(sessionID) {
|
|
638
|
+
for (const key of state.partTokenEstimates.keys()) {
|
|
639
|
+
if (key.startsWith(`${sessionID}:`)) state.partTokenEstimates.delete(key);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
on("session.created", (event) => {
|
|
643
|
+
recordSession(eventProperty(event, "info"), true);
|
|
644
|
+
actions.notify();
|
|
645
|
+
});
|
|
646
|
+
on("session.updated", (event) => {
|
|
647
|
+
recordSession(eventProperty(event, "info"), false);
|
|
648
|
+
actions.notify();
|
|
649
|
+
});
|
|
650
|
+
on("session.status", (event) => {
|
|
651
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
652
|
+
if (sessionID.length === 0) return;
|
|
653
|
+
recordEventAlias(event, sessionID);
|
|
654
|
+
const type = statusType(event);
|
|
655
|
+
log2(`session.status: ${sessionID} -> ${type}`);
|
|
656
|
+
if (type === "busy") {
|
|
657
|
+
const now = eventPerfTime(event, performance.now());
|
|
658
|
+
actions.startSessionTiming(sessionID, now);
|
|
659
|
+
actions.clearHoldTimer(sessionID);
|
|
660
|
+
const existingTurn = state.turns.get(sessionID);
|
|
661
|
+
if (!existingTurn || existingTurn.isComplete) startTurn(state.turns, sessionID, now);
|
|
662
|
+
const existing = state.requests.get(sessionID);
|
|
663
|
+
if (existing && existing.isComplete) {
|
|
664
|
+
const prev = existing;
|
|
665
|
+
state.requests.set(sessionID, createFreshMetrics(sessionID, "", prev.modelID, prev.providerID, now));
|
|
666
|
+
clearLiveSpeed(state.liveSpeeds, sessionID);
|
|
667
|
+
state.lastRequestSessionID = sessionID;
|
|
668
|
+
log2(`new turn after idle: session=${sessionID}`);
|
|
669
|
+
actions.notify();
|
|
670
|
+
} else if (!existing) {
|
|
671
|
+
state.requests.set(sessionID, createFreshMetrics(sessionID, "", "", "", now));
|
|
672
|
+
state.lastRequestSessionID = sessionID;
|
|
673
|
+
actions.notify();
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
if (type === "idle") {
|
|
677
|
+
completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log: log2 });
|
|
678
|
+
}
|
|
679
|
+
});
|
|
680
|
+
on("session.idle", (event) => {
|
|
681
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
682
|
+
if (sessionID.length === 0) return;
|
|
683
|
+
recordEventAlias(event, sessionID);
|
|
684
|
+
completeRequest({ state, actions, sessionID, now: eventPerfTime(event, performance.now()), holdDurationMs: config.holdDurationMs, log: log2 });
|
|
685
|
+
});
|
|
686
|
+
on("message.part.delta", (event) => {
|
|
687
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
688
|
+
const messageID = stringEventProperty(event, "messageID");
|
|
689
|
+
const delta = stringEventProperty(event, "delta");
|
|
690
|
+
const field = stringEventProperty(event, "field");
|
|
691
|
+
const partID = stringEventProperty(event, "partID") || `${messageID}:stream`;
|
|
692
|
+
if (sessionID.length === 0 || messageID.length === 0) return;
|
|
693
|
+
recordEventAlias(event, sessionID);
|
|
694
|
+
if (field !== "text") return;
|
|
695
|
+
actions.startSessionTiming(sessionID, eventPerfTime(event, performance.now()));
|
|
696
|
+
const now = eventPerfTime(event, performance.now());
|
|
697
|
+
ensureTurn(state.turns, sessionID, now);
|
|
698
|
+
applyAssistantPartDelta(ctx, sessionID, messageID, partID, delta, now);
|
|
699
|
+
});
|
|
700
|
+
on("session.next.step.started", (event) => {
|
|
701
|
+
const step = parseAssistantStepStarted(eventProperties(event), eventID(event));
|
|
702
|
+
if (!step) return;
|
|
703
|
+
recordEventAlias(event, step.sessionID);
|
|
704
|
+
const now = eventPerfTime(event, performance.now());
|
|
705
|
+
actions.startSessionTiming(step.sessionID, now);
|
|
706
|
+
const existingTurn = state.turns.get(step.sessionID);
|
|
707
|
+
if (!existingTurn || existingTurn.isComplete) startTurn(state.turns, step.sessionID, now);
|
|
708
|
+
state.assistantMessageIds.set(step.sessionID, step.messageID);
|
|
709
|
+
clearPartEstimates(step.sessionID);
|
|
710
|
+
if (step.modelID || step.providerID) {
|
|
711
|
+
state.sessionModels.set(step.sessionID, { modelID: step.modelID, providerID: step.providerID });
|
|
712
|
+
}
|
|
713
|
+
const current = currentRequest({ state, actions, sessionID: step.sessionID, messageID: step.messageID, now });
|
|
714
|
+
resetLiveSpeed(state.liveSpeeds, step.sessionID, step.messageID);
|
|
715
|
+
applySessionModel(current, state.sessionModels.get(step.sessionID));
|
|
716
|
+
actions.notify();
|
|
717
|
+
});
|
|
718
|
+
on("session.next.text.delta", (event) => applyAssistantDelta(ctx, event));
|
|
719
|
+
on("session.next.reasoning.delta", (event) => applyAssistantDelta(ctx, event));
|
|
720
|
+
on("session.tool.called", (event) => {
|
|
721
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
722
|
+
if (sessionID.length === 0) return;
|
|
723
|
+
recordEventAlias(event, sessionID);
|
|
724
|
+
recordToolCalled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
725
|
+
actions.notify();
|
|
726
|
+
});
|
|
727
|
+
on("session.tool.success", (event) => {
|
|
728
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
729
|
+
if (sessionID.length === 0) return;
|
|
730
|
+
recordEventAlias(event, sessionID);
|
|
731
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
732
|
+
actions.notify();
|
|
733
|
+
});
|
|
734
|
+
on("session.tool.failed", (event) => {
|
|
735
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
736
|
+
if (sessionID.length === 0) return;
|
|
737
|
+
recordEventAlias(event, sessionID);
|
|
738
|
+
recordToolSettled(state.turns, sessionID, stringEventProperty(event, "id"), eventPerfTime(event, performance.now()));
|
|
739
|
+
actions.notify();
|
|
740
|
+
});
|
|
741
|
+
on("session.next.step.ended", (event) => {
|
|
742
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
743
|
+
const fallbackMessageID = sessionID ? state.assistantMessageIds.get(sessionID) ?? eventID(event) : eventID(event);
|
|
744
|
+
const step = parseAssistantStepEnded(eventProperties(event), fallbackMessageID);
|
|
745
|
+
if (!step) return;
|
|
746
|
+
recordEventAlias(event, step.sessionID);
|
|
747
|
+
const now = eventPerfTime(event, performance.now());
|
|
748
|
+
actions.startSessionTiming(step.sessionID, now);
|
|
749
|
+
const turn = ensureTurn(state.turns, step.sessionID, now);
|
|
750
|
+
const existing = state.requests.get(step.sessionID);
|
|
751
|
+
const isCurrent = existing?.messageID === step.messageID;
|
|
752
|
+
if (turn.finalizedSteps.get(step.messageID)?.exact) {
|
|
753
|
+
log2(`duplicate finalized step ignored: session=${step.sessionID} message=${step.messageID}`);
|
|
754
|
+
return;
|
|
755
|
+
}
|
|
756
|
+
const shouldStore = !existing || existing.messageID.length === 0 || Boolean(state.userMessageIds.get(step.sessionID)?.has(existing.messageID));
|
|
757
|
+
const current = isCurrent ? existing : createFreshMetrics(step.sessionID, step.messageID, existing?.modelID ?? "", existing?.providerID ?? "", now);
|
|
758
|
+
if (shouldStore) {
|
|
759
|
+
state.requests.set(step.sessionID, current);
|
|
760
|
+
state.lastRequestSessionID = step.sessionID;
|
|
761
|
+
}
|
|
762
|
+
if (applyAssistantTokens(current, step.tokens)) {
|
|
763
|
+
log2(`exact: in=${step.tokens.input} out=${step.tokens.output} cr=${step.tokens.cacheRead} cw=${step.tokens.cacheWrite}`);
|
|
764
|
+
} else {
|
|
765
|
+
log2("assistant tokens all zero, skipping overwrite");
|
|
766
|
+
}
|
|
767
|
+
current.lastDeltaTime = now;
|
|
768
|
+
current.isStreaming = false;
|
|
769
|
+
retireRequestIntoTurn(turn, current, isCurrent || shouldStore);
|
|
770
|
+
if (state.liveSpeeds.get(step.sessionID)?.messageID === step.messageID) {
|
|
771
|
+
clearLiveSpeed(state.liveSpeeds, step.sessionID);
|
|
772
|
+
}
|
|
773
|
+
actions.notify();
|
|
774
|
+
});
|
|
775
|
+
on("message.part.updated", (event) => {
|
|
776
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
777
|
+
if (sessionID.length === 0) return;
|
|
778
|
+
recordEventAlias(event, sessionID);
|
|
779
|
+
const rawPart = eventProperty(event, "part");
|
|
780
|
+
const part = parseTextPart(rawPart);
|
|
781
|
+
const now = eventPerfTime(event, performance.now());
|
|
782
|
+
const msgIds = state.userMessageIds.get(sessionID);
|
|
783
|
+
if (part && msgIds?.has(part.messageID)) {
|
|
784
|
+
const current = state.requests.get(sessionID);
|
|
785
|
+
if (current) {
|
|
786
|
+
if (!current.isComplete) actions.startSessionTiming(sessionID, now);
|
|
787
|
+
current.estimatedInputTokens = estimateTokens(part.text, config.estimationRatio);
|
|
788
|
+
log2(`estimated input tokens: ${current.estimatedInputTokens} (textLen=${part.text.length})`);
|
|
789
|
+
actions.notify();
|
|
790
|
+
}
|
|
791
|
+
return;
|
|
792
|
+
}
|
|
793
|
+
const assistantPart = parseAssistantProgressPart(rawPart);
|
|
794
|
+
if (assistantPart) {
|
|
795
|
+
applyAssistantProgress(ctx, sessionID, assistantPart, now);
|
|
796
|
+
}
|
|
797
|
+
});
|
|
798
|
+
on("message.updated", (event) => {
|
|
799
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
800
|
+
if (sessionID.length === 0) return;
|
|
801
|
+
recordEventAlias(event, sessionID);
|
|
802
|
+
const info = eventProperty(event, "info");
|
|
803
|
+
const assistant = parseAssistantMessage(info);
|
|
804
|
+
if (assistant) {
|
|
805
|
+
const nextModel = mergeAssistantModel(state.sessionModels.get(sessionID), assistant);
|
|
806
|
+
if (nextModel) state.sessionModels.set(sessionID, nextModel);
|
|
807
|
+
const now = eventPerfTime(event, performance.now());
|
|
808
|
+
const tokens = assistant.tokens;
|
|
809
|
+
const existing = state.requests.get(sessionID);
|
|
810
|
+
const turn = ensureTurn(state.turns, sessionID, now);
|
|
811
|
+
const hasPositive = hasPositiveAssistantTokens(tokens);
|
|
812
|
+
const isCurrent = existing?.messageID === assistant.messageID;
|
|
813
|
+
const shouldUseAssistantMessage = assistant.messageID.length > 0 && (hasPositive || isCurrent);
|
|
814
|
+
const current = shouldUseAssistantMessage ? isCurrent ? existing : turn.finalizedSteps.has(assistant.messageID) ? createFreshMetrics(sessionID, assistant.messageID, assistant.modelID, assistant.providerID, now) : currentRequest({ state, actions, sessionID, messageID: assistant.messageID, now }) : existing;
|
|
815
|
+
if (current) {
|
|
816
|
+
if (!current.isComplete) actions.startSessionTiming(sessionID, now);
|
|
817
|
+
if (tokens) {
|
|
818
|
+
if (applyAssistantTokens(current, tokens)) {
|
|
819
|
+
log2(`exact: in=${tokens.input} out=${tokens.output} cr=${tokens.cacheRead} cw=${tokens.cacheWrite}`);
|
|
820
|
+
retireRequestIntoTurn(turn, current, isCurrent || state.requests.get(sessionID) === current);
|
|
821
|
+
} else {
|
|
822
|
+
log2(`assistant tokens all zero, skipping overwrite`);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
applySessionModel(current, state.sessionModels.get(sessionID));
|
|
826
|
+
if (assistant.completed && state.liveSpeeds.get(sessionID)?.messageID === assistant.messageID) {
|
|
827
|
+
clearLiveSpeed(state.liveSpeeds, sessionID);
|
|
828
|
+
}
|
|
829
|
+
actions.notify();
|
|
830
|
+
}
|
|
831
|
+
}
|
|
832
|
+
const user = parseUserMessage(info);
|
|
833
|
+
if (user) {
|
|
834
|
+
const userMsgID = user.messageID;
|
|
835
|
+
const current = state.requests.get(sessionID);
|
|
836
|
+
if (current && current.messageID === userMsgID) {
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
if (userMsgID && state.userMessageIds.get(sessionID)?.has(userMsgID)) {
|
|
840
|
+
return;
|
|
841
|
+
}
|
|
842
|
+
const now = eventPerfTime(event, performance.now());
|
|
843
|
+
const previous = state.requests.get(sessionID);
|
|
844
|
+
const previousTurn = state.turns.get(sessionID);
|
|
845
|
+
if (previous && previousTurn) retireRequestIntoTurn(previousTurn, previous);
|
|
846
|
+
startTurn(state.turns, sessionID, now);
|
|
847
|
+
clearLiveSpeed(state.liveSpeeds, sessionID);
|
|
848
|
+
clearPartEstimates(sessionID);
|
|
849
|
+
const fresh = createFreshMetrics(sessionID, userMsgID, "", "", now);
|
|
850
|
+
actions.startSessionTiming(sessionID, fresh.requestStartTime);
|
|
851
|
+
if (userMsgID) {
|
|
852
|
+
let msgIds = state.userMessageIds.get(sessionID);
|
|
853
|
+
if (!msgIds) {
|
|
854
|
+
msgIds = /* @__PURE__ */ new Set();
|
|
855
|
+
state.userMessageIds.set(sessionID, msgIds);
|
|
856
|
+
}
|
|
857
|
+
msgIds.add(userMsgID);
|
|
858
|
+
}
|
|
859
|
+
log2(`user msg: id=${userMsgID}`);
|
|
860
|
+
applySessionModel(fresh, state.sessionModels.get(sessionID));
|
|
861
|
+
state.requests.set(sessionID, fresh);
|
|
862
|
+
state.lastRequestSessionID = sessionID;
|
|
863
|
+
actions.notify();
|
|
864
|
+
}
|
|
865
|
+
});
|
|
866
|
+
on("session.deleted", (event) => {
|
|
867
|
+
const sessionID = stringEventProperty(event, "sessionID");
|
|
868
|
+
if (sessionID.length === 0) return;
|
|
869
|
+
state.sessionTree.deleteSession(sessionID);
|
|
870
|
+
state.sessionModels.delete(sessionID);
|
|
871
|
+
state.sessionTimings.delete(sessionID);
|
|
872
|
+
state.turns.delete(sessionID);
|
|
873
|
+
state.liveSpeeds.delete(sessionID);
|
|
874
|
+
state.userMessageIds.delete(sessionID);
|
|
875
|
+
state.assistantMessageIds.delete(sessionID);
|
|
876
|
+
state.sessionAliases.delete(sessionID);
|
|
877
|
+
for (const aliases of state.sessionAliases.values()) {
|
|
878
|
+
aliases.delete(sessionID);
|
|
879
|
+
}
|
|
880
|
+
for (const key of state.partTokenEstimates.keys()) {
|
|
881
|
+
if (key.startsWith(`${sessionID}:`)) state.partTokenEstimates.delete(key);
|
|
882
|
+
}
|
|
883
|
+
actions.clearHoldTimer(sessionID);
|
|
884
|
+
if (state.requests.has(sessionID)) {
|
|
885
|
+
state.requests.delete(sessionID);
|
|
886
|
+
if (state.lastRequestSessionID === sessionID) {
|
|
887
|
+
state.lastRequestSessionID = [...state.requests.keys()].at(-1) ?? null;
|
|
888
|
+
}
|
|
889
|
+
actions.notify();
|
|
890
|
+
}
|
|
891
|
+
});
|
|
892
|
+
return disposers;
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
// src/logger.ts
|
|
896
|
+
import { appendFileSync, existsSync as existsSync2, mkdirSync } from "node:fs";
|
|
897
|
+
import { join as join2 } from "node:path";
|
|
898
|
+
import { homedir as homedir2 } from "node:os";
|
|
899
|
+
|
|
900
|
+
// src/config.ts
|
|
901
|
+
import { readFileSync, existsSync } from "node:fs";
|
|
902
|
+
import { join } from "node:path";
|
|
903
|
+
import { homedir } from "node:os";
|
|
904
|
+
|
|
905
|
+
// src/types.ts
|
|
906
|
+
var DEFAULT_CONFIG = {
|
|
907
|
+
refreshIntervalMs: 200,
|
|
908
|
+
holdDurationMs: 0,
|
|
909
|
+
estimationRatio: 4,
|
|
910
|
+
enableLogging: false,
|
|
911
|
+
visible: {
|
|
912
|
+
speed: true,
|
|
913
|
+
ttft: true,
|
|
914
|
+
input: true,
|
|
915
|
+
output: true,
|
|
916
|
+
cache: false,
|
|
917
|
+
elapsed: true,
|
|
918
|
+
session: true,
|
|
919
|
+
model: true,
|
|
920
|
+
modelBreakdown: true
|
|
921
|
+
}
|
|
922
|
+
};
|
|
923
|
+
|
|
924
|
+
// src/config.ts
|
|
925
|
+
function getConfigPaths() {
|
|
926
|
+
return [
|
|
927
|
+
join(homedir(), ".config", "opencode", "opencode-metrics.json"),
|
|
928
|
+
join(homedir(), ".config", "opencode", "opencode-bar.json"),
|
|
929
|
+
join(process.cwd(), ".opencode", "opencode-metrics.json"),
|
|
930
|
+
join(process.cwd(), ".opencode", "opencode-bar.json")
|
|
931
|
+
];
|
|
932
|
+
}
|
|
933
|
+
var cachedConfig = null;
|
|
934
|
+
function getConfig() {
|
|
935
|
+
if (cachedConfig) return cachedConfig;
|
|
936
|
+
const merged = { ...DEFAULT_CONFIG };
|
|
937
|
+
for (const path of getConfigPaths()) {
|
|
938
|
+
if (!existsSync(path)) continue;
|
|
939
|
+
try {
|
|
940
|
+
const raw = readFileSync(path, "utf-8");
|
|
941
|
+
const parsed = JSON.parse(raw);
|
|
942
|
+
if (typeof parsed.refreshIntervalMs === "number" && parsed.refreshIntervalMs >= 100) {
|
|
943
|
+
merged.refreshIntervalMs = parsed.refreshIntervalMs;
|
|
944
|
+
}
|
|
945
|
+
if (typeof parsed.holdDurationMs === "number" && (parsed.holdDurationMs === 0 || parsed.holdDurationMs >= 1e3)) {
|
|
946
|
+
merged.holdDurationMs = parsed.holdDurationMs;
|
|
947
|
+
}
|
|
948
|
+
if (typeof parsed.estimationRatio === "number" && parsed.estimationRatio > 0) {
|
|
949
|
+
merged.estimationRatio = parsed.estimationRatio;
|
|
950
|
+
}
|
|
951
|
+
if (typeof parsed.enableLogging === "boolean") {
|
|
952
|
+
merged.enableLogging = parsed.enableLogging;
|
|
953
|
+
}
|
|
954
|
+
if (parsed.visible && typeof parsed.visible === "object") {
|
|
955
|
+
for (const key of Object.keys(merged.visible)) {
|
|
956
|
+
if (typeof parsed.visible[key] === "boolean") {
|
|
957
|
+
merged.visible[key] = parsed.visible[key];
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
}
|
|
961
|
+
} catch (error) {
|
|
962
|
+
if (process.env.OPENCODE_METRICS_DEBUG === "1") {
|
|
963
|
+
console.warn(`opencode-metrics: failed to read config ${path}`, error);
|
|
964
|
+
}
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
cachedConfig = merged;
|
|
968
|
+
return merged;
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// src/logger.ts
|
|
972
|
+
var logFile = null;
|
|
973
|
+
function getLogDir() {
|
|
974
|
+
if (process.platform === "win32") {
|
|
975
|
+
return join2(process.env.LOCALAPPDATA || join2(homedir2(), "AppData", "Local"), "opentui", "opencode-metrics", "logs");
|
|
976
|
+
}
|
|
977
|
+
const dataHome = process.env.XDG_DATA_HOME || join2(homedir2(), ".local", "share");
|
|
978
|
+
return join2(dataHome, "opentui", "opencode-metrics", "logs");
|
|
979
|
+
}
|
|
980
|
+
function getLogFile() {
|
|
981
|
+
if (logFile) return logFile;
|
|
982
|
+
const dir = getLogDir();
|
|
983
|
+
if (!existsSync2(dir)) mkdirSync(dir, { recursive: true });
|
|
984
|
+
logFile = join2(dir, "opencode-metrics.log");
|
|
985
|
+
return logFile;
|
|
986
|
+
}
|
|
987
|
+
function log(msg) {
|
|
988
|
+
const config = getConfig();
|
|
989
|
+
if (!config.enableLogging) return;
|
|
990
|
+
try {
|
|
991
|
+
appendFileSync(getLogFile(), `[${(/* @__PURE__ */ new Date()).toISOString()}] ${msg}
|
|
992
|
+
`);
|
|
993
|
+
} catch (error) {
|
|
994
|
+
if (process.env.OPENCODE_METRICS_DEBUG === "1") {
|
|
995
|
+
console.warn("opencode-metrics: failed to write log", error);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
// src/session-hydration.ts
|
|
1001
|
+
async function callWithFallback(attempts) {
|
|
1002
|
+
let lastResponse = null;
|
|
1003
|
+
for (let i = 0; i < attempts.length; i++) {
|
|
1004
|
+
const response = await attempts[i]();
|
|
1005
|
+
if (!isRecord3(response) || response.error == null) {
|
|
1006
|
+
if (i > 0) log(`sdk arg shapes: attempt ${i + 1}/${attempts.length} succeeded`);
|
|
1007
|
+
return response;
|
|
1008
|
+
}
|
|
1009
|
+
lastResponse = response;
|
|
1010
|
+
}
|
|
1011
|
+
log(`sdk arg shapes: all ${attempts.length} failed (response.error)`);
|
|
1012
|
+
return lastResponse;
|
|
1013
|
+
}
|
|
1014
|
+
function isRecord3(value) {
|
|
1015
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1016
|
+
}
|
|
1017
|
+
function timestampOrNull2(value) {
|
|
1018
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
1019
|
+
}
|
|
1020
|
+
function isHydrationApi(value) {
|
|
1021
|
+
if (!isRecord3(value)) return false;
|
|
1022
|
+
const client = value.client;
|
|
1023
|
+
if (!isRecord3(client)) return false;
|
|
1024
|
+
const session = client.session;
|
|
1025
|
+
return isRecord3(session) && typeof session.messages === "function" && typeof session.status === "function";
|
|
1026
|
+
}
|
|
1027
|
+
function unwrapData(value) {
|
|
1028
|
+
if (isRecord3(value) && "data" in value) return value.data;
|
|
1029
|
+
return value;
|
|
1030
|
+
}
|
|
1031
|
+
function unwrapMessageList(response) {
|
|
1032
|
+
const value = unwrapData(response);
|
|
1033
|
+
return Array.isArray(value) ? value : [];
|
|
1034
|
+
}
|
|
1035
|
+
function unwrapSessionStatus(response, sessionID) {
|
|
1036
|
+
const value = unwrapData(response);
|
|
1037
|
+
if (isRecord3(value)) {
|
|
1038
|
+
const bySessionID = value[sessionID];
|
|
1039
|
+
if (typeof bySessionID === "string" || isRecord3(bySessionID)) return bySessionID;
|
|
1040
|
+
}
|
|
1041
|
+
return value;
|
|
1042
|
+
}
|
|
1043
|
+
function statusKind(value) {
|
|
1044
|
+
if (typeof value === "string") return value;
|
|
1045
|
+
if (!isRecord3(value)) return "";
|
|
1046
|
+
return typeof value.type === "string" ? value.type : "";
|
|
1047
|
+
}
|
|
1048
|
+
function messageInfo(value) {
|
|
1049
|
+
if (!isRecord3(value)) return value;
|
|
1050
|
+
return isRecord3(value.info) ? value.info : value;
|
|
1051
|
+
}
|
|
1052
|
+
function toPerformanceTime(wallTime, now) {
|
|
1053
|
+
if (wallTime === null) return null;
|
|
1054
|
+
return now - Math.max(0, Date.now() - wallTime);
|
|
1055
|
+
}
|
|
1056
|
+
async function observablePartTimes(api, messageID, now) {
|
|
1057
|
+
const parts = api.client.message?.parts;
|
|
1058
|
+
if (typeof parts !== "function") return { first: null, last: null };
|
|
1059
|
+
let values;
|
|
1060
|
+
try {
|
|
1061
|
+
values = unwrapMessageList(await callWithFallback([
|
|
1062
|
+
() => parts({ id: messageID }),
|
|
1063
|
+
() => parts({ path: { messageID } }),
|
|
1064
|
+
() => parts({ path: { id: messageID } })
|
|
1065
|
+
]));
|
|
1066
|
+
} catch {
|
|
1067
|
+
return { first: null, last: null };
|
|
1068
|
+
}
|
|
1069
|
+
let firstWall = null;
|
|
1070
|
+
let lastWall = null;
|
|
1071
|
+
for (const value of values) {
|
|
1072
|
+
if (!isRecord3(value) || value.type !== "text" && value.type !== "reasoning") continue;
|
|
1073
|
+
const time = isRecord3(value.time) ? value.time : null;
|
|
1074
|
+
const start = timestampOrNull2(time?.start);
|
|
1075
|
+
const end = timestampOrNull2(time?.end);
|
|
1076
|
+
if (start !== null) firstWall = firstWall === null ? start : Math.min(firstWall, start);
|
|
1077
|
+
if (end !== null) lastWall = lastWall === null ? end : Math.max(lastWall, end);
|
|
1078
|
+
}
|
|
1079
|
+
return { first: toPerformanceTime(firstWall, now), last: toPerformanceTime(lastWall, now) };
|
|
1080
|
+
}
|
|
1081
|
+
function hydrateSessionTiming(rawMessages, now) {
|
|
1082
|
+
const intervals = [];
|
|
1083
|
+
const hasUserBoundary = rawMessages.some((raw) => parseUserMessage(messageInfo(raw)) !== null);
|
|
1084
|
+
if (!hasUserBoundary) {
|
|
1085
|
+
for (const raw of rawMessages) {
|
|
1086
|
+
const assistant = parseAssistantMessage(messageInfo(raw));
|
|
1087
|
+
const start = toPerformanceTime(assistant?.createdTime ?? null, now);
|
|
1088
|
+
const end = toPerformanceTime(assistant?.completedTime ?? null, now);
|
|
1089
|
+
if (start !== null && end !== null && end >= start) intervals.push({ start, end });
|
|
1090
|
+
}
|
|
1091
|
+
const elapsedMs2 = Math.round(intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0));
|
|
1092
|
+
return { elapsedMs: elapsedMs2, activeSince: null, intervals };
|
|
1093
|
+
}
|
|
1094
|
+
let groupStartWall = null;
|
|
1095
|
+
let groupEndWall = null;
|
|
1096
|
+
function closeGroup() {
|
|
1097
|
+
const start = toPerformanceTime(groupStartWall, now);
|
|
1098
|
+
const end = toPerformanceTime(groupEndWall, now);
|
|
1099
|
+
if (start !== null && end !== null && end >= start) intervals.push({ start, end });
|
|
1100
|
+
}
|
|
1101
|
+
for (const raw of rawMessages) {
|
|
1102
|
+
const info = messageInfo(raw);
|
|
1103
|
+
const user = parseUserMessage(info);
|
|
1104
|
+
if (user) {
|
|
1105
|
+
if (groupStartWall !== null) closeGroup();
|
|
1106
|
+
groupStartWall = user.createdTime;
|
|
1107
|
+
groupEndWall = null;
|
|
1108
|
+
continue;
|
|
1109
|
+
}
|
|
1110
|
+
const assistant = parseAssistantMessage(info);
|
|
1111
|
+
if (!assistant) continue;
|
|
1112
|
+
groupStartWall ??= assistant.createdTime;
|
|
1113
|
+
if (assistant.completedTime !== null) {
|
|
1114
|
+
groupEndWall = groupEndWall === null ? assistant.completedTime : Math.max(groupEndWall, assistant.completedTime);
|
|
1115
|
+
}
|
|
1116
|
+
}
|
|
1117
|
+
if (groupStartWall !== null) closeGroup();
|
|
1118
|
+
const elapsedMs = Math.round(intervals.reduce((total, interval) => total + Math.max(0, interval.end - interval.start), 0));
|
|
1119
|
+
return { elapsedMs, activeSince: null, intervals };
|
|
1120
|
+
}
|
|
1121
|
+
async function hydrateSession(input) {
|
|
1122
|
+
const rawMessages = unwrapMessageList(await callWithFallback([
|
|
1123
|
+
() => input.api.client.session.messages({ sessionID: input.sessionID }),
|
|
1124
|
+
() => input.api.client.session.messages({ id: input.sessionID }),
|
|
1125
|
+
() => input.api.client.session.messages({ path: { sessionID: input.sessionID } }),
|
|
1126
|
+
() => input.api.client.session.messages({ path: { id: input.sessionID } })
|
|
1127
|
+
]));
|
|
1128
|
+
const infos = rawMessages.map(messageInfo);
|
|
1129
|
+
const assistants = infos.map(parseAssistantMessage).filter((message) => message !== null && hasPositiveAssistantTokens(message.tokens));
|
|
1130
|
+
const assistant = assistants.at(-1);
|
|
1131
|
+
if (!assistant || !hasPositiveAssistantTokens(assistant.tokens)) return false;
|
|
1132
|
+
let lastUserIndex = -1;
|
|
1133
|
+
for (let index = infos.length - 1; index >= 0; index -= 1) {
|
|
1134
|
+
if (parseUserMessage(infos[index])) {
|
|
1135
|
+
lastUserIndex = index;
|
|
1136
|
+
break;
|
|
1137
|
+
}
|
|
1138
|
+
}
|
|
1139
|
+
const trailingAssistants = infos.slice(lastUserIndex + 1).map(parseAssistantMessage).filter((message) => message !== null && hasPositiveAssistantTokens(message.tokens));
|
|
1140
|
+
const lastUser = lastUserIndex >= 0 ? parseUserMessage(infos[lastUserIndex]) : null;
|
|
1141
|
+
const existing = input.state.requests.get(input.sessionID);
|
|
1142
|
+
if (existing && !existing.isComplete && existing.messageID !== assistant.messageID) return false;
|
|
1143
|
+
const requestStartTime = toPerformanceTime(lastUser?.createdTime ?? assistant.createdTime, input.now) ?? input.now;
|
|
1144
|
+
const completedTime = toPerformanceTime(assistant.completedTime, input.now);
|
|
1145
|
+
const sessionStatus = statusKind(unwrapSessionStatus(
|
|
1146
|
+
await input.api.client.session.status({ id: input.sessionID }),
|
|
1147
|
+
input.sessionID
|
|
1148
|
+
));
|
|
1149
|
+
log(`hydrate session=${input.sessionID.slice(0, 8)} status='${sessionStatus}'`);
|
|
1150
|
+
const shouldReuseExisting = existing?.messageID === assistant.messageID;
|
|
1151
|
+
const current = shouldReuseExisting ? existing : createFreshMetrics(
|
|
1152
|
+
input.sessionID,
|
|
1153
|
+
assistant.messageID,
|
|
1154
|
+
assistant.modelID,
|
|
1155
|
+
assistant.providerID,
|
|
1156
|
+
requestStartTime
|
|
1157
|
+
);
|
|
1158
|
+
if (!shouldReuseExisting || !current.isComplete) {
|
|
1159
|
+
current.requestStartTime = requestStartTime;
|
|
1160
|
+
}
|
|
1161
|
+
current.messageID = assistant.messageID;
|
|
1162
|
+
current.modelID = assistant.modelID;
|
|
1163
|
+
current.providerID = assistant.providerID;
|
|
1164
|
+
const partTimes = await observablePartTimes(input.api, assistant.messageID, input.now);
|
|
1165
|
+
current.firstTokenTime = current.firstTokenTime ?? partTimes.first;
|
|
1166
|
+
current.lastDeltaTime = current.lastDeltaTime ?? partTimes.last ?? completedTime ?? input.now;
|
|
1167
|
+
current.isStreaming = !assistant.completed && sessionStatus !== "idle";
|
|
1168
|
+
current.isComplete = assistant.completed || !current.isStreaming;
|
|
1169
|
+
if (current.isComplete && current.completeTime === null) {
|
|
1170
|
+
current.completeTime = completedTime ?? input.now;
|
|
1171
|
+
}
|
|
1172
|
+
if (!current.isComplete) {
|
|
1173
|
+
current.completeTime = null;
|
|
1174
|
+
current.lastDeltaTime = input.now;
|
|
1175
|
+
}
|
|
1176
|
+
applyAssistantTokens(current, assistant.tokens);
|
|
1177
|
+
const turnStartTime = toPerformanceTime(
|
|
1178
|
+
lastUser?.createdTime ?? trailingAssistants[0]?.createdTime ?? assistant.createdTime,
|
|
1179
|
+
input.now
|
|
1180
|
+
) ?? requestStartTime;
|
|
1181
|
+
const turn = createTurnMetrics(input.sessionID, turnStartTime);
|
|
1182
|
+
turn.firstTokenTime = toPerformanceTime(trailingAssistants[0]?.createdTime, input.now) ?? current.firstTokenTime;
|
|
1183
|
+
for (const step of trailingAssistants) {
|
|
1184
|
+
if (!step.tokens || !hasPositiveAssistantTokens(step.tokens)) continue;
|
|
1185
|
+
const stepStart = toPerformanceTime(step.createdTime, input.now) ?? turnStartTime;
|
|
1186
|
+
const stepMetrics = createFreshMetrics(
|
|
1187
|
+
input.sessionID,
|
|
1188
|
+
step.messageID,
|
|
1189
|
+
step.modelID,
|
|
1190
|
+
step.providerID,
|
|
1191
|
+
stepStart
|
|
1192
|
+
);
|
|
1193
|
+
applyAssistantTokens(stepMetrics, step.tokens);
|
|
1194
|
+
retireRequestIntoTurn(turn, stepMetrics);
|
|
1195
|
+
}
|
|
1196
|
+
if (sessionStatus === "idle" || current.isComplete) completeTurn(turn, completedTime ?? input.now);
|
|
1197
|
+
input.state.turns.set(input.sessionID, turn);
|
|
1198
|
+
const liveEntry = input.state.requests.get(input.sessionID);
|
|
1199
|
+
if (liveEntry !== void 0 && liveEntry !== current && !liveEntry.isComplete && liveEntry.messageID !== current.messageID) {
|
|
1200
|
+
return false;
|
|
1201
|
+
}
|
|
1202
|
+
input.state.requests.set(input.sessionID, current);
|
|
1203
|
+
input.state.lastRequestSessionID = input.sessionID;
|
|
1204
|
+
if (assistant.modelID || assistant.providerID) {
|
|
1205
|
+
input.state.sessionModels.set(input.sessionID, { modelID: assistant.modelID, providerID: assistant.providerID });
|
|
1206
|
+
}
|
|
1207
|
+
if (!input.state.sessionTimings.has(input.sessionID)) {
|
|
1208
|
+
const timing = hydrateSessionTiming(rawMessages, input.now);
|
|
1209
|
+
input.state.sessionTimings.set(input.sessionID, timing);
|
|
1210
|
+
const intervals = timing.intervals ?? [];
|
|
1211
|
+
const maxSpan = intervals.reduce((m, iv) => Math.max(m, iv.end - iv.start), 0);
|
|
1212
|
+
const oldestStart = intervals.reduce((m, iv) => Math.min(m, iv.start), Number.POSITIVE_INFINITY);
|
|
1213
|
+
log(
|
|
1214
|
+
`timing hydrate session=${input.sessionID.slice(0, 8)} elapsed=${Math.round(timing.elapsedMs)}ms activeSinceAge=${timing.activeSince === null ? "null" : `${Math.round(input.now - timing.activeSince)}ms`} intervals=${intervals.length} maxSpan=${Math.round(maxSpan)}ms oldestStartAge=${Number.isFinite(oldestStart) ? `${Math.round(input.now - oldestStart)}ms` : "n/a"}`
|
|
1215
|
+
);
|
|
1216
|
+
}
|
|
1217
|
+
return true;
|
|
1218
|
+
}
|
|
1219
|
+
|
|
1220
|
+
// src/session-tree.ts
|
|
1221
|
+
function createSessionTree() {
|
|
1222
|
+
const parentBySession = /* @__PURE__ */ new Map();
|
|
1223
|
+
function collectDescendants(sessionID, seen) {
|
|
1224
|
+
const descendants = [];
|
|
1225
|
+
for (const [candidateID, parentID] of parentBySession) {
|
|
1226
|
+
if (parentID !== sessionID || seen.has(candidateID)) continue;
|
|
1227
|
+
seen.add(candidateID);
|
|
1228
|
+
descendants.push(candidateID, ...collectDescendants(candidateID, seen));
|
|
1229
|
+
}
|
|
1230
|
+
return descendants;
|
|
1231
|
+
}
|
|
1232
|
+
return {
|
|
1233
|
+
setParent(sessionID, parentID) {
|
|
1234
|
+
parentBySession.set(sessionID, parentID);
|
|
1235
|
+
},
|
|
1236
|
+
deleteSession(sessionID) {
|
|
1237
|
+
parentBySession.delete(sessionID);
|
|
1238
|
+
},
|
|
1239
|
+
clear() {
|
|
1240
|
+
parentBySession.clear();
|
|
1241
|
+
},
|
|
1242
|
+
getScopeSessionIDs(sessionID, scope) {
|
|
1243
|
+
if (scope === "current") return [sessionID];
|
|
1244
|
+
const seen = /* @__PURE__ */ new Set([sessionID]);
|
|
1245
|
+
return [sessionID, ...collectDescendants(sessionID, seen)];
|
|
1246
|
+
},
|
|
1247
|
+
getChildSessionCount(sessionID) {
|
|
1248
|
+
return collectDescendants(sessionID, /* @__PURE__ */ new Set([sessionID])).length;
|
|
1249
|
+
}
|
|
1250
|
+
};
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
// src/session-timing.ts
|
|
1254
|
+
function startSessionTiming(timings, sessionID, now) {
|
|
1255
|
+
const timing = timings.get(sessionID);
|
|
1256
|
+
if (!timing) {
|
|
1257
|
+
timings.set(sessionID, { elapsedMs: 0, activeSince: now, intervals: [] });
|
|
1258
|
+
return;
|
|
1259
|
+
}
|
|
1260
|
+
if (timing.activeSince === null) timing.activeSince = now;
|
|
1261
|
+
}
|
|
1262
|
+
function stopSessionTiming(timings, sessionID, now) {
|
|
1263
|
+
const timing = timings.get(sessionID);
|
|
1264
|
+
if (!timing || timing.activeSince === null) return;
|
|
1265
|
+
timing.elapsedMs += Math.max(0, now - timing.activeSince);
|
|
1266
|
+
timing.intervals ??= [];
|
|
1267
|
+
timing.intervals.push({ start: timing.activeSince, end: now });
|
|
1268
|
+
timing.activeSince = null;
|
|
1269
|
+
}
|
|
1270
|
+
function getScopeElapsedMs(timings, sessionIDs, now) {
|
|
1271
|
+
const intervals = [];
|
|
1272
|
+
let unpositionedElapsedMs = 0;
|
|
1273
|
+
for (const sessionID of sessionIDs) {
|
|
1274
|
+
const timing = timings.get(sessionID);
|
|
1275
|
+
if (!timing) continue;
|
|
1276
|
+
const positionedMs = (timing.intervals ?? []).reduce(
|
|
1277
|
+
(total, interval) => total + Math.max(0, interval.end - interval.start),
|
|
1278
|
+
0
|
|
1279
|
+
);
|
|
1280
|
+
for (const interval of timing.intervals ?? []) intervals.push(interval);
|
|
1281
|
+
if (timing.activeSince !== null) intervals.push({ start: timing.activeSince, end: now });
|
|
1282
|
+
unpositionedElapsedMs += Math.max(0, timing.elapsedMs - positionedMs);
|
|
1283
|
+
}
|
|
1284
|
+
intervals.sort((left, right) => left.start - right.start || left.end - right.end);
|
|
1285
|
+
let unionMs = 0;
|
|
1286
|
+
let start = null;
|
|
1287
|
+
let end = null;
|
|
1288
|
+
for (const interval of intervals) {
|
|
1289
|
+
if (start === null || end === null) {
|
|
1290
|
+
start = interval.start;
|
|
1291
|
+
end = interval.end;
|
|
1292
|
+
continue;
|
|
1293
|
+
}
|
|
1294
|
+
if (interval.start <= end) {
|
|
1295
|
+
end = Math.max(end, interval.end);
|
|
1296
|
+
continue;
|
|
1297
|
+
}
|
|
1298
|
+
unionMs += Math.max(0, end - start);
|
|
1299
|
+
start = interval.start;
|
|
1300
|
+
end = interval.end;
|
|
1301
|
+
}
|
|
1302
|
+
if (start !== null && end !== null) unionMs += Math.max(0, end - start);
|
|
1303
|
+
return unionMs + unpositionedElapsedMs;
|
|
1304
|
+
}
|
|
1305
|
+
function getSessionElapsedMs(timing, now) {
|
|
1306
|
+
if (!timing) return 0;
|
|
1307
|
+
return timing.elapsedMs + (timing.activeSince === null ? 0 : Math.max(0, now - timing.activeSince));
|
|
1308
|
+
}
|
|
1309
|
+
|
|
1310
|
+
// src/collector.ts
|
|
1311
|
+
function isRecord4(value) {
|
|
1312
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1313
|
+
}
|
|
1314
|
+
function isTreeHydrationApi(value) {
|
|
1315
|
+
if (!isRecord4(value) || !isRecord4(value.client)) return false;
|
|
1316
|
+
const session = isRecord4(value.client.session) ? value.client.session : null;
|
|
1317
|
+
return session !== null && typeof session.children === "function";
|
|
1318
|
+
}
|
|
1319
|
+
function childSessions(value) {
|
|
1320
|
+
if (isRecord4(value) && value.error !== void 0 && value.error !== null) {
|
|
1321
|
+
throw new Error(`session.children returned an error: ${String(value.error)}`);
|
|
1322
|
+
}
|
|
1323
|
+
const data = Array.isArray(value) ? value : isRecord4(value) && Array.isArray(value.data) ? value.data : [];
|
|
1324
|
+
return data.flatMap((item) => {
|
|
1325
|
+
if (!isRecord4(item) || typeof item.id !== "string" || item.id.length === 0) return [];
|
|
1326
|
+
return [{ id: item.id, parentID: typeof item.parentID === "string" ? item.parentID : null }];
|
|
1327
|
+
});
|
|
1328
|
+
}
|
|
1329
|
+
function createCollector(api, config, log2) {
|
|
1330
|
+
const state = {
|
|
1331
|
+
requests: /* @__PURE__ */ new Map(),
|
|
1332
|
+
turns: /* @__PURE__ */ new Map(),
|
|
1333
|
+
liveSpeeds: /* @__PURE__ */ new Map(),
|
|
1334
|
+
holdTimers: /* @__PURE__ */ new Map(),
|
|
1335
|
+
sessionTree: createSessionTree(),
|
|
1336
|
+
sessionModels: /* @__PURE__ */ new Map(),
|
|
1337
|
+
sessionTimings: /* @__PURE__ */ new Map(),
|
|
1338
|
+
userMessageIds: /* @__PURE__ */ new Map(),
|
|
1339
|
+
assistantMessageIds: /* @__PURE__ */ new Map(),
|
|
1340
|
+
partTokenEstimates: /* @__PURE__ */ new Map(),
|
|
1341
|
+
sessionAliases: /* @__PURE__ */ new Map(),
|
|
1342
|
+
seenEventKeys: /* @__PURE__ */ new Set(),
|
|
1343
|
+
seenEventOrder: [],
|
|
1344
|
+
lastRequestSessionID: null
|
|
1345
|
+
};
|
|
1346
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
1347
|
+
const hydrationApi = isHydrationApi(api) ? api : null;
|
|
1348
|
+
const treeHydrationApi = isTreeHydrationApi(api) ? api : null;
|
|
1349
|
+
const hydratedSessions = /* @__PURE__ */ new Set();
|
|
1350
|
+
const hydratingSessions = /* @__PURE__ */ new Set();
|
|
1351
|
+
const hydrationRetryAfter = /* @__PURE__ */ new Map();
|
|
1352
|
+
const hydrationRetries = /* @__PURE__ */ new Map();
|
|
1353
|
+
const MAX_HYDRATION_RETRIES = 5;
|
|
1354
|
+
const loggedFallbacks = /* @__PURE__ */ new Set();
|
|
1355
|
+
const hydratedTreeRoots = /* @__PURE__ */ new Set();
|
|
1356
|
+
const hydratingTreeRoots = /* @__PURE__ */ new Set();
|
|
1357
|
+
const treeRetryAfter = /* @__PURE__ */ new Map();
|
|
1358
|
+
let disposed = false;
|
|
1359
|
+
const AGGREGATE_CACHE_TTL_MS = 150;
|
|
1360
|
+
let aggregateCacheKey = "";
|
|
1361
|
+
let aggregateCacheAt = 0;
|
|
1362
|
+
let aggregateCacheValue = null;
|
|
1363
|
+
function notify() {
|
|
1364
|
+
if (disposed) return;
|
|
1365
|
+
aggregateCacheKey = "";
|
|
1366
|
+
aggregateCacheAt = 0;
|
|
1367
|
+
aggregateCacheValue = null;
|
|
1368
|
+
for (const listener of listeners) listener();
|
|
1369
|
+
}
|
|
1370
|
+
function clearHoldTimer(sessionID) {
|
|
1371
|
+
const timer = state.holdTimers.get(sessionID);
|
|
1372
|
+
if (timer) {
|
|
1373
|
+
clearTimeout(timer);
|
|
1374
|
+
state.holdTimers.delete(sessionID);
|
|
1375
|
+
}
|
|
1376
|
+
}
|
|
1377
|
+
const disposers = registerEventHandlers({
|
|
1378
|
+
api,
|
|
1379
|
+
config,
|
|
1380
|
+
log: log2,
|
|
1381
|
+
state,
|
|
1382
|
+
actions: {
|
|
1383
|
+
notify,
|
|
1384
|
+
startSessionTiming: (sessionID, now) => startSessionTiming(state.sessionTimings, sessionID, now),
|
|
1385
|
+
stopSessionTiming: (sessionID, now) => stopSessionTiming(state.sessionTimings, sessionID, now),
|
|
1386
|
+
clearHoldTimer
|
|
1387
|
+
}
|
|
1388
|
+
});
|
|
1389
|
+
function hydrate(sessionID) {
|
|
1390
|
+
if (!hydrationApi || hydratedSessions.has(sessionID) || hydratingSessions.has(sessionID)) return;
|
|
1391
|
+
const now = performance.now();
|
|
1392
|
+
if ((hydrationRetryAfter.get(sessionID) ?? 0) > now) return;
|
|
1393
|
+
if ((hydrationRetries.get(sessionID) ?? 0) >= MAX_HYDRATION_RETRIES) return;
|
|
1394
|
+
hydratingSessions.add(sessionID);
|
|
1395
|
+
void (async () => {
|
|
1396
|
+
let hydrated = false;
|
|
1397
|
+
try {
|
|
1398
|
+
hydrated = await hydrateSession({ api: hydrationApi, state, sessionID, now });
|
|
1399
|
+
} catch (error) {
|
|
1400
|
+
log2(`session hydration failed: session=${sessionID} error=${String(error)}`);
|
|
1401
|
+
} finally {
|
|
1402
|
+
hydratingSessions.delete(sessionID);
|
|
1403
|
+
}
|
|
1404
|
+
if (disposed) return;
|
|
1405
|
+
const current = state.requests.get(sessionID);
|
|
1406
|
+
if (hydrated && current) {
|
|
1407
|
+
hydratedSessions.add(sessionID);
|
|
1408
|
+
hydrationRetryAfter.delete(sessionID);
|
|
1409
|
+
hydrationRetries.delete(sessionID);
|
|
1410
|
+
clearLiveSpeed(state.liveSpeeds, sessionID);
|
|
1411
|
+
log2(`hydrated session state: session=${sessionID} message=${current.messageID} in=${current.exactInputTokens} out=${current.exactOutputTokens}`);
|
|
1412
|
+
notify();
|
|
1413
|
+
} else {
|
|
1414
|
+
hydrationRetryAfter.set(sessionID, now + 2e3);
|
|
1415
|
+
hydrationRetries.set(sessionID, (hydrationRetries.get(sessionID) ?? 0) + 1);
|
|
1416
|
+
}
|
|
1417
|
+
})();
|
|
1418
|
+
}
|
|
1419
|
+
function hydrateTree(rootSessionID) {
|
|
1420
|
+
if (!treeHydrationApi || hydratedTreeRoots.has(rootSessionID) || hydratingTreeRoots.has(rootSessionID)) return;
|
|
1421
|
+
const now = performance.now();
|
|
1422
|
+
if ((treeRetryAfter.get(rootSessionID) ?? 0) > now) return;
|
|
1423
|
+
hydratingTreeRoots.add(rootSessionID);
|
|
1424
|
+
void (async () => {
|
|
1425
|
+
try {
|
|
1426
|
+
let parents = [rootSessionID];
|
|
1427
|
+
const visited = new Set(parents);
|
|
1428
|
+
while (parents.length > 0) {
|
|
1429
|
+
const responses = await Promise.all(parents.map(async (parentID) => ({
|
|
1430
|
+
parentID,
|
|
1431
|
+
response: await callWithFallback([
|
|
1432
|
+
() => treeHydrationApi.client.session.children({ sessionID: parentID }),
|
|
1433
|
+
() => treeHydrationApi.client.session.children({ path: { sessionID: parentID } }),
|
|
1434
|
+
() => treeHydrationApi.client.session.children({ path: { id: parentID } })
|
|
1435
|
+
])
|
|
1436
|
+
})));
|
|
1437
|
+
if (disposed) return;
|
|
1438
|
+
const next = [];
|
|
1439
|
+
for (const { parentID, response } of responses) {
|
|
1440
|
+
for (const child of childSessions(response)) {
|
|
1441
|
+
const childID = child.id;
|
|
1442
|
+
state.sessionTree.setParent(childID, child.parentID ?? parentID);
|
|
1443
|
+
hydrate(childID);
|
|
1444
|
+
if (!visited.has(childID)) {
|
|
1445
|
+
visited.add(childID);
|
|
1446
|
+
next.push(childID);
|
|
1447
|
+
}
|
|
1448
|
+
}
|
|
1449
|
+
}
|
|
1450
|
+
parents = next;
|
|
1451
|
+
}
|
|
1452
|
+
hydratedTreeRoots.add(rootSessionID);
|
|
1453
|
+
treeRetryAfter.delete(rootSessionID);
|
|
1454
|
+
notify();
|
|
1455
|
+
} catch (error) {
|
|
1456
|
+
if (!disposed) {
|
|
1457
|
+
treeRetryAfter.set(rootSessionID, performance.now() + 2e3);
|
|
1458
|
+
log2(`tree hydration failed: session=${rootSessionID} error=${String(error)}`);
|
|
1459
|
+
}
|
|
1460
|
+
} finally {
|
|
1461
|
+
hydratingTreeRoots.delete(rootSessionID);
|
|
1462
|
+
}
|
|
1463
|
+
})();
|
|
1464
|
+
}
|
|
1465
|
+
function normalizeSessionID(sessionID) {
|
|
1466
|
+
return typeof sessionID === "string" ? sessionID : "";
|
|
1467
|
+
}
|
|
1468
|
+
function hasUsefulMetrics(metrics) {
|
|
1469
|
+
return metrics.some((item) => getDisplayInputTokens(item) > 0 || getDisplayOutputTokens(item) > 0 || item.exactCacheReadTokens > 0 || item.exactCacheWriteTokens > 0 || item.firstTokenTime !== null || item.lastDeltaTime !== null);
|
|
1470
|
+
}
|
|
1471
|
+
function hasUsefulSession(sessionID) {
|
|
1472
|
+
const request = state.requests.get(sessionID);
|
|
1473
|
+
const turn = state.turns.get(sessionID);
|
|
1474
|
+
return Boolean(
|
|
1475
|
+
request && hasUsefulMetrics([request]) || turn && (turn.finalizedOutputTokens > 0 || turn.hasStickyContextTokens)
|
|
1476
|
+
);
|
|
1477
|
+
}
|
|
1478
|
+
function usefulMetricsFor(ids) {
|
|
1479
|
+
const metrics = ids.map((id) => state.requests.get(id)).filter((item) => item !== void 0);
|
|
1480
|
+
return metrics.length > 0 && ids.some(hasUsefulSession) ? metrics : [];
|
|
1481
|
+
}
|
|
1482
|
+
function aliasScopeSessionIDs(sessionID, scope) {
|
|
1483
|
+
for (const aliasID of state.sessionAliases.get(sessionID) ?? []) {
|
|
1484
|
+
const aliasIDs = state.sessionTree.getScopeSessionIDs(aliasID, scope);
|
|
1485
|
+
if (usefulMetricsFor(aliasIDs).length > 0) {
|
|
1486
|
+
return { rootID: aliasID, ids: aliasIDs };
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
return null;
|
|
1490
|
+
}
|
|
1491
|
+
function resolveMetricsSessionID(sessionID) {
|
|
1492
|
+
const requestedSessionID = normalizeSessionID(sessionID);
|
|
1493
|
+
const requested = state.requests.get(requestedSessionID);
|
|
1494
|
+
if (requested && hasUsefulMetrics([requested]) || hasUsefulSession(requestedSessionID)) return requestedSessionID;
|
|
1495
|
+
const alias = aliasScopeSessionIDs(requestedSessionID, "current");
|
|
1496
|
+
if (alias) return alias.rootID;
|
|
1497
|
+
return requestedSessionID;
|
|
1498
|
+
}
|
|
1499
|
+
function scopeSessionIDs(sessionID, scope) {
|
|
1500
|
+
const requestedSessionID = normalizeSessionID(sessionID);
|
|
1501
|
+
const requestedIDs = state.sessionTree.getScopeSessionIDs(requestedSessionID, scope);
|
|
1502
|
+
if (usefulMetricsFor(requestedIDs).length > 0) {
|
|
1503
|
+
return { rootID: requestedSessionID, ids: requestedIDs };
|
|
1504
|
+
}
|
|
1505
|
+
const alias = aliasScopeSessionIDs(requestedSessionID, scope);
|
|
1506
|
+
if (alias) {
|
|
1507
|
+
const fallbackKey = `${requestedSessionID}->${alias.rootID}`;
|
|
1508
|
+
if (!loggedFallbacks.has(fallbackKey)) {
|
|
1509
|
+
loggedFallbacks.add(fallbackKey);
|
|
1510
|
+
log2(`sidebar session alias: requested=${requestedSessionID || "(empty)"} metrics=${alias.rootID}`);
|
|
1511
|
+
}
|
|
1512
|
+
return alias;
|
|
1513
|
+
}
|
|
1514
|
+
return { rootID: requestedSessionID, ids: requestedIDs };
|
|
1515
|
+
}
|
|
1516
|
+
function aggregateByModel(ids, now, scope, rootID, foregroundTurnStart) {
|
|
1517
|
+
const modelGroups = /* @__PURE__ */ new Map();
|
|
1518
|
+
for (const id of ids) {
|
|
1519
|
+
const request = state.requests.get(id);
|
|
1520
|
+
if (!request) continue;
|
|
1521
|
+
const turn = state.turns.get(id);
|
|
1522
|
+
const belongsToForegroundTurn = id === rootID || Boolean(turn && (turn.turnStartTime >= foregroundTurnStart || !turn.isComplete));
|
|
1523
|
+
if (!belongsToForegroundTurn) continue;
|
|
1524
|
+
const key = `${request.modelID}|${request.providerID}`;
|
|
1525
|
+
const group = modelGroups.get(key) ?? [];
|
|
1526
|
+
group.push(request);
|
|
1527
|
+
modelGroups.set(key, group);
|
|
1528
|
+
}
|
|
1529
|
+
const result = [];
|
|
1530
|
+
for (const [key, metrics] of modelGroups) {
|
|
1531
|
+
const [modelID, providerID] = key.split("|");
|
|
1532
|
+
let inputTokens = 0;
|
|
1533
|
+
let outputTokens = 0;
|
|
1534
|
+
let cacheReadTokens = 0;
|
|
1535
|
+
let exactCacheCount = 0;
|
|
1536
|
+
let requestStartTime = Number.POSITIVE_INFINITY;
|
|
1537
|
+
let firstTokenTime = null;
|
|
1538
|
+
let completeTime = null;
|
|
1539
|
+
let isStreaming = false;
|
|
1540
|
+
let isComplete = true;
|
|
1541
|
+
const sessionCount = new Set(metrics.map((m) => m.sessionID)).size;
|
|
1542
|
+
for (const m of metrics) {
|
|
1543
|
+
inputTokens += getDisplayInputTokens(m);
|
|
1544
|
+
outputTokens += getDisplayOutputTokens(m);
|
|
1545
|
+
if (m.hasExactCacheReadTokens) {
|
|
1546
|
+
cacheReadTokens += Math.max(0, m.exactCacheReadTokens);
|
|
1547
|
+
exactCacheCount += 1;
|
|
1548
|
+
}
|
|
1549
|
+
requestStartTime = Math.min(requestStartTime, m.requestStartTime);
|
|
1550
|
+
if (m.firstTokenTime !== null) {
|
|
1551
|
+
firstTokenTime = firstTokenTime === null ? m.firstTokenTime : Math.min(firstTokenTime, m.firstTokenTime);
|
|
1552
|
+
}
|
|
1553
|
+
if (m.completeTime !== null) {
|
|
1554
|
+
completeTime = completeTime === null ? m.completeTime : Math.max(completeTime, m.completeTime);
|
|
1555
|
+
}
|
|
1556
|
+
isStreaming = isStreaming || m.isStreaming;
|
|
1557
|
+
isComplete = isComplete && m.isComplete;
|
|
1558
|
+
}
|
|
1559
|
+
const cacheReadCompleteness = exactCacheCount === 0 ? "unknown" : exactCacheCount === metrics.length ? "exact" : "partial";
|
|
1560
|
+
let ttft = null;
|
|
1561
|
+
for (const m of metrics) {
|
|
1562
|
+
const candidate = getTurnTtft(state.turns.get(m.sessionID), getTtft(m));
|
|
1563
|
+
if (candidate !== null && (ttft === null || candidate < ttft)) ttft = candidate;
|
|
1564
|
+
}
|
|
1565
|
+
let liveTps = 0;
|
|
1566
|
+
let liveRateCount = 0;
|
|
1567
|
+
for (const m of metrics) {
|
|
1568
|
+
const rate = getLiveTps(state.liveSpeeds.get(m.sessionID), now);
|
|
1569
|
+
if (rate !== null) {
|
|
1570
|
+
liveTps += rate;
|
|
1571
|
+
liveRateCount += 1;
|
|
1572
|
+
}
|
|
1573
|
+
}
|
|
1574
|
+
let displayTps = null;
|
|
1575
|
+
if (liveRateCount > 0 && !isComplete) {
|
|
1576
|
+
const saneLive = Number.isFinite(liveTps) && liveTps >= 0 && liveTps <= 300;
|
|
1577
|
+
if (saneLive) {
|
|
1578
|
+
displayTps = Math.round(liveTps * 10) / 10;
|
|
1579
|
+
}
|
|
1580
|
+
}
|
|
1581
|
+
if (displayTps === null && metrics.length > 0) {
|
|
1582
|
+
const latest = metrics.reduce((a, b) => b.requestStartTime >= a.requestStartTime ? b : a);
|
|
1583
|
+
const frTokens = getDisplayOutputTokens(latest);
|
|
1584
|
+
const genStart = latest.firstTokenTime ?? latest.requestStartTime;
|
|
1585
|
+
const latestLastActivity = latest.lastDeltaTime ?? null;
|
|
1586
|
+
const genEnd = isComplete && completeTime !== null ? completeTime : latest.isStreaming || latestLastActivity === null ? now : latestLastActivity;
|
|
1587
|
+
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
|
|
1588
|
+
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 6e4;
|
|
1589
|
+
let primary = null;
|
|
1590
|
+
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
1591
|
+
primary = Math.round(frTokens / ((genEnd - genStart) / 1e3) * 10) / 10;
|
|
1592
|
+
}
|
|
1593
|
+
const latestTurn = state.turns.get(latest.sessionID);
|
|
1594
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && latestTurn && frTokens > 0) {
|
|
1595
|
+
const turnEnd = completeTime ?? latestLastActivity ?? now;
|
|
1596
|
+
const turnWindow = turnEnd - latestTurn.turnStartTime - toolOverlapMs(latestTurn, latestTurn.turnStartTime, turnEnd);
|
|
1597
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary;
|
|
1598
|
+
} else {
|
|
1599
|
+
displayTps = primary;
|
|
1600
|
+
}
|
|
1601
|
+
}
|
|
1602
|
+
result.push({
|
|
1603
|
+
modelID,
|
|
1604
|
+
providerID,
|
|
1605
|
+
sessionID: providerID,
|
|
1606
|
+
sessionCount,
|
|
1607
|
+
inputTokens,
|
|
1608
|
+
outputTokens,
|
|
1609
|
+
cacheReadTokens,
|
|
1610
|
+
cacheReadCompleteness,
|
|
1611
|
+
requestStartTime: requestStartTime === Number.POSITIVE_INFINITY ? now : requestStartTime,
|
|
1612
|
+
firstTokenTime,
|
|
1613
|
+
completeTime: isComplete ? completeTime ?? now : null,
|
|
1614
|
+
ttft,
|
|
1615
|
+
liveTps: displayTps,
|
|
1616
|
+
isStreaming,
|
|
1617
|
+
isComplete
|
|
1618
|
+
});
|
|
1619
|
+
}
|
|
1620
|
+
return result.sort((a, b) => {
|
|
1621
|
+
const providerCompare = a.providerID.localeCompare(b.providerID);
|
|
1622
|
+
if (providerCompare !== 0) return providerCompare;
|
|
1623
|
+
return a.modelID.localeCompare(b.modelID);
|
|
38
1624
|
});
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
1625
|
+
}
|
|
1626
|
+
return {
|
|
1627
|
+
getCurrent(sessionID) {
|
|
1628
|
+
const requestedSessionID = normalizeSessionID(sessionID);
|
|
1629
|
+
hydrate(requestedSessionID);
|
|
1630
|
+
return state.requests.get(resolveMetricsSessionID(requestedSessionID)) ?? null;
|
|
1631
|
+
},
|
|
1632
|
+
getAggregate(sessionID, scope, now = performance.now()) {
|
|
1633
|
+
const requestedSessionID = normalizeSessionID(sessionID);
|
|
1634
|
+
hydrate(requestedSessionID);
|
|
1635
|
+
if (scope === "tree") hydrateTree(requestedSessionID);
|
|
1636
|
+
const cacheKey = `${requestedSessionID}:${scope}`;
|
|
1637
|
+
if (cacheKey === aggregateCacheKey && now - aggregateCacheAt < AGGREGATE_CACHE_TTL_MS) {
|
|
1638
|
+
return aggregateCacheValue;
|
|
1639
|
+
}
|
|
1640
|
+
const { rootID, ids } = scopeSessionIDs(requestedSessionID, scope);
|
|
1641
|
+
const foregroundTurn = state.turns.get(rootID);
|
|
1642
|
+
const foregroundRequest = state.requests.get(rootID);
|
|
1643
|
+
if (!foregroundTurn && !foregroundRequest) return null;
|
|
1644
|
+
const foregroundTurnStart = foregroundTurn?.turnStartTime ?? foregroundRequest.requestStartTime;
|
|
1645
|
+
let inputTokens = 0;
|
|
1646
|
+
let outputTokens = 0;
|
|
1647
|
+
let cacheReadTokens = 0;
|
|
1648
|
+
let cacheExactCount = 0;
|
|
1649
|
+
let contributingCount = 0;
|
|
1650
|
+
let liveTps = 0;
|
|
1651
|
+
let liveRateCount = 0;
|
|
1652
|
+
let isStreaming = false;
|
|
1653
|
+
const contributingSessionIDs = [];
|
|
1654
|
+
for (const id of ids) {
|
|
1655
|
+
const turn = state.turns.get(id);
|
|
1656
|
+
const request = state.requests.get(id);
|
|
1657
|
+
if (!turn && !request) continue;
|
|
1658
|
+
const belongsToForegroundTurn = id === rootID || Boolean(turn && (turn.turnStartTime >= foregroundTurnStart || !turn.isComplete));
|
|
1659
|
+
if (belongsToForegroundTurn) {
|
|
1660
|
+
const sessionInput = turn ? turnInputTokens(turn, request) : request ? getDisplayInputTokens(request) : 0;
|
|
1661
|
+
const sessionOutput = turn ? turn.finalizedOutputTokens + liveRequestOutput(turn, request) : request ? getDisplayOutputTokens(request) : 0;
|
|
1662
|
+
const hasContribution = sessionInput > 0 || sessionOutput > 0 || Boolean(request?.isStreaming);
|
|
1663
|
+
if (hasContribution) {
|
|
1664
|
+
inputTokens += sessionInput;
|
|
1665
|
+
outputTokens += sessionOutput;
|
|
1666
|
+
contributingCount += 1;
|
|
1667
|
+
contributingSessionIDs.push(id);
|
|
1668
|
+
if (turn?.hasStickyCacheReadTokens) {
|
|
1669
|
+
cacheReadTokens += turn.stickyCacheReadTokens;
|
|
1670
|
+
cacheExactCount += 1;
|
|
1671
|
+
} else if (!turn && request?.hasExactCacheReadTokens) {
|
|
1672
|
+
cacheReadTokens += Math.max(0, request.exactCacheReadTokens);
|
|
1673
|
+
cacheExactCount += 1;
|
|
43
1674
|
}
|
|
44
|
-
|
|
1675
|
+
}
|
|
1676
|
+
}
|
|
1677
|
+
const rate = getLiveTps(state.liveSpeeds.get(id), now);
|
|
1678
|
+
if (rate !== null) {
|
|
1679
|
+
liveTps += rate;
|
|
1680
|
+
liveRateCount += 1;
|
|
1681
|
+
}
|
|
1682
|
+
isStreaming = isStreaming || Boolean(request?.isStreaming);
|
|
1683
|
+
}
|
|
1684
|
+
const cacheReadCompleteness = cacheExactCount === 0 ? "unknown" : cacheExactCount === contributingCount ? "exact" : "partial";
|
|
1685
|
+
const requestStartTime = foregroundTurn?.turnStartTime ?? foregroundRequest.requestStartTime;
|
|
1686
|
+
const firstTokenTime = foregroundRequest?.firstTokenTime ?? null;
|
|
1687
|
+
const foregroundComplete = foregroundTurn?.isComplete ?? foregroundRequest?.isComplete ?? false;
|
|
1688
|
+
const isComplete = isStreaming ? false : foregroundComplete;
|
|
1689
|
+
const completeTime = foregroundTurn?.completeTime ?? foregroundRequest?.completeTime ?? null;
|
|
1690
|
+
let displayTps = null;
|
|
1691
|
+
if (liveRateCount > 0 && !isComplete) {
|
|
1692
|
+
const saneLive = Number.isFinite(liveTps) && liveTps >= 0 && liveTps <= 300;
|
|
1693
|
+
if (saneLive) {
|
|
1694
|
+
displayTps = Math.round(liveTps * 10) / 10;
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
if (displayTps === null && foregroundRequest) {
|
|
1698
|
+
const frTokens = getDisplayOutputTokens(foregroundRequest);
|
|
1699
|
+
const genStart = firstTokenTime ?? requestStartTime;
|
|
1700
|
+
const foregroundActive = foregroundRequest?.isStreaming ?? false;
|
|
1701
|
+
const foregroundLastActivity = foregroundRequest?.lastDeltaTime ?? null;
|
|
1702
|
+
const genEnd = isComplete ? completeTime : foregroundActive || foregroundLastActivity === null ? now : foregroundLastActivity;
|
|
1703
|
+
const saneStart = genStart !== null && Number.isFinite(genStart) && genStart >= 0 && genStart <= now;
|
|
1704
|
+
const saneEnd = genEnd !== null && Number.isFinite(genEnd) && genEnd >= 0 && genEnd <= now + 6e4;
|
|
1705
|
+
let primary = null;
|
|
1706
|
+
if (frTokens > 0 && saneStart && saneEnd && genEnd > genStart) {
|
|
1707
|
+
primary = Math.round(frTokens / ((genEnd - genStart) / 1e3) * 10) / 10;
|
|
1708
|
+
}
|
|
1709
|
+
if ((primary === null || primary > MAX_CREDIBLE_TPS) && foregroundTurn && frTokens > 0) {
|
|
1710
|
+
const turnEnd = completeTime ?? foregroundLastActivity ?? now;
|
|
1711
|
+
const turnWindow = turnEnd - foregroundTurn.turnStartTime - toolOverlapMs(foregroundTurn, foregroundTurn.turnStartTime, turnEnd);
|
|
1712
|
+
displayTps = credibleTps(frTokens, turnWindow) ?? primary;
|
|
1713
|
+
} else {
|
|
1714
|
+
displayTps = primary;
|
|
1715
|
+
}
|
|
1716
|
+
}
|
|
1717
|
+
const modelBreakdown = scope === "tree" ? aggregateByModel(ids, now, scope, rootID, foregroundTurnStart) : [];
|
|
1718
|
+
const ttft = getTurnTtft(foregroundTurn, foregroundRequest ? getTtft(foregroundRequest) : null);
|
|
1719
|
+
const result = {
|
|
1720
|
+
sessionIDs: contributingSessionIDs.length > 0 ? contributingSessionIDs : [rootID],
|
|
1721
|
+
childSessionCount: scope === "tree" ? state.sessionTree.getChildSessionCount(rootID) : 0,
|
|
1722
|
+
inputTokens,
|
|
1723
|
+
outputTokens,
|
|
1724
|
+
cacheReadTokens,
|
|
1725
|
+
cacheReadCompleteness,
|
|
1726
|
+
requestStartTime,
|
|
1727
|
+
firstTokenTime,
|
|
1728
|
+
completeTime: isComplete ? completeTime ?? now : null,
|
|
1729
|
+
ttft,
|
|
1730
|
+
liveTps: displayTps,
|
|
1731
|
+
isStreaming,
|
|
1732
|
+
isComplete,
|
|
1733
|
+
modelBreakdown
|
|
1734
|
+
};
|
|
1735
|
+
aggregateCacheKey = cacheKey;
|
|
1736
|
+
aggregateCacheAt = now;
|
|
1737
|
+
aggregateCacheValue = result;
|
|
1738
|
+
return result;
|
|
1739
|
+
},
|
|
1740
|
+
getSessionElapsedMs(sessionID, scope = "current", now = performance.now()) {
|
|
1741
|
+
const requestedSessionID = normalizeSessionID(sessionID);
|
|
1742
|
+
hydrate(requestedSessionID);
|
|
1743
|
+
if (scope === "tree") hydrateTree(requestedSessionID);
|
|
1744
|
+
const { rootID, ids } = scopeSessionIDs(requestedSessionID, scope);
|
|
1745
|
+
if (scope === "current") return getSessionElapsedMs(state.sessionTimings.get(rootID), now);
|
|
1746
|
+
return getScopeElapsedMs(state.sessionTimings, ids, now);
|
|
1747
|
+
},
|
|
1748
|
+
getChildSessionCount(sessionID) {
|
|
1749
|
+
return state.sessionTree.getChildSessionCount(sessionID);
|
|
1750
|
+
},
|
|
1751
|
+
setSessionParent(childID, parentID) {
|
|
1752
|
+
if (!childID) return;
|
|
1753
|
+
state.sessionTree.setParent(childID, parentID);
|
|
1754
|
+
},
|
|
1755
|
+
subscribe(listener) {
|
|
1756
|
+
listeners.add(listener);
|
|
1757
|
+
return () => {
|
|
1758
|
+
listeners.delete(listener);
|
|
1759
|
+
};
|
|
1760
|
+
},
|
|
1761
|
+
dispose() {
|
|
1762
|
+
disposed = true;
|
|
1763
|
+
for (const dispose of disposers.splice(0)) dispose();
|
|
1764
|
+
for (const timer of state.holdTimers.values()) clearTimeout(timer);
|
|
1765
|
+
state.holdTimers.clear();
|
|
1766
|
+
state.requests.clear();
|
|
1767
|
+
state.turns.clear();
|
|
1768
|
+
state.liveSpeeds.clear();
|
|
1769
|
+
state.sessionTree.clear();
|
|
1770
|
+
state.sessionModels.clear();
|
|
1771
|
+
state.sessionTimings.clear();
|
|
1772
|
+
state.userMessageIds.clear();
|
|
1773
|
+
state.assistantMessageIds.clear();
|
|
1774
|
+
state.partTokenEstimates.clear();
|
|
1775
|
+
state.sessionAliases.clear();
|
|
1776
|
+
state.seenEventKeys.clear();
|
|
1777
|
+
state.seenEventOrder.length = 0;
|
|
1778
|
+
state.lastRequestSessionID = null;
|
|
1779
|
+
loggedFallbacks.clear();
|
|
1780
|
+
hydratedSessions.clear();
|
|
1781
|
+
hydratingSessions.clear();
|
|
1782
|
+
hydrationRetryAfter.clear();
|
|
1783
|
+
hydratedTreeRoots.clear();
|
|
1784
|
+
hydratingTreeRoots.clear();
|
|
1785
|
+
treeRetryAfter.clear();
|
|
1786
|
+
listeners.clear();
|
|
1787
|
+
}
|
|
1788
|
+
};
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// src/components/SidebarMetrics.tsx
|
|
1792
|
+
import { createMemo, createSignal } from "solid-js";
|
|
1793
|
+
|
|
1794
|
+
// src/components/ui-element.ts
|
|
1795
|
+
import { createElement, insert, setProp } from "@opentui/solid";
|
|
1796
|
+
function element(tag, props = {}, children = []) {
|
|
1797
|
+
const node = createElement(tag);
|
|
1798
|
+
let ref;
|
|
1799
|
+
for (const [key, value] of Object.entries(props)) {
|
|
1800
|
+
if (value === void 0) continue;
|
|
1801
|
+
if (key === "ref") {
|
|
1802
|
+
ref = value;
|
|
1803
|
+
continue;
|
|
1804
|
+
}
|
|
1805
|
+
setProp(node, key, value);
|
|
1806
|
+
}
|
|
1807
|
+
if (children.length > 0) insert(node, children);
|
|
1808
|
+
ref?.(node);
|
|
1809
|
+
return node;
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// src/components/StatRow.tsx
|
|
1813
|
+
function StatRow(props) {
|
|
1814
|
+
let rowNode;
|
|
1815
|
+
let labelNode;
|
|
1816
|
+
let valueNode;
|
|
1817
|
+
const fg = () => {
|
|
1818
|
+
if (props.warning) return props.theme.warning;
|
|
1819
|
+
if (props.success) return props.theme.success ?? props.theme.accent;
|
|
1820
|
+
if (props.accent) return props.theme.accent;
|
|
1821
|
+
if (props.dim) return props.theme.textMuted;
|
|
1822
|
+
return props.theme.text;
|
|
1823
|
+
};
|
|
1824
|
+
const label2 = () => typeof props.label === "function" ? props.label() : props.label;
|
|
1825
|
+
const value = () => typeof props.value === "function" ? props.value() : props.value;
|
|
1826
|
+
const visible = () => typeof props.visible === "function" ? props.visible() : props.visible !== false;
|
|
1827
|
+
const content = () => `${props.icon ? `${props.icon} ` : ""}${value()}`;
|
|
1828
|
+
const syncContent = () => {
|
|
1829
|
+
const isVisible = visible();
|
|
1830
|
+
if (rowNode && !rowNode.isDestroyed) {
|
|
1831
|
+
rowNode.visible = isVisible;
|
|
1832
|
+
try {
|
|
1833
|
+
rowNode.height = isVisible ? "auto" : 0;
|
|
1834
|
+
} catch {
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
if (labelNode && !labelNode.isDestroyed) labelNode.content = isVisible ? label2() : "";
|
|
1838
|
+
if (!valueNode || valueNode.isDestroyed) return;
|
|
1839
|
+
valueNode.content = isVisible ? content() : "";
|
|
1840
|
+
valueNode.requestRender();
|
|
1841
|
+
};
|
|
1842
|
+
props.registerSync?.(syncContent);
|
|
1843
|
+
const attachRowNode = (node) => {
|
|
1844
|
+
rowNode = node;
|
|
1845
|
+
syncContent();
|
|
1846
|
+
};
|
|
1847
|
+
const attachLabelNode = (node) => {
|
|
1848
|
+
labelNode = node;
|
|
1849
|
+
syncContent();
|
|
1850
|
+
};
|
|
1851
|
+
const attachValueNode = (node) => {
|
|
1852
|
+
valueNode = node;
|
|
1853
|
+
syncContent();
|
|
1854
|
+
};
|
|
1855
|
+
return element("box", {
|
|
1856
|
+
ref: attachRowNode,
|
|
1857
|
+
width: "100%",
|
|
1858
|
+
flexDirection: "row",
|
|
1859
|
+
justifyContent: "space-between",
|
|
1860
|
+
visible: visible()
|
|
1861
|
+
}, [
|
|
1862
|
+
element("text", {
|
|
1863
|
+
ref: attachLabelNode,
|
|
1864
|
+
fg: props.theme.textMuted,
|
|
1865
|
+
content: visible() ? label2() : ""
|
|
1866
|
+
}),
|
|
1867
|
+
element("text", {
|
|
1868
|
+
ref: attachValueNode,
|
|
1869
|
+
fg: fg(),
|
|
1870
|
+
content: content()
|
|
1871
|
+
})
|
|
1872
|
+
]);
|
|
1873
|
+
}
|
|
1874
|
+
|
|
1875
|
+
// src/components/SidebarMetrics.tsx
|
|
1876
|
+
var MAX_MODEL_ROWS = 8;
|
|
1877
|
+
function SidebarMetrics(props) {
|
|
1878
|
+
let disposed = false;
|
|
1879
|
+
let refreshQueued = false;
|
|
1880
|
+
let interval;
|
|
1881
|
+
let unsub = () => {
|
|
1882
|
+
};
|
|
1883
|
+
let unsubController = () => {
|
|
1884
|
+
};
|
|
1885
|
+
const rowSyncs = /* @__PURE__ */ new Set();
|
|
1886
|
+
const registerRowSync = (sync) => {
|
|
1887
|
+
if (disposed) return () => {
|
|
1888
|
+
};
|
|
1889
|
+
rowSyncs.add(sync);
|
|
1890
|
+
return () => rowSyncs.delete(sync);
|
|
1891
|
+
};
|
|
1892
|
+
const syncRows = () => {
|
|
1893
|
+
if (disposed) return;
|
|
1894
|
+
for (const sync of rowSyncs) sync();
|
|
1895
|
+
};
|
|
1896
|
+
const [tick, setTick] = createSignal(0);
|
|
1897
|
+
const bump = () => {
|
|
1898
|
+
if (disposed) return;
|
|
1899
|
+
setTick((t) => t + 1);
|
|
1900
|
+
syncRows();
|
|
1901
|
+
if (refreshQueued) return;
|
|
1902
|
+
refreshQueued = true;
|
|
1903
|
+
queueMicrotask(() => {
|
|
1904
|
+
refreshQueued = false;
|
|
1905
|
+
if (disposed) return;
|
|
1906
|
+
syncRows();
|
|
1907
|
+
props.requestRender?.();
|
|
1908
|
+
});
|
|
1909
|
+
};
|
|
1910
|
+
const dispose = () => {
|
|
1911
|
+
if (disposed) return;
|
|
1912
|
+
disposed = true;
|
|
1913
|
+
refreshQueued = false;
|
|
1914
|
+
rowSyncs.clear();
|
|
1915
|
+
if (interval !== void 0) clearInterval(interval);
|
|
1916
|
+
unsub();
|
|
1917
|
+
unsubController();
|
|
1918
|
+
};
|
|
1919
|
+
interval = setInterval(bump, props.refreshIntervalMs);
|
|
1920
|
+
unsub = props.collector.subscribe(bump);
|
|
1921
|
+
unsubController = props.controller.subscribe(bump);
|
|
1922
|
+
const sectionEnabled = createMemo(() => {
|
|
1923
|
+
tick();
|
|
1924
|
+
return props.controller.prefs().section.enabled;
|
|
1925
|
+
});
|
|
1926
|
+
const requestNow = (m) => {
|
|
1927
|
+
const live = performance.now();
|
|
1928
|
+
return m.isComplete && m.completeTime !== null ? m.completeTime : live;
|
|
1929
|
+
};
|
|
1930
|
+
const rowVisible = (key) => {
|
|
1931
|
+
const barVis = props.barConfig.visible;
|
|
1932
|
+
const rowPrefs = props.controller.prefs().rows;
|
|
1933
|
+
return barVis[key] !== false && rowPrefs[key] !== false;
|
|
1934
|
+
};
|
|
1935
|
+
const collapsed = createMemo(() => {
|
|
1936
|
+
tick();
|
|
1937
|
+
return props.controller.collapsed();
|
|
1938
|
+
});
|
|
1939
|
+
const headerLabel = () => props.controller.prefs().section.label;
|
|
1940
|
+
let collapsedHeaderNode;
|
|
1941
|
+
let expandedHeaderNode;
|
|
1942
|
+
const syncHeader = () => {
|
|
1943
|
+
if (disposed) return;
|
|
1944
|
+
const isCollapsed = collapsed();
|
|
1945
|
+
for (const [node, visible] of [
|
|
1946
|
+
[collapsedHeaderNode, isCollapsed],
|
|
1947
|
+
[expandedHeaderNode, !isCollapsed]
|
|
1948
|
+
]) {
|
|
1949
|
+
if (!node || node.isDestroyed) continue;
|
|
1950
|
+
try {
|
|
1951
|
+
node.visible = visible;
|
|
1952
|
+
if (visible) {
|
|
1953
|
+
node.width = "auto";
|
|
1954
|
+
node.height = "auto";
|
|
1955
|
+
} else {
|
|
1956
|
+
node.width = 0;
|
|
1957
|
+
node.height = 0;
|
|
1958
|
+
}
|
|
1959
|
+
} catch {
|
|
1960
|
+
}
|
|
1961
|
+
}
|
|
1962
|
+
};
|
|
1963
|
+
registerRowSync(syncHeader);
|
|
1964
|
+
const attachCollapsedHeaderNode = (node) => {
|
|
1965
|
+
collapsedHeaderNode = node;
|
|
1966
|
+
syncHeader();
|
|
1967
|
+
};
|
|
1968
|
+
const attachExpandedHeaderNode = (node) => {
|
|
1969
|
+
expandedHeaderNode = node;
|
|
1970
|
+
syncHeader();
|
|
1971
|
+
};
|
|
1972
|
+
const toggleCollapsed = () => props.controller.toggleCollapsed();
|
|
1973
|
+
const attachBoxToggle = (node) => {
|
|
1974
|
+
node.onMouseDown = toggleCollapsed;
|
|
1975
|
+
};
|
|
1976
|
+
const currentScope = () => props.controller.prefs().scope;
|
|
1977
|
+
const currentAggregate = () => props.collector.getAggregate(props.sessionID, currentScope());
|
|
1978
|
+
const hasAggregate = () => currentAggregate() !== null;
|
|
1979
|
+
const expandedActive = () => !collapsed() && hasAggregate();
|
|
1980
|
+
const expandedIdle = () => !collapsed() && !hasAggregate();
|
|
1981
|
+
const collapsedActive = () => collapsed() && hasAggregate();
|
|
1982
|
+
const frozenNow = () => {
|
|
1983
|
+
const m = currentAggregate();
|
|
1984
|
+
if (!m) return performance.now();
|
|
1985
|
+
return requestNow(m);
|
|
1986
|
+
};
|
|
1987
|
+
const speedValue = () => {
|
|
1988
|
+
const m = currentAggregate();
|
|
1989
|
+
return m?.liveTps === null || m?.liveTps === void 0 ? "\u2014" : `${m.liveTps.toFixed(1)} t/s`;
|
|
1990
|
+
};
|
|
1991
|
+
const elapsedValue = () => {
|
|
1992
|
+
const m = currentAggregate();
|
|
1993
|
+
return formatElapsed(m ? frozenNow() - m.requestStartTime : 0);
|
|
1994
|
+
};
|
|
1995
|
+
const ttftValue = () => {
|
|
1996
|
+
const ttft = currentAggregate()?.ttft ?? null;
|
|
1997
|
+
return ttft !== null ? formatDuration(ttft) : "--";
|
|
1998
|
+
};
|
|
1999
|
+
const tokenValue = () => {
|
|
2000
|
+
const m = currentAggregate();
|
|
2001
|
+
const inputTokens = m?.inputTokens ?? 0;
|
|
2002
|
+
const outputTokens = m?.outputTokens ?? 0;
|
|
2003
|
+
return `${rowVisible("input") ? `\u2193 ${formatTokens(inputTokens)} in` : ""}${rowVisible("input") && rowVisible("output") ? " " : ""}${rowVisible("output") ? `\u2191 ${formatTokens(outputTokens)} out` : ""}`;
|
|
2004
|
+
};
|
|
2005
|
+
const cacheValue = () => {
|
|
2006
|
+
const m = currentAggregate();
|
|
2007
|
+
return formatCacheRead(m?.cacheReadTokens ?? 0, m?.cacheReadCompleteness ?? "unknown");
|
|
2008
|
+
};
|
|
2009
|
+
const sessionValue = () => {
|
|
2010
|
+
return formatElapsed(props.collector.getSessionElapsedMs(props.sessionID, currentScope(), performance.now()));
|
|
2011
|
+
};
|
|
2012
|
+
const headerBox = element("box", {
|
|
2013
|
+
width: "100%",
|
|
2014
|
+
flexDirection: "row",
|
|
2015
|
+
alignItems: "center",
|
|
2016
|
+
ref: attachBoxToggle
|
|
2017
|
+
}, [
|
|
2018
|
+
element("text", {
|
|
2019
|
+
ref: attachCollapsedHeaderNode,
|
|
2020
|
+
fg: props.theme.text,
|
|
2021
|
+
visible: false
|
|
2022
|
+
}, [
|
|
2023
|
+
element("b", {}, ["\u25B6 ", headerLabel()])
|
|
2024
|
+
]),
|
|
2025
|
+
element("text", {
|
|
2026
|
+
ref: attachExpandedHeaderNode,
|
|
2027
|
+
fg: props.theme.text
|
|
2028
|
+
}, [
|
|
2029
|
+
element("b", {}, ["\u25BC ", headerLabel()])
|
|
2030
|
+
])
|
|
2031
|
+
]);
|
|
2032
|
+
const rows = [];
|
|
2033
|
+
rows.push(StatRow({
|
|
2034
|
+
theme: props.theme,
|
|
2035
|
+
label: "Status",
|
|
2036
|
+
value: "No active request",
|
|
2037
|
+
dim: true,
|
|
2038
|
+
icon: "\u25CB",
|
|
2039
|
+
visible: expandedIdle,
|
|
2040
|
+
registerSync: registerRowSync
|
|
2041
|
+
}));
|
|
2042
|
+
if (rowVisible("speed")) {
|
|
2043
|
+
rows.push(StatRow({
|
|
2044
|
+
theme: props.theme,
|
|
2045
|
+
label: "TPS",
|
|
2046
|
+
value: speedValue,
|
|
2047
|
+
accent: true,
|
|
2048
|
+
icon: "\u26A1",
|
|
2049
|
+
registerSync: registerRowSync,
|
|
2050
|
+
visible: expandedActive
|
|
2051
|
+
}));
|
|
2052
|
+
}
|
|
2053
|
+
if (rowVisible("elapsed")) {
|
|
2054
|
+
rows.push(StatRow({
|
|
2055
|
+
theme: props.theme,
|
|
2056
|
+
label: "Elapsed",
|
|
2057
|
+
value: elapsedValue,
|
|
2058
|
+
icon: "\u25B9",
|
|
2059
|
+
registerSync: registerRowSync,
|
|
2060
|
+
visible: expandedActive
|
|
2061
|
+
}));
|
|
2062
|
+
}
|
|
2063
|
+
if (rowVisible("ttft")) {
|
|
2064
|
+
rows.push(StatRow({
|
|
2065
|
+
theme: props.theme,
|
|
2066
|
+
label: "TTFT",
|
|
2067
|
+
value: ttftValue,
|
|
2068
|
+
icon: "\u23F1",
|
|
2069
|
+
registerSync: registerRowSync,
|
|
2070
|
+
visible: expandedActive
|
|
2071
|
+
}));
|
|
2072
|
+
}
|
|
2073
|
+
if (rowVisible("input") || rowVisible("output")) {
|
|
2074
|
+
rows.push(StatRow({
|
|
2075
|
+
theme: props.theme,
|
|
2076
|
+
label: "Tokens",
|
|
2077
|
+
value: tokenValue,
|
|
2078
|
+
registerSync: registerRowSync,
|
|
2079
|
+
visible: expandedActive
|
|
2080
|
+
}));
|
|
2081
|
+
}
|
|
2082
|
+
if (rowVisible("cache")) {
|
|
2083
|
+
rows.push(StatRow({
|
|
2084
|
+
theme: props.theme,
|
|
2085
|
+
label: "Cache",
|
|
2086
|
+
value: cacheValue,
|
|
2087
|
+
dim: true,
|
|
2088
|
+
icon: "\u25CB",
|
|
2089
|
+
registerSync: registerRowSync,
|
|
2090
|
+
visible: expandedActive
|
|
2091
|
+
}));
|
|
2092
|
+
}
|
|
2093
|
+
if (rowVisible("session")) {
|
|
2094
|
+
rows.push(StatRow({
|
|
2095
|
+
theme: props.theme,
|
|
2096
|
+
label: "Session",
|
|
2097
|
+
value: sessionValue,
|
|
2098
|
+
icon: "\u25F7",
|
|
2099
|
+
registerSync: registerRowSync,
|
|
2100
|
+
visible: expandedActive
|
|
2101
|
+
}));
|
|
2102
|
+
}
|
|
2103
|
+
if (rowVisible("modelBreakdown")) {
|
|
2104
|
+
for (let i = 0; i < MAX_MODEL_ROWS; i++) {
|
|
2105
|
+
const modelLabel = () => {
|
|
2106
|
+
const m = currentAggregate()?.modelBreakdown[i];
|
|
2107
|
+
if (!m) return "";
|
|
2108
|
+
const count = m.sessionCount > 1 ? ` \xD7${m.sessionCount}` : "";
|
|
2109
|
+
return `${m.modelID}${count}`;
|
|
2110
|
+
};
|
|
2111
|
+
const modelValue = () => {
|
|
2112
|
+
const m = currentAggregate()?.modelBreakdown[i];
|
|
2113
|
+
if (!m) return "";
|
|
2114
|
+
const parts = [];
|
|
2115
|
+
if (rowVisible("speed")) parts.push(`\u26A1${m.liveTps !== null ? m.liveTps.toFixed(1) : "\u2014"}`);
|
|
2116
|
+
if (rowVisible("ttft")) parts.push(`\u23F1${m.ttft !== null ? " " + formatDuration(m.ttft) : " --"}`);
|
|
2117
|
+
if (rowVisible("input")) parts.push(`\u2193${formatTokens(m.inputTokens)}`);
|
|
2118
|
+
if (rowVisible("output")) parts.push(`\u2191${formatTokens(m.outputTokens)}`);
|
|
2119
|
+
return parts.join(" ");
|
|
2120
|
+
};
|
|
2121
|
+
const visible = () => expandedActive() && (currentAggregate()?.modelBreakdown.length ?? 0) > i;
|
|
2122
|
+
let wrapperNode;
|
|
2123
|
+
const syncWrapper = () => {
|
|
2124
|
+
if (disposed) return;
|
|
2125
|
+
if (!wrapperNode || wrapperNode.isDestroyed) return;
|
|
2126
|
+
const v = visible();
|
|
2127
|
+
try {
|
|
2128
|
+
wrapperNode.height = v ? "auto" : 0;
|
|
2129
|
+
wrapperNode.marginTop = v ? 1 : 0;
|
|
2130
|
+
wrapperNode.marginLeft = v ? 1 : 0;
|
|
2131
|
+
} catch {
|
|
45
2132
|
}
|
|
46
|
-
|
|
2133
|
+
};
|
|
2134
|
+
const attachWrapper = (node) => {
|
|
2135
|
+
wrapperNode = node;
|
|
2136
|
+
syncWrapper();
|
|
2137
|
+
};
|
|
2138
|
+
registerRowSync(syncWrapper);
|
|
2139
|
+
rows.push(element("box", {
|
|
2140
|
+
ref: attachWrapper,
|
|
2141
|
+
width: "100%",
|
|
2142
|
+
flexDirection: "column",
|
|
2143
|
+
marginLeft: 1,
|
|
2144
|
+
marginTop: 1
|
|
2145
|
+
}, [
|
|
2146
|
+
StatRow({
|
|
2147
|
+
theme: props.theme,
|
|
2148
|
+
label: modelLabel,
|
|
2149
|
+
value: () => "",
|
|
2150
|
+
dim: true,
|
|
2151
|
+
registerSync: registerRowSync,
|
|
2152
|
+
visible
|
|
2153
|
+
}),
|
|
2154
|
+
StatRow({
|
|
2155
|
+
theme: props.theme,
|
|
2156
|
+
label: () => "",
|
|
2157
|
+
value: modelValue,
|
|
2158
|
+
dim: true,
|
|
2159
|
+
registerSync: registerRowSync,
|
|
2160
|
+
visible
|
|
2161
|
+
})
|
|
2162
|
+
]));
|
|
2163
|
+
}
|
|
2164
|
+
}
|
|
2165
|
+
if (rowVisible("speed")) {
|
|
2166
|
+
rows.push(StatRow({
|
|
2167
|
+
theme: props.theme,
|
|
2168
|
+
label: "TPS",
|
|
2169
|
+
value: speedValue,
|
|
2170
|
+
accent: true,
|
|
2171
|
+
icon: "\u26A1",
|
|
2172
|
+
registerSync: registerRowSync,
|
|
2173
|
+
visible: collapsedActive
|
|
2174
|
+
}));
|
|
2175
|
+
}
|
|
2176
|
+
if (rowVisible("session")) {
|
|
2177
|
+
rows.push(StatRow({
|
|
2178
|
+
theme: props.theme,
|
|
2179
|
+
label: "Session",
|
|
2180
|
+
value: sessionValue,
|
|
2181
|
+
icon: "\u25F7",
|
|
2182
|
+
registerSync: registerRowSync,
|
|
2183
|
+
visible: collapsedActive
|
|
2184
|
+
}));
|
|
2185
|
+
}
|
|
2186
|
+
const contentBox = element("box", {
|
|
2187
|
+
width: "100%",
|
|
2188
|
+
flexDirection: "column"
|
|
2189
|
+
}, rows);
|
|
2190
|
+
const root = element("box", {
|
|
2191
|
+
width: "100%",
|
|
2192
|
+
flexDirection: "column",
|
|
2193
|
+
height: sectionEnabled() ? "auto" : 0
|
|
2194
|
+
}, [headerBox, contentBox]);
|
|
2195
|
+
return { node: root, dispose };
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// src/tui-preferences.ts
|
|
2199
|
+
import { createSignal as createSignal2 } from "solid-js";
|
|
2200
|
+
|
|
2201
|
+
// src/tui-prefs-io.ts
|
|
2202
|
+
import { readFileSync as readFileSync2, watch } from "node:fs";
|
|
2203
|
+
import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
2204
|
+
import { homedir as homedir3 } from "node:os";
|
|
2205
|
+
import { basename, dirname, join as join3 } from "node:path";
|
|
2206
|
+
import { parse, stringify } from "comment-json";
|
|
2207
|
+
var TUI_PREFS_FILE_ENV = "OPENCODE_TUI_PREFERENCES_FILE";
|
|
2208
|
+
var FILE_NAME = "tui-preferences.jsonc";
|
|
2209
|
+
function getTuiPreferencesFile() {
|
|
2210
|
+
const override = process.env[TUI_PREFS_FILE_ENV];
|
|
2211
|
+
if (override) return override;
|
|
2212
|
+
const configDir = process.env.OPENCODE_CONFIG_DIR || join3(process.env.XDG_CONFIG_HOME || join3(homedir3(), ".config"), "opencode");
|
|
2213
|
+
return join3(configDir, FILE_NAME);
|
|
2214
|
+
}
|
|
2215
|
+
function isRecord5(value) {
|
|
2216
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2217
|
+
}
|
|
2218
|
+
async function readTuiPreferencesFile() {
|
|
2219
|
+
try {
|
|
2220
|
+
const raw = await readFile(getTuiPreferencesFile(), "utf8");
|
|
2221
|
+
if (raw.trim() === "") return {};
|
|
2222
|
+
const root = parse(raw);
|
|
2223
|
+
return isRecord5(root) ? root : {};
|
|
2224
|
+
} catch {
|
|
2225
|
+
return {};
|
|
2226
|
+
}
|
|
2227
|
+
}
|
|
2228
|
+
function readTuiPreferencesFileSync() {
|
|
2229
|
+
try {
|
|
2230
|
+
const raw = readFileSync2(getTuiPreferencesFile(), "utf8");
|
|
2231
|
+
if (raw.trim() === "") return {};
|
|
2232
|
+
const root = parse(raw);
|
|
2233
|
+
return isRecord5(root) ? root : {};
|
|
2234
|
+
} catch {
|
|
2235
|
+
return {};
|
|
2236
|
+
}
|
|
2237
|
+
}
|
|
2238
|
+
var TEMPLATE = `// Shared preferences for OpenCode TUI plugins.
|
|
2239
|
+
// One top-level key per plugin (short name). See each plugin's README for its
|
|
2240
|
+
// supported settings. This file is safe to hand-edit; plugins update individual
|
|
2241
|
+
// keys and preserve the rest (values and comments).
|
|
2242
|
+
{}
|
|
2243
|
+
`;
|
|
2244
|
+
function setDeep(root, path, value) {
|
|
2245
|
+
let node = root;
|
|
2246
|
+
for (let i = 0; i < path.length - 1; i += 1) {
|
|
2247
|
+
const key = path[i];
|
|
2248
|
+
const child = node[key];
|
|
2249
|
+
if (child === void 0 || child === null) {
|
|
2250
|
+
node[key] = {};
|
|
2251
|
+
} else if (!isRecord5(child)) {
|
|
2252
|
+
return false;
|
|
2253
|
+
}
|
|
2254
|
+
node = node[key];
|
|
2255
|
+
}
|
|
2256
|
+
node[path[path.length - 1]] = value;
|
|
2257
|
+
return true;
|
|
2258
|
+
}
|
|
2259
|
+
async function writePreference(pluginKey, path, value) {
|
|
2260
|
+
const file = getTuiPreferencesFile();
|
|
2261
|
+
await mkdir(dirname(file), { recursive: true });
|
|
2262
|
+
let text;
|
|
2263
|
+
try {
|
|
2264
|
+
text = await readFile(file, "utf8");
|
|
2265
|
+
} catch {
|
|
2266
|
+
text = "";
|
|
2267
|
+
}
|
|
2268
|
+
if (text.trim() === "") text = TEMPLATE;
|
|
2269
|
+
let root;
|
|
2270
|
+
try {
|
|
2271
|
+
root = parse(text);
|
|
2272
|
+
} catch {
|
|
2273
|
+
return;
|
|
2274
|
+
}
|
|
2275
|
+
if (!isRecord5(root)) root = {};
|
|
2276
|
+
if (!setDeep(root, [pluginKey, ...path], value)) {
|
|
2277
|
+
return;
|
|
2278
|
+
}
|
|
2279
|
+
const next = `${stringify(root, null, 2)}
|
|
2280
|
+
`;
|
|
2281
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
2282
|
+
await writeFile(tmp, next, "utf8");
|
|
2283
|
+
await rename(tmp, file);
|
|
2284
|
+
}
|
|
2285
|
+
var writeChain = Promise.resolve();
|
|
2286
|
+
function queueTuiPreferenceUpdate(pluginKey, path, value) {
|
|
2287
|
+
writeChain = writeChain.then(() => writePreference(pluginKey, path, value)).catch(() => {
|
|
2288
|
+
});
|
|
2289
|
+
return writeChain;
|
|
2290
|
+
}
|
|
2291
|
+
var WATCH_DEBOUNCE_MS = 150;
|
|
2292
|
+
function watchTuiPreferences(onChange) {
|
|
2293
|
+
const file = getTuiPreferencesFile();
|
|
2294
|
+
const name = basename(file);
|
|
2295
|
+
let timer = null;
|
|
2296
|
+
let lastSeen = null;
|
|
2297
|
+
void readFile(file, "utf8").then((text) => {
|
|
2298
|
+
if (lastSeen === null) lastSeen = text;
|
|
2299
|
+
}).catch(() => {
|
|
2300
|
+
});
|
|
2301
|
+
try {
|
|
2302
|
+
const watcher = watch(dirname(file), (_event, filename) => {
|
|
2303
|
+
const isOurs = filename === name || filename?.startsWith(`${name}.`) && filename.endsWith(".tmp");
|
|
2304
|
+
if (filename != null && !isOurs) return;
|
|
2305
|
+
if (timer) clearTimeout(timer);
|
|
2306
|
+
timer = setTimeout(() => {
|
|
2307
|
+
timer = null;
|
|
2308
|
+
void readFile(file, "utf8").catch(() => null).then((text) => {
|
|
2309
|
+
if (text === null) return;
|
|
2310
|
+
if (text === lastSeen) return;
|
|
2311
|
+
lastSeen = text;
|
|
2312
|
+
onChange();
|
|
2313
|
+
});
|
|
2314
|
+
}, WATCH_DEBOUNCE_MS);
|
|
47
2315
|
});
|
|
48
|
-
|
|
2316
|
+
return () => {
|
|
2317
|
+
if (timer) clearTimeout(timer);
|
|
2318
|
+
watcher.close();
|
|
2319
|
+
};
|
|
2320
|
+
} catch {
|
|
2321
|
+
return () => {
|
|
2322
|
+
};
|
|
2323
|
+
}
|
|
2324
|
+
}
|
|
2325
|
+
|
|
2326
|
+
// src/tui-preferences.ts
|
|
2327
|
+
var PLUGIN_KEY = "oc-metricboard";
|
|
2328
|
+
var DEFAULT_SLOT_ORDER = 160;
|
|
2329
|
+
var DEFAULT_PREFS = {
|
|
2330
|
+
forceToTop: false,
|
|
2331
|
+
order: DEFAULT_SLOT_ORDER,
|
|
2332
|
+
scope: "current",
|
|
2333
|
+
section: {
|
|
2334
|
+
enabled: true,
|
|
2335
|
+
collapsed: null,
|
|
2336
|
+
rememberCollapsed: true,
|
|
2337
|
+
label: "MetricBoard"
|
|
2338
|
+
},
|
|
2339
|
+
rows: {
|
|
2340
|
+
speed: true,
|
|
2341
|
+
ttft: true,
|
|
2342
|
+
input: true,
|
|
2343
|
+
output: true,
|
|
2344
|
+
cache: true,
|
|
2345
|
+
elapsed: true,
|
|
2346
|
+
session: true,
|
|
2347
|
+
model: true,
|
|
2348
|
+
modelBreakdown: true
|
|
2349
|
+
}
|
|
2350
|
+
};
|
|
2351
|
+
function bool(value, fallback) {
|
|
2352
|
+
return typeof value === "boolean" ? value : fallback;
|
|
2353
|
+
}
|
|
2354
|
+
function int(value, fallback, min, max) {
|
|
2355
|
+
if (typeof value !== "number" || !Number.isFinite(value)) return fallback;
|
|
2356
|
+
return Math.min(Math.max(Math.round(value), min), max);
|
|
2357
|
+
}
|
|
2358
|
+
function label(value, fallback, maxLength) {
|
|
2359
|
+
if (typeof value !== "string" || value.length === 0) return fallback;
|
|
2360
|
+
return value.slice(0, maxLength);
|
|
2361
|
+
}
|
|
2362
|
+
function metricsScope(value, fallback) {
|
|
2363
|
+
return value === "current" || value === "tree" ? value : fallback;
|
|
2364
|
+
}
|
|
2365
|
+
function resolveMetricsPrefs(root) {
|
|
2366
|
+
const entry = root[PLUGIN_KEY];
|
|
2367
|
+
if (!isRecord5(entry)) return structuredClone(DEFAULT_PREFS);
|
|
2368
|
+
const d = DEFAULT_PREFS;
|
|
2369
|
+
const section = isRecord5(entry.section) ? entry.section : {};
|
|
2370
|
+
const rows = isRecord5(entry.rows) ? entry.rows : {};
|
|
2371
|
+
return {
|
|
2372
|
+
forceToTop: bool(entry.forceToTop, d.forceToTop),
|
|
2373
|
+
order: int(entry.order, d.order, -1e4, 1e4),
|
|
2374
|
+
scope: metricsScope(entry.scope, d.scope),
|
|
2375
|
+
section: {
|
|
2376
|
+
enabled: bool(section.enabled, d.section.enabled),
|
|
2377
|
+
collapsed: typeof section.collapsed === "boolean" ? section.collapsed : null,
|
|
2378
|
+
rememberCollapsed: bool(section.rememberCollapsed, d.section.rememberCollapsed),
|
|
2379
|
+
label: label(section.label, d.section.label, 24)
|
|
2380
|
+
},
|
|
2381
|
+
rows: {
|
|
2382
|
+
speed: bool(rows.speed, d.rows.speed),
|
|
2383
|
+
ttft: bool(rows.ttft, d.rows.ttft),
|
|
2384
|
+
input: bool(rows.input, d.rows.input),
|
|
2385
|
+
output: bool(rows.output, d.rows.output),
|
|
2386
|
+
cache: bool(rows.cache, d.rows.cache),
|
|
2387
|
+
elapsed: bool(rows.elapsed, d.rows.elapsed),
|
|
2388
|
+
session: bool(rows.session, d.rows.session),
|
|
2389
|
+
model: bool(rows.model, d.rows.model),
|
|
2390
|
+
modelBreakdown: bool(rows.modelBreakdown, d.rows.modelBreakdown)
|
|
2391
|
+
}
|
|
2392
|
+
};
|
|
2393
|
+
}
|
|
2394
|
+
var FORCE_TOP_BASE = -1e5;
|
|
2395
|
+
function computeEffectiveOrder(root, pluginKey, defaultOrder) {
|
|
2396
|
+
const entry = root[pluginKey];
|
|
2397
|
+
if (!isRecord5(entry)) return defaultOrder;
|
|
2398
|
+
if (entry.forceToTop === true) {
|
|
2399
|
+
return FORCE_TOP_BASE + Object.keys(root).indexOf(pluginKey);
|
|
2400
|
+
}
|
|
2401
|
+
return int(entry.order, defaultOrder, -1e4, 1e4);
|
|
2402
|
+
}
|
|
2403
|
+
function createMetricsSidebarController(initialPrefs, requestRender) {
|
|
2404
|
+
const [prefs, setPrefs] = createSignal2(initialPrefs);
|
|
2405
|
+
const seedCollapsed = initialPrefs.section.rememberCollapsed && initialPrefs.section.collapsed != null ? initialPrefs.section.collapsed : false;
|
|
2406
|
+
const [collapsed, setCollapsed] = createSignal2(seedCollapsed);
|
|
2407
|
+
let lastPersistedCollapsed = initialPrefs.section.collapsed;
|
|
2408
|
+
let lastApplied = JSON.stringify(initialPrefs);
|
|
2409
|
+
const listeners = /* @__PURE__ */ new Set();
|
|
2410
|
+
const notify = () => {
|
|
2411
|
+
requestRender?.();
|
|
2412
|
+
for (const cb of listeners) cb();
|
|
2413
|
+
};
|
|
2414
|
+
const disposeWatcher = watchTuiPreferences(() => {
|
|
2415
|
+
void (async () => {
|
|
2416
|
+
const next = resolveMetricsPrefs(await readTuiPreferencesFile());
|
|
2417
|
+
const serialized = JSON.stringify(next);
|
|
2418
|
+
if (serialized === lastApplied) return;
|
|
2419
|
+
lastApplied = serialized;
|
|
2420
|
+
setPrefs(next);
|
|
2421
|
+
if (next.section.rememberCollapsed && next.section.collapsed != null && next.section.collapsed !== lastPersistedCollapsed) {
|
|
2422
|
+
lastPersistedCollapsed = next.section.collapsed;
|
|
2423
|
+
setCollapsed(next.section.collapsed);
|
|
2424
|
+
}
|
|
2425
|
+
notify();
|
|
2426
|
+
})();
|
|
2427
|
+
});
|
|
2428
|
+
void disposeWatcher;
|
|
2429
|
+
function toggleCollapsed() {
|
|
2430
|
+
const next = !collapsed();
|
|
2431
|
+
setCollapsed(next);
|
|
2432
|
+
notify();
|
|
2433
|
+
if (prefs().section.rememberCollapsed) {
|
|
2434
|
+
void queueTuiPreferenceUpdate(PLUGIN_KEY, ["section", "collapsed"], next).then(() => {
|
|
2435
|
+
lastPersistedCollapsed = next;
|
|
2436
|
+
});
|
|
2437
|
+
}
|
|
2438
|
+
}
|
|
2439
|
+
function subscribe(cb) {
|
|
2440
|
+
listeners.add(cb);
|
|
2441
|
+
return () => listeners.delete(cb);
|
|
2442
|
+
}
|
|
2443
|
+
return { prefs, collapsed, toggleCollapsed, subscribe };
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// src/tui-v2.tsx
|
|
2447
|
+
import { createElement as createElement2 } from "@opentui/solid";
|
|
2448
|
+
var V2_EVENT_MAP = {
|
|
2449
|
+
"message.part.delta": null,
|
|
2450
|
+
"message.part.updated": null,
|
|
2451
|
+
"message.updated": null,
|
|
2452
|
+
"session.created": "session.created",
|
|
2453
|
+
"session.updated": "session.updated",
|
|
2454
|
+
"session.deleted": "session.deleted",
|
|
2455
|
+
"session.status": null,
|
|
2456
|
+
"session.idle": "session.idle",
|
|
2457
|
+
"session.next.text.delta": "session.text.delta",
|
|
2458
|
+
"session.next.reasoning.delta": "session.reasoning.delta",
|
|
2459
|
+
"session.next.step.started": "session.step.started",
|
|
2460
|
+
"session.next.step.ended": "session.step.ended",
|
|
2461
|
+
"session.tool.called": "session.tool.called",
|
|
2462
|
+
"session.tool.success": "session.tool.success",
|
|
2463
|
+
"session.tool.failed": "session.tool.failed"
|
|
2464
|
+
};
|
|
2465
|
+
function isRecord6(value) {
|
|
2466
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2467
|
+
}
|
|
2468
|
+
function str(value) {
|
|
2469
|
+
return typeof value === "string" ? value : "";
|
|
2470
|
+
}
|
|
2471
|
+
function translateExecutionStartedToBusy(event) {
|
|
2472
|
+
const data = isRecord6(event.data) ? event.data : {};
|
|
2473
|
+
const sessionID = str(data.sessionID);
|
|
2474
|
+
if (!sessionID) return null;
|
|
2475
|
+
const created = typeof event.created === "number" ? event.created : void 0;
|
|
2476
|
+
return {
|
|
2477
|
+
type: "session.status",
|
|
2478
|
+
...created !== void 0 ? { created } : {},
|
|
2479
|
+
properties: { sessionID, status: { type: "busy" } }
|
|
2480
|
+
};
|
|
2481
|
+
}
|
|
2482
|
+
function translateExecutionSettledToIdle(event) {
|
|
2483
|
+
const data = isRecord6(event.data) ? event.data : {};
|
|
2484
|
+
const sessionID = str(data.sessionID);
|
|
2485
|
+
if (!sessionID) return null;
|
|
2486
|
+
const created = typeof event.created === "number" ? event.created : void 0;
|
|
2487
|
+
return {
|
|
2488
|
+
type: "session.idle",
|
|
2489
|
+
...created !== void 0 ? { created } : {},
|
|
2490
|
+
properties: { sessionID }
|
|
2491
|
+
};
|
|
2492
|
+
}
|
|
2493
|
+
function eventSessionID(event) {
|
|
2494
|
+
if (!isRecord6(event)) return "";
|
|
2495
|
+
return str(isRecord6(event.data) ? event.data.sessionID : void 0);
|
|
2496
|
+
}
|
|
2497
|
+
function createV1EventShim(ctx, holder) {
|
|
2498
|
+
let lastFamilySyncAt = -Infinity;
|
|
2499
|
+
const syncFamily = (sessionID) => {
|
|
2500
|
+
const data = ctx.data;
|
|
2501
|
+
try {
|
|
2502
|
+
if (!holder.collector || !data?.session || !sessionID) return;
|
|
2503
|
+
const now = Date.now();
|
|
2504
|
+
if (now - lastFamilySyncAt < 2e3) return;
|
|
2505
|
+
lastFamilySyncAt = now;
|
|
2506
|
+
const rootID = data.session.root(sessionID);
|
|
2507
|
+
const ids = data.session.family(rootID) ?? [];
|
|
2508
|
+
for (const id of ids) {
|
|
2509
|
+
if (id !== rootID) holder.collector.setSessionParent(id, rootID);
|
|
2510
|
+
}
|
|
2511
|
+
} catch {
|
|
2512
|
+
}
|
|
2513
|
+
};
|
|
2514
|
+
const subscribeV2 = (v2Type, subscribee) => {
|
|
2515
|
+
try {
|
|
2516
|
+
const unsub = ctx.data.on(v2Type, (event) => {
|
|
2517
|
+
try {
|
|
2518
|
+
subscribee(event);
|
|
2519
|
+
} catch (error) {
|
|
2520
|
+
log(`v2 event dispatch failed: ${String(error)}`);
|
|
2521
|
+
}
|
|
2522
|
+
});
|
|
2523
|
+
return typeof unsub === "function" ? unsub : () => {
|
|
2524
|
+
};
|
|
2525
|
+
} catch (error) {
|
|
2526
|
+
log(`v2 event subscribe failed (${v2Type}): ${String(error)}`);
|
|
2527
|
+
return () => {
|
|
2528
|
+
};
|
|
2529
|
+
}
|
|
2530
|
+
};
|
|
2531
|
+
return {
|
|
2532
|
+
event: {
|
|
2533
|
+
on(type, handler) {
|
|
2534
|
+
if (/\.1$|\.2$/.test(type)) return () => {
|
|
2535
|
+
};
|
|
2536
|
+
if (type === "session.status") {
|
|
2537
|
+
return subscribeV2("session.execution.started", (event) => {
|
|
2538
|
+
const translated = translateExecutionStartedToBusy(event);
|
|
2539
|
+
if (translated !== null) handler(translated);
|
|
2540
|
+
});
|
|
2541
|
+
}
|
|
2542
|
+
if (type === "session.idle") {
|
|
2543
|
+
const unsubs = [
|
|
2544
|
+
subscribeV2("session.idle", handler)
|
|
2545
|
+
];
|
|
2546
|
+
for (const settled of [
|
|
2547
|
+
"session.execution.succeeded",
|
|
2548
|
+
"session.execution.failed",
|
|
2549
|
+
"session.execution.interrupted"
|
|
2550
|
+
]) {
|
|
2551
|
+
unsubs.push(subscribeV2(settled, (event) => {
|
|
2552
|
+
const translated = translateExecutionSettledToIdle(event);
|
|
2553
|
+
if (translated !== null) handler(translated);
|
|
2554
|
+
}));
|
|
2555
|
+
}
|
|
2556
|
+
return () => {
|
|
2557
|
+
for (const unsub of unsubs) {
|
|
2558
|
+
try {
|
|
2559
|
+
unsub();
|
|
2560
|
+
} catch {
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
};
|
|
2564
|
+
}
|
|
2565
|
+
const v2Type = V2_EVENT_MAP[type];
|
|
2566
|
+
if (!v2Type) return () => {
|
|
2567
|
+
};
|
|
2568
|
+
return subscribeV2(v2Type, (event) => {
|
|
2569
|
+
syncFamily(eventSessionID(event));
|
|
2570
|
+
handler(event);
|
|
2571
|
+
});
|
|
2572
|
+
}
|
|
2573
|
+
}
|
|
2574
|
+
};
|
|
2575
|
+
}
|
|
2576
|
+
function pickThemeToken(theme, paths) {
|
|
2577
|
+
for (const path of paths) {
|
|
2578
|
+
let value = theme;
|
|
2579
|
+
for (const key of path) {
|
|
2580
|
+
if (!isRecord6(value)) {
|
|
2581
|
+
value = void 0;
|
|
2582
|
+
break;
|
|
2583
|
+
}
|
|
2584
|
+
value = value[key];
|
|
2585
|
+
}
|
|
2586
|
+
if (value !== void 0 && value !== null) return value;
|
|
2587
|
+
}
|
|
2588
|
+
return void 0;
|
|
2589
|
+
}
|
|
2590
|
+
function v1ThemeFromV2(theme) {
|
|
2591
|
+
return {
|
|
2592
|
+
text: pickThemeToken(theme, [["text", "default"]]),
|
|
2593
|
+
textMuted: pickThemeToken(theme, [["text", "subdued"]]),
|
|
2594
|
+
accent: pickThemeToken(theme, [["text", "feedback", "info", "default"], ["text", "default"]]),
|
|
2595
|
+
warning: pickThemeToken(theme, [["text", "feedback", "warning", "default"]]),
|
|
2596
|
+
success: pickThemeToken(theme, [["text", "feedback", "success", "default"]])
|
|
2597
|
+
};
|
|
2598
|
+
}
|
|
2599
|
+
function setupTuiV2(ctx) {
|
|
2600
|
+
if (!ctx || !ctx.ui || typeof ctx.ui.slot !== "function") {
|
|
2601
|
+
log("opencode-metrics v2 entry: no ui.slot in this context, skipping");
|
|
2602
|
+
return () => {
|
|
2603
|
+
};
|
|
2604
|
+
}
|
|
2605
|
+
const config = getConfig();
|
|
2606
|
+
log("opencode-metrics v2 entry initialized");
|
|
2607
|
+
const seedRoot = readTuiPreferencesFileSync();
|
|
2608
|
+
const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER);
|
|
2609
|
+
const prefs = resolveMetricsPrefs(seedRoot);
|
|
2610
|
+
const requestRender = () => {
|
|
2611
|
+
try {
|
|
2612
|
+
ctx.renderer?.requestRender?.();
|
|
2613
|
+
} catch {
|
|
2614
|
+
}
|
|
2615
|
+
};
|
|
2616
|
+
const controller = createMetricsSidebarController(prefs, requestRender);
|
|
2617
|
+
const holder = { collector: null };
|
|
2618
|
+
const collector = createCollector(createV1EventShim(ctx, holder), config, log);
|
|
2619
|
+
holder.collector = collector;
|
|
2620
|
+
const theme = v1ThemeFromV2(ctx.theme);
|
|
2621
|
+
const disposers = [];
|
|
2622
|
+
disposers.push(
|
|
2623
|
+
ctx.ui.slot({
|
|
2624
|
+
append: "sidebar.content",
|
|
2625
|
+
render: (input) => {
|
|
2626
|
+
const sessionID = input?.sessionID ?? "";
|
|
2627
|
+
if (!sessionID) return createElement2("box");
|
|
2628
|
+
const sidebar = SidebarMetrics({
|
|
2629
|
+
sessionID,
|
|
2630
|
+
collector,
|
|
2631
|
+
refreshIntervalMs: config.refreshIntervalMs,
|
|
2632
|
+
barConfig: config,
|
|
2633
|
+
theme,
|
|
2634
|
+
controller,
|
|
2635
|
+
requestRender
|
|
2636
|
+
});
|
|
2637
|
+
disposers.push(sidebar.dispose);
|
|
2638
|
+
return sidebar.node;
|
|
2639
|
+
}
|
|
2640
|
+
})
|
|
2641
|
+
);
|
|
2642
|
+
return () => {
|
|
2643
|
+
for (const dispose of disposers) {
|
|
2644
|
+
try {
|
|
2645
|
+
dispose();
|
|
2646
|
+
} catch {
|
|
2647
|
+
}
|
|
2648
|
+
}
|
|
2649
|
+
disposers.length = 0;
|
|
2650
|
+
collector.dispose();
|
|
2651
|
+
};
|
|
2652
|
+
}
|
|
2653
|
+
|
|
2654
|
+
// src/tui.tsx
|
|
2655
|
+
var plugin = async (api, _options, _meta) => {
|
|
2656
|
+
const config = getConfig();
|
|
2657
|
+
const collector = createCollector(api, config, log);
|
|
2658
|
+
const seedRoot = readTuiPreferencesFileSync();
|
|
2659
|
+
const effectiveOrder = computeEffectiveOrder(seedRoot, PLUGIN_KEY, DEFAULT_SLOT_ORDER);
|
|
2660
|
+
const prefs = resolveMetricsPrefs(seedRoot);
|
|
2661
|
+
const controller = createMetricsSidebarController(prefs, () => api.renderer.requestRender());
|
|
2662
|
+
const sidebarDisposers = [];
|
|
2663
|
+
api.slots.register({
|
|
2664
|
+
order: effectiveOrder,
|
|
2665
|
+
slots: {
|
|
2666
|
+
sidebar_content(ctx, props) {
|
|
2667
|
+
const sidebar = SidebarMetrics({
|
|
2668
|
+
sessionID: props.session_id,
|
|
2669
|
+
collector,
|
|
2670
|
+
refreshIntervalMs: config.refreshIntervalMs,
|
|
2671
|
+
barConfig: config,
|
|
2672
|
+
theme: ctx.theme.current,
|
|
2673
|
+
controller,
|
|
2674
|
+
requestRender: () => api.renderer.requestRender()
|
|
2675
|
+
});
|
|
2676
|
+
sidebarDisposers.push(sidebar.dispose);
|
|
2677
|
+
return sidebar.node;
|
|
2678
|
+
}
|
|
2679
|
+
}
|
|
2680
|
+
});
|
|
2681
|
+
api.lifecycle.onDispose(() => {
|
|
2682
|
+
for (const dispose of sidebarDisposers.splice(0)) {
|
|
2683
|
+
try {
|
|
2684
|
+
dispose();
|
|
2685
|
+
} catch {
|
|
2686
|
+
}
|
|
2687
|
+
}
|
|
2688
|
+
collector.dispose();
|
|
2689
|
+
});
|
|
2690
|
+
log("opencode-metrics sidebar initialized");
|
|
2691
|
+
};
|
|
2692
|
+
var pluginModule = {
|
|
2693
|
+
id: "oc-metricboard",
|
|
2694
|
+
tui: plugin,
|
|
2695
|
+
setup: setupTuiV2
|
|
49
2696
|
};
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
* - OpenCode V2 (2.0.x) validates `{ id, setup }` and rejects V1-only modules.
|
|
54
|
-
* Exporting both keys from one `./tui` module satisfies both hosts.
|
|
55
|
-
*/
|
|
56
|
-
const pluginModule = {
|
|
57
|
-
id: "oc-metricboard",
|
|
58
|
-
tui: plugin,
|
|
59
|
-
setup: setupTuiV2,
|
|
2697
|
+
var tui_default = pluginModule;
|
|
2698
|
+
export {
|
|
2699
|
+
tui_default as default
|
|
60
2700
|
};
|
|
61
|
-
export default pluginModule;
|