opencode-extended-sidebar 0.2.90

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 (105) hide show
  1. package/.oesignore +17 -0
  2. package/CHANGELOG.md +95 -0
  3. package/LICENSE +21 -0
  4. package/README.md +235 -0
  5. package/oes.json +11 -0
  6. package/package.json +100 -0
  7. package/scripts/install-git-hooks.mjs +29 -0
  8. package/src/pware.oc.core/constants/index.ts +18 -0
  9. package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
  10. package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
  11. package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
  12. package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
  13. package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
  14. package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
  15. package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
  16. package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
  17. package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
  18. package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
  19. package/src/pware.oc.core/git/index.ts +7 -0
  20. package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
  21. package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
  22. package/src/pware.oc.core/index.ts +18 -0
  23. package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
  24. package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
  25. package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
  26. package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
  27. package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
  28. package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
  29. package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
  30. package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
  31. package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
  32. package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
  33. package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
  34. package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
  35. package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
  36. package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
  37. package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
  38. package/src/pware.oc.omo/constants/index.ts +15 -0
  39. package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
  40. package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
  41. package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
  42. package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
  43. package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
  44. package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
  45. package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
  46. package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
  47. package/src/pware.oc.omo/index.ts +7 -0
  48. package/src/pware.oc.omo/resolver/index.ts +19 -0
  49. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
  50. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
  51. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
  52. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
  53. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
  54. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
  55. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
  56. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
  57. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
  58. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
  59. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
  60. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
  61. package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
  62. package/src/pware.oc.opencode/constants/index.ts +10 -0
  63. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
  64. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
  65. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
  66. package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
  67. package/src/pware.oc.opencode/index.ts +8 -0
  68. package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
  69. package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
  70. package/src/pware.oc.opencode/resolver/index.ts +170 -0
  71. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
  72. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
  73. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
  74. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
  75. package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
  76. package/src/pware.oc.perf/index.ts +14 -0
  77. package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
  78. package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
  79. package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
  80. package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
  81. package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
  82. package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
  83. package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
  84. package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
  85. package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
  86. package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
  87. package/src/pware.oc.runtime/index.ts +13 -0
  88. package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
  89. package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
  90. package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
  91. package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
  92. package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
  93. package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
  94. package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
  95. package/src/pware.oc.runtime/resolver/index.ts +224 -0
  96. package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
  97. package/src/pware.oc.ui/index.ts +12 -0
  98. package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
  99. package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
  100. package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
  101. package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
  102. package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
  103. package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
  104. package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
  105. package/src/pware.oc.ui.tsx +98 -0
@@ -0,0 +1,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 } 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
+ }