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.
@@ -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 };