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.
@@ -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
+ }