opencode-extended-sidebar 0.2.90 → 0.2.91

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.
Files changed (108) hide show
  1. package/.oesignore +17 -17
  2. package/CHANGELOG.md +96 -95
  3. package/LICENSE +21 -21
  4. package/README.md +150 -235
  5. package/oes.json +13 -11
  6. package/package.json +100 -100
  7. package/scripts/install-git-hooks.mjs +29 -29
  8. package/src/pware.oc.core/constants/index.ts +18 -18
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -29
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +15 -13
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -241
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -55
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -65
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -58
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -63
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -34
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -74
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -69
  19. package/src/pware.oc.core/git/index.ts +7 -7
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -237
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -87
  22. package/src/pware.oc.core/index.ts +19 -18
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -42
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -48
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -97
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -280
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -79
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -135
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -234
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +99 -89
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -240
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -74
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -726
  34. package/src/pware.oc.core/pware.oc.core.runtimeConfig.ts +41 -0
  35. package/src/pware.oc.core/pware.oc.core.sqlite.ts +164 -175
  36. package/src/pware.oc.core/pware.oc.core.sqliteGateway.ts +89 -0
  37. package/src/pware.oc.core/pware.oc.core.status.ts +214 -214
  38. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -63
  39. package/src/pware.oc.core/pware.oc.core.width.ts +215 -215
  40. package/src/pware.oc.omo/constants/index.ts +15 -15
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -18
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -49
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -29
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -11
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -60
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -49
  47. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -25
  48. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -38
  49. package/src/pware.oc.omo/index.ts +7 -7
  50. package/src/pware.oc.omo/resolver/index.ts +19 -19
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -179
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -116
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -34
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -503
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -40
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -269
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -18
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -18
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -155
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +363 -361
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -18
  62. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -11
  63. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -14
  64. package/src/pware.oc.opencode/constants/index.ts +10 -10
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -15
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -18
  67. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -45
  68. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -58
  69. package/src/pware.oc.opencode/index.ts +8 -8
  70. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -182
  71. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -399
  72. package/src/pware.oc.opencode/resolver/index.ts +223 -170
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +150 -162
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +149 -185
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +248 -211
  76. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +22 -26
  77. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +224 -236
  78. package/src/pware.oc.perf/index.ts +14 -14
  79. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -15
  80. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -284
  81. package/src/pware.oc.perf/pware.oc.perf.reader.ts +962 -927
  82. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -88
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -118
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -79
  85. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -71
  86. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -214
  87. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -179
  88. package/src/pware.oc.perf/pware.oc.perf.view.tsx +463 -462
  89. package/src/pware.oc.runtime/index.ts +13 -13
  90. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -142
  91. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +87 -79
  92. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +282 -253
  93. package/src/pware.oc.runtime/pware.oc.runtime.omoRead.ts +37 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +83 -95
  95. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +130 -129
  96. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -105
  97. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +44 -43
  98. package/src/pware.oc.runtime/resolver/index.ts +268 -224
  99. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -146
  100. package/src/pware.oc.ui/index.ts +12 -12
  101. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +381 -339
  102. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -144
  103. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -111
  104. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -82
  105. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -843
  106. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +552 -526
  107. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1718 -1629
  108. package/src/pware.oc.ui.tsx +98 -98
