opencode-metrics-plugin 0.1.5
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/LICENSE +21 -0
- package/README.md +390 -0
- package/dist/backfill-cli.d.ts +1 -0
- package/dist/backfill-cli.js +74 -0
- package/dist/backfill.d.ts +58 -0
- package/dist/backfill.js +550 -0
- package/dist/compile-analyzer.d.ts +40 -0
- package/dist/compile-analyzer.js +161 -0
- package/dist/dirs.d.ts +34 -0
- package/dist/dirs.js +44 -0
- package/dist/event-logger.d.ts +20 -0
- package/dist/event-logger.js +329 -0
- package/dist/index.d.ts +51 -0
- package/dist/index.js +61 -0
- package/dist/logger.d.ts +10 -0
- package/dist/logger.js +76 -0
- package/dist/metrics-engine.d.ts +37 -0
- package/dist/metrics-engine.js +331 -0
- package/dist/metrics-handlers.d.ts +9 -0
- package/dist/metrics-handlers.js +648 -0
- package/dist/metrics-output.d.ts +11 -0
- package/dist/metrics-output.js +673 -0
- package/dist/metrics-steps.d.ts +19 -0
- package/dist/metrics-steps.js +90 -0
- package/dist/metrics-types.d.ts +381 -0
- package/dist/metrics-types.js +102 -0
- package/dist/summary-store.d.ts +150 -0
- package/dist/summary-store.js +560 -0
- package/package.json +43 -0
|
@@ -0,0 +1,648 @@
|
|
|
1
|
+
import { createTokenUsage } from "./metrics-types.js";
|
|
2
|
+
import { parseHvigorwOutput } from "./compile-analyzer.js";
|
|
3
|
+
// ─── Input Simplification ────────────────────────────────────────────────────
|
|
4
|
+
function simplifyInput(raw) {
|
|
5
|
+
if (!raw)
|
|
6
|
+
return {};
|
|
7
|
+
const result = {};
|
|
8
|
+
for (const [key, value] of Object.entries(raw)) {
|
|
9
|
+
if (value !== undefined)
|
|
10
|
+
result[key] = value;
|
|
11
|
+
}
|
|
12
|
+
return result;
|
|
13
|
+
}
|
|
14
|
+
// ─── Invalid Tool Detection ──────────────────────────────────────────────────
|
|
15
|
+
function isInvalidTool(part) {
|
|
16
|
+
if (part.tool === "invalid")
|
|
17
|
+
return true;
|
|
18
|
+
const state = part.state;
|
|
19
|
+
if (state?.title === "Invalid Tool")
|
|
20
|
+
return true;
|
|
21
|
+
const input = state?.input;
|
|
22
|
+
if (input?.error !== undefined)
|
|
23
|
+
return true;
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
// ─── Stage Detection (skill-based) ─────────────────────────────────────────
|
|
27
|
+
function handleStageDetection(state, part, now) {
|
|
28
|
+
const tool = part.tool;
|
|
29
|
+
const toolState = part.state;
|
|
30
|
+
const status = toolState?.status;
|
|
31
|
+
const parentMsgId = part.messageID || "";
|
|
32
|
+
// Track skill calls
|
|
33
|
+
if (tool === "skill" && status === "completed") {
|
|
34
|
+
const skillName = toolState?.input?.name || "";
|
|
35
|
+
const callID = part.callID;
|
|
36
|
+
if (skillName && callID) {
|
|
37
|
+
state.skillCallMap.set(callID, { skillName, parentMsgId, status: "completed" });
|
|
38
|
+
}
|
|
39
|
+
const isDevSkill = skillName === "harmonyos-atomic-dev" || skillName === "harmonyos-dev" || skillName === "harmonyos-dev4app";
|
|
40
|
+
const isBuildSkill = skillName === "harmonyos-hvigor";
|
|
41
|
+
if (isDevSkill && !state.htmlPreviewMsgId) {
|
|
42
|
+
// harmonyos dev skills → start dev phase (standard mode only, no html_preview)
|
|
43
|
+
transitionStage(state, "dev", now);
|
|
44
|
+
}
|
|
45
|
+
else if (isBuildSkill) {
|
|
46
|
+
// harmonyos-hvigor skill → start build phase
|
|
47
|
+
transitionStage(state, "build", now);
|
|
48
|
+
}
|
|
49
|
+
else if (!state.currentStage) {
|
|
50
|
+
// First non-dev/build skill → start design phase
|
|
51
|
+
transitionStage(state, "design", now);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
// html_preview tool → end design, start dev (plugin mode)
|
|
55
|
+
if (tool === "html_preview" && (status === "completed" || status === "running") && !state.htmlPreviewMsgId) {
|
|
56
|
+
state.htmlPreviewMsgId = parentMsgId;
|
|
57
|
+
transitionStage(state, "dev", now);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
function transitionStage(state, newStage, now) {
|
|
61
|
+
if (state.currentStage) {
|
|
62
|
+
state.currentStage.endTime = now;
|
|
63
|
+
state.currentStage.tokens = { ...state._stageTokens };
|
|
64
|
+
if (state.currentStage.stage === "build") {
|
|
65
|
+
state.currentStage.hvigorwCalls = state.compileStats.hvigorwCalls;
|
|
66
|
+
}
|
|
67
|
+
state.stages.push(state.currentStage);
|
|
68
|
+
}
|
|
69
|
+
state.lastStageEndTime = now;
|
|
70
|
+
state._stageTokens = createTokenUsage();
|
|
71
|
+
state.currentStage = {
|
|
72
|
+
stage: newStage,
|
|
73
|
+
startTime: now,
|
|
74
|
+
endTime: 0,
|
|
75
|
+
hvigorwCalls: 0,
|
|
76
|
+
tokens: createTokenUsage(),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
// ─── Handler Functions ───────────────────────────────────────────────────────
|
|
80
|
+
function handlePartUpdated(state, props) {
|
|
81
|
+
const part = props.part;
|
|
82
|
+
if (!part)
|
|
83
|
+
return;
|
|
84
|
+
// New activity — allow next session.idle to create a round
|
|
85
|
+
state._roundIdleProcessed = false;
|
|
86
|
+
state._idleFlushed = false;
|
|
87
|
+
const partType = part.type;
|
|
88
|
+
// Tool metrics
|
|
89
|
+
if (partType === "tool") {
|
|
90
|
+
const status = part.state?.status;
|
|
91
|
+
const callID = part.callID;
|
|
92
|
+
const parentMsgId = part.messageID || "";
|
|
93
|
+
const toolState = part.state;
|
|
94
|
+
// Dedup: check existing entry before updating (only when callID is present)
|
|
95
|
+
const existingEntry = callID ? state.toolCallMap.get(callID) : undefined;
|
|
96
|
+
const alreadyRunning = callID && existingEntry?.status === "running";
|
|
97
|
+
const alreadyCompleted = callID && existingEntry?.status === "completed";
|
|
98
|
+
const alreadyError = callID && existingEntry?.status === "error";
|
|
99
|
+
if (status === "running") {
|
|
100
|
+
const rawInput = toolState?.input;
|
|
101
|
+
state.toolCallMap.set(callID, {
|
|
102
|
+
callID,
|
|
103
|
+
tool: part.tool || "unknown",
|
|
104
|
+
status: "running",
|
|
105
|
+
input: simplifyInput(rawInput),
|
|
106
|
+
outputPreview: "",
|
|
107
|
+
startMs: toolState?.time?.start || 0,
|
|
108
|
+
endMs: 0,
|
|
109
|
+
parentMsgId,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
if (status === "completed" || status === "error") {
|
|
113
|
+
const tc = existingEntry || {
|
|
114
|
+
callID,
|
|
115
|
+
tool: part.tool || "unknown",
|
|
116
|
+
status,
|
|
117
|
+
input: {},
|
|
118
|
+
outputPreview: "",
|
|
119
|
+
startMs: 0,
|
|
120
|
+
endMs: 0,
|
|
121
|
+
parentMsgId,
|
|
122
|
+
};
|
|
123
|
+
tc.status = status;
|
|
124
|
+
tc.endMs = toolState?.time?.end || 0;
|
|
125
|
+
if (!tc.startMs)
|
|
126
|
+
tc.startMs = toolState?.time?.start || 0;
|
|
127
|
+
// Truncate output to 500 chars
|
|
128
|
+
const output = toolState?.output || toolState?.raw || "";
|
|
129
|
+
tc.outputPreview = typeof output === "string" ? output.slice(0, 500) : "";
|
|
130
|
+
// Update input if available
|
|
131
|
+
if (toolState?.input && Object.keys(toolState.input).length > 0) {
|
|
132
|
+
Object.assign(tc.input, simplifyInput(toolState.input));
|
|
133
|
+
}
|
|
134
|
+
state.toolCallMap.set(callID, tc);
|
|
135
|
+
}
|
|
136
|
+
if (status === "running" && !alreadyRunning) {
|
|
137
|
+
if (isInvalidTool(part)) {
|
|
138
|
+
state.tools.invalidCalls++;
|
|
139
|
+
}
|
|
140
|
+
else {
|
|
141
|
+
state.tools.totalCalls++;
|
|
142
|
+
state._roundToolCalls++;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
if (status === "completed" && !alreadyCompleted) {
|
|
146
|
+
state.tools.completedCalls++;
|
|
147
|
+
const toolName = part.tool || "unknown";
|
|
148
|
+
const time = toolState?.time;
|
|
149
|
+
const duration = time && time.start && time.end
|
|
150
|
+
? (time.end - time.start)
|
|
151
|
+
: 0;
|
|
152
|
+
// Update distribution
|
|
153
|
+
const stats = state.tools.distribution.get(toolName) ?? { calls: 0, errors: 0, totalDuration: 0, maxDuration: 0 };
|
|
154
|
+
stats.calls++;
|
|
155
|
+
stats.totalDuration += duration;
|
|
156
|
+
if (duration > stats.maxDuration)
|
|
157
|
+
stats.maxDuration = duration;
|
|
158
|
+
state.tools.distribution.set(toolName, stats);
|
|
159
|
+
// Update slowest call
|
|
160
|
+
if (duration > state.tools.slowestCall.duration) {
|
|
161
|
+
state.tools.slowestCall = { tool: toolName, duration };
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
if (status === "error" && !alreadyError) {
|
|
165
|
+
state.tools.errorCalls++;
|
|
166
|
+
state._roundErrors++;
|
|
167
|
+
const toolName = part.tool || "unknown";
|
|
168
|
+
const stats = state.tools.distribution.get(toolName) ?? { calls: 0, errors: 0, totalDuration: 0, maxDuration: 0 };
|
|
169
|
+
stats.errors++;
|
|
170
|
+
state.tools.distribution.set(toolName, stats);
|
|
171
|
+
}
|
|
172
|
+
// Compile stats extraction (with dedup)
|
|
173
|
+
if (part.tool === "bash") {
|
|
174
|
+
const command = state.toolCallMap.get(callID)?.input?.command || "";
|
|
175
|
+
if (command.includes("hvigorw")) {
|
|
176
|
+
if (status === "completed" && !alreadyCompleted) {
|
|
177
|
+
state.compileStats.hvigorwCalls++;
|
|
178
|
+
const output = toolState?.output || "";
|
|
179
|
+
const result = parseHvigorwOutput(output);
|
|
180
|
+
state.compileStats.lastBuildSuccess = result.success;
|
|
181
|
+
// First build per round
|
|
182
|
+
if (!state._roundFirstBuildTracked) {
|
|
183
|
+
state.compileStats.firstBuildPerRound.push(result.success);
|
|
184
|
+
state._roundFirstBuildTracked = true;
|
|
185
|
+
}
|
|
186
|
+
// Error codes
|
|
187
|
+
for (const err of result.errors) {
|
|
188
|
+
const existing = state.compileStats.errorCodes.get(err.code);
|
|
189
|
+
if (existing) {
|
|
190
|
+
existing.count++;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
state.compileStats.errorCodes.set(err.code, {
|
|
194
|
+
count: 1, type: err.type, message: err.message,
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
// Warnings
|
|
199
|
+
for (const warn of result.warnings) {
|
|
200
|
+
const existing = state.compileStats.warnings.get(warn.type);
|
|
201
|
+
if (existing) {
|
|
202
|
+
existing.count++;
|
|
203
|
+
existing.entries.push(warn);
|
|
204
|
+
}
|
|
205
|
+
else {
|
|
206
|
+
state.compileStats.warnings.set(warn.type, {
|
|
207
|
+
count: 1, entries: [warn],
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
// Module timings
|
|
212
|
+
for (const [module, timing] of result.moduleTimings) {
|
|
213
|
+
const existing = state.compileStats.moduleTimings.get(module);
|
|
214
|
+
if (existing) {
|
|
215
|
+
existing.totalDuration += timing.totalDuration;
|
|
216
|
+
existing.taskCount += timing.taskCount;
|
|
217
|
+
if (timing.slowestTask.duration > existing.slowestTask.duration) {
|
|
218
|
+
existing.slowestTask = timing.slowestTask;
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
else {
|
|
222
|
+
state.compileStats.moduleTimings.set(module, { ...timing });
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// Fix cycle tracking
|
|
226
|
+
if (!result.success) {
|
|
227
|
+
if (state.compileStats._pendingFix) {
|
|
228
|
+
state.compileStats._pendingFix.attempts++;
|
|
229
|
+
}
|
|
230
|
+
else {
|
|
231
|
+
state.compileStats._pendingFix = {
|
|
232
|
+
failTime: Date.now(), attempts: 1, codeChanges: 0,
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
state.compileStats.hvigorwErrors++;
|
|
236
|
+
}
|
|
237
|
+
else {
|
|
238
|
+
if (state.compileStats._pendingFix) {
|
|
239
|
+
state.compileStats.fixCycles.push({
|
|
240
|
+
failTime: state.compileStats._pendingFix.failTime,
|
|
241
|
+
successTime: Date.now(),
|
|
242
|
+
attempts: state.compileStats._pendingFix.attempts,
|
|
243
|
+
codeChanges: state.compileStats._pendingFix.codeChanges,
|
|
244
|
+
});
|
|
245
|
+
state.compileStats._pendingFix = null;
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (status === "error" && !alreadyError) {
|
|
250
|
+
state.compileStats.hvigorwErrors++;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
if (part.tool === "write" && !alreadyCompleted) {
|
|
255
|
+
const entry = state.toolCallMap.get(callID);
|
|
256
|
+
const filePath = entry?.input?.filePath || entry?.input?.path || "";
|
|
257
|
+
if (filePath.endsWith(".ets") && status === "completed") {
|
|
258
|
+
const content = entry?.input?.content;
|
|
259
|
+
if (content) {
|
|
260
|
+
state.compileStats.etsLines += content.split("\n").filter(l => l.length > 0).length;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
// Fix cycle: count code changes
|
|
265
|
+
if ((part.tool === "write" || part.tool === "edit") && status === "completed") {
|
|
266
|
+
if (state.compileStats._pendingFix) {
|
|
267
|
+
state.compileStats._pendingFix.codeChanges++;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
// ─── Planning (todowrite) tracking ──────────────────────────────────────
|
|
271
|
+
const toolNameForPlanning = part.tool || "";
|
|
272
|
+
if (toolNameForPlanning === "todowrite" && status === "completed" && !alreadyCompleted) {
|
|
273
|
+
const time = toolState?.time;
|
|
274
|
+
const startTime = time?.start || 0;
|
|
275
|
+
const endTime = time?.end || 0;
|
|
276
|
+
// Extract todos from metadata.todos (completed) or state.input.todos (fallback)
|
|
277
|
+
const metadata = part.metadata;
|
|
278
|
+
const rawTodos = metadata?.todos
|
|
279
|
+
|| toolState?.input?.todos
|
|
280
|
+
|| [];
|
|
281
|
+
const todos = rawTodos.map(t => ({
|
|
282
|
+
content: t.content || "",
|
|
283
|
+
status: t.status || "pending",
|
|
284
|
+
priority: t.priority || "medium",
|
|
285
|
+
}));
|
|
286
|
+
// Skip empty todowrite calls (no-op planning rounds)
|
|
287
|
+
if (todos.length === 0)
|
|
288
|
+
return;
|
|
289
|
+
state.planningCalls.push({
|
|
290
|
+
callID,
|
|
291
|
+
startTime,
|
|
292
|
+
endTime,
|
|
293
|
+
durationMs: endTime > startTime ? endTime - startTime : 0,
|
|
294
|
+
todos,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
// ─── SkillSearch tracking ──────────────────────────────────────────────
|
|
298
|
+
const toolName = part.tool || "";
|
|
299
|
+
if (toolName === "skillSearch" && callID) {
|
|
300
|
+
if (status === "running" && !alreadyRunning) {
|
|
301
|
+
const rawInput = toolState?.input;
|
|
302
|
+
state.skillSearchMap.set(callID, {
|
|
303
|
+
callID,
|
|
304
|
+
skillPath: rawInput?.skill_path || "",
|
|
305
|
+
query: rawInput?.query || "",
|
|
306
|
+
topK: rawInput?.topK || 3,
|
|
307
|
+
output: "",
|
|
308
|
+
startMs: toolState?.time?.start || 0,
|
|
309
|
+
endMs: 0,
|
|
310
|
+
followUpReads: [],
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
if (status === "completed" && !alreadyCompleted) {
|
|
314
|
+
let entry = state.skillSearchMap.get(callID);
|
|
315
|
+
if (!entry) {
|
|
316
|
+
const rawInput = toolState?.input;
|
|
317
|
+
entry = {
|
|
318
|
+
callID,
|
|
319
|
+
skillPath: rawInput?.skill_path || "",
|
|
320
|
+
query: rawInput?.query || "",
|
|
321
|
+
topK: rawInput?.topK || 3,
|
|
322
|
+
output: "",
|
|
323
|
+
startMs: toolState?.time?.start || 0,
|
|
324
|
+
endMs: 0,
|
|
325
|
+
followUpReads: [],
|
|
326
|
+
};
|
|
327
|
+
state.skillSearchMap.set(callID, entry);
|
|
328
|
+
}
|
|
329
|
+
entry.output = toolState?.output || "";
|
|
330
|
+
entry.endMs = toolState?.time?.end || 0;
|
|
331
|
+
if (!entry.startMs) {
|
|
332
|
+
entry.startMs = toolState?.time?.start || 0;
|
|
333
|
+
}
|
|
334
|
+
state._pendingReadChain = { searchCallID: callID };
|
|
335
|
+
}
|
|
336
|
+
if (status === "error" && !alreadyError) {
|
|
337
|
+
let entry = state.skillSearchMap.get(callID);
|
|
338
|
+
if (!entry) {
|
|
339
|
+
const rawInput = toolState?.input;
|
|
340
|
+
entry = {
|
|
341
|
+
callID,
|
|
342
|
+
skillPath: rawInput?.skill_path || "",
|
|
343
|
+
query: rawInput?.query || "",
|
|
344
|
+
topK: rawInput?.topK || 3,
|
|
345
|
+
output: "",
|
|
346
|
+
startMs: toolState?.time?.start || 0,
|
|
347
|
+
endMs: 0,
|
|
348
|
+
followUpReads: [],
|
|
349
|
+
};
|
|
350
|
+
state.skillSearchMap.set(callID, entry);
|
|
351
|
+
}
|
|
352
|
+
entry.endMs = toolState?.time?.end || 0;
|
|
353
|
+
state._pendingReadChain = null;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
// Follow-up read chain: track read calls immediately after skillSearch
|
|
357
|
+
if (toolName === "read" && status === "completed" && !alreadyCompleted && state._pendingReadChain) {
|
|
358
|
+
const entry = state.skillSearchMap.get(state._pendingReadChain.searchCallID);
|
|
359
|
+
if (entry) {
|
|
360
|
+
const readEntry = state.toolCallMap.get(callID);
|
|
361
|
+
const filePath = readEntry?.input?.filePath || readEntry?.input?.path || "";
|
|
362
|
+
entry.followUpReads.push({ filePath, callID });
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
else if (toolName !== "skillSearch" && toolName !== "read" && state._pendingReadChain) {
|
|
366
|
+
state._pendingReadChain = null;
|
|
367
|
+
}
|
|
368
|
+
// Stage detection on tool calls
|
|
369
|
+
handleStageDetection(state, part, Date.now());
|
|
370
|
+
}
|
|
371
|
+
// Step-finish — extract tokens
|
|
372
|
+
if (partType === "step-finish") {
|
|
373
|
+
const tokens = part.tokens;
|
|
374
|
+
if (tokens) {
|
|
375
|
+
const cache = tokens.cache || {};
|
|
376
|
+
const input = tokens.input || 0;
|
|
377
|
+
const output = tokens.output || 0;
|
|
378
|
+
const reasoning = tokens.reasoning || 0;
|
|
379
|
+
const cacheRead = cache.read || 0;
|
|
380
|
+
const cacheWrite = cache.write || 0;
|
|
381
|
+
const total = tokens.total || 0;
|
|
382
|
+
// Accumulate to session totals
|
|
383
|
+
state.tokens.input += input;
|
|
384
|
+
state.tokens.output += output;
|
|
385
|
+
state.tokens.reasoning += reasoning;
|
|
386
|
+
state.tokens.cacheRead += cacheRead;
|
|
387
|
+
state.tokens.cacheWrite += cacheWrite;
|
|
388
|
+
state.tokens.total += total;
|
|
389
|
+
// Accumulate to per-round tokens
|
|
390
|
+
state._roundTokens.input += input;
|
|
391
|
+
state._roundTokens.output += output;
|
|
392
|
+
state._roundTokens.reasoning += reasoning;
|
|
393
|
+
state._roundTokens.cacheRead += cacheRead;
|
|
394
|
+
state._roundTokens.cacheWrite += cacheWrite;
|
|
395
|
+
state._roundTokens.total += total;
|
|
396
|
+
// Accumulate to current stage tokens
|
|
397
|
+
state._stageTokens.input += input;
|
|
398
|
+
state._stageTokens.output += output;
|
|
399
|
+
state._stageTokens.reasoning += reasoning;
|
|
400
|
+
state._stageTokens.cacheRead += cacheRead;
|
|
401
|
+
state._stageTokens.cacheWrite += cacheWrite;
|
|
402
|
+
state._stageTokens.total += total;
|
|
403
|
+
// Track model token distribution
|
|
404
|
+
if (state.model.current) {
|
|
405
|
+
const modelTokens = state.model.tokenDistribution.get(state.model.current) ?? createTokenUsage();
|
|
406
|
+
modelTokens.input += input;
|
|
407
|
+
modelTokens.output += output;
|
|
408
|
+
modelTokens.reasoning += reasoning;
|
|
409
|
+
modelTokens.cacheRead += cacheRead;
|
|
410
|
+
modelTokens.cacheWrite += cacheWrite;
|
|
411
|
+
modelTokens.total += total;
|
|
412
|
+
state.model.tokenDistribution.set(state.model.current, modelTokens);
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
// Text part — track first token latency
|
|
417
|
+
if (partType === "text" && !state._hasTextPart) {
|
|
418
|
+
state._hasTextPart = true;
|
|
419
|
+
state._firstTextTime = Date.now();
|
|
420
|
+
}
|
|
421
|
+
// Accumulate text length
|
|
422
|
+
if (partType === "text") {
|
|
423
|
+
const msgId = part.messageID;
|
|
424
|
+
const text = part.text;
|
|
425
|
+
if (msgId && text) {
|
|
426
|
+
state.textLengthMap.set(msgId, (state.textLengthMap.get(msgId) || 0) + text.length);
|
|
427
|
+
// Capture user message text for round snapshot
|
|
428
|
+
if (state._userMessageIds.has(msgId)) {
|
|
429
|
+
state._pendingUserMessage.push(text);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function handleSessionUpdated(state, props) {
|
|
435
|
+
const info = props.info;
|
|
436
|
+
if (!info)
|
|
437
|
+
return;
|
|
438
|
+
// Agent attribution
|
|
439
|
+
const agent = info.agent;
|
|
440
|
+
if (agent && agent !== state.agent.current) {
|
|
441
|
+
if (state.agent.current) {
|
|
442
|
+
// Finalize previous agent usage
|
|
443
|
+
const elapsed = Date.now() - state.agent.lastSwitchTime;
|
|
444
|
+
const prev = state.agent.usage.get(state.agent.current) ?? 0;
|
|
445
|
+
state.agent.usage.set(state.agent.current, prev + elapsed);
|
|
446
|
+
}
|
|
447
|
+
state.agent.current = agent;
|
|
448
|
+
state.agent.switches++;
|
|
449
|
+
state.agent.lastSwitchTime = Date.now();
|
|
450
|
+
}
|
|
451
|
+
// Model attribution
|
|
452
|
+
const model = info.model;
|
|
453
|
+
const modelId = model?.id;
|
|
454
|
+
if (modelId && modelId !== state.model.current) {
|
|
455
|
+
state.model.current = modelId;
|
|
456
|
+
state.model.switches++;
|
|
457
|
+
}
|
|
458
|
+
// Update sessionMeta
|
|
459
|
+
if (agent)
|
|
460
|
+
state.sessionMeta.agent = agent;
|
|
461
|
+
if (modelId)
|
|
462
|
+
state.sessionMeta.model = modelId;
|
|
463
|
+
// Extract working directory
|
|
464
|
+
const directory = info.directory;
|
|
465
|
+
if (directory)
|
|
466
|
+
state.sessionMeta.workingDirectory = directory;
|
|
467
|
+
}
|
|
468
|
+
function handleMessageUpdated(state, props) {
|
|
469
|
+
const info = props.info;
|
|
470
|
+
if (!info)
|
|
471
|
+
return;
|
|
472
|
+
// Track user message IDs so we can capture their text from message.part.updated
|
|
473
|
+
if (info.role === "user") {
|
|
474
|
+
const msgId = info.id;
|
|
475
|
+
if (msgId)
|
|
476
|
+
state._userMessageIds.add(msgId);
|
|
477
|
+
state._roundIdleProcessed = false;
|
|
478
|
+
return;
|
|
479
|
+
}
|
|
480
|
+
if (info.role !== "assistant")
|
|
481
|
+
return;
|
|
482
|
+
const msgId = info.id;
|
|
483
|
+
if (!msgId)
|
|
484
|
+
return;
|
|
485
|
+
const existing = state.messageMap.get(msgId);
|
|
486
|
+
const tokens = info.tokens;
|
|
487
|
+
const cache = tokens?.cache || {};
|
|
488
|
+
const newTokens = {
|
|
489
|
+
input: tokens?.input || 0,
|
|
490
|
+
output: tokens?.output || 0,
|
|
491
|
+
reasoning: tokens?.reasoning || 0,
|
|
492
|
+
cacheRead: cache.read || 0,
|
|
493
|
+
cacheWrite: cache.write || 0,
|
|
494
|
+
total: tokens?.total || 0,
|
|
495
|
+
};
|
|
496
|
+
const time = info.time;
|
|
497
|
+
const finish = info.finish || "";
|
|
498
|
+
// Prefer entries with finish + completed time
|
|
499
|
+
if (!existing || (finish && time?.completed)) {
|
|
500
|
+
state.messageMap.set(msgId, {
|
|
501
|
+
msgId,
|
|
502
|
+
tokens: newTokens.total > 0 ? newTokens : (existing?.tokens || newTokens),
|
|
503
|
+
cost: info.cost || existing?.cost || 0,
|
|
504
|
+
startTime: time?.created || existing?.startTime || 0,
|
|
505
|
+
endTime: time?.completed || existing?.endTime || 0,
|
|
506
|
+
finish: finish || existing?.finish || "",
|
|
507
|
+
order: existing?.order ?? state.orderCounter++,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
else if (newTokens.total > (existing?.tokens.total || 0)) {
|
|
511
|
+
existing.tokens = newTokens;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
function handleSubAgentParts(sub, event) {
|
|
515
|
+
const props = event.properties;
|
|
516
|
+
if (!props)
|
|
517
|
+
return;
|
|
518
|
+
if (event.type === "message.part.updated") {
|
|
519
|
+
const part = props.part;
|
|
520
|
+
if (!part)
|
|
521
|
+
return;
|
|
522
|
+
const partType = part.type;
|
|
523
|
+
const msgId = part.messageID || "";
|
|
524
|
+
// Text → update textLengthMap
|
|
525
|
+
if (partType === "text" && msgId) {
|
|
526
|
+
const text = part.text || "";
|
|
527
|
+
if (text) {
|
|
528
|
+
sub.textLengthMap.set(msgId, (sub.textLengthMap.get(msgId) || 0) + text.length);
|
|
529
|
+
if (sub._userMessageIds.has(msgId)) {
|
|
530
|
+
sub._pendingUserMessage.push(text);
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
// Tool → write to toolCallMap
|
|
535
|
+
if (partType === "tool") {
|
|
536
|
+
const callID = part.callID;
|
|
537
|
+
const toolState = part.state;
|
|
538
|
+
const status = toolState?.status;
|
|
539
|
+
const parentMsgId = msgId;
|
|
540
|
+
if (status === "running" && callID) {
|
|
541
|
+
const rawInput = toolState?.input;
|
|
542
|
+
sub.toolCallMap.set(callID, {
|
|
543
|
+
callID,
|
|
544
|
+
tool: part.tool || "unknown",
|
|
545
|
+
status: "running",
|
|
546
|
+
input: simplifyInput(rawInput),
|
|
547
|
+
outputPreview: "",
|
|
548
|
+
startMs: toolState?.time?.start || 0,
|
|
549
|
+
endMs: 0,
|
|
550
|
+
parentMsgId,
|
|
551
|
+
});
|
|
552
|
+
}
|
|
553
|
+
if ((status === "completed" || status === "error") && callID) {
|
|
554
|
+
const existing = sub.toolCallMap.get(callID);
|
|
555
|
+
const tc = existing || {
|
|
556
|
+
callID,
|
|
557
|
+
tool: part.tool || "unknown",
|
|
558
|
+
status,
|
|
559
|
+
input: {},
|
|
560
|
+
outputPreview: "",
|
|
561
|
+
startMs: 0,
|
|
562
|
+
endMs: 0,
|
|
563
|
+
parentMsgId,
|
|
564
|
+
};
|
|
565
|
+
tc.status = status;
|
|
566
|
+
tc.endMs = toolState?.time?.end || 0;
|
|
567
|
+
if (!tc.startMs)
|
|
568
|
+
tc.startMs = toolState?.time?.start || 0;
|
|
569
|
+
const output = toolState?.output || toolState?.raw || "";
|
|
570
|
+
tc.outputPreview = typeof output === "string" ? output.slice(0, 500) : "";
|
|
571
|
+
if (toolState?.input && Object.keys(toolState.input).length > 0) {
|
|
572
|
+
Object.assign(tc.input, simplifyInput(toolState.input));
|
|
573
|
+
}
|
|
574
|
+
sub.toolCallMap.set(callID, tc);
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
// Step-finish → create/update messageMap entry with tokens
|
|
578
|
+
if (partType === "step-finish" && msgId) {
|
|
579
|
+
const tokens = part.tokens;
|
|
580
|
+
if (tokens) {
|
|
581
|
+
const cache = tokens.cache || {};
|
|
582
|
+
const newTokens = {
|
|
583
|
+
input: tokens.input || 0,
|
|
584
|
+
output: tokens.output || 0,
|
|
585
|
+
reasoning: tokens.reasoning || 0,
|
|
586
|
+
cacheRead: cache.read || 0,
|
|
587
|
+
cacheWrite: cache.write || 0,
|
|
588
|
+
total: tokens.total || 0,
|
|
589
|
+
};
|
|
590
|
+
const existing = sub.messageMap.get(msgId);
|
|
591
|
+
sub.messageMap.set(msgId, {
|
|
592
|
+
msgId,
|
|
593
|
+
tokens: newTokens,
|
|
594
|
+
cost: existing?.cost || 0,
|
|
595
|
+
startTime: existing?.startTime || 0,
|
|
596
|
+
endTime: existing?.endTime || 0,
|
|
597
|
+
finish: existing?.finish || "",
|
|
598
|
+
order: existing?.order ?? sub.orderCounter++,
|
|
599
|
+
});
|
|
600
|
+
}
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
// message.updated → update finish/tokens/cost on messageMap entry
|
|
604
|
+
if (event.type === "message.updated") {
|
|
605
|
+
const info = props.info;
|
|
606
|
+
if (!info)
|
|
607
|
+
return;
|
|
608
|
+
if (info.role === "user") {
|
|
609
|
+
const msgId = info.id;
|
|
610
|
+
if (msgId)
|
|
611
|
+
sub._userMessageIds.add(msgId);
|
|
612
|
+
return;
|
|
613
|
+
}
|
|
614
|
+
if (info.role !== "assistant")
|
|
615
|
+
return;
|
|
616
|
+
const msgId = info.id;
|
|
617
|
+
if (!msgId)
|
|
618
|
+
return;
|
|
619
|
+
const existing = sub.messageMap.get(msgId);
|
|
620
|
+
const tokens = info.tokens;
|
|
621
|
+
const cache = tokens?.cache || {};
|
|
622
|
+
const newTokens = {
|
|
623
|
+
input: tokens?.input || 0,
|
|
624
|
+
output: tokens?.output || 0,
|
|
625
|
+
reasoning: tokens?.reasoning || 0,
|
|
626
|
+
cacheRead: cache.read || 0,
|
|
627
|
+
cacheWrite: cache.write || 0,
|
|
628
|
+
total: tokens?.total || 0,
|
|
629
|
+
};
|
|
630
|
+
const time = info.time;
|
|
631
|
+
const finish = info.finish || "";
|
|
632
|
+
if (!existing || (finish && time?.completed)) {
|
|
633
|
+
sub.messageMap.set(msgId, {
|
|
634
|
+
msgId,
|
|
635
|
+
tokens: newTokens.total > 0 ? newTokens : (existing?.tokens || newTokens),
|
|
636
|
+
cost: info.cost || existing?.cost || 0,
|
|
637
|
+
startTime: time?.created || existing?.startTime || 0,
|
|
638
|
+
endTime: time?.completed || existing?.endTime || 0,
|
|
639
|
+
finish: finish || existing?.finish || "",
|
|
640
|
+
order: existing?.order ?? sub.orderCounter++,
|
|
641
|
+
});
|
|
642
|
+
}
|
|
643
|
+
else if (newTokens.total > (existing?.tokens.total || 0)) {
|
|
644
|
+
existing.tokens = newTokens;
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
export { handlePartUpdated, handleSessionUpdated, handleMessageUpdated, handleSubAgentParts };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { MetricsDirs } from "./dirs.js";
|
|
2
|
+
import type { SessionMetricsState, MetricsOutput } from "./metrics-types.js";
|
|
3
|
+
declare function flushMetrics(sessionId: string, state: SessionMetricsState, now: number, dirs?: Partial<MetricsDirs>): MetricsOutput;
|
|
4
|
+
declare function handleSessionIdle(state: SessionMetricsState, sessionId: string, dirs?: Partial<MetricsDirs>): MetricsOutput | undefined;
|
|
5
|
+
declare function mergeChildMetrics(parent: SessionMetricsState, child: SessionMetricsState, meta: {
|
|
6
|
+
childId: string;
|
|
7
|
+
agentName: string;
|
|
8
|
+
title: string;
|
|
9
|
+
}): void;
|
|
10
|
+
export declare function mergeMetricsOutput(existing: MetricsOutput | null, fresh: MetricsOutput): MetricsOutput;
|
|
11
|
+
export { flushMetrics, handleSessionIdle, mergeChildMetrics };
|