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,673 @@
1
+ import * as fs from "fs";
2
+ import * as path from "path";
3
+ import { log } from "./logger.js";
4
+ import { createTokenUsage } from "./metrics-types.js";
5
+ import { getMetricsDir, getSummaryFile } from "./dirs.js";
6
+ import { upsertSessionSnapshot } from "./summary-store.js";
7
+ import { buildSteps, extractStepContent } from "./metrics-steps.js";
8
+ function filterByKeys(source, keys) {
9
+ const result = new Map();
10
+ for (const key of keys) {
11
+ const val = source.get(key);
12
+ if (val !== undefined)
13
+ result.set(key, val);
14
+ }
15
+ return result;
16
+ }
17
+ function flushMetrics(sessionId, state, now, dirs) {
18
+ // Finalize agent usage for current agent
19
+ if (state.agent.current) {
20
+ const elapsed = now - state.agent.lastSwitchTime;
21
+ const prev = state.agent.usage.get(state.agent.current) ?? 0;
22
+ state.agent.usage.set(state.agent.current, prev + elapsed);
23
+ }
24
+ const endTime = now;
25
+ const duration = endTime - state.startTime;
26
+ // Calculate cache hit rate
27
+ const denom = state.tokens.input + state.tokens.cacheRead;
28
+ const cacheHitRate = denom > 0 ? state.tokens.cacheRead / denom : 0;
29
+ // Build tool distribution from Map to Record
30
+ const toolDistribution = {};
31
+ for (const [tool, stats] of state.tools.distribution) {
32
+ toolDistribution[tool] = {
33
+ calls: stats.calls,
34
+ errors: stats.errors,
35
+ avgDuration: stats.calls > 0 ? stats.totalDuration / stats.calls : 0,
36
+ maxDuration: stats.maxDuration,
37
+ };
38
+ }
39
+ // Build model token distribution from Map to Record
40
+ const modelTokenDistribution = {};
41
+ for (const [modelId, tokens] of state.model.tokenDistribution) {
42
+ modelTokenDistribution[modelId] = { ...tokens };
43
+ }
44
+ // Build agent usage from Map to Record
45
+ const agentUsage = {};
46
+ for (const [agent, usage] of state.agent.usage) {
47
+ agentUsage[agent] = usage;
48
+ }
49
+ const totalCompleted = state.tools.completedCalls + state.tools.errorCalls;
50
+ const successRate = totalCompleted > 0 ? state.tools.completedCalls / totalCompleted : 0;
51
+ // Consolidate header with agent/model info
52
+ const header = {
53
+ ...state.sessionMeta,
54
+ sessionId,
55
+ startTime: new Date(state.startTime).toISOString(),
56
+ agent: state.agent.current || state.sessionMeta.agent,
57
+ agentSwitches: state.agent.switches,
58
+ agentUsage,
59
+ model: state.model.current || state.sessionMeta.model,
60
+ modelSwitches: state.model.switches,
61
+ modelTokenDistribution,
62
+ };
63
+ // Aggregate parent step tokens from messageMap (covers message.updated without step-finish)
64
+ const parentStepTokens = createTokenUsage();
65
+ for (const entry of state.messageMap.values()) {
66
+ parentStepTokens.input += entry.tokens.input;
67
+ parentStepTokens.output += entry.tokens.output;
68
+ parentStepTokens.reasoning += entry.tokens.reasoning;
69
+ parentStepTokens.cacheRead += entry.tokens.cacheRead;
70
+ parentStepTokens.cacheWrite += entry.tokens.cacheWrite;
71
+ parentStepTokens.total += entry.tokens.total;
72
+ }
73
+ // Extract content from parent log once (sub-agent events are also written here)
74
+ const allContentMaps = extractStepContent(sessionId, { eventsDir: dirs?.eventsDir });
75
+ // Count parent steps (messages with finish + tokens)
76
+ const parentStepCount = [...state.messageMap.values()].filter(m => m.finish && m.tokens.total > 0).length;
77
+ // Build sub-agents first so we can aggregate their tokens/tools
78
+ const subagents = [];
79
+ let subAgentTotalCalls = 0;
80
+ let subAgentStepCount = 0;
81
+ const subAgentTokens = createTokenUsage();
82
+ log.info("[Metrics] flushMetrics subagents", { sessionId, subAgentsCount: state.subAgents.size, subAgentsKeys: [...state.subAgents.keys()] });
83
+ for (const [childId, sub] of state.subAgents) {
84
+ // Filter content maps to this sub-agent's message IDs
85
+ const subMsgIds = new Set(sub.messageMap.keys());
86
+ const subContentMaps = {
87
+ textMap: filterByKeys(allContentMaps.textMap, subMsgIds),
88
+ reasoningMap: filterByKeys(allContentMaps.reasoningMap, subMsgIds),
89
+ toolOutputMap: allContentMaps.toolOutputMap,
90
+ };
91
+ const steps = buildSteps(sub, subContentMaps);
92
+ // Aggregate sub-agent tokens from messageMap
93
+ const subTokens = createTokenUsage();
94
+ for (const entry of sub.messageMap.values()) {
95
+ subTokens.input += entry.tokens.input;
96
+ subTokens.output += entry.tokens.output;
97
+ subTokens.reasoning += entry.tokens.reasoning;
98
+ subTokens.cacheRead += entry.tokens.cacheRead;
99
+ subTokens.cacheWrite += entry.tokens.cacheWrite;
100
+ subTokens.total += entry.tokens.total;
101
+ }
102
+ // Aggregate sub-agent tool stats + distribution from toolCallMap
103
+ let totalCalls = 0;
104
+ let completedCalls = 0;
105
+ let errorCalls = 0;
106
+ const subDistribution = {};
107
+ let subSlowestCall = { tool: "", duration: 0 };
108
+ for (const tc of sub.toolCallMap.values()) {
109
+ if (tc.status === "completed") {
110
+ totalCalls++;
111
+ completedCalls++;
112
+ }
113
+ else if (tc.status === "error") {
114
+ totalCalls++;
115
+ errorCalls++;
116
+ }
117
+ if (tc.status === "completed" || tc.status === "error") {
118
+ const duration = tc.endMs && tc.startMs ? tc.endMs - tc.startMs : 0;
119
+ const stats = subDistribution[tc.tool] ?? { calls: 0, errors: 0, totalDuration: 0, maxDuration: 0 };
120
+ stats.calls++;
121
+ stats.totalDuration += duration;
122
+ if (duration > stats.maxDuration)
123
+ stats.maxDuration = duration;
124
+ if (tc.status === "error")
125
+ stats.errors++;
126
+ subDistribution[tc.tool] = stats;
127
+ if (duration > subSlowestCall.duration)
128
+ subSlowestCall = { tool: tc.tool, duration };
129
+ }
130
+ }
131
+ const subSuccessRate = totalCalls > 0 ? completedCalls / totalCalls : 0;
132
+ // Convert distribution totalDuration 鈫?avgDuration
133
+ const distribution = {};
134
+ for (const [tool, stats] of Object.entries(subDistribution)) {
135
+ distribution[tool] = {
136
+ calls: stats.calls,
137
+ errors: stats.errors,
138
+ avgDuration: stats.calls > 0 ? stats.totalDuration / stats.calls : 0,
139
+ maxDuration: stats.maxDuration,
140
+ };
141
+ }
142
+ // Accumulate into parent totals
143
+ subAgentTokens.input += subTokens.input;
144
+ subAgentTokens.output += subTokens.output;
145
+ subAgentTokens.reasoning += subTokens.reasoning;
146
+ subAgentTokens.cacheRead += subTokens.cacheRead;
147
+ subAgentTokens.cacheWrite += subTokens.cacheWrite;
148
+ subAgentTokens.total += subTokens.total;
149
+ subAgentTotalCalls += totalCalls;
150
+ subAgentStepCount += steps.length;
151
+ subagents.push({
152
+ sessionId: childId,
153
+ agent: sub.agentName,
154
+ title: sub.title,
155
+ steps,
156
+ tokens: subTokens,
157
+ tools: {
158
+ totalCalls,
159
+ invalidCalls: 0,
160
+ successRate: subSuccessRate,
161
+ distribution,
162
+ slowestCall: subSlowestCall,
163
+ },
164
+ ...(sub._pendingUserMessage.length > 0
165
+ ? { userMessages: [...sub._pendingUserMessage] }
166
+ : {}),
167
+ });
168
+ }
169
+ // Build planning metrics 鈥?summary from last call's todos, history in calls[]
170
+ const planning = (() => {
171
+ const calls = state.planningCalls;
172
+ const rounds = calls.length;
173
+ const empty = {
174
+ planningRounds: 0, totalPlanningDuration: 0, avgPlanningDuration: 0,
175
+ totalTasks: 0, completedTasks: 0, inProgressTasks: 0, pendingTasks: 0,
176
+ completionRate: 0, priorityDistribution: {}, calls: [],
177
+ };
178
+ if (rounds === 0)
179
+ return empty;
180
+ const totalDuration = calls.reduce((sum, c) => sum + c.durationMs, 0);
181
+ const lastTodos = calls[calls.length - 1].todos;
182
+ const totalTasks = lastTodos.length;
183
+ const completedTasks = lastTodos.filter(t => t.status === "completed").length;
184
+ const inProgressTasks = lastTodos.filter(t => t.status === "in_progress").length;
185
+ const pendingTasks = lastTodos.filter(t => t.status === "pending").length;
186
+ const priorityDistribution = {};
187
+ for (const t of lastTodos) {
188
+ priorityDistribution[t.priority] = (priorityDistribution[t.priority] || 0) + 1;
189
+ }
190
+ return {
191
+ planningRounds: rounds,
192
+ totalPlanningDuration: totalDuration,
193
+ avgPlanningDuration: Math.round(totalDuration / rounds),
194
+ totalTasks,
195
+ completedTasks,
196
+ inProgressTasks,
197
+ pendingTasks,
198
+ completionRate: totalTasks > 0 ? completedTasks / totalTasks : 0,
199
+ priorityDistribution,
200
+ calls,
201
+ };
202
+ })();
203
+ const systemPrompts = {};
204
+ for (const [modelId, system] of state.systemPrompts) {
205
+ systemPrompts[modelId] = system;
206
+ }
207
+ const output = {
208
+ sessionId,
209
+ startTime: state.startTime,
210
+ endTime,
211
+ duration,
212
+ systemPrompts,
213
+ rounds: state.rounds,
214
+ tokens: (() => {
215
+ const totalTokens = Math.max(state.tokens.total, parentStepTokens.total) + subAgentTokens.total;
216
+ const totalSteps = parentStepCount + subAgentStepCount;
217
+ return {
218
+ // Use the larger of step-finish tokens or messageMap tokens for parent
219
+ input: Math.max(state.tokens.input, parentStepTokens.input) + subAgentTokens.input,
220
+ output: Math.max(state.tokens.output, parentStepTokens.output) + subAgentTokens.output,
221
+ reasoning: Math.max(state.tokens.reasoning, parentStepTokens.reasoning) + subAgentTokens.reasoning,
222
+ cacheRead: Math.max(state.tokens.cacheRead, parentStepTokens.cacheRead) + subAgentTokens.cacheRead,
223
+ cacheWrite: Math.max(state.tokens.cacheWrite, parentStepTokens.cacheWrite) + subAgentTokens.cacheWrite,
224
+ total: totalTokens,
225
+ cacheHitRate,
226
+ avgTokensPerStep: totalSteps > 0 ? Math.round(totalTokens / totalSteps) : 0,
227
+ };
228
+ })(),
229
+ tools: {
230
+ totalCalls: state.tools.totalCalls + subAgentTotalCalls,
231
+ invalidCalls: state.tools.invalidCalls,
232
+ successRate,
233
+ distribution: toolDistribution,
234
+ slowestCall: state.tools.slowestCall,
235
+ },
236
+ compactions: state.compactions,
237
+ anomaly: state.anomaly,
238
+ stages: state.stages.map(s => ({
239
+ stage: s.stage,
240
+ duration: s.endTime - s.startTime,
241
+ hvigorwCalls: s.hvigorwCalls,
242
+ tokens: { ...s.tokens },
243
+ })),
244
+ header,
245
+ codeStats: {
246
+ etsLines: state.compileStats.etsLines,
247
+ buildSuccess: state.compileStats.lastBuildSuccess,
248
+ fixCompileCount: state.compileStats.hvigorwCalls,
249
+ totalCompileErrors: state.compileStats.hvigorwErrors,
250
+ firstBuildPerRound: state.compileStats.firstBuildPerRound,
251
+ firstBuildPassRate: state.compileStats.firstBuildPerRound.length > 0
252
+ ? state.compileStats.firstBuildPerRound.filter(Boolean).length / state.compileStats.firstBuildPerRound.length
253
+ : 0,
254
+ errorCodes: [...state.compileStats.errorCodes.entries()]
255
+ .map(([code, info]) => ({ code, ...info }))
256
+ .sort((a, b) => b.count - a.count),
257
+ warnings: (() => {
258
+ const entries = [];
259
+ const byType = {};
260
+ let total = 0;
261
+ for (const [type, info] of state.compileStats.warnings) {
262
+ byType[type] = info.count;
263
+ total += info.count;
264
+ for (const entry of info.entries)
265
+ entries.push(entry);
266
+ }
267
+ return { total, byType, entries };
268
+ })(),
269
+ fixCycles: {
270
+ successRate: state.compileStats.fixCycles.length > 0
271
+ ? state.compileStats.fixCycles.filter(c => c.successTime > 0).length / state.compileStats.fixCycles.length
272
+ : 0,
273
+ avgAttempts: state.compileStats.fixCycles.length > 0
274
+ ? state.compileStats.fixCycles.reduce((sum, c) => sum + c.attempts, 0) / state.compileStats.fixCycles.length
275
+ : 0,
276
+ cycles: state.compileStats.fixCycles.map(c => ({
277
+ success: c.successTime > 0,
278
+ attempts: c.attempts,
279
+ codeChanges: c.codeChanges,
280
+ durationMs: c.successTime > 0 ? c.successTime - c.failTime : 0,
281
+ })),
282
+ },
283
+ moduleTimings: [...state.compileStats.moduleTimings.entries()]
284
+ .map(([module, timing]) => ({
285
+ module,
286
+ totalDurationMs: timing.totalDuration,
287
+ taskCount: timing.taskCount,
288
+ slowestTask: timing.slowestTask,
289
+ }))
290
+ .sort((a, b) => b.totalDurationMs - a.totalDurationMs),
291
+ },
292
+ responseLength: [...state.textLengthMap.values()].reduce((sum, len) => sum + len, 0),
293
+ skills: [...state.skillCallMap.values()].map(entry => ({
294
+ skillName: entry.skillName,
295
+ status: entry.status,
296
+ })),
297
+ skillSearches: [...state.skillSearchMap.values()].map(entry => ({
298
+ query: entry.query,
299
+ skillPath: entry.skillPath,
300
+ topK: entry.topK,
301
+ output: entry.output,
302
+ durationMs: entry.endMs && entry.startMs ? entry.endMs - entry.startMs : 0,
303
+ followUpReads: entry.followUpReads.map(r => r.filePath),
304
+ })),
305
+ planning,
306
+ steps: buildSteps(state, {
307
+ textMap: filterByKeys(allContentMaps.textMap, new Set(state.messageMap.keys())),
308
+ reasoningMap: filterByKeys(allContentMaps.reasoningMap, new Set(state.messageMap.keys())),
309
+ toolOutputMap: allContentMaps.toolOutputMap,
310
+ }),
311
+ subagents,
312
+ };
313
+ try {
314
+ const metricsDir = dirs?.metricsDir ?? getMetricsDir();
315
+ fs.mkdirSync(metricsDir, { recursive: true });
316
+ const filePath = path.join(metricsDir, `${sessionId}.json`);
317
+ let merged = { ...output, source: "live" };
318
+ try {
319
+ const existingRaw = fs.readFileSync(filePath, "utf-8");
320
+ const existingOutput = JSON.parse(existingRaw);
321
+ merged = { ...mergeMetricsOutput(existingOutput, output), source: "live" };
322
+ }
323
+ catch {
324
+ // File doesn't exist or is corrupted 鈥?use fresh output
325
+ }
326
+ const tmpPath = filePath + ".tmp";
327
+ fs.writeFileSync(tmpPath, JSON.stringify(merged, null, 2));
328
+ fs.renameSync(tmpPath, filePath);
329
+ // 摘要/详情双表联动入库(force:与文件同拍最新;缺省写全局共享库,显式 summaryFile 可隔离)
330
+ const summaryFile = dirs?.summaryFile ?? getSummaryFile();
331
+ upsertSessionSnapshot(summaryFile, merged, filePath, "force");
332
+ }
333
+ catch (err) {
334
+ log.info("Failed to flush metrics", { sessionId, error: String(err) });
335
+ }
336
+ return output;
337
+ }
338
+ function handleSessionIdle(state, sessionId, dirs) {
339
+ const now = Date.now();
340
+ // Build round snapshot
341
+ const roundDuration = now - state._roundStartTime;
342
+ const firstTokenLatency = state._hasTextPart && state._firstEventTime > 0
343
+ ? state._firstTextTime - state._firstEventTime
344
+ : 0;
345
+ // Skip duplicate session.idle 鈥?round was already created
346
+ if (state._roundIdleProcessed) {
347
+ return;
348
+ }
349
+ const isEmptyRound = state._roundTokens.total === 0 && state._roundToolCalls === 0;
350
+ // Only create round snapshot for non-empty rounds
351
+ if (!isEmptyRound) {
352
+ const round = {
353
+ roundIndex: state.rounds.length,
354
+ duration: roundDuration,
355
+ firstTokenLatency,
356
+ tokens: { ...state._roundTokens },
357
+ toolCalls: state._roundToolCalls,
358
+ errors: state._roundErrors,
359
+ userMessage: state._pendingUserMessage.length > 0 ? [...state._pendingUserMessage] : undefined,
360
+ };
361
+ state.rounds.push(round);
362
+ }
363
+ state._roundIdleProcessed = true;
364
+ // Reset per-round state
365
+ state._roundStartTime = now;
366
+ state._firstEventTime = 0;
367
+ state._firstTextTime = 0;
368
+ state._hasFirstEvent = false;
369
+ state._hasTextPart = false;
370
+ state._roundTokens = createTokenUsage();
371
+ state._roundToolCalls = 0;
372
+ state._roundErrors = 0;
373
+ state._pendingUserMessage = [];
374
+ state._roundFirstBuildTracked = false;
375
+ // Finalize current stage
376
+ if (state.currentStage) {
377
+ state.currentStage.endTime = now;
378
+ state.currentStage.tokens = { ...state._stageTokens };
379
+ if (state.currentStage.stage === "build") {
380
+ state.currentStage.hvigorwCalls = state.compileStats.hvigorwCalls;
381
+ }
382
+ state.stages.push(state.currentStage);
383
+ state.currentStage = null;
384
+ }
385
+ state.lastStageEndTime = now;
386
+ state._stageTokens = createTokenUsage();
387
+ // Flush metrics to disk
388
+ state.endTime = now;
389
+ state._idleFlushed = true;
390
+ return flushMetrics(sessionId, state, now, dirs);
391
+ }
392
+ function mergeChildMetrics(parent, child, meta) {
393
+ // Merge tokens
394
+ parent.tokens.input += child.tokens.input;
395
+ parent.tokens.output += child.tokens.output;
396
+ parent.tokens.reasoning += child.tokens.reasoning;
397
+ parent.tokens.cacheRead += child.tokens.cacheRead;
398
+ parent.tokens.cacheWrite += child.tokens.cacheWrite;
399
+ parent.tokens.total += child.tokens.total;
400
+ // Merge tools
401
+ parent.tools.totalCalls += child.tools.totalCalls;
402
+ parent.tools.invalidCalls += child.tools.invalidCalls;
403
+ parent.tools.completedCalls += child.tools.completedCalls;
404
+ parent.tools.errorCalls += child.tools.errorCalls;
405
+ if (child.tools.slowestCall.duration > parent.tools.slowestCall.duration) {
406
+ parent.tools.slowestCall = { ...child.tools.slowestCall };
407
+ }
408
+ for (const [tool, stats] of child.tools.distribution) {
409
+ const existing = parent.tools.distribution.get(tool) ?? { calls: 0, errors: 0, totalDuration: 0, maxDuration: 0 };
410
+ existing.calls += stats.calls;
411
+ existing.errors += stats.errors;
412
+ existing.totalDuration += stats.totalDuration;
413
+ if (stats.maxDuration > existing.maxDuration)
414
+ existing.maxDuration = stats.maxDuration;
415
+ parent.tools.distribution.set(tool, existing);
416
+ }
417
+ // Merge compactions
418
+ parent.compactions += child.compactions;
419
+ // Merge anomaly
420
+ if (child.anomaly.triggered) {
421
+ parent.anomaly.triggered = true;
422
+ parent.anomaly.events.push(...child.anomaly.events);
423
+ }
424
+ // Merge rounds (skip empty rounds)
425
+ for (const round of child.rounds) {
426
+ if (round.tokens.total > 0 || round.toolCalls > 0) {
427
+ parent.rounds.push({ ...round, roundIndex: parent.rounds.length });
428
+ }
429
+ }
430
+ // Merge stages (skip stages with no tokens 鈥?these correspond to empty rounds)
431
+ for (const stage of child.stages) {
432
+ if (stage.tokens.total > 0) {
433
+ parent.stages.push({ ...stage });
434
+ }
435
+ }
436
+ // Store child maps in subAgents (not merged into parent)
437
+ parent.subAgents.set(meta.childId, {
438
+ sessionId: meta.childId,
439
+ agentName: meta.agentName,
440
+ title: meta.title,
441
+ messageMap: child.messageMap,
442
+ toolCallMap: child.toolCallMap,
443
+ textLengthMap: child.textLengthMap,
444
+ orderCounter: 0,
445
+ _userMessageIds: new Set(),
446
+ _pendingUserMessage: [],
447
+ });
448
+ // Merge compileStats (additive)
449
+ parent.compileStats.hvigorwCalls += child.compileStats.hvigorwCalls;
450
+ parent.compileStats.hvigorwErrors += child.compileStats.hvigorwErrors;
451
+ parent.compileStats.etsLines += child.compileStats.etsLines;
452
+ // lastBuildSuccess: take child's value if child had any builds
453
+ if (child.compileStats.hvigorwCalls > 0 || child.compileStats.hvigorwErrors > 0) {
454
+ parent.compileStats.lastBuildSuccess = child.compileStats.lastBuildSuccess;
455
+ }
456
+ // Merge firstBuildPerRound
457
+ parent.compileStats.firstBuildPerRound.push(...child.compileStats.firstBuildPerRound);
458
+ // Merge errorCodes
459
+ for (const [code, info] of child.compileStats.errorCodes) {
460
+ const existing = parent.compileStats.errorCodes.get(code);
461
+ if (existing) {
462
+ existing.count += info.count;
463
+ }
464
+ else {
465
+ parent.compileStats.errorCodes.set(code, { ...info });
466
+ }
467
+ }
468
+ // Merge warnings
469
+ for (const [type, info] of child.compileStats.warnings) {
470
+ const existing = parent.compileStats.warnings.get(type);
471
+ if (existing) {
472
+ existing.count += info.count;
473
+ existing.entries.push(...info.entries);
474
+ }
475
+ else {
476
+ parent.compileStats.warnings.set(type, { count: info.count, entries: [...info.entries] });
477
+ }
478
+ }
479
+ // Merge moduleTimings
480
+ for (const [module, timing] of child.compileStats.moduleTimings) {
481
+ const existing = parent.compileStats.moduleTimings.get(module);
482
+ if (existing) {
483
+ existing.totalDuration += timing.totalDuration;
484
+ existing.taskCount += timing.taskCount;
485
+ if (timing.slowestTask.duration > existing.slowestTask.duration) {
486
+ existing.slowestTask = { ...timing.slowestTask };
487
+ }
488
+ }
489
+ else {
490
+ parent.compileStats.moduleTimings.set(module, { ...timing, slowestTask: { ...timing.slowestTask } });
491
+ }
492
+ }
493
+ // Merge fixCycles
494
+ parent.compileStats.fixCycles.push(...child.compileStats.fixCycles.map(c => ({ ...c })));
495
+ // Merge skillCallMap
496
+ for (const [callID, entry] of child.skillCallMap) {
497
+ if (!parent.skillCallMap.has(callID)) {
498
+ parent.skillCallMap.set(callID, { ...entry });
499
+ }
500
+ }
501
+ // Merge skillSearchMap
502
+ for (const [callID, entry] of child.skillSearchMap) {
503
+ if (!parent.skillSearchMap.has(callID)) {
504
+ parent.skillSearchMap.set(callID, {
505
+ ...entry,
506
+ followUpReads: [...entry.followUpReads],
507
+ });
508
+ }
509
+ }
510
+ // Merge htmlPreviewMsgId (take first non-null)
511
+ if (!parent.htmlPreviewMsgId && child.htmlPreviewMsgId) {
512
+ parent.htmlPreviewMsgId = child.htmlPreviewMsgId;
513
+ }
514
+ // Merge planning data
515
+ parent.planningCalls.push(...child.planningCalls);
516
+ }
517
+ export function mergeMetricsOutput(existing, fresh) {
518
+ if (!existing)
519
+ return fresh;
520
+ // startTime: min of both
521
+ const startTime = Math.min(existing.startTime, fresh.startTime);
522
+ // duration: fresh.endTime - min startTime
523
+ const duration = fresh.endTime - startTime;
524
+ // Rounds: keep existing, append fresh rounds with new indices only
525
+ // roundIndex resets on restart, so we can't use it as a dedup key with fresh-wins.
526
+ // Strategy: existing rounds are always kept; fresh rounds are added only if their
527
+ // index doesn't already exist in existing. This preserves old session data.
528
+ const existingRoundIndices = new Set(existing.rounds.map(r => r.roundIndex));
529
+ const rounds = [
530
+ ...existing.rounds,
531
+ ...fresh.rounds.filter(r => !existingRoundIndices.has(r.roundIndex)),
532
+ ].sort((a, b) => a.roundIndex - b.roundIndex);
533
+ // Steps: append with dedup by startTime+endTime (fresh wins on collision)
534
+ const stepSeen = new Set();
535
+ const steps = [];
536
+ for (const s of fresh.steps) {
537
+ const k = `${s.startTime}:${s.endTime}`;
538
+ stepSeen.add(k);
539
+ steps.push(s);
540
+ }
541
+ for (const s of existing.steps) {
542
+ if (!stepSeen.has(`${s.startTime}:${s.endTime}`))
543
+ steps.push(s);
544
+ }
545
+ // Subagents: append with dedup by sessionId (fresh wins)
546
+ const subagentMap = new Map();
547
+ for (const s of existing.subagents)
548
+ subagentMap.set(s.sessionId, s);
549
+ for (const s of fresh.subagents)
550
+ subagentMap.set(s.sessionId, s);
551
+ const subagents = [...subagentMap.values()];
552
+ // Planning: append calls with dedup by callID (existing wins on collision)
553
+ const callSeen = new Set();
554
+ const mergedCalls = [];
555
+ for (const c of existing.planning.calls) {
556
+ if (!callSeen.has(c.callID)) {
557
+ callSeen.add(c.callID);
558
+ mergedCalls.push(c);
559
+ }
560
+ }
561
+ for (const c of fresh.planning.calls) {
562
+ if (!callSeen.has(c.callID)) {
563
+ callSeen.add(c.callID);
564
+ mergedCalls.push(c);
565
+ }
566
+ }
567
+ const planning = {
568
+ ...fresh.planning,
569
+ calls: mergedCalls,
570
+ };
571
+ // Skills: append with dedup by skillName (fresh wins)
572
+ const skillMap = new Map();
573
+ for (const s of existing.skills)
574
+ skillMap.set(s.skillName, s);
575
+ for (const s of fresh.skills)
576
+ skillMap.set(s.skillName, s);
577
+ const skills = [...skillMap.values()];
578
+ // SkillSearches: append with dedup by query+skillPath (fresh wins)
579
+ const searchMap = new Map();
580
+ for (const s of existing.skillSearches)
581
+ searchMap.set(`${s.query}:${s.skillPath}`, s);
582
+ for (const s of fresh.skillSearches)
583
+ searchMap.set(`${s.query}:${s.skillPath}`, s);
584
+ const skillSearches = [...searchMap.values()];
585
+ // codeStats.errorCodes: merge by code, accumulate count, keep existing type/message
586
+ const errorCodeMap = new Map();
587
+ for (const e of existing.codeStats.errorCodes)
588
+ errorCodeMap.set(e.code, { ...e });
589
+ for (const e of fresh.codeStats.errorCodes) {
590
+ const prev = errorCodeMap.get(e.code);
591
+ if (prev) {
592
+ prev.count += e.count;
593
+ // keep existing type/message
594
+ }
595
+ else {
596
+ errorCodeMap.set(e.code, { ...e });
597
+ }
598
+ }
599
+ const errorCodes = [...errorCodeMap.values()];
600
+ // codeStats.warnings: merge by type, accumulate count, append entries
601
+ const warnByType = { ...existing.codeStats.warnings.byType };
602
+ const warnEntries = [...existing.codeStats.warnings.entries];
603
+ let warnTotal = existing.codeStats.warnings.total;
604
+ for (const [type, count] of Object.entries(fresh.codeStats.warnings.byType)) {
605
+ warnByType[type] = (warnByType[type] || 0) + count;
606
+ warnTotal += count;
607
+ }
608
+ warnEntries.push(...fresh.codeStats.warnings.entries);
609
+ const warnings = { total: warnTotal, byType: warnByType, entries: warnEntries };
610
+ // codeStats.moduleTimings: merge by module, accumulate totalDuration/taskCount, max slowestTask
611
+ const moduleMap = new Map();
612
+ for (const m of existing.codeStats.moduleTimings)
613
+ moduleMap.set(m.module, { ...m, slowestTask: { ...m.slowestTask } });
614
+ for (const m of fresh.codeStats.moduleTimings) {
615
+ const prev = moduleMap.get(m.module);
616
+ if (prev) {
617
+ prev.totalDurationMs += m.totalDurationMs;
618
+ prev.taskCount += m.taskCount;
619
+ if (m.slowestTask.duration > prev.slowestTask.duration) {
620
+ prev.slowestTask = { ...m.slowestTask };
621
+ }
622
+ }
623
+ else {
624
+ moduleMap.set(m.module, { ...m, slowestTask: { ...m.slowestTask } });
625
+ }
626
+ }
627
+ const moduleTimings = [...moduleMap.values()];
628
+ // codeStats.fixCycles: append (skip dedup)
629
+ const fixCycles = {
630
+ ...fresh.codeStats.fixCycles,
631
+ cycles: [...existing.codeStats.fixCycles.cycles, ...fresh.codeStats.fixCycles.cycles],
632
+ };
633
+ // codeStats.firstBuildPerRound: fresh accumulates all rounds, use fresh
634
+ const firstBuildPerRound = fresh.codeStats.firstBuildPerRound;
635
+ // systemPrompts: union of keys, fresh values win
636
+ const systemPrompts = { ...existing.systemPrompts };
637
+ for (const [key, val] of Object.entries(fresh.systemPrompts)) {
638
+ systemPrompts[key] = val;
639
+ }
640
+ return {
641
+ sessionId: fresh.sessionId,
642
+ startTime,
643
+ endTime: fresh.endTime,
644
+ duration,
645
+ systemPrompts,
646
+ rounds,
647
+ tokens: fresh.tokens,
648
+ tools: fresh.tools,
649
+ compactions: fresh.compactions,
650
+ anomaly: fresh.anomaly,
651
+ stages: fresh.stages,
652
+ header: fresh.header,
653
+ codeStats: {
654
+ etsLines: fresh.codeStats.etsLines,
655
+ buildSuccess: fresh.codeStats.buildSuccess,
656
+ fixCompileCount: fresh.codeStats.fixCompileCount,
657
+ totalCompileErrors: fresh.codeStats.totalCompileErrors,
658
+ firstBuildPerRound,
659
+ firstBuildPassRate: fresh.codeStats.firstBuildPassRate,
660
+ errorCodes,
661
+ warnings,
662
+ fixCycles,
663
+ moduleTimings,
664
+ },
665
+ responseLength: fresh.responseLength,
666
+ skills,
667
+ skillSearches,
668
+ steps,
669
+ subagents,
670
+ planning,
671
+ };
672
+ }
673
+ export { flushMetrics, handleSessionIdle, mergeChildMetrics };
@@ -0,0 +1,19 @@
1
+ import type { MessageEntry, ToolCallEntry, StepData } from "./metrics-types.js";
2
+ declare function buildSteps(maps: {
3
+ messageMap: Map<string, MessageEntry>;
4
+ toolCallMap: Map<string, ToolCallEntry>;
5
+ textLengthMap: Map<string, number>;
6
+ }, contentMaps?: {
7
+ textMap: Map<string, string>;
8
+ reasoningMap: Map<string, string>;
9
+ toolOutputMap: Map<string, string>;
10
+ }): StepData[];
11
+ declare function extractStepContent(sessionId: string, opts?: {
12
+ eventsDir?: string;
13
+ filterMsgIds?: Set<string>;
14
+ }): {
15
+ textMap: Map<string, string>;
16
+ reasoningMap: Map<string, string>;
17
+ toolOutputMap: Map<string, string>;
18
+ };
19
+ export { buildSteps, extractStepContent };