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.
- package/.oesignore +17 -0
- package/CHANGELOG.md +95 -0
- package/LICENSE +21 -0
- package/README.md +235 -0
- package/oes.json +11 -0
- package/package.json +100 -0
- package/scripts/install-git-hooks.mjs +29 -0
- package/src/pware.oc.core/constants/index.ts +18 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventKind.ts +29 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventName.ts +13 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.eventType.ts +241 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.myWork.ts +55 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.partType.ts +65 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.phase.ts +58 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.pulse.ts +63 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.rowKind.ts +34 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.status.ts +74 -0
- package/src/pware.oc.core/constants/pware.oc.core.constants.toolName.ts +69 -0
- package/src/pware.oc.core/git/index.ts +7 -0
- package/src/pware.oc.core/git/pware.oc.core.git.ts +237 -0
- package/src/pware.oc.core/git/pware.oc.core.gitignore.ts +87 -0
- package/src/pware.oc.core/index.ts +18 -0
- package/src/pware.oc.core/pware.oc.core.bus.ts +42 -0
- package/src/pware.oc.core/pware.oc.core.cache.ts +48 -0
- package/src/pware.oc.core/pware.oc.core.clipboard.ts +97 -0
- package/src/pware.oc.core/pware.oc.core.debug.ts +280 -0
- package/src/pware.oc.core/pware.oc.core.events.ts +79 -0
- package/src/pware.oc.core/pware.oc.core.glyph.ts +135 -0
- package/src/pware.oc.core/pware.oc.core.layout.ts +234 -0
- package/src/pware.oc.core/pware.oc.core.oes.ts +89 -0
- package/src/pware.oc.core/pware.oc.core.paths.ts +240 -0
- package/src/pware.oc.core/pware.oc.core.preview.ts +74 -0
- package/src/pware.oc.core/pware.oc.core.pulse.ts +726 -0
- package/src/pware.oc.core/pware.oc.core.sqlite.ts +175 -0
- package/src/pware.oc.core/pware.oc.core.status.ts +214 -0
- package/src/pware.oc.core/pware.oc.core.timing.ts +63 -0
- package/src/pware.oc.core/pware.oc.core.width.ts +215 -0
- package/src/pware.oc.omo/constants/index.ts +15 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.backgroundTask.ts +18 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.boulderStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.docKind.ts +29 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.eventName.ts +11 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.planStatus.ts +60 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.reviewStatus.ts +49 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.startWork.ts +25 -0
- package/src/pware.oc.omo/constants/pware.oc.omo.constants.verdict.ts +38 -0
- package/src/pware.oc.omo/index.ts +7 -0
- package/src/pware.oc.omo/resolver/index.ts +19 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approval.ts +179 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalGroup.ts +116 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.approvalState.ts +34 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.boulder.ts +503 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.config.ts +40 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.doc.ts +269 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.draftFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.notepadsFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.plan.ts +155 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.planFile.ts +361 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.proofFile.ts +18 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.rulesFile.ts +11 -0
- package/src/pware.oc.omo/resolver/pware.oc.omo.resolver.runContinuationFile.ts +14 -0
- package/src/pware.oc.opencode/constants/index.ts +10 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.eventName.ts +15 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.fileTouch.ts +18 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.questionKind.ts +45 -0
- package/src/pware.oc.opencode/constants/pware.oc.opencode.constants.sessionStatus.ts +58 -0
- package/src/pware.oc.opencode/index.ts +8 -0
- package/src/pware.oc.opencode/pware.oc.opencode.events.ts +182 -0
- package/src/pware.oc.opencode/pware.oc.opencode.files.ts +399 -0
- package/src/pware.oc.opencode/resolver/index.ts +170 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.file.ts +162 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.question.ts +185 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.session.ts +211 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.todo.ts +26 -0
- package/src/pware.oc.opencode/resolver/pware.oc.opencode.resolver.tool.ts +236 -0
- package/src/pware.oc.perf/index.ts +14 -0
- package/src/pware.oc.perf/pware.oc.perf.asciichart.d.ts +15 -0
- package/src/pware.oc.perf/pware.oc.perf.charts.ts +284 -0
- package/src/pware.oc.perf/pware.oc.perf.reader.ts +927 -0
- package/src/pware.oc.perf/pware.oc.perf.realtime.ts +88 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeBlock.ts +118 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeCpuRam.ts +79 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeSampler.ts +71 -0
- package/src/pware.oc.perf/pware.oc.perf.realtimeTimeline.ts +214 -0
- package/src/pware.oc.perf/pware.oc.perf.self.ts +179 -0
- package/src/pware.oc.perf/pware.oc.perf.view.tsx +462 -0
- package/src/pware.oc.runtime/index.ts +13 -0
- package/src/pware.oc.runtime/pware.oc.runtime.monitor.ts +142 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork-enrich.ts +79 -0
- package/src/pware.oc.runtime/pware.oc.runtime.mywork.ts +253 -0
- package/src/pware.oc.runtime/pware.oc.runtime.questions.ts +95 -0
- package/src/pware.oc.runtime/pware.oc.runtime.snapshotClient.ts +129 -0
- package/src/pware.oc.runtime/pware.oc.runtime.source.ts +105 -0
- package/src/pware.oc.runtime/pware.oc.runtime.worker.ts +43 -0
- package/src/pware.oc.runtime/resolver/index.ts +224 -0
- package/src/pware.oc.runtime/resolver/pware.oc.runtime.resolver.delegate.ts +146 -0
- package/src/pware.oc.ui/index.ts +12 -0
- package/src/pware.oc.ui/pware.oc.ui.chrome.tsx +339 -0
- package/src/pware.oc.ui/pware.oc.ui.glyphs.tsx +144 -0
- package/src/pware.oc.ui/pware.oc.ui.host.tsx +111 -0
- package/src/pware.oc.ui/pware.oc.ui.live.tsx +82 -0
- package/src/pware.oc.ui/pware.oc.ui.menudialogs.tsx +843 -0
- package/src/pware.oc.ui/pware.oc.ui.sections.tsx +526 -0
- package/src/pware.oc.ui/pware.oc.ui.sidebar.tsx +1629 -0
- 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
|
+
}
|