@@ -1,927 +1,962 @@
1
- /**
2
- * Turn timing from opencode.db: wait / think / recv / tool split per model.
3
- * Reads through json_extract, so only numbers and statuses leave SQLite —
4
- * message text, reasoning text and tool I/O never enter the process.
5
- */
6
- import fs from "node:fs"
7
- import os from "node:os"
8
- import path from "node:path"
9
- import { dbg, profile } from "../pware.oc.core/pware.oc.core.debug.js"
10
- import { createStampCache } from "../pware.oc.core/pware.oc.core.cache.js"
11
- import { finiteNum } from "../pware.oc.core/pware.oc.core.paths.js"
12
- import { formatDuration, formatWhen, shortToolLabel, toEpochMs } from "../pware.oc.core/pware.oc.core.pulse.js"
13
- import { openReadonlyDb, withDbRead, type SqlDb } from "../pware.oc.core/pware.oc.core.sqlite.js"
14
- import { PART_TYPE_REASONING, PART_TYPE_TEXT, PART_TYPE_TOOL } from "../pware.oc.core/constants/pware.oc.core.constants.partType.js"
15
- import { TOOL_QUESTION } from "../pware.oc.core/constants/pware.oc.core.constants.toolName.js"
16
- import { STATUS_ERROR } from "../pware.oc.core/constants/pware.oc.core.constants.status.js"
17
- import {
18
- PERF_LOG_KIND_MODELS,
19
- PERF_LOG_KIND_TIME,
20
- PERF_PHASE_IDLE,
21
- PERF_PHASE_RECV,
22
- PERF_PHASE_THINK,
23
- PERF_PHASE_TOOL,
24
- PERF_PHASE_WAIT,
25
- type PerfLogKind,
26
- type PerfPhase,
27
- } from "../pware.oc.core/constants/pware.oc.core.constants.phase.js"
28
-
29
- export type { PerfLogKind, PerfPhase }
30
-
31
- export type PhaseSplit = Record<PerfPhase, number>
32
-
33
- export type ModelPerf = {
34
- key: string
35
- model: string
36
- provider: string
37
- turns: number
38
- /** Averages per turn, null when nothing was measurable. */
39
- waitMs: number | null
40
- thinkMs: number | null
41
- recvMs: number | null
42
- turnMs: number | null
43
- tokensPerSec: number | null
44
- toolMs: number
45
- tools: number
46
- errors: number
47
- tokensIn: number
48
- tokensOut: number
49
- tokensReasoning: number
50
- cacheRead: number
51
- cacheWrite: number
52
- cost: number
53
- lastAt: number
54
- }
55
-
56
- export type ToolPerf = {
57
- name: string
58
- count: number
59
- errors: number
60
- totalMs: number
61
- avgMs: number
62
- }
63
-
64
- export type PerfTotals = {
65
- turns: number
66
- errors: number
67
- aborts: number
68
- wallMs: number
69
- activeMs: number
70
- phases: PhaseSplit
71
- tokensIn: number
72
- tokensOut: number
73
- tokensReasoning: number
74
- cacheRead: number
75
- cacheWrite: number
76
- cost: number
77
- /** cacheRead / (input + cacheRead), null when nothing was sent yet. */
78
- cacheHit: number | null
79
- }
80
-
81
- /** One assistant turn, oldest first, for the trend charts. */
82
- export type TrendPoint = {
83
- at: number
84
- waitMs: number | null
85
- tokensPerSec: number | null
86
- }
87
-
88
- export type SessionPerf = {
89
- id: string
90
- title: string
91
- model: string
92
- turns: number
93
- waitMs: number | null
94
- tokensPerSec: number | null
95
- toolShare: number | null
96
- }
97
-
98
- export type PerfSnapshot = {
99
- present: boolean
100
- sessionId: string
101
- totals: PerfTotals
102
- models: ModelPerf[]
103
- tools: ToolPerf[]
104
- trend: TrendPoint[]
105
- history: SessionPerf[]
106
- error: string | null
107
- }
108
-
109
- function emptyPhases(): PhaseSplit {
110
- return { wait: 0, think: 0, recv: 0, tool: 0, idle: 0 }
111
- }
112
-
113
- export function emptyPerf(sessionId: string, error: string | null = null): PerfSnapshot {
114
- return {
115
- present: false,
116
- sessionId,
117
- totals: {
118
- turns: 0,
119
- errors: 0,
120
- aborts: 0,
121
- wallMs: 0,
122
- activeMs: 0,
123
- phases: emptyPhases(),
124
- tokensIn: 0,
125
- tokensOut: 0,
126
- tokensReasoning: 0,
127
- cacheRead: 0,
128
- cacheWrite: 0,
129
- cost: 0,
130
- cacheHit: null,
131
- },
132
- models: [],
133
- tools: [],
134
- trend: [],
135
- history: [],
136
- error,
137
- }
138
- }
139
-
140
- const MSG_SQL = `
141
- SELECT id,
142
- json_extract(data,'$.role') AS role,
143
- json_extract(data,'$.modelID') AS model,
144
- json_extract(data,'$.providerID') AS provider,
145
- json_extract(data,'$.time.created') AS created,
146
- json_extract(data,'$.time.completed') AS completed,
147
- json_extract(data,'$.error.name') AS err,
148
- json_extract(data,'$.tokens.input') AS tin,
149
- json_extract(data,'$.tokens.output') AS tout,
150
- json_extract(data,'$.tokens.reasoning') AS treason,
151
- json_extract(data,'$.tokens.cache.read') AS cread,
152
- json_extract(data,'$.tokens.cache.write') AS cwrite,
153
- json_extract(data,'$.cost') AS cost
154
- FROM message
155
- WHERE session_id = ?
156
- ORDER BY time_created DESC
157
- LIMIT ?`
158
-
159
- const PART_SQL = `
160
- SELECT message_id AS mid,
161
- json_extract(data,'$.type') AS kind,
162
- json_extract(data,'$.time.start') AS pstart,
163
- json_extract(data,'$.time.end') AS pend,
164
- json_extract(data,'$.tool') AS tool,
165
- json_extract(data,'$.state.status') AS status,
166
- json_extract(data,'$.state.time.start') AS tstart,
167
- json_extract(data,'$.state.time.end') AS tend
168
- FROM part
169
- WHERE session_id = ?
170
- ORDER BY time_created DESC
171
- LIMIT ?`
172
-
173
- /** On-click log only — same hint columns as Current tools, never the blob. */
174
- const LOG_PART_SQL = `
175
- SELECT message_id AS mid,
176
- json_extract(data,'$.type') AS kind,
177
- json_extract(data,'$.time.start') AS pstart,
178
- json_extract(data,'$.time.end') AS pend,
179
- json_extract(data,'$.tool') AS tool,
180
- json_extract(data,'$.state.status') AS status,
181
- json_extract(data,'$.state.time.start') AS tstart,
182
- json_extract(data,'$.state.time.end') AS tend,
183
- json_extract(data,'$.state.title') AS title,
184
- json_extract(data,'$.state.input.command') AS command,
185
- json_extract(data,'$.input.command') AS command2,
186
- json_extract(data,'$.state.input.filePath') AS filePath,
187
- json_extract(data,'$.input.filePath') AS filePath2,
188
- json_extract(data,'$.state.input.pattern') AS pattern,
189
- json_extract(data,'$.input.pattern') AS pattern2,
190
- json_extract(data,'$.state.input.description') AS description,
191
- json_extract(data,'$.input.description') AS description2,
192
- json_extract(data,'$.state.input.subagent_type') AS subagent,
193
- json_extract(data,'$.state.input.category') AS category
194
- FROM part
195
- WHERE session_id = ?
196
- ORDER BY time_created DESC
197
- LIMIT ?`
198
-
199
- export type MsgRow = {
200
- id: string
201
- role: string | null
202
- model: string | null
203
- provider: string | null
204
- created: number | null
205
- completed: number | null
206
- err: string | null
207
- tin: number | null
208
- tout: number | null
209
- treason: number | null
210
- cread: number | null
211
- cwrite: number | null
212
- cost: number | null
213
- }
214
-
215
- export type PartRow = {
216
- mid: string
217
- kind: string | null
218
- pstart: number | null
219
- pend: number | null
220
- tool: string | null
221
- status: string | null
222
- tstart: number | null
223
- tend: number | null
224
- /** Log-only hints — absent on the live PART_SQL scan. */
225
- title?: string | null
226
- command?: string | null
227
- filePath?: string | null
228
- pattern?: string | null
229
- description?: string | null
230
- subagent?: string | null
231
- }
232
-
233
- /** Per-message timing collected from its parts. */
234
- type Bucket = {
235
- firstOut: number | null
236
- textStart: number | null
237
- textEnd: number | null
238
- thinkMs: number
239
- toolMs: number
240
- tools: number
241
- toolErrors: number
242
- }
243
-
244
- function num(v: unknown): number {
245
- return finiteNum(v)
246
- }
247
-
248
-
249
- function span(from: number | null, to: number | null): number {
250
- if (from == null || to == null || to < from) return 0
251
- return to - from
252
- }
253
-
254
- function newBucket(): Bucket {
255
- return {
256
- firstOut: null,
257
- textStart: null,
258
- textEnd: null,
259
- thinkMs: 0,
260
- toolMs: 0,
261
- tools: 0,
262
- toolErrors: 0,
263
- }
264
- }
265
-
266
- function avg(total: number, n: number): number | null {
267
- return n > 0 ? Math.round(total / n) : null
268
- }
269
-
270
- /** Tools that pause for a human rather than do work — never tool timing. */
271
- const NON_TOOLS = new Set<string>([TOOL_QUESTION])
272
-
273
- function countsAsTool(tool: string | null | undefined): boolean {
274
- return !NON_TOOLS.has((tool || "").trim().toLowerCase())
275
- }
276
-
277
- /** Drop the provider prefix; keep the tail, which carries the variant. */
278
- function modelLabel(provider: string | null, model: string | null): string {
279
- const m = (model || "").trim()
280
- if (m) return m
281
- return (provider || "model").trim() || "model"
282
- }
283
-
284
- function logDur(ms: number): string {
285
- return formatDuration(ms) || "0ms"
286
- }
287
-
288
- function logStatus(err: string | null | undefined): string {
289
- const raw = (err || "").trim()
290
- if (!raw) return "ok"
291
- return raw.toLowerCase().includes("abort") ? "abort" : "error"
292
- }
293
-
294
- /** Aligned columns for a Perf log. Last cell is not padded. */
295
- export function formatColumns(headers: string[], rows: string[][]): string {
296
- if (headers.length === 0) return ""
297
- const widths = headers.map((h, i) => {
298
- let w = h.length
299
- for (const row of rows) {
300
- const cell = row[i] ?? ""
301
- if (cell.length > w) w = cell.length
302
- }
303
- return w
304
- })
305
- const line = (cells: string[]) =>
306
- cells.map((c, i) => (i >= widths.length - 1 ? c : (c ?? "").padEnd(widths[i]!))).join(" ")
307
- return [line(headers), ...rows.map((r) => line(headers.map((_, i) => r[i] ?? "")))].join("\n")
308
- }
309
-
310
- export type PerfLogRow = {
311
- at: number | null
312
- end?: number | null
313
- phase: string
314
- /** Bare tool id (`bash`, `read`). Empty on wait/think/recv. */
315
- tool?: string
316
- /** Exact call hint (`db.ts`, `bun test …`) or the model name. */
317
- name: string
318
- status: string
319
- ms: number
320
- extra?: string
321
- }
322
-
323
- function hintStr(v: unknown): string | null {
324
- return typeof v === "string" && v.trim() ? v.trim() : null
325
- }
326
-
327
- /** Bare tool + the specific call (file / command / pattern). Never I/O. */
328
- export function toolLogCall(part: PartRow): { tool: string; call: string } {
329
- const tool = hintStr(part.tool) || "tool"
330
- const label = shortToolLabel({
331
- tool,
332
- title: part.title,
333
- command: part.command,
334
- filePath: part.filePath,
335
- pattern: part.pattern,
336
- description: part.description,
337
- subagent: part.subagent,
338
- maxHint: 48,
339
- })
340
- const prefix = `${tool} `
341
- const call = label.startsWith(prefix) ? label.slice(prefix.length) : label === tool ? "—" : label
342
- return { tool, call }
343
- }
344
-
345
- export function perfLogKindLabel(kind: PerfLogKind): string {
346
- return kind === PERF_PHASE_TOOL ? "tools" : kind
347
- }
348
-
349
- export function perfLogFileName(kind: PerfLogKind, generatedAt: number, toolFilter?: string): string {
350
- const stamp = formatWhen(generatedAt).replace(/[: ]/g, "-")
351
- const ms = String(generatedAt % 1000).padStart(3, "0")
352
- const toolPart = toolFilter ? `-${toolFilter.replace(/[^a-z0-9_-]/gi, "_")}` : ""
353
- return `perf-${perfLogKindLabel(kind)}${toolPart}-${stamp}-${ms}.log`
354
- }
355
-
356
- /** One dated event per turn / tool / idle gap. Exported for tests. */
357
- export function collectPerfLogRows(
358
- kind: PerfLogKind,
359
- msgs: MsgRow[],
360
- parts: PartRow[],
361
- ): PerfLogRow[] {
362
- const { byMsg } = collectParts(parts)
363
- const rows: PerfLogRow[] = []
364
- const assistant: Array<{
365
- start: number | null
366
- end: number | null
367
- model: string
368
- status: string
369
- waitMs: number
370
- thinkMs: number
371
- recvMs: number
372
- toolMs: number
373
- tin: number
374
- tout: number
375
- }> = []
376
-
377
- for (const row of msgs) {
378
- if ((row.role || "") !== "assistant") continue
379
- const start = toEpochMs(row.created)
380
- const end = toEpochMs(row.completed)
381
- const b = byMsg.get(String(row.id)) ?? newBucket()
382
- const model = modelLabel(row.provider, row.model)
383
- const status = logStatus(row.err)
384
- const waitMs = span(start, b.firstOut)
385
- const recvMs = span(b.textStart, b.textEnd)
386
- assistant.push({
387
- start,
388
- end,
389
- model,
390
- status,
391
- waitMs,
392
- thinkMs: b.thinkMs,
393
- recvMs,
394
- toolMs: b.toolMs,
395
- tin: num(row.tin),
396
- tout: num(row.tout),
397
- })
398
- if (kind === PERF_PHASE_WAIT || kind === PERF_LOG_KIND_TIME) {
399
- rows.push({ at: start, end, phase: PERF_PHASE_WAIT, name: model, status, ms: waitMs })
400
- }
401
- if ((kind === PERF_PHASE_THINK || kind === PERF_LOG_KIND_TIME) && b.thinkMs > 0) {
402
- rows.push({ at: start, end, phase: PERF_PHASE_THINK, name: model, status, ms: b.thinkMs })
403
- }
404
- if (kind === PERF_PHASE_RECV || kind === PERF_LOG_KIND_TIME) {
405
- rows.push({ at: start, end, phase: PERF_PHASE_RECV, name: model, status, ms: recvMs })
406
- }
407
- if (kind === PERF_LOG_KIND_MODELS) {
408
- rows.push({
409
- at: start,
410
- end,
411
- phase: "turn",
412
- name: model,
413
- status,
414
- ms: span(start, end),
415
- extra: [logDur(waitMs), logDur(b.thinkMs), logDur(recvMs), logDur(b.toolMs), String(num(row.tin)), String(num(row.tout))].join("\t"),
416
- })
417
- }
418
- }
419
-
420
- if (kind === PERF_PHASE_TOOL || kind === PERF_LOG_KIND_TIME) {
421
- for (const part of parts) {
422
- if ((part.kind || "") !== PERF_PHASE_TOOL) continue
423
- if (!countsAsTool(part.tool)) continue
424
- const start = toEpochMs(part.tstart)
425
- const end = toEpochMs(part.tend)
426
- const ms = span(start, end)
427
- const failed = (part.status || "").toLowerCase() === STATUS_ERROR
428
- const { tool, call } = toolLogCall(part)
429
- rows.push({
430
- at: start,
431
- end,
432
- phase: PERF_PHASE_TOOL,
433
- tool,
434
- name: call,
435
- status: failed ? "error" : hintStr(part.status) || "ok",
436
- ms,
437
- })
438
- }
439
- }
440
-
441
- if (kind === PERF_PHASE_IDLE || kind === PERF_LOG_KIND_TIME) {
442
- const ordered = [...assistant].sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
443
- for (let i = 0; i < ordered.length - 1; i += 1) {
444
- const cur = ordered[i]
445
- const next = ordered[i + 1]
446
- if (!cur || !next) continue
447
- const from = cur.end ?? cur.start
448
- const gap = span(from, next.start)
449
- if (gap > 0) {
450
- rows.push({ at: from, end: next.start, phase: PERF_PHASE_IDLE, name: "gap", status: "ok", ms: gap })
451
- }
452
- }
453
- }
454
-
455
- rows.sort((a, b) => (a.at ?? 0) - (b.at ?? 0) || a.phase.localeCompare(b.phase))
456
- return rows
457
- }
458
-
459
- export type PerfLogDoc = {
460
- title: string
461
- fileName: string
462
- text: string
463
- written: string | null
464
- }
465
-
466
- function toolSummary(rows: PerfLogRow[]): { headers: string[]; rows: string[][] } {
467
- const by = new Map<string, { tool: string; call: string; count: number; errors: number; totalMs: number }>()
468
- for (const row of rows) {
469
- if (row.phase !== PERF_PHASE_TOOL) continue
470
- const tool = row.tool || row.name
471
- const call = row.tool ? row.name : "—"
472
- const key = `${tool}\t${call}`
473
- const agg = by.get(key) ?? { tool, call, count: 0, errors: 0, totalMs: 0 }
474
- agg.count += 1
475
- agg.totalMs += row.ms
476
- if (row.status === STATUS_ERROR) agg.errors += 1
477
- by.set(key, agg)
478
- }
479
- const list = [...by.values()]
480
- .map((a) => ({ ...a, avgMs: a.count > 0 ? Math.round(a.totalMs / a.count) : 0 }))
481
- .sort((a, b) => b.totalMs - a.totalMs || b.count - a.count)
482
- return {
483
- headers: ["tool", "call", "count", "errors", "total", "avg"],
484
- rows: list.map((t) => [t.tool, t.call, String(t.count), String(t.errors), logDur(t.totalMs), logDur(t.avgMs)]),
485
- }
486
- }
487
-
488
- /** Build the dated column log. `now` is the generation stamp. */
489
- export function formatPerfLog(
490
- kind: PerfLogKind,
491
- sessionId: string,
492
- now: number,
493
- rows: PerfLogRow[],
494
- titleOverride?: string,
495
- ): string {
496
- const title = titleOverride ?? perfLogKindLabel(kind)
497
- const head = [
498
- `# Perf ${title} log`,
499
- `# generated ${formatWhen(now)}`,
500
- `# session ${sessionId}`,
501
- `# rows ${rows.length}`,
502
- "",
503
- ]
504
- const parts = [...head]
505
- if (kind === PERF_PHASE_TOOL || kind === PERF_LOG_KIND_TIME) {
506
- const sum = toolSummary(rows)
507
- if (sum.rows.length > 0) {
508
- parts.push(formatColumns(sum.headers, sum.rows), "")
509
- }
510
- }
511
- if (rows.length === 0) {
512
- parts.push("(no rows)")
513
- return `${parts.join("\n")}\n`
514
- }
515
- if (kind === PERF_LOG_KIND_MODELS) {
516
- const headers = ["when", "model", "wait", "think", "recv", "tools", "in", "out", "status"]
517
- const body = rows.map((r) => {
518
- const extra = (r.extra ?? "").split("\t")
519
- return [
520
- formatWhen(r.at),
521
- r.name,
522
- extra[0] || "",
523
- extra[1] || "—",
524
- extra[2] || "—",
525
- extra[3] || "—",
526
- extra[4] || "0",
527
- extra[5] || "0",
528
- r.status,
529
- ]
530
- })
531
- parts.push(formatColumns(headers, body))
532
- } else if (kind === PERF_PHASE_TOOL) {
533
- parts.push(
534
- formatColumns(
535
- ["when", "ended", "tool", "call", "status", "duration"],
536
- rows.map((r) => [
537
- formatWhen(r.at),
538
- formatWhen(r.end),
539
- r.tool || r.name,
540
- r.tool ? r.name : "—",
541
- r.status,
542
- logDur(r.ms),
543
- ]),
544
- ),
545
- )
546
- } else if (kind === PERF_LOG_KIND_TIME) {
547
- parts.push(
548
- formatColumns(
549
- ["when", "ended", "phase", "tool", "call", "status", "duration"],
550
- rows.map((r) => [
551
- formatWhen(r.at),
552
- formatWhen(r.end),
553
- r.phase,
554
- r.tool || "—",
555
- r.name,
556
- r.status,
557
- logDur(r.ms),
558
- ]),
559
- ),
560
- )
561
- } else {
562
- parts.push(
563
- formatColumns(
564
- ["when", "ended", "name", "status", "duration"],
565
- rows.map((r) => [formatWhen(r.at), formatWhen(r.end), r.name, r.status, logDur(r.ms)]),
566
- ),
567
- )
568
- }
569
- return `${parts.join("\n")}\n`
570
- }
571
-
572
- export function writePerfLog(text: string, fileName: string, dir?: string): string | null {
573
- try {
574
- const root = dir ?? path.join(os.tmpdir(), "oes-perf")
575
- fs.mkdirSync(root, { recursive: true })
576
- const abs = path.join(root, fileName)
577
- fs.writeFileSync(abs, text, "utf8")
578
- return abs
579
- } catch {
580
- return null
581
- }
582
- }
583
-
584
- export function readPerfLog(opts: {
585
- dbPath: string
586
- sessionId: string
587
- turns: number
588
- kind: PerfLogKind
589
- now: number
590
- logDir?: string
591
- /** When set, filter tool-phase rows to this bare tool name (e.g. "bash"). */
592
- toolFilter?: string
593
- }): PerfLogDoc | null {
594
- if (!opts.dbPath || !fs.existsSync(opts.dbPath)) return null
595
- const load = (): PerfLogDoc | null => {
596
- const db = openReadonlyDb(opts.dbPath)
597
- if (!db) return null
598
- const { msgs, parts } = readRows(db, opts.sessionId, opts.turns, true)
599
- const allRows = collectPerfLogRows(opts.kind, msgs, parts)
600
- const rows = opts.toolFilter
601
- ? allRows.filter((r) => r.tool === opts.toolFilter)
602
- : allRows
603
- const kindLabel = opts.toolFilter
604
- ? `${perfLogKindLabel(opts.kind)} · ${opts.toolFilter}`
605
- : perfLogKindLabel(opts.kind)
606
- const text = formatPerfLog(opts.kind, opts.sessionId, opts.now, rows, kindLabel)
607
- const fileName = perfLogFileName(opts.kind, opts.now, opts.toolFilter)
608
- const written = writePerfLog(text, fileName, opts.logDir)
609
- return { title: `${kindLabel} log`, fileName, text, written }
610
- }
611
- return withDbRead(load, () => null)
612
- }
613
-
614
- function collectParts(rows: PartRow[]): {
615
- byMsg: Map<string, Bucket>
616
- tools: Map<string, ToolPerf>
617
- } {
618
- const byMsg = new Map<string, Bucket>()
619
- const tools = new Map<string, ToolPerf>()
620
- for (const row of rows) {
621
- const kind = row.kind || ""
622
- if (kind !== PART_TYPE_TEXT && kind !== PART_TYPE_REASONING && kind !== PART_TYPE_TOOL) continue
623
- const mid = String(row.mid || "")
624
- if (!mid) continue
625
- const b = byMsg.get(mid) ?? newBucket()
626
-
627
- if (kind === PART_TYPE_TEXT || kind === PART_TYPE_REASONING) {
628
- const start = toEpochMs(row.pstart)
629
- const end = toEpochMs(row.pend)
630
- if (start != null && (b.firstOut == null || start < b.firstOut)) b.firstOut = start
631
- if (kind === PART_TYPE_REASONING) {
632
- b.thinkMs += span(start, end)
633
- } else {
634
- if (start != null && (b.textStart == null || start < b.textStart)) b.textStart = start
635
- if (end != null && (b.textEnd == null || end > b.textEnd)) b.textEnd = end
636
- }
637
- byMsg.set(mid, b)
638
- continue
639
- }
640
-
641
- if (!countsAsTool(row.tool)) continue
642
- const start = toEpochMs(row.tstart)
643
- const end = toEpochMs(row.tend)
644
- const ms = span(start, end)
645
- const failed = (row.status || "").toLowerCase() === STATUS_ERROR
646
- b.tools += 1
647
- b.toolMs += ms
648
- if (failed) b.toolErrors += 1
649
- byMsg.set(mid, b)
650
-
651
- const name = (row.tool || "tool").trim() || "tool"
652
- const agg = tools.get(name) ?? { name, count: 0, errors: 0, totalMs: 0, avgMs: 0 }
653
- agg.count += 1
654
- agg.totalMs += ms
655
- if (failed) agg.errors += 1
656
- tools.set(name, agg)
657
- }
658
- return { byMsg, tools }
659
- }
660
-
661
- type LogPartSql = PartRow & {
662
- command2?: string | null
663
- filePath2?: string | null
664
- pattern2?: string | null
665
- description2?: string | null
666
- category?: string | null
667
- }
668
-
669
- function foldLogPart(row: LogPartSql): PartRow {
670
- return {
671
- mid: row.mid,
672
- kind: row.kind,
673
- pstart: row.pstart,
674
- pend: row.pend,
675
- tool: row.tool,
676
- status: row.status,
677
- tstart: row.tstart,
678
- tend: row.tend,
679
- title: hintStr(row.title),
680
- command: hintStr(row.command) || hintStr(row.command2),
681
- filePath: hintStr(row.filePath) || hintStr(row.filePath2),
682
- pattern: hintStr(row.pattern) || hintStr(row.pattern2),
683
- description: hintStr(row.description) || hintStr(row.description2),
684
- subagent: hintStr(row.subagent) || hintStr(row.category),
685
- }
686
- }
687
-
688
- function readRows(
689
- db: SqlDb,
690
- sessionId: string,
691
- turns: number,
692
- detailed = false,
693
- ): { msgs: MsgRow[]; parts: PartRow[] } {
694
- const partLimit = Math.min(6000, Math.max(200, turns * 12))
695
- const msgs = db.all<MsgRow>(MSG_SQL, sessionId, turns)
696
- const parts = detailed
697
- ? db.all<LogPartSql>(LOG_PART_SQL, sessionId, partLimit).map(foldLogPart)
698
- : db.all<PartRow>(PART_SQL, sessionId, partLimit)
699
- return { msgs, parts }
700
- }
701
-
702
- /** Fold message + part rows into the Perf snapshot. Exported for tests. */
703
- export function aggregate(sessionId: string, msgs: MsgRow[], parts: PartRow[]): PerfSnapshot {
704
- const snap = emptyPerf(sessionId)
705
- snap.present = true
706
- const { byMsg, tools } = collectParts(parts)
707
-
708
- const models = new Map<string, ModelPerf>()
709
- const waitCount = new Map<string, number>()
710
- const thinkCount = new Map<string, number>()
711
- const recvCount = new Map<string, number>()
712
- const turnCount = new Map<string, number>()
713
- const sums = new Map<string, { wait: number; think: number; recv: number; turn: number }>()
714
- const trend: TrendPoint[] = []
715
-
716
- let firstStart = 0
717
- let lastEnd = 0
718
-
719
- for (const row of msgs) {
720
- if ((row.role || "") !== "assistant") continue
721
- const start = toEpochMs(row.created)
722
- const end = toEpochMs(row.completed)
723
- const b = byMsg.get(String(row.id)) ?? newBucket()
724
-
725
- const waitMs = span(start, b.firstOut)
726
- const recvMs = span(b.textStart, b.textEnd)
727
- const turnMs = span(start, end)
728
- const out = num(row.tout)
729
-
730
- snap.totals.turns += 1
731
- if (row.err) {
732
- if (String(row.err).toLowerCase().includes("abort")) snap.totals.aborts += 1
733
- else snap.totals.errors += 1
734
- }
735
- snap.totals.phases.wait += waitMs
736
- snap.totals.phases.think += b.thinkMs
737
- snap.totals.phases.recv += recvMs
738
- snap.totals.phases.tool += b.toolMs
739
- snap.totals.activeMs += turnMs
740
- snap.totals.tokensIn += num(row.tin)
741
- snap.totals.tokensOut += out
742
- snap.totals.tokensReasoning += num(row.treason)
743
- snap.totals.cacheRead += num(row.cread)
744
- snap.totals.cacheWrite += num(row.cwrite)
745
- snap.totals.cost += typeof row.cost === "number" && row.cost > 0 ? row.cost : 0
746
- if (start != null && (firstStart === 0 || start < firstStart)) firstStart = start
747
- const tail = end ?? b.textEnd ?? start
748
- if (tail != null && tail > lastEnd) lastEnd = tail
749
-
750
- const key = `${row.provider || "?"}/${row.model || "?"}`
751
- const m = models.get(key) ?? {
752
- key,
753
- model: modelLabel(row.provider, row.model),
754
- provider: (row.provider || "").trim(),
755
- turns: 0,
756
- waitMs: null,
757
- thinkMs: null,
758
- recvMs: null,
759
- turnMs: null,
760
- tokensPerSec: null,
761
- toolMs: 0,
762
- tools: 0,
763
- errors: 0,
764
- tokensIn: 0,
765
- tokensOut: 0,
766
- tokensReasoning: 0,
767
- cacheRead: 0,
768
- cacheWrite: 0,
769
- cost: 0,
770
- lastAt: 0,
771
- }
772
- m.turns += 1
773
- m.toolMs += b.toolMs
774
- m.tools += b.tools
775
- m.errors += b.toolErrors + (row.err ? 1 : 0)
776
- m.tokensIn += num(row.tin)
777
- m.tokensOut += out
778
- m.tokensReasoning += num(row.treason)
779
- m.cacheRead += num(row.cread)
780
- m.cacheWrite += num(row.cwrite)
781
- m.cost += typeof row.cost === "number" && row.cost > 0 ? row.cost : 0
782
- if (start != null && start > m.lastAt) m.lastAt = start
783
- models.set(key, m)
784
-
785
- const s = sums.get(key) ?? { wait: 0, think: 0, recv: 0, turn: 0 }
786
- if (waitMs > 0) {
787
- s.wait += waitMs
788
- waitCount.set(key, (waitCount.get(key) ?? 0) + 1)
789
- }
790
- if (b.thinkMs > 0) {
791
- s.think += b.thinkMs
792
- thinkCount.set(key, (thinkCount.get(key) ?? 0) + 1)
793
- }
794
- if (recvMs > 0) {
795
- s.recv += recvMs
796
- recvCount.set(key, (recvCount.get(key) ?? 0) + 1)
797
- }
798
- if (turnMs > 0) {
799
- s.turn += turnMs
800
- turnCount.set(key, (turnCount.get(key) ?? 0) + 1)
801
- }
802
- sums.set(key, s)
803
-
804
- trend.push({
805
- at: start ?? 0,
806
- waitMs: waitMs > 0 ? waitMs : null,
807
- tokensPerSec: recvMs > 0 && out > 0 ? (out / recvMs) * 1000 : null,
808
- })
809
- }
810
-
811
- for (const [key, m] of models) {
812
- const s = sums.get(key) ?? { wait: 0, think: 0, recv: 0, turn: 0 }
813
- m.waitMs = avg(s.wait, waitCount.get(key) ?? 0)
814
- m.thinkMs = avg(s.think, thinkCount.get(key) ?? 0)
815
- m.recvMs = avg(s.recv, recvCount.get(key) ?? 0)
816
- m.turnMs = avg(s.turn, turnCount.get(key) ?? 0)
817
- m.tokensPerSec = s.recv > 0 ? (m.tokensOut / s.recv) * 1000 : null
818
- }
819
-
820
- // Tool calls can straddle turn boundaries, so phases are measured against the
821
- // whole window rather than the summed turn durations.
822
- const p = snap.totals.phases
823
- snap.totals.wallMs = span(firstStart, lastEnd)
824
- p.idle = Math.max(0, snap.totals.wallMs - (p.wait + p.think + p.recv + p.tool))
825
- const sent = snap.totals.tokensIn + snap.totals.cacheRead
826
- snap.totals.cacheHit = sent > 0 ? snap.totals.cacheRead / sent : null
827
-
828
- snap.models = [...models.values()].sort((a, b) => b.turns - a.turns || b.lastAt - a.lastAt)
829
- snap.tools = [...tools.values()]
830
- .map((t) => ({ ...t, avgMs: t.count > 0 ? Math.round(t.totalMs / t.count) : 0 }))
831
- .sort((a, b) => b.totalMs - a.totalMs || b.count - a.count)
832
- snap.trend = trend.reverse()
833
- return snap
834
- }
835
-
836
- /** Compact per-session row for the History section. */
837
- function sessionPerf(db: SqlDb, id: string, title: string, turns: number): SessionPerf | null {
838
- const { msgs, parts } = readRows(db, id, turns)
839
- const agg = aggregate(id, msgs, parts)
840
- if (agg.totals.turns === 0) return null
841
- const top = agg.models[0]
842
- const active = agg.totals.activeMs
843
- return {
844
- id,
845
- title,
846
- model: top?.model ?? "",
847
- turns: agg.totals.turns,
848
- waitMs: top?.waitMs ?? null,
849
- tokensPerSec: top?.tokensPerSec ?? null,
850
- toolShare: active > 0 ? agg.totals.phases.tool / active : null,
851
- }
852
- }
853
-
854
- export type PerfOptions = {
855
- dbPath: string
856
- sessionId: string
857
- /** How many recent messages to scan. */
858
- turns: number
859
- /** Recent sessions for the History section, current one excluded by the caller. */
860
- history?: Array<{ id: string; title: string }>
861
- historyTurns?: number
862
- /** Snapshot fingerprint — identical input returns the cached result. */
863
- cacheKey?: string
864
- }
865
-
866
- const perfCache = createStampCache<PerfSnapshot>()
867
- let histKey = ""
868
- let histAt = 0
869
- let histCached: SessionPerf[] = []
870
- const HIST_TTL_MS = 10_000
871
-
872
- export function resetPerfCache(): void {
873
- perfCache.reset()
874
- histKey = ""
875
- histAt = 0
876
- histCached = []
877
- }
878
-
879
- export function readPerfSnapshot(opts: PerfOptions): PerfSnapshot {
880
- const key = opts.cacheKey
881
- ? `${opts.cacheKey}::${opts.turns}::${(opts.history ?? []).map((h) => h.id).join(",")}`
882
- : ""
883
-
884
- const load = (): PerfSnapshot => {
885
- if (!opts.dbPath || !fs.existsSync(opts.dbPath)) {
886
- dbg("perf", "db missing", { dbPath: opts.dbPath, sessionId: opts.sessionId })
887
- return emptyPerf(opts.sessionId, "db missing")
888
- }
889
- const db = openReadonlyDb(opts.dbPath)
890
- if (!db) {
891
- dbg("perf", "sqlite unavailable", { dbPath: opts.dbPath })
892
- return emptyPerf(opts.sessionId, "sqlite unavailable")
893
- }
894
- const { msgs, parts } = readRows(db, opts.sessionId, opts.turns)
895
- dbg("perf", "loaded", { dbPath: opts.dbPath, sessionId: opts.sessionId, msgs: msgs.length, parts: parts.length })
896
- const snap = aggregate(opts.sessionId, msgs, parts)
897
- const hKey = (opts.history ?? []).map((h) => h.id).join(",")
898
- const now = Date.now()
899
- if (hKey === histKey && now - histAt < HIST_TTL_MS) {
900
- snap.history = histCached
901
- return snap
902
- }
903
- const histTurns = Math.max(20, Math.min(opts.historyTurns ?? 60, opts.turns))
904
- for (const h of opts.history ?? []) {
905
- if (h.id === opts.sessionId) continue
906
- try {
907
- const row = sessionPerf(db, h.id, h.title, histTurns)
908
- if (row) snap.history.push(row)
909
- } catch {
910
- // one unreadable session must not sink the tab
911
- }
912
- }
913
- histKey = hKey
914
- histAt = now
915
- histCached = snap.history
916
- return snap
917
- }
918
-
919
- const run = () =>
920
- profile("perf.read", () =>
921
- withDbRead(load, (e) =>
922
- emptyPerf(opts.sessionId, e instanceof Error ? e.message : "perf read failed"),
923
- ),
924
- )
925
- if (!key) return run()
926
- return perfCache.get(key, run)
927
- }
1
+ /**
2
+ * Turn timing from opencode.db: wait / think / recv / tool split per model.
3
+ * Reads through json_extract, so only numbers and statuses leave SQLite —
4
+ * message text, reasoning text and tool I/O never enter the process.
5
+ */
6
+ import fs from "node:fs"
7
+ import os from "node:os"
8
+ import path from "node:path"
9
+ import { dbg, profile, profileAsync } from "../pware.oc.core/pware.oc.core.debug.js"
10
+ import { createStampCache } from "../pware.oc.core/pware.oc.core.cache.js"
11
+ import {
12
+ ReadonlyDbUnavailableError,
13
+ readonlyReadGateway,
14
+ } from "../pware.oc.core/pware.oc.core.sqliteGateway.js"
15
+ import { finiteNum } from "../pware.oc.core/pware.oc.core.paths.js"
16
+ import { formatDuration, formatWhen, shortToolLabel, toEpochMs } from "../pware.oc.core/pware.oc.core.pulse.js"
17
+ import type { SqlDb } from "../pware.oc.core/pware.oc.core.sqlite.js"
18
+ import { PART_TYPE_REASONING, PART_TYPE_TEXT, PART_TYPE_TOOL } from "../pware.oc.core/constants/pware.oc.core.constants.partType.js"
19
+ import { TOOL_QUESTION } from "../pware.oc.core/constants/pware.oc.core.constants.toolName.js"
20
+ import { STATUS_ERROR } from "../pware.oc.core/constants/pware.oc.core.constants.status.js"
21
+ import {
22
+ PERF_LOG_KIND_MODELS,
23
+ PERF_LOG_KIND_TIME,
24
+ PERF_PHASE_IDLE,
25
+ PERF_PHASE_RECV,
26
+ PERF_PHASE_THINK,
27
+ PERF_PHASE_TOOL,
28
+ PERF_PHASE_WAIT,
29
+ type PerfLogKind,
30
+ type PerfPhase,
31
+ } from "../pware.oc.core/constants/pware.oc.core.constants.phase.js"
32
+
33
+ export type { PerfLogKind, PerfPhase }
34
+
35
+ export type PhaseSplit = Record<PerfPhase, number>
36
+
37
+ export type ModelPerf = {
38
+ key: string
39
+ model: string
40
+ provider: string
41
+ turns: number
42
+ /** Averages per turn, null when nothing was measurable. */
43
+ waitMs: number | null
44
+ thinkMs: number | null
45
+ recvMs: number | null
46
+ turnMs: number | null
47
+ tokensPerSec: number | null
48
+ toolMs: number
49
+ tools: number
50
+ errors: number
51
+ tokensIn: number
52
+ tokensOut: number
53
+ tokensReasoning: number
54
+ cacheRead: number
55
+ cacheWrite: number
56
+ cost: number
57
+ lastAt: number
58
+ }
59
+
60
+ export type ToolPerf = {
61
+ name: string
62
+ count: number
63
+ errors: number
64
+ totalMs: number
65
+ avgMs: number
66
+ }
67
+
68
+ export type PerfTotals = {
69
+ turns: number
70
+ errors: number
71
+ aborts: number
72
+ wallMs: number
73
+ activeMs: number
74
+ phases: PhaseSplit
75
+ tokensIn: number
76
+ tokensOut: number
77
+ tokensReasoning: number
78
+ cacheRead: number
79
+ cacheWrite: number
80
+ cost: number
81
+ /** cacheRead / (input + cacheRead), null when nothing was sent yet. */
82
+ cacheHit: number | null
83
+ }
84
+
85
+ /** One assistant turn, oldest first, for the trend charts. */
86
+ export type TrendPoint = {
87
+ at: number
88
+ waitMs: number | null
89
+ tokensPerSec: number | null
90
+ }
91
+
92
+ export type SessionPerf = {
93
+ id: string
94
+ title: string
95
+ model: string
96
+ turns: number
97
+ waitMs: number | null
98
+ tokensPerSec: number | null
99
+ toolShare: number | null
100
+ }
101
+
102
+ export type PerfSnapshot = {
103
+ present: boolean
104
+ sessionId: string
105
+ totals: PerfTotals
106
+ models: ModelPerf[]
107
+ tools: ToolPerf[]
108
+ trend: TrendPoint[]
109
+ history: SessionPerf[]
110
+ error: string | null
111
+ }
112
+
113
+ function emptyPhases(): PhaseSplit {
114
+ return { wait: 0, think: 0, recv: 0, tool: 0, idle: 0 }
115
+ }
116
+
117
+ export function emptyPerf(sessionId: string, error: string | null = null): PerfSnapshot {
118
+ return {
119
+ present: false,
120
+ sessionId,
121
+ totals: {
122
+ turns: 0,
123
+ errors: 0,
124
+ aborts: 0,
125
+ wallMs: 0,
126
+ activeMs: 0,
127
+ phases: emptyPhases(),
128
+ tokensIn: 0,
129
+ tokensOut: 0,
130
+ tokensReasoning: 0,
131
+ cacheRead: 0,
132
+ cacheWrite: 0,
133
+ cost: 0,
134
+ cacheHit: null,
135
+ },
136
+ models: [],
137
+ tools: [],
138
+ trend: [],
139
+ history: [],
140
+ error,
141
+ }
142
+ }
143
+
144
+ const MSG_SQL = `
145
+ SELECT id,
146
+ json_extract(data,'$.role') AS role,
147
+ json_extract(data,'$.modelID') AS model,
148
+ json_extract(data,'$.providerID') AS provider,
149
+ json_extract(data,'$.time.created') AS created,
150
+ json_extract(data,'$.time.completed') AS completed,
151
+ json_extract(data,'$.error.name') AS err,
152
+ json_extract(data,'$.tokens.input') AS tin,
153
+ json_extract(data,'$.tokens.output') AS tout,
154
+ json_extract(data,'$.tokens.reasoning') AS treason,
155
+ json_extract(data,'$.tokens.cache.read') AS cread,
156
+ json_extract(data,'$.tokens.cache.write') AS cwrite,
157
+ json_extract(data,'$.cost') AS cost
158
+ FROM message
159
+ WHERE session_id = ?
160
+ ORDER BY time_created DESC
161
+ LIMIT ?`
162
+
163
+ const PART_SQL = `
164
+ SELECT message_id AS mid,
165
+ json_extract(data,'$.type') AS kind,
166
+ json_extract(data,'$.time.start') AS pstart,
167
+ json_extract(data,'$.time.end') AS pend,
168
+ json_extract(data,'$.tool') AS tool,
169
+ json_extract(data,'$.state.status') AS status,
170
+ json_extract(data,'$.state.time.start') AS tstart,
171
+ json_extract(data,'$.state.time.end') AS tend
172
+ FROM part
173
+ WHERE session_id = ?
174
+ ORDER BY time_created DESC
175
+ LIMIT ?`
176
+
177
+ /** On-click log only — same hint columns as Current tools, never the blob. */
178
+ const LOG_PART_SQL = `
179
+ SELECT message_id AS mid,
180
+ json_extract(data,'$.type') AS kind,
181
+ json_extract(data,'$.time.start') AS pstart,
182
+ json_extract(data,'$.time.end') AS pend,
183
+ json_extract(data,'$.tool') AS tool,
184
+ json_extract(data,'$.state.status') AS status,
185
+ json_extract(data,'$.state.time.start') AS tstart,
186
+ json_extract(data,'$.state.time.end') AS tend,
187
+ json_extract(data,'$.state.title') AS title,
188
+ json_extract(data,'$.state.input.command') AS command,
189
+ json_extract(data,'$.input.command') AS command2,
190
+ json_extract(data,'$.state.input.filePath') AS filePath,
191
+ json_extract(data,'$.input.filePath') AS filePath2,
192
+ json_extract(data,'$.state.input.pattern') AS pattern,
193
+ json_extract(data,'$.input.pattern') AS pattern2,
194
+ json_extract(data,'$.state.input.description') AS description,
195
+ json_extract(data,'$.input.description') AS description2,
196
+ json_extract(data,'$.state.input.subagent_type') AS subagent,
197
+ json_extract(data,'$.state.input.category') AS category
198
+ FROM part
199
+ WHERE session_id = ?
200
+ ORDER BY time_created DESC
201
+ LIMIT ?`
202
+
203
+ export type MsgRow = {
204
+ id: string
205
+ role: string | null
206
+ model: string | null
207
+ provider: string | null
208
+ created: number | null
209
+ completed: number | null
210
+ err: string | null
211
+ tin: number | null
212
+ tout: number | null
213
+ treason: number | null
214
+ cread: number | null
215
+ cwrite: number | null
216
+ cost: number | null
217
+ }
218
+
219
+ export type PartRow = {
220
+ mid: string
221
+ kind: string | null
222
+ pstart: number | null
223
+ pend: number | null
224
+ tool: string | null
225
+ status: string | null
226
+ tstart: number | null
227
+ tend: number | null
228
+ /** Log-only hints — absent on the live PART_SQL scan. */
229
+ title?: string | null
230
+ command?: string | null
231
+ filePath?: string | null
232
+ pattern?: string | null
233
+ description?: string | null
234
+ subagent?: string | null
235
+ }
236
+
237
+ /** Per-message timing collected from its parts. */
238
+ type Bucket = {
239
+ firstOut: number | null
240
+ textStart: number | null
241
+ textEnd: number | null
242
+ thinkMs: number
243
+ toolMs: number
244
+ tools: number
245
+ toolErrors: number
246
+ }
247
+
248
+ function num(v: unknown): number {
249
+ return finiteNum(v)
250
+ }
251
+
252
+
253
+ function span(from: number | null, to: number | null): number {
254
+ if (from == null || to == null || to < from) return 0
255
+ return to - from
256
+ }
257
+
258
+ function newBucket(): Bucket {
259
+ return {
260
+ firstOut: null,
261
+ textStart: null,
262
+ textEnd: null,
263
+ thinkMs: 0,
264
+ toolMs: 0,
265
+ tools: 0,
266
+ toolErrors: 0,
267
+ }
268
+ }
269
+
270
+ function avg(total: number, n: number): number | null {
271
+ return n > 0 ? Math.round(total / n) : null
272
+ }
273
+
274
+ /** Tools that pause for a human rather than do work — never tool timing. */
275
+ const NON_TOOLS = new Set<string>([TOOL_QUESTION])
276
+
277
+ function countsAsTool(tool: string | null | undefined): boolean {
278
+ return !NON_TOOLS.has((tool || "").trim().toLowerCase())
279
+ }
280
+
281
+ /** Drop the provider prefix; keep the tail, which carries the variant. */
282
+ function modelLabel(provider: string | null, model: string | null): string {
283
+ const m = (model || "").trim()
284
+ if (m) return m
285
+ return (provider || "model").trim() || "model"
286
+ }
287
+
288
+ function logDur(ms: number): string {
289
+ return formatDuration(ms) || "0ms"
290
+ }
291
+
292
+ function logStatus(err: string | null | undefined): string {
293
+ const raw = (err || "").trim()
294
+ if (!raw) return "ok"
295
+ return raw.toLowerCase().includes("abort") ? "abort" : "error"
296
+ }
297
+
298
+ /** Aligned columns for a Perf log. Last cell is not padded. */
299
+ export function formatColumns(headers: string[], rows: string[][]): string {
300
+ if (headers.length === 0) return ""
301
+ const widths = headers.map((h, i) => {
302
+ let w = h.length
303
+ for (const row of rows) {
304
+ const cell = row[i] ?? ""
305
+ if (cell.length > w) w = cell.length
306
+ }
307
+ return w
308
+ })
309
+ const line = (cells: string[]) =>
310
+ cells.map((c, i) => (i >= widths.length - 1 ? c : (c ?? "").padEnd(widths[i]!))).join(" ")
311
+ return [line(headers), ...rows.map((r) => line(headers.map((_, i) => r[i] ?? "")))].join("\n")
312
+ }
313
+
314
+ export type PerfLogRow = {
315
+ at: number | null
316
+ end?: number | null
317
+ phase: string
318
+ /** Bare tool id (`bash`, `read`). Empty on wait/think/recv. */
319
+ tool?: string
320
+ /** Exact call hint (`db.ts`, `bun test …`) or the model name. */
321
+ name: string
322
+ status: string
323
+ ms: number
324
+ extra?: string
325
+ }
326
+
327
+ function hintStr(v: unknown): string | null {
328
+ return typeof v === "string" && v.trim() ? v.trim() : null
329
+ }
330
+
331
+ /** Bare tool + the specific call (file / command / pattern). Never I/O. */
332
+ export function toolLogCall(part: PartRow): { tool: string; call: string } {
333
+ const tool = hintStr(part.tool) || "tool"
334
+ const label = shortToolLabel({
335
+ tool,
336
+ title: part.title,
337
+ command: part.command,
338
+ filePath: part.filePath,
339
+ pattern: part.pattern,
340
+ description: part.description,
341
+ subagent: part.subagent,
342
+ maxHint: 48,
343
+ })
344
+ const prefix = `${tool} `
345
+ const call = label.startsWith(prefix) ? label.slice(prefix.length) : label === tool ? "—" : label
346
+ return { tool, call }
347
+ }
348
+
349
+ export function perfLogKindLabel(kind: PerfLogKind): string {
350
+ return kind === PERF_PHASE_TOOL ? "tools" : kind
351
+ }
352
+
353
+ export function perfLogFileName(kind: PerfLogKind, generatedAt: number, toolFilter?: string): string {
354
+ const stamp = formatWhen(generatedAt).replace(/[: ]/g, "-")
355
+ const ms = String(generatedAt % 1000).padStart(3, "0")
356
+ const toolPart = toolFilter ? `-${toolFilter.replace(/[^a-z0-9_-]/gi, "_")}` : ""
357
+ return `perf-${perfLogKindLabel(kind)}${toolPart}-${stamp}-${ms}.log`
358
+ }
359
+
360
+ /** One dated event per turn / tool / idle gap. Exported for tests. */
361
+ export function collectPerfLogRows(
362
+ kind: PerfLogKind,
363
+ msgs: MsgRow[],
364
+ parts: PartRow[],
365
+ ): PerfLogRow[] {
366
+ const { byMsg } = collectParts(parts)
367
+ const rows: PerfLogRow[] = []
368
+ const assistant: Array<{
369
+ start: number | null
370
+ end: number | null
371
+ model: string
372
+ status: string
373
+ waitMs: number
374
+ thinkMs: number
375
+ recvMs: number
376
+ toolMs: number
377
+ tin: number
378
+ tout: number
379
+ }> = []
380
+
381
+ for (const row of msgs) {
382
+ if ((row.role || "") !== "assistant") continue
383
+ const start = toEpochMs(row.created)
384
+ const end = toEpochMs(row.completed)
385
+ const b = byMsg.get(String(row.id)) ?? newBucket()
386
+ const model = modelLabel(row.provider, row.model)
387
+ const status = logStatus(row.err)
388
+ const waitMs = span(start, b.firstOut)
389
+ const recvMs = span(b.textStart, b.textEnd)
390
+ assistant.push({
391
+ start,
392
+ end,
393
+ model,
394
+ status,
395
+ waitMs,
396
+ thinkMs: b.thinkMs,
397
+ recvMs,
398
+ toolMs: b.toolMs,
399
+ tin: num(row.tin),
400
+ tout: num(row.tout),
401
+ })
402
+ if (kind === PERF_PHASE_WAIT || kind === PERF_LOG_KIND_TIME) {
403
+ rows.push({ at: start, end, phase: PERF_PHASE_WAIT, name: model, status, ms: waitMs })
404
+ }
405
+ if ((kind === PERF_PHASE_THINK || kind === PERF_LOG_KIND_TIME) && b.thinkMs > 0) {
406
+ rows.push({ at: start, end, phase: PERF_PHASE_THINK, name: model, status, ms: b.thinkMs })
407
+ }
408
+ if (kind === PERF_PHASE_RECV || kind === PERF_LOG_KIND_TIME) {
409
+ rows.push({ at: start, end, phase: PERF_PHASE_RECV, name: model, status, ms: recvMs })
410
+ }
411
+ if (kind === PERF_LOG_KIND_MODELS) {
412
+ rows.push({
413
+ at: start,
414
+ end,
415
+ phase: "turn",
416
+ name: model,
417
+ status,
418
+ ms: span(start, end),
419
+ extra: [logDur(waitMs), logDur(b.thinkMs), logDur(recvMs), logDur(b.toolMs), String(num(row.tin)), String(num(row.tout))].join("\t"),
420
+ })
421
+ }
422
+ }
423
+
424
+ if (kind === PERF_PHASE_TOOL || kind === PERF_LOG_KIND_TIME) {
425
+ for (const part of parts) {
426
+ if ((part.kind || "") !== PERF_PHASE_TOOL) continue
427
+ if (!countsAsTool(part.tool)) continue
428
+ const start = toEpochMs(part.tstart)
429
+ const end = toEpochMs(part.tend)
430
+ const ms = span(start, end)
431
+ const failed = (part.status || "").toLowerCase() === STATUS_ERROR
432
+ const { tool, call } = toolLogCall(part)
433
+ rows.push({
434
+ at: start,
435
+ end,
436
+ phase: PERF_PHASE_TOOL,
437
+ tool,
438
+ name: call,
439
+ status: failed ? "error" : hintStr(part.status) || "ok",
440
+ ms,
441
+ })
442
+ }
443
+ }
444
+
445
+ if (kind === PERF_PHASE_IDLE || kind === PERF_LOG_KIND_TIME) {
446
+ const ordered = [...assistant].sort((a, b) => (a.start ?? 0) - (b.start ?? 0))
447
+ for (let i = 0; i < ordered.length - 1; i += 1) {
448
+ const cur = ordered[i]
449
+ const next = ordered[i + 1]
450
+ if (!cur || !next) continue
451
+ const from = cur.end ?? cur.start
452
+ const gap = span(from, next.start)
453
+ if (gap > 0) {
454
+ rows.push({ at: from, end: next.start, phase: PERF_PHASE_IDLE, name: "gap", status: "ok", ms: gap })
455
+ }
456
+ }
457
+ }
458
+
459
+ rows.sort((a, b) => (a.at ?? 0) - (b.at ?? 0) || a.phase.localeCompare(b.phase))
460
+ return rows
461
+ }
462
+
463
+ export type PerfLogDoc = {
464
+ title: string
465
+ fileName: string
466
+ text: string
467
+ written: string | null
468
+ }
469
+
470
+ function toolSummary(rows: PerfLogRow[]): { headers: string[]; rows: string[][] } {
471
+ const by = new Map<string, { tool: string; call: string; count: number; errors: number; totalMs: number }>()
472
+ for (const row of rows) {
473
+ if (row.phase !== PERF_PHASE_TOOL) continue
474
+ const tool = row.tool || row.name
475
+ const call = row.tool ? row.name : "—"
476
+ const key = `${tool}\t${call}`
477
+ const agg = by.get(key) ?? { tool, call, count: 0, errors: 0, totalMs: 0 }
478
+ agg.count += 1
479
+ agg.totalMs += row.ms
480
+ if (row.status === STATUS_ERROR) agg.errors += 1
481
+ by.set(key, agg)
482
+ }
483
+ const list = [...by.values()]
484
+ .map((a) => ({ ...a, avgMs: a.count > 0 ? Math.round(a.totalMs / a.count) : 0 }))
485
+ .sort((a, b) => b.totalMs - a.totalMs || b.count - a.count)
486
+ return {
487
+ headers: ["tool", "call", "count", "errors", "total", "avg"],
488
+ rows: list.map((t) => [t.tool, t.call, String(t.count), String(t.errors), logDur(t.totalMs), logDur(t.avgMs)]),
489
+ }
490
+ }
491
+
492
+ /** Build the dated column log. `now` is the generation stamp. */
493
+ export function formatPerfLog(
494
+ kind: PerfLogKind,
495
+ sessionId: string,
496
+ now: number,
497
+ rows: PerfLogRow[],
498
+ titleOverride?: string,
499
+ ): string {
500
+ const title = titleOverride ?? perfLogKindLabel(kind)
501
+ const head = [
502
+ `# Perf ${title} log`,
503
+ `# generated ${formatWhen(now)}`,
504
+ `# session ${sessionId}`,
505
+ `# rows ${rows.length}`,
506
+ "",
507
+ ]
508
+ const parts = [...head]
509
+ if (kind === PERF_PHASE_TOOL || kind === PERF_LOG_KIND_TIME) {
510
+ const sum = toolSummary(rows)
511
+ if (sum.rows.length > 0) {
512
+ parts.push(formatColumns(sum.headers, sum.rows), "")
513
+ }
514
+ }
515
+ if (rows.length === 0) {
516
+ parts.push("(no rows)")
517
+ return `${parts.join("\n")}\n`
518
+ }
519
+ if (kind === PERF_LOG_KIND_MODELS) {
520
+ const headers = ["when", "model", "wait", "think", "recv", "tools", "in", "out", "status"]
521
+ const body = rows.map((r) => {
522
+ const extra = (r.extra ?? "").split("\t")
523
+ return [
524
+ formatWhen(r.at),
525
+ r.name,
526
+ extra[0] || "",
527
+ extra[1] || "",
528
+ extra[2] || "—",
529
+ extra[3] || "—",
530
+ extra[4] || "0",
531
+ extra[5] || "0",
532
+ r.status,
533
+ ]
534
+ })
535
+ parts.push(formatColumns(headers, body))
536
+ } else if (kind === PERF_PHASE_TOOL) {
537
+ parts.push(
538
+ formatColumns(
539
+ ["when", "ended", "tool", "call", "status", "duration"],
540
+ rows.map((r) => [
541
+ formatWhen(r.at),
542
+ formatWhen(r.end),
543
+ r.tool || r.name,
544
+ r.tool ? r.name : "—",
545
+ r.status,
546
+ logDur(r.ms),
547
+ ]),
548
+ ),
549
+ )
550
+ } else if (kind === PERF_LOG_KIND_TIME) {
551
+ parts.push(
552
+ formatColumns(
553
+ ["when", "ended", "phase", "tool", "call", "status", "duration"],
554
+ rows.map((r) => [
555
+ formatWhen(r.at),
556
+ formatWhen(r.end),
557
+ r.phase,
558
+ r.tool || "—",
559
+ r.name,
560
+ r.status,
561
+ logDur(r.ms),
562
+ ]),
563
+ ),
564
+ )
565
+ } else {
566
+ parts.push(
567
+ formatColumns(
568
+ ["when", "ended", "name", "status", "duration"],
569
+ rows.map((r) => [formatWhen(r.at), formatWhen(r.end), r.name, r.status, logDur(r.ms)]),
570
+ ),
571
+ )
572
+ }
573
+ return `${parts.join("\n")}\n`
574
+ }
575
+
576
+ export function writePerfLog(text: string, fileName: string, dir?: string): string | null {
577
+ try {
578
+ const root = dir ?? path.join(os.tmpdir(), "oes-perf")
579
+ fs.mkdirSync(root, { recursive: true })
580
+ const abs = path.join(root, fileName)
581
+ fs.writeFileSync(abs, text, "utf8")
582
+ return abs
583
+ } catch {
584
+ return null
585
+ }
586
+ }
587
+
588
+ export async function readPerfLog(opts: {
589
+ dbPath: string
590
+ sessionId: string
591
+ turns: number
592
+ kind: PerfLogKind
593
+ now: number
594
+ logDir?: string
595
+ /** When set, filter tool-phase rows to this bare tool name (e.g. "bash"). */
596
+ toolFilter?: string
597
+ }): Promise<PerfLogDoc | null> {
598
+ if (!opts.dbPath || !fs.existsSync(opts.dbPath)) return null
599
+ const partLimit = Math.min(6000, Math.max(200, opts.turns * 12))
600
+ const data = await readonlyReadGateway.run<{ readonly msgs: MsgRow[]; readonly parts: PartRow[] } | null>({
601
+ dbPath: opts.dbPath,
602
+ initial: () => ({ msgs: [], parts: [] }),
603
+ stages: [
604
+ (db, state) => state && { ...state, msgs: db.all<MsgRow>(MSG_SQL, opts.sessionId, opts.turns) },
605
+ (db, state) => state && {
606
+ ...state,
607
+ parts: db.all<LogPartSql>(LOG_PART_SQL, opts.sessionId, partLimit).map(foldLogPart),
608
+ },
609
+ ],
610
+ fallback: () => null,
611
+ })
612
+ if (!data) return null
613
+ const allRows = collectPerfLogRows(opts.kind, data.msgs, data.parts)
614
+ const rows = opts.toolFilter ? allRows.filter((row) => row.tool === opts.toolFilter) : allRows
615
+ const kindLabel = opts.toolFilter
616
+ ? `${perfLogKindLabel(opts.kind)} · ${opts.toolFilter}`
617
+ : perfLogKindLabel(opts.kind)
618
+ const text = formatPerfLog(opts.kind, opts.sessionId, opts.now, rows, kindLabel)
619
+ const fileName = perfLogFileName(opts.kind, opts.now, opts.toolFilter)
620
+ const written = writePerfLog(text, fileName, opts.logDir)
621
+ return { title: `${kindLabel} log`, fileName, text, written }
622
+ }
623
+
624
+ function collectParts(rows: PartRow[]): {
625
+ byMsg: Map<string, Bucket>
626
+ tools: Map<string, ToolPerf>
627
+ } {
628
+ const byMsg = new Map<string, Bucket>()
629
+ const tools = new Map<string, ToolPerf>()
630
+ for (const row of rows) {
631
+ const kind = row.kind || ""
632
+ if (kind !== PART_TYPE_TEXT && kind !== PART_TYPE_REASONING && kind !== PART_TYPE_TOOL) continue
633
+ const mid = String(row.mid || "")
634
+ if (!mid) continue
635
+ const b = byMsg.get(mid) ?? newBucket()
636
+
637
+ if (kind === PART_TYPE_TEXT || kind === PART_TYPE_REASONING) {
638
+ const start = toEpochMs(row.pstart)
639
+ const end = toEpochMs(row.pend)
640
+ if (start != null && (b.firstOut == null || start < b.firstOut)) b.firstOut = start
641
+ if (kind === PART_TYPE_REASONING) {
642
+ b.thinkMs += span(start, end)
643
+ } else {
644
+ if (start != null && (b.textStart == null || start < b.textStart)) b.textStart = start
645
+ if (end != null && (b.textEnd == null || end > b.textEnd)) b.textEnd = end
646
+ }
647
+ byMsg.set(mid, b)
648
+ continue
649
+ }
650
+
651
+ if (!countsAsTool(row.tool)) continue
652
+ const start = toEpochMs(row.tstart)
653
+ const end = toEpochMs(row.tend)
654
+ const ms = span(start, end)
655
+ const failed = (row.status || "").toLowerCase() === STATUS_ERROR
656
+ b.tools += 1
657
+ b.toolMs += ms
658
+ if (failed) b.toolErrors += 1
659
+ byMsg.set(mid, b)
660
+
661
+ const name = (row.tool || "tool").trim() || "tool"
662
+ const agg = tools.get(name) ?? { name, count: 0, errors: 0, totalMs: 0, avgMs: 0 }
663
+ agg.count += 1
664
+ agg.totalMs += ms
665
+ if (failed) agg.errors += 1
666
+ tools.set(name, agg)
667
+ }
668
+ return { byMsg, tools }
669
+ }
670
+
671
+ type LogPartSql = PartRow & {
672
+ command2?: string | null
673
+ filePath2?: string | null
674
+ pattern2?: string | null
675
+ description2?: string | null
676
+ category?: string | null
677
+ }
678
+
679
+ function foldLogPart(row: LogPartSql): PartRow {
680
+ return {
681
+ mid: row.mid,
682
+ kind: row.kind,
683
+ pstart: row.pstart,
684
+ pend: row.pend,
685
+ tool: row.tool,
686
+ status: row.status,
687
+ tstart: row.tstart,
688
+ tend: row.tend,
689
+ title: hintStr(row.title),
690
+ command: hintStr(row.command) || hintStr(row.command2),
691
+ filePath: hintStr(row.filePath) || hintStr(row.filePath2),
692
+ pattern: hintStr(row.pattern) || hintStr(row.pattern2),
693
+ description: hintStr(row.description) || hintStr(row.description2),
694
+ subagent: hintStr(row.subagent) || hintStr(row.category),
695
+ }
696
+ }
697
+
698
+ function readRows(
699
+ db: SqlDb,
700
+ sessionId: string,
701
+ turns: number,
702
+ detailed = false,
703
+ ): { msgs: MsgRow[]; parts: PartRow[] } {
704
+ const partLimit = Math.min(6000, Math.max(200, turns * 12))
705
+ const msgs = db.all<MsgRow>(MSG_SQL, sessionId, turns)
706
+ const parts = detailed
707
+ ? db.all<LogPartSql>(LOG_PART_SQL, sessionId, partLimit).map(foldLogPart)
708
+ : db.all<PartRow>(PART_SQL, sessionId, partLimit)
709
+ return { msgs, parts }
710
+ }
711
+
712
+ /** Fold message + part rows into the Perf snapshot. Exported for tests. */
713
+ export function aggregate(sessionId: string, msgs: MsgRow[], parts: PartRow[]): PerfSnapshot {
714
+ const snap = emptyPerf(sessionId)
715
+ snap.present = true
716
+ const { byMsg, tools } = collectParts(parts)
717
+
718
+ const models = new Map<string, ModelPerf>()
719
+ const waitCount = new Map<string, number>()
720
+ const thinkCount = new Map<string, number>()
721
+ const recvCount = new Map<string, number>()
722
+ const turnCount = new Map<string, number>()
723
+ const sums = new Map<string, { wait: number; think: number; recv: number; turn: number }>()
724
+ const trend: TrendPoint[] = []
725
+
726
+ let firstStart = 0
727
+ let lastEnd = 0
728
+
729
+ for (const row of msgs) {
730
+ if ((row.role || "") !== "assistant") continue
731
+ const start = toEpochMs(row.created)
732
+ const end = toEpochMs(row.completed)
733
+ const b = byMsg.get(String(row.id)) ?? newBucket()
734
+
735
+ const waitMs = span(start, b.firstOut)
736
+ const recvMs = span(b.textStart, b.textEnd)
737
+ const turnMs = span(start, end)
738
+ const out = num(row.tout)
739
+
740
+ snap.totals.turns += 1
741
+ if (row.err) {
742
+ if (String(row.err).toLowerCase().includes("abort")) snap.totals.aborts += 1
743
+ else snap.totals.errors += 1
744
+ }
745
+ snap.totals.phases.wait += waitMs
746
+ snap.totals.phases.think += b.thinkMs
747
+ snap.totals.phases.recv += recvMs
748
+ snap.totals.phases.tool += b.toolMs
749
+ snap.totals.activeMs += turnMs
750
+ snap.totals.tokensIn += num(row.tin)
751
+ snap.totals.tokensOut += out
752
+ snap.totals.tokensReasoning += num(row.treason)
753
+ snap.totals.cacheRead += num(row.cread)
754
+ snap.totals.cacheWrite += num(row.cwrite)
755
+ snap.totals.cost += typeof row.cost === "number" && row.cost > 0 ? row.cost : 0
756
+ if (start != null && (firstStart === 0 || start < firstStart)) firstStart = start
757
+ const tail = end ?? b.textEnd ?? start
758
+ if (tail != null && tail > lastEnd) lastEnd = tail
759
+
760
+ const key = `${row.provider || "?"}/${row.model || "?"}`
761
+ const m = models.get(key) ?? {
762
+ key,
763
+ model: modelLabel(row.provider, row.model),
764
+ provider: (row.provider || "").trim(),
765
+ turns: 0,
766
+ waitMs: null,
767
+ thinkMs: null,
768
+ recvMs: null,
769
+ turnMs: null,
770
+ tokensPerSec: null,
771
+ toolMs: 0,
772
+ tools: 0,
773
+ errors: 0,
774
+ tokensIn: 0,
775
+ tokensOut: 0,
776
+ tokensReasoning: 0,
777
+ cacheRead: 0,
778
+ cacheWrite: 0,
779
+ cost: 0,
780
+ lastAt: 0,
781
+ }
782
+ m.turns += 1
783
+ m.toolMs += b.toolMs
784
+ m.tools += b.tools
785
+ m.errors += b.toolErrors + (row.err ? 1 : 0)
786
+ m.tokensIn += num(row.tin)
787
+ m.tokensOut += out
788
+ m.tokensReasoning += num(row.treason)
789
+ m.cacheRead += num(row.cread)
790
+ m.cacheWrite += num(row.cwrite)
791
+ m.cost += typeof row.cost === "number" && row.cost > 0 ? row.cost : 0
792
+ if (start != null && start > m.lastAt) m.lastAt = start
793
+ models.set(key, m)
794
+
795
+ const s = sums.get(key) ?? { wait: 0, think: 0, recv: 0, turn: 0 }
796
+ if (waitMs > 0) {
797
+ s.wait += waitMs
798
+ waitCount.set(key, (waitCount.get(key) ?? 0) + 1)
799
+ }
800
+ if (b.thinkMs > 0) {
801
+ s.think += b.thinkMs
802
+ thinkCount.set(key, (thinkCount.get(key) ?? 0) + 1)
803
+ }
804
+ if (recvMs > 0) {
805
+ s.recv += recvMs
806
+ recvCount.set(key, (recvCount.get(key) ?? 0) + 1)
807
+ }
808
+ if (turnMs > 0) {
809
+ s.turn += turnMs
810
+ turnCount.set(key, (turnCount.get(key) ?? 0) + 1)
811
+ }
812
+ sums.set(key, s)
813
+
814
+ trend.push({
815
+ at: start ?? 0,
816
+ waitMs: waitMs > 0 ? waitMs : null,
817
+ tokensPerSec: recvMs > 0 && out > 0 ? (out / recvMs) * 1000 : null,
818
+ })
819
+ }
820
+
821
+ for (const [key, m] of models) {
822
+ const s = sums.get(key) ?? { wait: 0, think: 0, recv: 0, turn: 0 }
823
+ m.waitMs = avg(s.wait, waitCount.get(key) ?? 0)
824
+ m.thinkMs = avg(s.think, thinkCount.get(key) ?? 0)
825
+ m.recvMs = avg(s.recv, recvCount.get(key) ?? 0)
826
+ m.turnMs = avg(s.turn, turnCount.get(key) ?? 0)
827
+ m.tokensPerSec = s.recv > 0 ? (m.tokensOut / s.recv) * 1000 : null
828
+ }
829
+
830
+ // Tool calls can straddle turn boundaries, so phases are measured against the
831
+ // whole window rather than the summed turn durations.
832
+ const p = snap.totals.phases
833
+ snap.totals.wallMs = span(firstStart, lastEnd)
834
+ p.idle = Math.max(0, snap.totals.wallMs - (p.wait + p.think + p.recv + p.tool))
835
+ const sent = snap.totals.tokensIn + snap.totals.cacheRead
836
+ snap.totals.cacheHit = sent > 0 ? snap.totals.cacheRead / sent : null
837
+
838
+ snap.models = [...models.values()].sort((a, b) => b.turns - a.turns || b.lastAt - a.lastAt)
839
+ snap.tools = [...tools.values()]
840
+ .map((t) => ({ ...t, avgMs: t.count > 0 ? Math.round(t.totalMs / t.count) : 0 }))
841
+ .sort((a, b) => b.totalMs - a.totalMs || b.count - a.count)
842
+ snap.trend = trend.reverse()
843
+ return snap
844
+ }
845
+
846
+ /** Compact per-session row for the History section. */
847
+ function sessionPerf(db: SqlDb, id: string, title: string, turns: number): SessionPerf | null {
848
+ const { msgs, parts } = readRows(db, id, turns)
849
+ const agg = aggregate(id, msgs, parts)
850
+ if (agg.totals.turns === 0) return null
851
+ const top = agg.models[0]
852
+ const active = agg.totals.activeMs
853
+ return {
854
+ id,
855
+ title,
856
+ model: top?.model ?? "",
857
+ turns: agg.totals.turns,
858
+ waitMs: top?.waitMs ?? null,
859
+ tokensPerSec: top?.tokensPerSec ?? null,
860
+ toolShare: active > 0 ? agg.totals.phases.tool / active : null,
861
+ }
862
+ }
863
+
864
+ export type PerfOptions = {
865
+ dbPath: string
866
+ sessionId: string
867
+ /** How many recent messages to scan. */
868
+ turns: number
869
+ /** Recent sessions for the History section, current one excluded by the caller. */
870
+ history?: Array<{ id: string; title: string }>
871
+ historyTurns?: number
872
+ /** Snapshot fingerprint identical input returns the cached result. */
873
+ cacheKey?: string
874
+ }
875
+
876
+ const perfCache = createStampCache<PerfSnapshot>()
877
+ let histKey = ""
878
+ let histAt = 0
879
+ let histCached: SessionPerf[] = []
880
+ const HIST_TTL_MS = 10_000
881
+
882
+ export function resetPerfCache(): void {
883
+ perfCache.reset()
884
+ histKey = ""
885
+ histAt = 0
886
+ histCached = []
887
+ }
888
+
889
+ type PerfReadState = {
890
+ readonly ok: boolean
891
+ readonly msgs: MsgRow[]
892
+ readonly parts: PartRow[]
893
+ readonly snapshot: PerfSnapshot
894
+ }
895
+
896
+ export async function readPerfSnapshot(opts: PerfOptions): Promise<PerfSnapshot> {
897
+ const key = opts.cacheKey
898
+ ? `${opts.cacheKey}::${opts.turns}::${(opts.history ?? []).map((h) => h.id).join(",")}`
899
+ : ""
900
+ const hit = key ? perfCache.peek(key) : undefined
901
+ if (hit) return hit
902
+ if (!opts.dbPath || !fs.existsSync(opts.dbPath)) {
903
+ dbg("perf", "db missing", { dbPath: opts.dbPath, sessionId: opts.sessionId })
904
+ return emptyPerf(opts.sessionId, "db missing")
905
+ }
906
+
907
+ const partLimit = Math.min(6000, Math.max(200, opts.turns * 12))
908
+ const hKey = (opts.history ?? []).map((history) => history.id).join(",")
909
+ const historyHit = hKey === histKey && Date.now() - histAt < HIST_TTL_MS
910
+ const histTurns = Math.max(20, Math.min(opts.historyTurns ?? 60, opts.turns))
911
+ const historyStages = historyHit
912
+ ? []
913
+ : (opts.history ?? [])
914
+ .filter((history) => history.id !== opts.sessionId)
915
+ .map((history) => (db: SqlDb, state: PerfReadState): PerfReadState => {
916
+ const row = sessionPerf(db, history.id, history.title, histTurns)
917
+ if (!row) return state
918
+ return {
919
+ ...state,
920
+ snapshot: { ...state.snapshot, history: [...state.snapshot.history, row] },
921
+ }
922
+ })
923
+ const result = await profileAsync("perf.read", () =>
924
+ readonlyReadGateway.run<PerfReadState>({
925
+ dbPath: opts.dbPath,
926
+ initial: () => ({ ok: true, msgs: [], parts: [], snapshot: emptyPerf(opts.sessionId) }),
927
+ stages: [
928
+ (db, state) => ({ ...state, msgs: db.all<MsgRow>(MSG_SQL, opts.sessionId, opts.turns) }),
929
+ (db, state) => ({ ...state, parts: db.all<PartRow>(PART_SQL, opts.sessionId, partLimit) }),
930
+ (_db, state) => ({ ...state, snapshot: aggregate(opts.sessionId, state.msgs, state.parts) }),
931
+ ...historyStages,
932
+ ],
933
+ fallback: (error) => ({
934
+ ok: false,
935
+ msgs: [],
936
+ parts: [],
937
+ snapshot: emptyPerf(
938
+ opts.sessionId,
939
+ error instanceof ReadonlyDbUnavailableError
940
+ ? "sqlite unavailable"
941
+ : error instanceof Error
942
+ ? error.message
943
+ : "perf read failed",
944
+ ),
945
+ }),
946
+ }),
947
+ )
948
+ if (result.ok && historyHit) result.snapshot.history = histCached
949
+ else if (result.ok) {
950
+ histKey = hKey
951
+ histAt = Date.now()
952
+ histCached = result.snapshot.history
953
+ }
954
+ dbg("perf", "loaded", {
955
+ dbPath: opts.dbPath,
956
+ sessionId: opts.sessionId,
957
+ msgs: result.msgs.length,
958
+ parts: result.parts.length,
959
+ })
960
+ if (key && result.ok) perfCache.set(key, result.snapshot)
961
+ return result.snapshot
962
+ }