codex-session-insights 0.1.0
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/LICENSE +21 -0
- package/README.md +199 -0
- package/bin/codex-insights.js +9 -0
- package/lib/cli.js +1002 -0
- package/lib/codex-data.js +640 -0
- package/lib/llm-insights.js +1486 -0
- package/lib/model-provider.js +589 -0
- package/lib/report.js +1383 -0
- package/lib/types.d.ts +87 -0
- package/package.json +47 -0
|
@@ -0,0 +1,640 @@
|
|
|
1
|
+
import os from 'node:os'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
import crypto from 'node:crypto'
|
|
4
|
+
import { promises as fs } from 'node:fs'
|
|
5
|
+
import { execFile } from 'node:child_process'
|
|
6
|
+
import { promisify } from 'node:util'
|
|
7
|
+
|
|
8
|
+
const execFileAsync = promisify(execFile)
|
|
9
|
+
const MAX_TRANSCRIPT_CHARS = 30000
|
|
10
|
+
const USER_TRANSCRIPT_LIMIT = 500
|
|
11
|
+
const ASSISTANT_TRANSCRIPT_LIMIT = 180
|
|
12
|
+
const FAILURE_SUMMARY_LIMIT = 120
|
|
13
|
+
const TASK_AGENT_TOOLS = new Set([
|
|
14
|
+
'spawn_agent',
|
|
15
|
+
'send_input',
|
|
16
|
+
'wait_agent',
|
|
17
|
+
'resume_agent',
|
|
18
|
+
'close_agent',
|
|
19
|
+
])
|
|
20
|
+
|
|
21
|
+
export function resolveCodexHome(explicitHome) {
|
|
22
|
+
if (explicitHome) return path.resolve(explicitHome)
|
|
23
|
+
if (process.env.CODEX_HOME) return path.resolve(process.env.CODEX_HOME)
|
|
24
|
+
return path.join(os.homedir(), '.codex')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export async function findStateDatabase(codexHome) {
|
|
28
|
+
const entries = await fs.readdir(codexHome, { withFileTypes: true })
|
|
29
|
+
const matches = []
|
|
30
|
+
|
|
31
|
+
for (const entry of entries) {
|
|
32
|
+
if (!entry.isFile()) continue
|
|
33
|
+
if (!/^state_\d+\.sqlite$/.test(entry.name)) continue
|
|
34
|
+
const filePath = path.join(codexHome, entry.name)
|
|
35
|
+
const stat = await fs.stat(filePath)
|
|
36
|
+
matches.push({ filePath, mtimeMs: stat.mtimeMs })
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
matches.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
40
|
+
return matches[0]?.filePath ?? null
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export async function loadThreads({
|
|
44
|
+
codexHome,
|
|
45
|
+
sinceEpochSeconds,
|
|
46
|
+
limit,
|
|
47
|
+
includeArchived,
|
|
48
|
+
includeSubagents,
|
|
49
|
+
}) {
|
|
50
|
+
const dbPath = await findStateDatabase(codexHome)
|
|
51
|
+
if (!dbPath) {
|
|
52
|
+
throw new Error(`No state_*.sqlite database found in ${codexHome}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const where = []
|
|
56
|
+
if (!includeArchived) where.push('archived = 0')
|
|
57
|
+
if (!includeSubagents) where.push("(agent_role is null or agent_role = '')")
|
|
58
|
+
if (sinceEpochSeconds) where.push(`updated_at >= ${Number(sinceEpochSeconds)}`)
|
|
59
|
+
|
|
60
|
+
const sql = [
|
|
61
|
+
'select',
|
|
62
|
+
'id, rollout_path, created_at, updated_at, source, model_provider, cwd,',
|
|
63
|
+
'title, tokens_used, archived, git_branch, cli_version,',
|
|
64
|
+
'first_user_message, model, reasoning_effort, agent_role, agent_nickname, agent_path',
|
|
65
|
+
'from threads',
|
|
66
|
+
where.length ? `where ${where.join(' and ')}` : '',
|
|
67
|
+
'order by updated_at desc',
|
|
68
|
+
limit ? `limit ${Number(limit)}` : '',
|
|
69
|
+
';',
|
|
70
|
+
]
|
|
71
|
+
.filter(Boolean)
|
|
72
|
+
.join(' ')
|
|
73
|
+
|
|
74
|
+
const { stdout } = await execFileAsync('sqlite3', ['-json', dbPath, sql], {
|
|
75
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
76
|
+
})
|
|
77
|
+
|
|
78
|
+
const rows = stdout.trim() ? JSON.parse(stdout) : []
|
|
79
|
+
return rows.map(row => ({
|
|
80
|
+
id: String(row.id),
|
|
81
|
+
rolloutPath: String(row.rollout_path),
|
|
82
|
+
createdAt: Number(row.created_at),
|
|
83
|
+
updatedAt: Number(row.updated_at),
|
|
84
|
+
source: String(row.source ?? ''),
|
|
85
|
+
modelProvider: String(row.model_provider ?? ''),
|
|
86
|
+
cwd: String(row.cwd ?? ''),
|
|
87
|
+
title: String(row.title ?? ''),
|
|
88
|
+
tokensUsed: Number(row.tokens_used ?? 0),
|
|
89
|
+
archived: Number(row.archived ?? 0) === 1,
|
|
90
|
+
gitBranch: row.git_branch ? String(row.git_branch) : '',
|
|
91
|
+
cliVersion: row.cli_version ? String(row.cli_version) : '',
|
|
92
|
+
firstUserMessage: row.first_user_message ? String(row.first_user_message) : '',
|
|
93
|
+
model: row.model ? String(row.model) : '',
|
|
94
|
+
reasoningEffort: row.reasoning_effort ? String(row.reasoning_effort) : '',
|
|
95
|
+
agentRole: row.agent_role ? String(row.agent_role) : '',
|
|
96
|
+
agentNickname: row.agent_nickname ? String(row.agent_nickname) : '',
|
|
97
|
+
agentPath: row.agent_path ? String(row.agent_path) : '',
|
|
98
|
+
}))
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export async function loadRolloutEvents(rolloutPath) {
|
|
102
|
+
const raw = await fs.readFile(rolloutPath, 'utf8')
|
|
103
|
+
const lines = raw.split('\n')
|
|
104
|
+
const events = []
|
|
105
|
+
|
|
106
|
+
for (const line of lines) {
|
|
107
|
+
if (!line.trim()) continue
|
|
108
|
+
try {
|
|
109
|
+
events.push(JSON.parse(line))
|
|
110
|
+
} catch {
|
|
111
|
+
// Ignore malformed lines and keep processing the rest of the rollout.
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return events
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export function filterSubstantiveThreads(threadSummaries) {
|
|
119
|
+
return [...threadSummaries]
|
|
120
|
+
.filter(thread => thread.userMessages >= 2)
|
|
121
|
+
.filter(thread => thread.durationMinutes >= 1)
|
|
122
|
+
.filter(thread => Boolean(String(thread.transcriptForAnalysis || '').trim()))
|
|
123
|
+
.sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
function extractAssistantText(messagePayload) {
|
|
127
|
+
if (!messagePayload || !Array.isArray(messagePayload.content)) return ''
|
|
128
|
+
return messagePayload.content
|
|
129
|
+
.map(part => {
|
|
130
|
+
if (typeof part?.text === 'string') return part.text
|
|
131
|
+
if (typeof part?.content === 'string') return part.content
|
|
132
|
+
return ''
|
|
133
|
+
})
|
|
134
|
+
.filter(Boolean)
|
|
135
|
+
.join('\n')
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function extractTokenSnapshot(payload) {
|
|
139
|
+
const total = payload?.info?.total_token_usage
|
|
140
|
+
if (!total) return null
|
|
141
|
+
return {
|
|
142
|
+
inputTokens: Number(total.input_tokens ?? 0),
|
|
143
|
+
cachedInputTokens: Number(total.cached_input_tokens ?? 0),
|
|
144
|
+
outputTokens: Number(total.output_tokens ?? 0),
|
|
145
|
+
reasoningOutputTokens: Number(total.reasoning_output_tokens ?? 0),
|
|
146
|
+
totalTokens: Number(total.total_tokens ?? 0),
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function summarizeThread(thread, events) {
|
|
151
|
+
const toolCounts = {}
|
|
152
|
+
const commandKindCounts = {}
|
|
153
|
+
const toolFailures = {}
|
|
154
|
+
const toolErrorCategories = {}
|
|
155
|
+
const userMessageTimestamps = []
|
|
156
|
+
const responseTimesSeconds = []
|
|
157
|
+
const commandFailures = []
|
|
158
|
+
const commandDurationsMs = []
|
|
159
|
+
const commandSamples = []
|
|
160
|
+
const transcriptLines = []
|
|
161
|
+
const filesModified = new Set()
|
|
162
|
+
let userMessages = 0
|
|
163
|
+
let assistantMessages = 0
|
|
164
|
+
let reasoningItems = 0
|
|
165
|
+
let commentaryMessages = 0
|
|
166
|
+
let finalMessages = 0
|
|
167
|
+
let userInterruptions = 0
|
|
168
|
+
let toolErrors = 0
|
|
169
|
+
let usesTaskAgent = false
|
|
170
|
+
let usesMcp = false
|
|
171
|
+
let usesWebSearch = false
|
|
172
|
+
let usesWebFetch = false
|
|
173
|
+
let gitCommits = 0
|
|
174
|
+
let gitPushes = 0
|
|
175
|
+
let linesAdded = 0
|
|
176
|
+
let linesRemoved = 0
|
|
177
|
+
let lastAssistantTimestampMs = null
|
|
178
|
+
let latestTokenSnapshot = null
|
|
179
|
+
let activeToolRun = false
|
|
180
|
+
let lastTranscriptLine = ''
|
|
181
|
+
|
|
182
|
+
for (const event of events) {
|
|
183
|
+
const ts = Date.parse(event.timestamp)
|
|
184
|
+
|
|
185
|
+
if (event.type === 'event_msg' && event.payload?.type === 'user_message') {
|
|
186
|
+
userMessages += 1
|
|
187
|
+
appendTranscriptLine(
|
|
188
|
+
transcriptLines,
|
|
189
|
+
`[User] ${sanitizeTranscriptText(event.payload.message, USER_TRANSCRIPT_LIMIT)}`,
|
|
190
|
+
value => {
|
|
191
|
+
lastTranscriptLine = value
|
|
192
|
+
},
|
|
193
|
+
lastTranscriptLine,
|
|
194
|
+
)
|
|
195
|
+
if (Number.isFinite(ts)) {
|
|
196
|
+
userMessageTimestamps.push(ts)
|
|
197
|
+
const gapSeconds =
|
|
198
|
+
lastAssistantTimestampMs === null ? null : (ts - lastAssistantTimestampMs) / 1000
|
|
199
|
+
if (gapSeconds !== null && gapSeconds > 2 && gapSeconds < 3600) {
|
|
200
|
+
responseTimesSeconds.push(gapSeconds)
|
|
201
|
+
}
|
|
202
|
+
if (activeToolRun && gapSeconds !== null && gapSeconds < 180) {
|
|
203
|
+
userInterruptions += 1
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
activeToolRun = false
|
|
207
|
+
continue
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
if (event.type === 'event_msg' && event.payload?.type === 'token_count') {
|
|
211
|
+
const snapshot = extractTokenSnapshot(event.payload)
|
|
212
|
+
if (snapshot) latestTokenSnapshot = snapshot
|
|
213
|
+
continue
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
if (event.type === 'event_msg' && event.payload?.type === 'exec_command_end') {
|
|
217
|
+
const payload = event.payload
|
|
218
|
+
const parsed = Array.isArray(payload.parsed_cmd) ? payload.parsed_cmd[0] : null
|
|
219
|
+
const key =
|
|
220
|
+
typeof parsed?.type === 'string'
|
|
221
|
+
? parsed.type
|
|
222
|
+
: Array.isArray(payload.command)
|
|
223
|
+
? payload.command[2] ?? payload.command[0]
|
|
224
|
+
: 'exec_command'
|
|
225
|
+
const commandText = Array.isArray(payload.command) ? payload.command.join(' ') : ''
|
|
226
|
+
commandKindCounts[key] = (commandKindCounts[key] || 0) + 1
|
|
227
|
+
if (/\bgit\s+commit\b/.test(commandText)) gitCommits += 1
|
|
228
|
+
if (/\bgit\s+push\b/.test(commandText)) gitPushes += 1
|
|
229
|
+
if (Number(payload.exit_code ?? 0) !== 0) {
|
|
230
|
+
toolFailures[key] = (toolFailures[key] || 0) + 1
|
|
231
|
+
toolErrorCategories[key] = (toolErrorCategories[key] || 0) + 1
|
|
232
|
+
toolErrors += 1
|
|
233
|
+
const failureSummary =
|
|
234
|
+
sanitizeTranscriptText(payload.aggregated_output, FAILURE_SUMMARY_LIMIT) ||
|
|
235
|
+
sanitizeTranscriptText(commandText, FAILURE_SUMMARY_LIMIT)
|
|
236
|
+
commandFailures.push({
|
|
237
|
+
command: commandText,
|
|
238
|
+
exitCode: Number(payload.exit_code ?? 0),
|
|
239
|
+
output: failureSummary,
|
|
240
|
+
})
|
|
241
|
+
}
|
|
242
|
+
const duration = payload.duration
|
|
243
|
+
if (duration) {
|
|
244
|
+
const ms = Number(duration.secs ?? 0) * 1000 + Number(duration.nanos ?? 0) / 1e6
|
|
245
|
+
if (ms > 0) commandDurationsMs.push(ms)
|
|
246
|
+
}
|
|
247
|
+
if (commandSamples.length < 8) {
|
|
248
|
+
commandSamples.push({
|
|
249
|
+
command: commandText,
|
|
250
|
+
status: String(payload.status ?? ''),
|
|
251
|
+
exitCode: Number(payload.exit_code ?? 0),
|
|
252
|
+
})
|
|
253
|
+
}
|
|
254
|
+
if (Number(payload.exit_code ?? 0) !== 0) {
|
|
255
|
+
appendTranscriptLine(
|
|
256
|
+
transcriptLines,
|
|
257
|
+
`[Command failed: ${key}] exit=${Number(payload.exit_code ?? 0)}`,
|
|
258
|
+
value => {
|
|
259
|
+
lastTranscriptLine = value
|
|
260
|
+
},
|
|
261
|
+
lastTranscriptLine,
|
|
262
|
+
)
|
|
263
|
+
}
|
|
264
|
+
activeToolRun = true
|
|
265
|
+
continue
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
if (event.type !== 'response_item') continue
|
|
269
|
+
const payload = event.payload
|
|
270
|
+
|
|
271
|
+
if (payload?.type === 'function_call') {
|
|
272
|
+
const name = String(payload.name ?? 'function_call')
|
|
273
|
+
toolCounts[name] = (toolCounts[name] || 0) + 1
|
|
274
|
+
markToolUsage(name, {
|
|
275
|
+
usesTaskAgent: value => {
|
|
276
|
+
if (value) usesTaskAgent = true
|
|
277
|
+
},
|
|
278
|
+
usesMcp: value => {
|
|
279
|
+
if (value) usesMcp = true
|
|
280
|
+
},
|
|
281
|
+
usesWebSearch: value => {
|
|
282
|
+
if (value) usesWebSearch = true
|
|
283
|
+
},
|
|
284
|
+
usesWebFetch: value => {
|
|
285
|
+
if (value) usesWebFetch = true
|
|
286
|
+
},
|
|
287
|
+
})
|
|
288
|
+
appendTranscriptLine(
|
|
289
|
+
transcriptLines,
|
|
290
|
+
`[Tool: ${name}]`,
|
|
291
|
+
value => {
|
|
292
|
+
lastTranscriptLine = value
|
|
293
|
+
},
|
|
294
|
+
lastTranscriptLine,
|
|
295
|
+
)
|
|
296
|
+
activeToolRun = true
|
|
297
|
+
|
|
298
|
+
if (name === 'apply_patch') {
|
|
299
|
+
const patchStats = parseApplyPatchStats(payload.arguments)
|
|
300
|
+
linesAdded += patchStats.linesAdded
|
|
301
|
+
linesRemoved += patchStats.linesRemoved
|
|
302
|
+
for (const filePath of patchStats.filesModified) {
|
|
303
|
+
filesModified.add(filePath)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
continue
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
if (payload?.type === 'custom_tool_call') {
|
|
310
|
+
const name = String(payload.name ?? 'custom_tool_call')
|
|
311
|
+
toolCounts[name] = (toolCounts[name] || 0) + 1
|
|
312
|
+
if (payload.status && payload.status !== 'completed') {
|
|
313
|
+
toolFailures[name] = (toolFailures[name] || 0) + 1
|
|
314
|
+
toolErrorCategories[name] = (toolErrorCategories[name] || 0) + 1
|
|
315
|
+
toolErrors += 1
|
|
316
|
+
}
|
|
317
|
+
if (name === 'apply_patch') {
|
|
318
|
+
const patchStats = parseApplyPatchStats(payload.input)
|
|
319
|
+
linesAdded += patchStats.linesAdded
|
|
320
|
+
linesRemoved += patchStats.linesRemoved
|
|
321
|
+
for (const filePath of patchStats.filesModified) {
|
|
322
|
+
filesModified.add(filePath)
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
appendTranscriptLine(
|
|
326
|
+
transcriptLines,
|
|
327
|
+
`[Tool: ${name}]`,
|
|
328
|
+
value => {
|
|
329
|
+
lastTranscriptLine = value
|
|
330
|
+
},
|
|
331
|
+
lastTranscriptLine,
|
|
332
|
+
)
|
|
333
|
+
if (payload.status && payload.status !== 'completed') {
|
|
334
|
+
appendTranscriptLine(
|
|
335
|
+
transcriptLines,
|
|
336
|
+
`[Tool failed: ${name}] status=${sanitizeTranscriptText(payload.status, 40)}`,
|
|
337
|
+
value => {
|
|
338
|
+
lastTranscriptLine = value
|
|
339
|
+
},
|
|
340
|
+
lastTranscriptLine,
|
|
341
|
+
)
|
|
342
|
+
}
|
|
343
|
+
activeToolRun = true
|
|
344
|
+
continue
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
if (payload?.type === 'function_call_output') {
|
|
348
|
+
activeToolRun = true
|
|
349
|
+
continue
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
if (payload?.type === 'reasoning') {
|
|
353
|
+
reasoningItems += 1
|
|
354
|
+
continue
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
if (payload?.type === 'message' && payload.role === 'assistant') {
|
|
358
|
+
assistantMessages += 1
|
|
359
|
+
const phase = String(payload.phase ?? 'assistant')
|
|
360
|
+
if (phase === 'commentary') commentaryMessages += 1
|
|
361
|
+
if (phase === 'final_answer') finalMessages += 1
|
|
362
|
+
if (Number.isFinite(ts)) {
|
|
363
|
+
lastAssistantTimestampMs = ts
|
|
364
|
+
}
|
|
365
|
+
const text = extractAssistantText(payload)
|
|
366
|
+
if (text) {
|
|
367
|
+
appendTranscriptLine(
|
|
368
|
+
transcriptLines,
|
|
369
|
+
`[Assistant] ${sanitizeTranscriptText(text, ASSISTANT_TRANSCRIPT_LIMIT)}`,
|
|
370
|
+
value => {
|
|
371
|
+
lastTranscriptLine = value
|
|
372
|
+
},
|
|
373
|
+
lastTranscriptLine,
|
|
374
|
+
)
|
|
375
|
+
}
|
|
376
|
+
activeToolRun = phase !== 'final_answer'
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
const startedAtMs = events.length ? Date.parse(events[0].timestamp) : thread.createdAt * 1000
|
|
381
|
+
const endedAtMs = events.length
|
|
382
|
+
? Date.parse(events[events.length - 1].timestamp)
|
|
383
|
+
: thread.updatedAt * 1000
|
|
384
|
+
const durationMinutes = Math.max(
|
|
385
|
+
0,
|
|
386
|
+
Math.round(((endedAtMs - startedAtMs) / 1000 / 60) * 10) / 10,
|
|
387
|
+
)
|
|
388
|
+
|
|
389
|
+
const sortedUserTs = [...userMessageTimestamps].sort((a, b) => a - b)
|
|
390
|
+
const activeHours = sortedUserTs.map(tsMs => new Date(tsMs).getHours())
|
|
391
|
+
|
|
392
|
+
return {
|
|
393
|
+
id: thread.id,
|
|
394
|
+
title: thread.title,
|
|
395
|
+
firstUserMessage: sanitizeTranscriptText(thread.firstUserMessage, 1200),
|
|
396
|
+
cwd: thread.cwd,
|
|
397
|
+
model: thread.model,
|
|
398
|
+
modelProvider: thread.modelProvider,
|
|
399
|
+
createdAt: new Date(thread.createdAt * 1000).toISOString(),
|
|
400
|
+
updatedAt: new Date(thread.updatedAt * 1000).toISOString(),
|
|
401
|
+
durationMinutes,
|
|
402
|
+
userMessages,
|
|
403
|
+
assistantMessages,
|
|
404
|
+
commentaryMessages,
|
|
405
|
+
finalMessages,
|
|
406
|
+
reasoningItems,
|
|
407
|
+
toolCounts,
|
|
408
|
+
commandKindCounts,
|
|
409
|
+
toolFailures,
|
|
410
|
+
totalToolCalls: Object.values(toolCounts).reduce((sum, count) => sum + count, 0),
|
|
411
|
+
totalCommandFailures: commandFailures.length,
|
|
412
|
+
commandFailures: commandFailures.slice(0, 8),
|
|
413
|
+
commandSamples,
|
|
414
|
+
averageCommandDurationMs: average(commandDurationsMs),
|
|
415
|
+
medianResponseTimeSeconds: median(responseTimesSeconds),
|
|
416
|
+
averageResponseTimeSeconds: average(responseTimesSeconds),
|
|
417
|
+
activeHours,
|
|
418
|
+
userMessageTimestamps: sortedUserTs.map(tsMs => new Date(tsMs).toISOString()),
|
|
419
|
+
transcriptForAnalysis: clampTranscript(transcriptLines.join('\n')),
|
|
420
|
+
gitCommits,
|
|
421
|
+
gitPushes,
|
|
422
|
+
userInterruptions,
|
|
423
|
+
toolErrors,
|
|
424
|
+
toolErrorCategories,
|
|
425
|
+
usesTaskAgent,
|
|
426
|
+
usesMcp,
|
|
427
|
+
usesWebSearch,
|
|
428
|
+
usesWebFetch,
|
|
429
|
+
linesAdded,
|
|
430
|
+
linesRemoved,
|
|
431
|
+
filesModified: filesModified.size,
|
|
432
|
+
tokenUsage: latestTokenSnapshot ?? {
|
|
433
|
+
inputTokens: 0,
|
|
434
|
+
cachedInputTokens: 0,
|
|
435
|
+
outputTokens: 0,
|
|
436
|
+
reasoningOutputTokens: 0,
|
|
437
|
+
totalTokens: thread.tokensUsed,
|
|
438
|
+
},
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export async function collectThreadSummaries(options) {
|
|
443
|
+
const threads = await loadThreads(options)
|
|
444
|
+
const cacheDir = resolveSessionMetaCacheDir(options.cacheDir)
|
|
445
|
+
await fs.mkdir(cacheDir, { recursive: true })
|
|
446
|
+
|
|
447
|
+
const summaries = []
|
|
448
|
+
for (const thread of threads) {
|
|
449
|
+
const summary = await loadCachedOrFreshSummary(thread, cacheDir)
|
|
450
|
+
summaries.push(summary)
|
|
451
|
+
}
|
|
452
|
+
return summaries
|
|
453
|
+
}
|
|
454
|
+
|
|
455
|
+
async function loadCachedOrFreshSummary(thread, cacheDir) {
|
|
456
|
+
const cachePath = path.join(cacheDir, `${thread.id}.json`)
|
|
457
|
+
const versionKey = hashObject({
|
|
458
|
+
id: thread.id,
|
|
459
|
+
rolloutPath: thread.rolloutPath,
|
|
460
|
+
updatedAt: thread.updatedAt,
|
|
461
|
+
tokensUsed: thread.tokensUsed,
|
|
462
|
+
model: thread.model,
|
|
463
|
+
firstUserMessage: thread.firstUserMessage,
|
|
464
|
+
})
|
|
465
|
+
const cached = await readJson(cachePath)
|
|
466
|
+
if (cached?.versionKey === versionKey && cached?.summary) {
|
|
467
|
+
return cached.summary
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
let summary
|
|
471
|
+
try {
|
|
472
|
+
const events = await loadRolloutEvents(thread.rolloutPath)
|
|
473
|
+
summary = summarizeThread(thread, events)
|
|
474
|
+
} catch (error) {
|
|
475
|
+
summary = buildFallbackSummary(thread, error)
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
await fs.writeFile(cachePath, JSON.stringify({ versionKey, summary }, null, 2), 'utf8')
|
|
479
|
+
return summary
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
function buildFallbackSummary(thread, error) {
|
|
483
|
+
return {
|
|
484
|
+
id: thread.id,
|
|
485
|
+
title: thread.title,
|
|
486
|
+
firstUserMessage: sanitizeTranscriptText(thread.firstUserMessage, 1200),
|
|
487
|
+
cwd: thread.cwd,
|
|
488
|
+
model: thread.model,
|
|
489
|
+
modelProvider: thread.modelProvider,
|
|
490
|
+
createdAt: new Date(thread.createdAt * 1000).toISOString(),
|
|
491
|
+
updatedAt: new Date(thread.updatedAt * 1000).toISOString(),
|
|
492
|
+
durationMinutes: 0,
|
|
493
|
+
userMessages: 0,
|
|
494
|
+
assistantMessages: 0,
|
|
495
|
+
commentaryMessages: 0,
|
|
496
|
+
finalMessages: 0,
|
|
497
|
+
reasoningItems: 0,
|
|
498
|
+
toolCounts: {},
|
|
499
|
+
commandKindCounts: {},
|
|
500
|
+
toolFailures: {},
|
|
501
|
+
totalToolCalls: 0,
|
|
502
|
+
totalCommandFailures: 0,
|
|
503
|
+
commandFailures: [
|
|
504
|
+
{
|
|
505
|
+
command: 'rollout_read',
|
|
506
|
+
exitCode: 1,
|
|
507
|
+
output: error instanceof Error ? error.message : String(error),
|
|
508
|
+
},
|
|
509
|
+
],
|
|
510
|
+
commandSamples: [],
|
|
511
|
+
averageCommandDurationMs: 0,
|
|
512
|
+
medianResponseTimeSeconds: 0,
|
|
513
|
+
averageResponseTimeSeconds: 0,
|
|
514
|
+
activeHours: [],
|
|
515
|
+
userMessageTimestamps: [],
|
|
516
|
+
transcriptForAnalysis: '',
|
|
517
|
+
gitCommits: 0,
|
|
518
|
+
gitPushes: 0,
|
|
519
|
+
userInterruptions: 0,
|
|
520
|
+
toolErrors: 1,
|
|
521
|
+
toolErrorCategories: { rollout_read: 1 },
|
|
522
|
+
usesTaskAgent: false,
|
|
523
|
+
usesMcp: false,
|
|
524
|
+
usesWebSearch: false,
|
|
525
|
+
usesWebFetch: false,
|
|
526
|
+
linesAdded: 0,
|
|
527
|
+
linesRemoved: 0,
|
|
528
|
+
filesModified: 0,
|
|
529
|
+
tokenUsage: {
|
|
530
|
+
inputTokens: 0,
|
|
531
|
+
cachedInputTokens: 0,
|
|
532
|
+
outputTokens: 0,
|
|
533
|
+
reasoningOutputTokens: 0,
|
|
534
|
+
totalTokens: thread.tokensUsed,
|
|
535
|
+
},
|
|
536
|
+
}
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function parseApplyPatchStats(rawPatch) {
|
|
540
|
+
const filesModified = new Set()
|
|
541
|
+
let linesAdded = 0
|
|
542
|
+
let linesRemoved = 0
|
|
543
|
+
|
|
544
|
+
const patch = String(rawPatch ?? '')
|
|
545
|
+
for (const line of patch.split('\n')) {
|
|
546
|
+
if (
|
|
547
|
+
line.startsWith('*** Update File: ') ||
|
|
548
|
+
line.startsWith('*** Add File: ') ||
|
|
549
|
+
line.startsWith('*** Delete File: ')
|
|
550
|
+
) {
|
|
551
|
+
const filePath = line.replace(/^\*\*\* (?:Update|Add|Delete) File: /, '').trim()
|
|
552
|
+
if (filePath) filesModified.add(filePath)
|
|
553
|
+
continue
|
|
554
|
+
}
|
|
555
|
+
if (line.startsWith('+') && !line.startsWith('+++')) {
|
|
556
|
+
linesAdded += 1
|
|
557
|
+
continue
|
|
558
|
+
}
|
|
559
|
+
if (line.startsWith('-') && !line.startsWith('---')) {
|
|
560
|
+
linesRemoved += 1
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
return { filesModified, linesAdded, linesRemoved }
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function markToolUsage(name, setters) {
|
|
568
|
+
if (TASK_AGENT_TOOLS.has(name)) {
|
|
569
|
+
setters.usesTaskAgent(true)
|
|
570
|
+
}
|
|
571
|
+
if (name.startsWith('mcp__')) {
|
|
572
|
+
setters.usesMcp(true)
|
|
573
|
+
}
|
|
574
|
+
if (name.startsWith('web.')) {
|
|
575
|
+
if (name.includes('search_query') || name.includes('image_query')) {
|
|
576
|
+
setters.usesWebSearch(true)
|
|
577
|
+
}
|
|
578
|
+
if (
|
|
579
|
+
name.includes('open') ||
|
|
580
|
+
name.includes('click') ||
|
|
581
|
+
name.includes('find') ||
|
|
582
|
+
name.includes('screenshot')
|
|
583
|
+
) {
|
|
584
|
+
setters.usesWebFetch(true)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
function clampTranscript(text) {
|
|
590
|
+
const clean = String(text).trim()
|
|
591
|
+
if (clean.length <= MAX_TRANSCRIPT_CHARS) return clean
|
|
592
|
+
return `${clean.slice(0, MAX_TRANSCRIPT_CHARS)}\n[Transcript truncated]`
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
function appendTranscriptLine(lines, line, setLastLine, lastLine) {
|
|
596
|
+
const clean = String(line ?? '').trim()
|
|
597
|
+
if (!clean || clean === lastLine) return
|
|
598
|
+
lines.push(clean)
|
|
599
|
+
setLastLine(clean)
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
function sanitizeTranscriptText(value, limit) {
|
|
603
|
+
const text = String(value ?? '')
|
|
604
|
+
.replace(/<system_instruction>[\s\S]*?<\/system_instruction>/g, ' ')
|
|
605
|
+
.replace(/\s+/g, ' ')
|
|
606
|
+
.trim()
|
|
607
|
+
if (!text) return ''
|
|
608
|
+
if (text.length <= limit) return text
|
|
609
|
+
return `${text.slice(0, limit)}...`
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
function resolveSessionMetaCacheDir(explicitRoot) {
|
|
613
|
+
const root = explicitRoot
|
|
614
|
+
? path.resolve(explicitRoot)
|
|
615
|
+
: path.join(os.homedir(), '.codex-insights-cache')
|
|
616
|
+
return path.join(root, 'session-meta')
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
function hashObject(value) {
|
|
620
|
+
return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function readJson(filePath) {
|
|
624
|
+
try {
|
|
625
|
+
return JSON.parse(await fs.readFile(filePath, 'utf8'))
|
|
626
|
+
} catch {
|
|
627
|
+
return null
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function average(values) {
|
|
632
|
+
if (!values.length) return 0
|
|
633
|
+
return values.reduce((sum, value) => sum + value, 0) / values.length
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
function median(values) {
|
|
637
|
+
if (!values.length) return 0
|
|
638
|
+
const sorted = [...values].sort((a, b) => a - b)
|
|
639
|
+
return sorted[Math.floor(sorted.length / 2)] ?? 0
|
|
640
|
+
}
|