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,1486 @@
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 { buildReport } from './report.js'
6
+ import { callStructuredModel, callTextModel } from './model-provider.js'
7
+ import { filterSubstantiveThreads } from './codex-data.js'
8
+
9
+ /**
10
+ * @typedef {Object} InsightRunOptions
11
+ * @property {string=} provider
12
+ * @property {string=} apiKey
13
+ * @property {string=} apiBase
14
+ * @property {string=} codexBin
15
+ * @property {string=} cacheDir
16
+ * @property {string=} facetModel
17
+ * @property {string=} facetEffort
18
+ * @property {string=} fastSectionModel
19
+ * @property {string=} fastSectionEffort
20
+ * @property {string=} insightModel
21
+ * @property {string=} insightEffort
22
+ * @property {number=} facetLimit
23
+ * @property {string=} lang
24
+ * @property {(usage: any) => void=} onUsage
25
+ * @property {(event: any) => void=} onProgress
26
+ * @property {string=} usageStage
27
+ */
28
+
29
+ const DEFAULT_PROVIDER = 'codex-cli'
30
+ const DEFAULT_FACET_MODEL = 'gpt-5.4-mini'
31
+ const DEFAULT_FAST_SECTION_MODEL = 'gpt-5.4-mini'
32
+ const DEFAULT_INSIGHT_MODEL = 'gpt-5.4'
33
+ const DEFAULT_FACET_EFFORT = 'low'
34
+ const DEFAULT_FAST_SECTION_EFFORT = 'low'
35
+ const DEFAULT_INSIGHT_EFFORT = 'high'
36
+ const DEFAULT_FACET_LIMIT = 50
37
+ const MAX_FACET_EXTRACTIONS = 50
38
+ const LONG_TRANSCRIPT_THRESHOLD = 30000
39
+ const TRANSCRIPT_CHUNK_SIZE = 25000
40
+ const FACET_TRANSCRIPT_SUMMARY_DIRECTIVE =
41
+ 'Summarize this coding-session transcript segment into compact bullets (4-8 max). Keep only user goals, assistant outcomes, tool failures, and terminal failures that materially affect results. Do not include raw tool arguments or command output.'
42
+ const MAX_CONTEXT_FACETS = 24
43
+ const MAX_FRICTION_DETAILS = 12
44
+ const MAX_USER_INSTRUCTIONS = 8
45
+ const MAX_RECENT_THREADS = 8
46
+ const SECTION_CONCURRENCY = 3
47
+ const FACET_SCHEMA_VERSION = 2
48
+ const SECTION_SYSTEM_PROMPT =
49
+ 'You are generating a Codex session insights report. Use only the provided evidence. Be concrete, diagnostic, and concise. Use second person. Do not flatter. Do not speculate past the data. If evidence is weak or mixed, be conservative and say less.'
50
+ const AT_A_GLANCE_SYSTEM_PROMPT =
51
+ 'You are writing the At a Glance section for a Codex usage report. Use only the supplied report context and section digest. Be high-signal, specific, and coaching rather than promotional. Use second person. Do not mention exact token or usage numbers. Do not speculate.'
52
+
53
+ const FACET_SCHEMA = {
54
+ type: 'object',
55
+ additionalProperties: false,
56
+ properties: {
57
+ underlying_goal: { type: 'string' },
58
+ goal_categories: {
59
+ type: 'object',
60
+ additionalProperties: { type: 'number' },
61
+ },
62
+ outcome: { type: 'string' },
63
+ user_satisfaction_counts: {
64
+ type: 'object',
65
+ additionalProperties: { type: 'number' },
66
+ },
67
+ assistant_helpfulness: { type: 'string' },
68
+ session_type: { type: 'string' },
69
+ friction_counts: {
70
+ type: 'object',
71
+ additionalProperties: { type: 'number' },
72
+ },
73
+ friction_detail: { type: 'string' },
74
+ primary_success: { type: 'string' },
75
+ brief_summary: { type: 'string' },
76
+ user_instructions: {
77
+ type: 'array',
78
+ items: { type: 'string' },
79
+ },
80
+ },
81
+ required: [
82
+ 'underlying_goal',
83
+ 'goal_categories',
84
+ 'outcome',
85
+ 'user_satisfaction_counts',
86
+ 'assistant_helpfulness',
87
+ 'session_type',
88
+ 'friction_counts',
89
+ 'friction_detail',
90
+ 'primary_success',
91
+ 'brief_summary',
92
+ 'user_instructions',
93
+ ],
94
+ }
95
+
96
+ const PROJECT_AREAS_SCHEMA = {
97
+ type: 'object',
98
+ additionalProperties: false,
99
+ properties: {
100
+ areas: {
101
+ type: 'array',
102
+ items: {
103
+ type: 'object',
104
+ additionalProperties: false,
105
+ properties: {
106
+ name: { type: 'string' },
107
+ session_count: { type: 'number' },
108
+ description: { type: 'string' },
109
+ },
110
+ required: ['name', 'session_count', 'description'],
111
+ },
112
+ },
113
+ },
114
+ required: ['areas'],
115
+ }
116
+
117
+ const INTERACTION_STYLE_SCHEMA = {
118
+ type: 'object',
119
+ additionalProperties: false,
120
+ properties: {
121
+ narrative: { type: 'string' },
122
+ key_pattern: { type: 'string' },
123
+ },
124
+ required: ['narrative', 'key_pattern'],
125
+ }
126
+
127
+ const WHAT_WORKS_SCHEMA = {
128
+ type: 'object',
129
+ additionalProperties: false,
130
+ properties: {
131
+ intro: { type: 'string' },
132
+ impressive_workflows: {
133
+ type: 'array',
134
+ items: {
135
+ type: 'object',
136
+ additionalProperties: false,
137
+ properties: {
138
+ title: { type: 'string' },
139
+ description: { type: 'string' },
140
+ },
141
+ required: ['title', 'description'],
142
+ },
143
+ },
144
+ },
145
+ required: ['intro', 'impressive_workflows'],
146
+ }
147
+
148
+ const FRICTION_SCHEMA = {
149
+ type: 'object',
150
+ additionalProperties: false,
151
+ properties: {
152
+ intro: { type: 'string' },
153
+ categories: {
154
+ type: 'array',
155
+ items: {
156
+ type: 'object',
157
+ additionalProperties: false,
158
+ properties: {
159
+ category: { type: 'string' },
160
+ description: { type: 'string' },
161
+ examples: {
162
+ type: 'array',
163
+ items: { type: 'string' },
164
+ },
165
+ },
166
+ required: ['category', 'description', 'examples'],
167
+ },
168
+ },
169
+ },
170
+ required: ['intro', 'categories'],
171
+ }
172
+
173
+ const SUGGESTIONS_SCHEMA = {
174
+ type: 'object',
175
+ additionalProperties: false,
176
+ properties: {
177
+ agents_md_additions: {
178
+ type: 'array',
179
+ items: {
180
+ type: 'object',
181
+ additionalProperties: false,
182
+ properties: {
183
+ addition: { type: 'string' },
184
+ why: { type: 'string' },
185
+ prompt_scaffold: { type: 'string' },
186
+ },
187
+ required: ['addition', 'why', 'prompt_scaffold'],
188
+ },
189
+ },
190
+ features_to_try: {
191
+ type: 'array',
192
+ items: {
193
+ type: 'object',
194
+ additionalProperties: false,
195
+ properties: {
196
+ feature: { type: 'string' },
197
+ one_liner: { type: 'string' },
198
+ why_for_you: { type: 'string' },
199
+ example_code: { type: 'string' },
200
+ },
201
+ required: ['feature', 'one_liner', 'why_for_you', 'example_code'],
202
+ },
203
+ },
204
+ usage_patterns: {
205
+ type: 'array',
206
+ items: {
207
+ type: 'object',
208
+ additionalProperties: false,
209
+ properties: {
210
+ title: { type: 'string' },
211
+ suggestion: { type: 'string' },
212
+ detail: { type: 'string' },
213
+ copyable_prompt: { type: 'string' },
214
+ },
215
+ required: ['title', 'suggestion', 'detail', 'copyable_prompt'],
216
+ },
217
+ },
218
+ },
219
+ required: ['agents_md_additions', 'features_to_try', 'usage_patterns'],
220
+ }
221
+
222
+ const HORIZON_SCHEMA = {
223
+ type: 'object',
224
+ additionalProperties: false,
225
+ properties: {
226
+ intro: { type: 'string' },
227
+ opportunities: {
228
+ type: 'array',
229
+ items: {
230
+ type: 'object',
231
+ additionalProperties: false,
232
+ properties: {
233
+ title: { type: 'string' },
234
+ whats_possible: { type: 'string' },
235
+ how_to_try: { type: 'string' },
236
+ copyable_prompt: { type: 'string' },
237
+ },
238
+ required: ['title', 'whats_possible', 'how_to_try', 'copyable_prompt'],
239
+ },
240
+ },
241
+ },
242
+ required: ['intro', 'opportunities'],
243
+ }
244
+
245
+ const FUN_ENDING_SCHEMA = {
246
+ type: 'object',
247
+ additionalProperties: false,
248
+ properties: {
249
+ headline: { type: 'string' },
250
+ detail: { type: 'string' },
251
+ },
252
+ required: ['headline', 'detail'],
253
+ }
254
+
255
+ const AT_A_GLANCE_SCHEMA = {
256
+ type: 'object',
257
+ additionalProperties: false,
258
+ properties: {
259
+ whats_working: { type: 'string' },
260
+ whats_hindering: { type: 'string' },
261
+ quick_wins: { type: 'string' },
262
+ ambitious_workflows: { type: 'string' },
263
+ },
264
+ required: [
265
+ 'whats_working',
266
+ 'whats_hindering',
267
+ 'quick_wins',
268
+ 'ambitious_workflows',
269
+ ],
270
+ }
271
+
272
+ const SECTION_DEFS = [
273
+ {
274
+ name: 'project_areas',
275
+ modelTier: 'fast',
276
+ contextKind: 'project_areas',
277
+ schemaName: 'codex_project_areas',
278
+ schema: PROJECT_AREAS_SCHEMA,
279
+ prompt: `Analyze this Codex usage data and identify project areas.
280
+
281
+ RESPOND WITH ONLY A VALID JSON OBJECT:
282
+ {
283
+ "areas": [
284
+ {"name": "Area name", "session_count": 0, "description": "2-3 sentences about what was worked on and how Codex was used."}
285
+ ]
286
+ }
287
+
288
+ Include 4-5 areas. Skip Codex self-hosting/meta work unless it is a dominant project area.
289
+
290
+ Guardrails:
291
+ - Use concrete project or workstream names, not generic labels like "coding" or "development"
292
+ - Base areas on repeated evidence across summaries, not one-off threads
293
+ - Prefer project + task framing over tool-centric framing
294
+ - Group related tasks into a coherent workstream instead of listing each task separately
295
+ - Each description should sound like a mini report paragraph: what kinds of work clustered together, then how Codex contributed
296
+ - Prefer descriptions that mention representative tasks or artifacts instead of vague labels`,
297
+ },
298
+ {
299
+ name: 'interaction_style',
300
+ modelTier: 'full',
301
+ contextKind: 'interaction_style',
302
+ schemaName: 'codex_interaction_style',
303
+ schema: INTERACTION_STYLE_SCHEMA,
304
+ prompt: `Analyze this Codex usage data and describe the user's interaction style.
305
+
306
+ RESPOND WITH ONLY A VALID JSON OBJECT:
307
+ {
308
+ "narrative": "2-3 paragraphs analyzing how the user interacts with Codex. Use second person. Focus on planning style, interruption pattern, trust in execution, and how they balance exploration vs edits.",
309
+ "key_pattern": "One sentence summary of the most distinctive interaction pattern"
310
+ }
311
+
312
+ Guardrails:
313
+ - Focus on stable interaction patterns, not isolated moments
314
+ - Talk about how the user scopes work, interrupts, redirects, or trusts execution
315
+ - Do not infer user preference from Codex's default tool mix or harness behavior; high exec/tool usage can reflect the agent's operating style rather than the user's instructions
316
+ - Treat shell usage, file reads, and verification commands as weak evidence unless the user explicitly asked for that working style
317
+ - If evidence is mixed, describe the tension instead of forcing one clean story`,
318
+ },
319
+ {
320
+ name: 'what_works',
321
+ modelTier: 'fast',
322
+ contextKind: 'what_works',
323
+ schemaName: 'codex_what_works',
324
+ schema: WHAT_WORKS_SCHEMA,
325
+ prompt: `Analyze this Codex usage data and identify what is working well. Use second person.
326
+
327
+ RESPOND WITH ONLY A VALID JSON OBJECT:
328
+ {
329
+ "intro": "1 sentence of context",
330
+ "impressive_workflows": [
331
+ {"title": "Short title", "description": "2-3 sentences describing the workflow or habit that works well."}
332
+ ]
333
+ }
334
+
335
+ Include 3 impressive workflows.
336
+
337
+ Guardrails:
338
+ - Only include workflows supported by repeated evidence or clearly strong outcomes
339
+ - Prefer user habits and collaboration patterns over tool name lists
340
+ - Avoid generic praise; explain why the workflow works`,
341
+ },
342
+ {
343
+ name: 'friction_analysis',
344
+ modelTier: 'full',
345
+ contextKind: 'friction_analysis',
346
+ schemaName: 'codex_friction_analysis',
347
+ schema: FRICTION_SCHEMA,
348
+ prompt: `Analyze this Codex usage data and identify friction points. Use second person.
349
+
350
+ RESPOND WITH ONLY A VALID JSON OBJECT:
351
+ {
352
+ "intro": "1 sentence summarizing the friction pattern",
353
+ "categories": [
354
+ {"category": "Concrete category name", "description": "1-2 sentences describing the pattern", "examples": ["specific example", "another example"]}
355
+ ]
356
+ }
357
+
358
+ Include 3 friction categories with 2 examples each.
359
+
360
+ Guardrails:
361
+ - Separate model-side friction from user/workflow-side friction when useful
362
+ - Examples must be concrete and tied to the supplied evidence
363
+ - Do not invent root causes that are not visible in the data`,
364
+ },
365
+ {
366
+ name: 'suggestions',
367
+ modelTier: 'fast',
368
+ contextKind: 'suggestions',
369
+ schemaName: 'codex_suggestions',
370
+ schema: SUGGESTIONS_SCHEMA,
371
+ prompt: `Analyze this Codex usage data and suggest improvements that the user can immediately act on.
372
+
373
+ ## CODEX FEATURES REFERENCE
374
+ 1. **MCP Servers**
375
+ - Good for databases, GitHub/Linear, internal APIs, external tools
376
+ 2. **Skills**
377
+ - Reusable local workflows triggered by intent or explicit skill usage
378
+ 3. **codex exec**
379
+ - Non-interactive Codex runs for scripts, CI, and repeatable workflows
380
+ 4. **Sub-agents**
381
+ - Split bounded work across focused agents when tasks can run independently
382
+ 5. **AGENTS.md**
383
+ - Persistent repo instructions so repeated context does not need to be restated
384
+
385
+ RESPOND WITH ONLY A VALID JSON OBJECT:
386
+ {
387
+ "agents_md_additions": [
388
+ {"addition": "A specific AGENTS.md addition", "why": "Why this would help based on repeated sessions", "prompt_scaffold": "Where to place it"}
389
+ ],
390
+ "features_to_try": [
391
+ {"feature": "Feature name", "one_liner": "What it does", "why_for_you": "Why it helps this user", "example_code": "Copyable command, config, or snippet"}
392
+ ],
393
+ "usage_patterns": [
394
+ {"title": "Short title", "suggestion": "1 sentence telling the user what to do", "detail": "2-4 sentences explaining why now and how it helps", "copyable_prompt": "Specific prompt to paste into Codex"}
395
+ ]
396
+ }
397
+
398
+ Prioritize suggestions grounded in repeated patterns, not isolated sessions.
399
+
400
+ Guardrails:
401
+ - Suggest only actions that clearly connect to repeated evidence
402
+ - Avoid generic advice like "give more context" unless it is overwhelmingly justified
403
+ - Prefer changes with strong leverage: repo memory, repeatable workflows, automation, or parallelism
404
+ - Write AGENTS.md additions as directly pasteable instruction lines, not commentary about instructions
405
+ - Make feature examples immediately usable; avoid placeholders like "insert your repo path here" unless unavoidable
406
+ - Make usage pattern suggestions sound like concrete next actions the user can try today, not abstract best practices`,
407
+ },
408
+ {
409
+ name: 'on_the_horizon',
410
+ modelTier: 'full',
411
+ contextKind: 'on_the_horizon',
412
+ schemaName: 'codex_on_the_horizon',
413
+ schema: HORIZON_SCHEMA,
414
+ prompt: `Analyze this Codex usage data and identify future opportunities that are ambitious but still actionable.
415
+
416
+ RESPOND WITH ONLY A VALID JSON OBJECT:
417
+ {
418
+ "intro": "1 sentence about how the user's workflows could evolve",
419
+ "opportunities": [
420
+ {"title": "Short title", "whats_possible": "2-3 ambitious sentences", "how_to_try": "1-2 sentences mentioning concrete tooling", "copyable_prompt": "Detailed prompt to try"}
421
+ ]
422
+ }
423
+
424
+ Include 3 opportunities. Think in terms of stronger multi-step execution, persistent repo memory, and parallel work.
425
+
426
+ Guardrails:
427
+ - Stay adjacent to the user's actual workflows; do not jump to fantasies detached from evidence
428
+ - Push toward ambitious but plausible next-step workflows
429
+ - Mention concrete Codex capabilities when relevant, not vague future AI claims
430
+ - The "how_to_try" field should read like a getting-started instruction, not a vague observation
431
+ - The "copyable_prompt" should be detailed enough that the user could paste it into Codex with minimal edits`,
432
+ },
433
+ {
434
+ name: 'fun_ending',
435
+ modelTier: 'fast',
436
+ contextKind: 'fun_ending',
437
+ schemaName: 'codex_fun_ending',
438
+ schema: FUN_ENDING_SCHEMA,
439
+ prompt: `Analyze this Codex usage data and find one memorable qualitative moment.
440
+
441
+ RESPOND WITH ONLY A VALID JSON OBJECT:
442
+ {
443
+ "headline": "A memorable moment from the sessions",
444
+ "detail": "Brief context"
445
+ }
446
+
447
+ Pick something human, funny, or surprising, not a statistic.
448
+
449
+ Guardrails:
450
+ - Prefer a memorable pattern or moment visible in the supplied summaries
451
+ - Do not fabricate narrative detail that is not present in the data`,
452
+ },
453
+ ]
454
+
455
+ /**
456
+ * @param {{ threadSummaries: any[], options?: InsightRunOptions }} param0
457
+ */
458
+ export async function generateLlmInsights({ threadSummaries, options = {} }) {
459
+ const provider = options.provider || DEFAULT_PROVIDER
460
+ const usageTracker = createUsageTracker(provider)
461
+ const providerOptions = buildProviderOptions(options, provider, usage => usageTracker.add(usage))
462
+ const facetModel = options.facetModel || DEFAULT_FACET_MODEL
463
+ const fastSectionModel = options.fastSectionModel || DEFAULT_FAST_SECTION_MODEL
464
+ const insightModel = options.insightModel || DEFAULT_INSIGHT_MODEL
465
+ const facetEffort = options.facetEffort || DEFAULT_FACET_EFFORT
466
+ const fastSectionEffort = options.fastSectionEffort || DEFAULT_FAST_SECTION_EFFORT
467
+ const insightEffort = options.insightEffort || DEFAULT_INSIGHT_EFFORT
468
+ const cacheRoot = resolveCacheRoot(options.cacheDir)
469
+ const facetCacheDir = path.join(cacheRoot, 'facets')
470
+ await fs.mkdir(facetCacheDir, { recursive: true })
471
+
472
+ const candidateThreads = filterSubstantiveThreads(threadSummaries)
473
+ const facetLimit = Math.min(
474
+ Number(options.facetLimit ?? DEFAULT_FACET_LIMIT),
475
+ MAX_FACET_EXTRACTIONS,
476
+ )
477
+
478
+ const facetJobs = await planFacetJobs(candidateThreads, {
479
+ cacheDir: facetCacheDir,
480
+ model: facetModel,
481
+ uncachedLimit: facetLimit,
482
+ })
483
+ emitProgress(options, {
484
+ kind: 'facets:planned',
485
+ total: facetJobs.length,
486
+ uncached: facetJobs.filter(job => !job.cachedFacet).length,
487
+ cached: facetJobs.filter(job => Boolean(job.cachedFacet)).length,
488
+ })
489
+
490
+ let completedFacetJobs = 0
491
+ const rawFacets = await mapLimit(facetJobs, 4, async job => {
492
+ const facet =
493
+ job.cachedFacet ||
494
+ (await getFacetForThread(job.thread, {
495
+ cacheDir: facetCacheDir,
496
+ model: facetModel,
497
+ provider,
498
+ providerOptions,
499
+ }))
500
+ completedFacetJobs += 1
501
+ emitProgress(options, {
502
+ kind: 'facets:progress',
503
+ completed: completedFacetJobs,
504
+ total: facetJobs.length,
505
+ cached: Boolean(job.cachedFacet),
506
+ })
507
+ return facet
508
+ })
509
+
510
+ const minimalThreadIds = new Set(
511
+ rawFacets.filter(isWarmupMinimalFacet).map(facet => facet.threadId),
512
+ )
513
+ const reportThreads = candidateThreads.filter(thread => !minimalThreadIds.has(thread.id))
514
+ const facets = rawFacets.filter(facet => !minimalThreadIds.has(facet.threadId))
515
+ const report = buildReport(reportThreads, { threadPreviewLimit: 50 })
516
+ const context = buildInsightContext(report, reportThreads, facets)
517
+
518
+ emitProgress(options, {
519
+ kind: 'sections:planned',
520
+ total: SECTION_DEFS.length + 1,
521
+ })
522
+
523
+ let completedSections = 0
524
+ const sectionResults = await mapLimit(SECTION_DEFS, SECTION_CONCURRENCY, async section => {
525
+ const sectionContext = buildSectionContext(context, section.contextKind)
526
+ const result = await callStructuredModel({
527
+ provider,
528
+ model: resolveSectionModel(section, { fastSectionModel, insightModel }),
529
+ schemaName: section.schemaName,
530
+ schema: section.schema,
531
+ systemPrompt: `${SECTION_SYSTEM_PROMPT} ${getNarrativeLanguageInstruction(options.lang)}`.trim(),
532
+ userPrompt: `${section.prompt}\n\n${getNarrativeLanguageInstruction(options.lang)}\n\nDATA:\n${compactJson(sectionContext)}`,
533
+ options: {
534
+ ...providerOptions,
535
+ fallbackModels: resolveModelFallbacks(
536
+ resolveSectionModel(section, { fastSectionModel, insightModel }),
537
+ ),
538
+ usageStage: `section:${section.name}`,
539
+ reasoningEffort: resolveSectionEffort(section, { fastSectionEffort, insightEffort }),
540
+ },
541
+ })
542
+ completedSections += 1
543
+ emitProgress(options, {
544
+ kind: 'sections:progress',
545
+ completed: completedSections,
546
+ total: SECTION_DEFS.length + 1,
547
+ section: section.name,
548
+ })
549
+ return { name: section.name, result }
550
+ })
551
+
552
+ const insights = {}
553
+ for (const section of sectionResults) {
554
+ insights[section.name] = section.result
555
+ }
556
+
557
+ const atAGlance = await callStructuredModel({
558
+ provider,
559
+ model: insightModel,
560
+ schemaName: 'codex_at_a_glance',
561
+ schema: AT_A_GLANCE_SCHEMA,
562
+ systemPrompt: `${AT_A_GLANCE_SYSTEM_PROMPT} ${getNarrativeLanguageInstruction(options.lang)}`.trim(),
563
+ userPrompt: buildAtAGlancePrompt(buildSectionContext(context, 'at_a_glance'), insights),
564
+ options: {
565
+ ...providerOptions,
566
+ fallbackModels: resolveModelFallbacks(insightModel),
567
+ usageStage: 'section:at_a_glance',
568
+ reasoningEffort: insightEffort,
569
+ },
570
+ })
571
+ completedSections += 1
572
+ emitProgress(options, {
573
+ kind: 'sections:progress',
574
+ completed: completedSections,
575
+ total: SECTION_DEFS.length + 1,
576
+ section: 'at_a_glance',
577
+ })
578
+ insights.at_a_glance = atAGlance
579
+
580
+ return { insights, facets, reportThreads, analysisUsage: usageTracker.snapshot() }
581
+ }
582
+
583
+ /**
584
+ * @param {{ threadSummaries: any[], options?: InsightRunOptions }} param0
585
+ */
586
+ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} }) {
587
+ const facetModel = options.facetModel || DEFAULT_FACET_MODEL
588
+ const fastSectionModel = options.fastSectionModel || DEFAULT_FAST_SECTION_MODEL
589
+ const insightModel = options.insightModel || DEFAULT_INSIGHT_MODEL
590
+ const cacheRoot = resolveCacheRoot(options.cacheDir)
591
+ const facetCacheDir = path.join(cacheRoot, 'facets')
592
+ await fs.mkdir(facetCacheDir, { recursive: true })
593
+
594
+ const candidateThreads = filterSubstantiveThreads(threadSummaries)
595
+ const facetLimit = Math.min(
596
+ Number(options.facetLimit ?? DEFAULT_FACET_LIMIT),
597
+ MAX_FACET_EXTRACTIONS,
598
+ )
599
+ const facetJobs = await planFacetJobs(candidateThreads, {
600
+ cacheDir: facetCacheDir,
601
+ model: facetModel,
602
+ uncachedLimit: facetLimit,
603
+ })
604
+
605
+ const uncachedFacetJobs = facetJobs.filter(job => !job.cachedFacet)
606
+ const cachedFacetJobs = facetJobs.length - uncachedFacetJobs.length
607
+ let chunkSummaryCalls = 0
608
+ let combineSummaryCalls = 0
609
+ let estimatedFacetInputTokens = 0
610
+ let estimatedFacetOutputTokens = 0
611
+ let estimatedSummaryInputTokens = 0
612
+ let estimatedSummaryOutputTokens = 0
613
+
614
+ for (const job of uncachedFacetJobs) {
615
+ const transcript = String(job.thread.transcriptForAnalysis || '').trim()
616
+ const transcriptChars = transcript.length
617
+ if (!transcriptChars) {
618
+ estimatedFacetInputTokens += 600
619
+ estimatedFacetOutputTokens += 350
620
+ continue
621
+ }
622
+
623
+ if (transcriptChars <= LONG_TRANSCRIPT_THRESHOLD) {
624
+ estimatedFacetInputTokens += estimateTokensFromChars(transcriptChars) + 1200
625
+ estimatedFacetOutputTokens += 350
626
+ continue
627
+ }
628
+
629
+ const chunks = chunkText(transcript, TRANSCRIPT_CHUNK_SIZE)
630
+ chunkSummaryCalls += chunks.length
631
+ for (const chunk of chunks) {
632
+ estimatedSummaryInputTokens += estimateTokensFromChars(chunk.length) + 220
633
+ estimatedSummaryOutputTokens += 260
634
+ }
635
+ const combinedSummaryChars = chunks.length * 1100
636
+ if (combinedSummaryChars > LONG_TRANSCRIPT_THRESHOLD) {
637
+ combineSummaryCalls += 1
638
+ estimatedSummaryInputTokens += estimateTokensFromChars(combinedSummaryChars) + 180
639
+ estimatedSummaryOutputTokens += 320
640
+ }
641
+ estimatedFacetInputTokens += 2400
642
+ estimatedFacetOutputTokens += 350
643
+ }
644
+
645
+ const estimatedSectionInputs = estimateSectionInputs(candidateThreads, facetJobs)
646
+ const fastSectionCalls = SECTION_DEFS.filter(section => section.modelTier === 'fast').length
647
+ const fullSectionCalls = SECTION_DEFS.filter(section => section.modelTier !== 'fast').length
648
+ const estimatedFastSectionInputTokens = SECTION_DEFS.filter(section => section.modelTier === 'fast')
649
+ .reduce((sum, section) => sum + estimatedSectionInputs[section.contextKind] + 500, 0)
650
+ const estimatedFastSectionOutputTokens = fastSectionCalls * 500
651
+ const estimatedFullSectionInputTokens = SECTION_DEFS.filter(section => section.modelTier !== 'fast')
652
+ .reduce((sum, section) => sum + estimatedSectionInputs[section.contextKind] + 650, 0)
653
+ const estimatedFullSectionOutputTokens = fullSectionCalls * 700
654
+ const estimatedAtAGlanceInputTokens = estimatedSectionInputs.at_a_glance + 2200
655
+ const estimatedAtAGlanceOutputTokens = 260
656
+
657
+ const byStage = [
658
+ buildEstimateBucket(
659
+ 'facet_extraction',
660
+ uncachedFacetJobs.length,
661
+ estimatedFacetInputTokens,
662
+ 0,
663
+ estimatedFacetOutputTokens,
664
+ ),
665
+ buildEstimateBucket(
666
+ 'transcript_summary:chunk',
667
+ chunkSummaryCalls,
668
+ estimatedSummaryInputTokens - (combineSummaryCalls ? estimateTokensFromChars(combineSummaryCalls * 1100) + combineSummaryCalls * 180 : 0),
669
+ 0,
670
+ estimatedSummaryOutputTokens - combineSummaryCalls * 320,
671
+ ),
672
+ buildEstimateBucket(
673
+ 'transcript_summary:combine',
674
+ combineSummaryCalls,
675
+ combineSummaryCalls ? estimateTokensFromChars(combineSummaryCalls * 1100) + combineSummaryCalls * 180 : 0,
676
+ 0,
677
+ combineSummaryCalls * 320,
678
+ ),
679
+ buildEstimateBucket(
680
+ 'section:fast',
681
+ fastSectionCalls,
682
+ estimatedFastSectionInputTokens,
683
+ 0,
684
+ estimatedFastSectionOutputTokens,
685
+ ),
686
+ buildEstimateBucket(
687
+ 'section:full',
688
+ fullSectionCalls,
689
+ estimatedFullSectionInputTokens,
690
+ 0,
691
+ estimatedFullSectionOutputTokens,
692
+ ),
693
+ buildEstimateBucket(
694
+ 'section:at_a_glance',
695
+ 1,
696
+ estimatedAtAGlanceInputTokens,
697
+ 0,
698
+ estimatedAtAGlanceOutputTokens,
699
+ ),
700
+ ].filter(bucket => bucket.calls > 0)
701
+
702
+ const byModel = aggregateEstimateByModel([
703
+ {
704
+ label: facetModel,
705
+ calls: uncachedFacetJobs.length + chunkSummaryCalls + combineSummaryCalls,
706
+ inputTokens: estimatedFacetInputTokens + estimatedSummaryInputTokens,
707
+ cachedInputTokens: 0,
708
+ outputTokens: estimatedFacetOutputTokens + estimatedSummaryOutputTokens,
709
+ totalTokens:
710
+ estimatedFacetInputTokens +
711
+ estimatedSummaryInputTokens +
712
+ estimatedFacetOutputTokens +
713
+ estimatedSummaryOutputTokens,
714
+ },
715
+ {
716
+ label: fastSectionModel,
717
+ calls: fastSectionCalls,
718
+ inputTokens: estimatedFastSectionInputTokens,
719
+ cachedInputTokens: 0,
720
+ outputTokens: estimatedFastSectionOutputTokens,
721
+ totalTokens: estimatedFastSectionInputTokens + estimatedFastSectionOutputTokens,
722
+ },
723
+ {
724
+ label: insightModel,
725
+ calls: fullSectionCalls + 1,
726
+ inputTokens: estimatedFullSectionInputTokens + estimatedAtAGlanceInputTokens,
727
+ cachedInputTokens: 0,
728
+ outputTokens: estimatedFullSectionOutputTokens + estimatedAtAGlanceOutputTokens,
729
+ totalTokens:
730
+ estimatedFullSectionInputTokens +
731
+ estimatedAtAGlanceInputTokens +
732
+ estimatedFullSectionOutputTokens +
733
+ estimatedAtAGlanceOutputTokens,
734
+ },
735
+ ])
736
+
737
+ const totalInputTokens =
738
+ estimatedFacetInputTokens +
739
+ estimatedSummaryInputTokens +
740
+ estimatedFastSectionInputTokens +
741
+ estimatedFullSectionInputTokens +
742
+ estimatedAtAGlanceInputTokens
743
+ const totalOutputTokens =
744
+ estimatedFacetOutputTokens +
745
+ estimatedSummaryOutputTokens +
746
+ estimatedFastSectionOutputTokens +
747
+ estimatedFullSectionOutputTokens +
748
+ estimatedAtAGlanceOutputTokens
749
+ const totalTokens = totalInputTokens + totalOutputTokens
750
+
751
+ return {
752
+ provider: options.provider || DEFAULT_PROVIDER,
753
+ candidateThreads: candidateThreads.length,
754
+ plannedFacetThreads: facetJobs.length,
755
+ uncachedFacetThreads: uncachedFacetJobs.length,
756
+ cachedFacetThreads: cachedFacetJobs,
757
+ longTranscriptThreads: uncachedFacetJobs.filter(job => String(job.thread.transcriptForAnalysis || '').trim().length > LONG_TRANSCRIPT_THRESHOLD).length,
758
+ estimatedCalls:
759
+ uncachedFacetJobs.length +
760
+ chunkSummaryCalls +
761
+ combineSummaryCalls +
762
+ fastSectionCalls +
763
+ fullSectionCalls +
764
+ 1,
765
+ estimatedInputTokens: Math.round(totalInputTokens),
766
+ estimatedOutputTokens: Math.round(totalOutputTokens),
767
+ estimatedTotalTokens: Math.round(totalTokens),
768
+ estimatedRange: {
769
+ low: Math.round(totalTokens * 0.8),
770
+ high: Math.round(totalTokens * 1.35),
771
+ },
772
+ byStage,
773
+ byModel,
774
+ }
775
+ }
776
+
777
+ async function planFacetJobs(threadSummaries, { cacheDir, model, uncachedLimit }) {
778
+ const jobs = []
779
+ let uncachedCount = 0
780
+
781
+ for (const thread of threadSummaries) {
782
+ const cachedFacet = await readCachedFacet(thread, { cacheDir, model })
783
+ if (cachedFacet) {
784
+ jobs.push({ thread, cachedFacet })
785
+ continue
786
+ }
787
+ if (uncachedCount >= uncachedLimit) continue
788
+ uncachedCount += 1
789
+ jobs.push({ thread, cachedFacet: null })
790
+ }
791
+
792
+ return jobs
793
+ }
794
+
795
+ async function readCachedFacet(thread, { cacheDir, model }) {
796
+ const cachePath = path.join(cacheDir, `${thread.id}.json`)
797
+ const cached = await readJson(cachePath)
798
+ if (!cached?.facet || cached.versionKey !== buildFacetVersionKey(thread, model)) {
799
+ return null
800
+ }
801
+ return cached.facet
802
+ }
803
+
804
+ async function getFacetForThread(thread, { cacheDir, model, provider, providerOptions }) {
805
+ const cachePath = path.join(cacheDir, `${thread.id}.json`)
806
+ const versionKey = buildFacetVersionKey(thread, model)
807
+ const cached = await readJson(cachePath)
808
+ if (cached?.versionKey === versionKey && cached?.facet) {
809
+ return cached.facet
810
+ }
811
+
812
+ const transcript = await prepareTranscriptForFacetExtraction(thread, {
813
+ model,
814
+ provider,
815
+ providerOptions,
816
+ })
817
+ const prompt = `Analyze this Codex coding session and extract structured facets.
818
+
819
+ CRITICAL GUIDELINES:
820
+ 1. goal_categories should count only what the user explicitly asked for.
821
+ 2. user_satisfaction_counts should rely on explicit user signals or strong transcript evidence.
822
+ 3. friction_counts should be specific: misunderstood_request, wrong_approach, buggy_code, user_rejected_action, excessive_changes, tool_failed, slow_or_verbose, user_unclear, external_issue.
823
+ 4. If the session is mostly warmup, rehearsal, or cache-filling, use warmup_minimal as the only goal category.
824
+ 5. If evidence is insufficient after transcript compression, use conservative values such as unclear_from_transcript rather than guessing.
825
+ 6. Do not infer the user's goal from assistant or tool activity alone.
826
+ 7. Do not count assistant-led exploration or extra implementation work unless the user clearly asked for it.
827
+
828
+ Allowed values:
829
+ - outcome: fully_achieved | mostly_achieved | partially_achieved | not_achieved | unclear_from_transcript
830
+ - assistant_helpfulness: unhelpful | slightly_helpful | moderately_helpful | very_helpful | essential
831
+ - session_type: single_task | multi_task | iterative_refinement | exploration | quick_question
832
+ - primary_success: none | fast_accurate_search | correct_code_edits | good_explanations | proactive_help | multi_file_changes | good_debugging
833
+
834
+ Language:
835
+ - Keep enum values and keys exactly as requested.
836
+ - Write free-text fields in ${describeLanguage(langFromProviderOptions(providerOptions))}.
837
+
838
+ Transcript:
839
+ ${transcript}
840
+
841
+ Summary stats:
842
+ ${JSON.stringify(
843
+ {
844
+ title: thread.title,
845
+ cwd: thread.cwd,
846
+ durationMinutes: thread.durationMinutes,
847
+ userMessages: thread.userMessages,
848
+ assistantMessages: thread.assistantMessages,
849
+ totalToolCalls: thread.totalToolCalls,
850
+ totalCommandFailures: thread.totalCommandFailures,
851
+ toolCounts: thread.toolCounts,
852
+ toolFailures: thread.toolFailures,
853
+ userInterruptions: thread.userInterruptions,
854
+ usesTaskAgent: thread.usesTaskAgent,
855
+ usesMcp: thread.usesMcp,
856
+ usesWebSearch: thread.usesWebSearch,
857
+ usesWebFetch: thread.usesWebFetch,
858
+ },
859
+ null,
860
+ 2,
861
+ )}
862
+
863
+ RESPOND WITH ONLY A VALID JSON OBJECT matching the requested schema.`
864
+
865
+ const rawFacet = await callStructuredModel({
866
+ provider,
867
+ model,
868
+ schemaName: 'codex_session_facet',
869
+ schema: FACET_SCHEMA,
870
+ systemPrompt:
871
+ `You extract structured coding-session facets from compressed transcripts. Use only transcript evidence. Be conservative when evidence is weak. Do not infer intent from tool activity alone. ${getStructuredLanguageInstruction(langFromProviderOptions(providerOptions))}`.trim(),
872
+ userPrompt: prompt,
873
+ options: {
874
+ ...providerOptions,
875
+ fallbackModels: resolveModelFallbacks(model),
876
+ usageStage: 'facet_extraction',
877
+ reasoningEffort: provider === 'codex-cli' ? providerOptions.facetEffort : undefined,
878
+ },
879
+ })
880
+
881
+ const facet = {
882
+ threadId: thread.id,
883
+ title: thread.title,
884
+ cwd: thread.cwd,
885
+ updatedAt: thread.updatedAt,
886
+ durationMinutes: thread.durationMinutes,
887
+ userMessages: thread.userMessages,
888
+ assistantMessages: thread.assistantMessages,
889
+ totalToolCalls: thread.totalToolCalls,
890
+ totalCommandFailures: thread.totalCommandFailures,
891
+ ...rawFacet,
892
+ }
893
+
894
+ await fs.writeFile(cachePath, JSON.stringify({ versionKey, facet }, null, 2), 'utf8')
895
+ return facet
896
+ }
897
+
898
+ async function prepareTranscriptForFacetExtraction(thread, { model, provider, providerOptions }) {
899
+ const transcript = String(thread.transcriptForAnalysis || '').trim()
900
+ if (!transcript) {
901
+ return `${thread.title || '(untitled)'}\n${thread.firstUserMessage || ''}`.trim()
902
+ }
903
+ if (transcript.length <= LONG_TRANSCRIPT_THRESHOLD) {
904
+ return transcript
905
+ }
906
+
907
+ const chunks = chunkText(transcript, TRANSCRIPT_CHUNK_SIZE)
908
+ const chunkSummaries = await mapLimit(chunks, 3, async (chunk, index) =>
909
+ callTextModel({
910
+ provider,
911
+ model,
912
+ systemPrompt:
913
+ `${FACET_TRANSCRIPT_SUMMARY_DIRECTIVE}\n\nPreserve user goal, outcome, friction, command/tool issues, and what the assistant actually achieved.`,
914
+ userPrompt: `Chunk ${index + 1} of ${chunks.length}\n\n${chunk}`,
915
+ options: {
916
+ ...providerOptions,
917
+ fallbackModels: resolveModelFallbacks(model),
918
+ usageStage: 'transcript_summary:chunk',
919
+ reasoningEffort: provider === 'codex-cli' ? providerOptions.facetEffort : undefined,
920
+ },
921
+ }),
922
+ )
923
+
924
+ const combined = chunkSummaries
925
+ .map((summary, index) => `Chunk ${index + 1} summary:\n${summary.trim()}`)
926
+ .join('\n\n')
927
+
928
+ if (combined.length <= LONG_TRANSCRIPT_THRESHOLD) {
929
+ return `[Long transcript summarized before facet extraction]\n${combined}`
930
+ }
931
+
932
+ const finalSummary = await callTextModel({
933
+ provider,
934
+ model,
935
+ systemPrompt:
936
+ 'Combine these coding-session chunk summaries into one compact transcript summary. Keep only material signal for later facet extraction. Do not carry boilerplate, stack traces, or command details.',
937
+ userPrompt: combined,
938
+ options: {
939
+ ...providerOptions,
940
+ fallbackModels: resolveModelFallbacks(model),
941
+ usageStage: 'transcript_summary:combine',
942
+ reasoningEffort: provider === 'codex-cli' ? providerOptions.facetEffort : undefined,
943
+ },
944
+ })
945
+
946
+ return `[Long transcript summarized before facet extraction]\n${finalSummary.trim()}`
947
+ }
948
+
949
+ function buildInsightContext(report, threadSummaries, facets) {
950
+ const goalCategories = {}
951
+ const outcomes = {}
952
+ const satisfaction = {}
953
+ const sessionTypes = {}
954
+ const friction = {}
955
+ const success = {}
956
+
957
+ for (const facet of facets) {
958
+ addCounts(goalCategories, facet.goal_categories)
959
+ addCounts(satisfaction, facet.user_satisfaction_counts)
960
+ addCounts(friction, facet.friction_counts)
961
+ if (facet.session_type) {
962
+ sessionTypes[facet.session_type] = (sessionTypes[facet.session_type] || 0) + 1
963
+ }
964
+ if (facet.outcome) outcomes[facet.outcome] = (outcomes[facet.outcome] || 0) + 1
965
+ if (facet.primary_success && facet.primary_success !== 'none') {
966
+ success[facet.primary_success] = (success[facet.primary_success] || 0) + 1
967
+ }
968
+ }
969
+
970
+ const sortedFacets = [...facets].sort((a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt))
971
+ const frictionDetails = sortedFacets
972
+ .filter(facet => facet.friction_detail && facet.friction_detail !== 'none')
973
+ .slice(0, MAX_FRICTION_DETAILS)
974
+ .map(facet => ({
975
+ threadId: facet.threadId,
976
+ title: facet.title,
977
+ cwd: facet.cwd,
978
+ friction_counts: facet.friction_counts,
979
+ friction_detail: facet.friction_detail,
980
+ outcome: facet.outcome,
981
+ }))
982
+
983
+ const userInstructions = Array.from(
984
+ new Set(
985
+ sortedFacets
986
+ .flatMap(facet => facet.user_instructions || [])
987
+ .map(sanitizeContextText)
988
+ .filter(Boolean),
989
+ ),
990
+ ).slice(0, MAX_USER_INSTRUCTIONS)
991
+
992
+ return {
993
+ metadata: {
994
+ generated_at: report.metadata.generatedAt,
995
+ thread_count: report.metadata.threadCount,
996
+ date_range: report.metadata.dateRange,
997
+ },
998
+ summary: {
999
+ total_user_messages: report.summary.totalUserMessages,
1000
+ total_tool_calls: report.summary.totalToolCalls,
1001
+ total_failures: report.summary.totalFailures,
1002
+ total_duration_hours: report.summary.totalDurationHours,
1003
+ total_tokens: report.summary.totalTokens,
1004
+ average_response_time_seconds: report.summary.averageResponseTimeSeconds,
1005
+ total_git_commits: report.summary.totalGitCommits,
1006
+ total_tool_errors: report.summary.totalToolErrors,
1007
+ total_files_modified: report.summary.totalFilesModified,
1008
+ total_lines_added: report.summary.totalLinesAdded,
1009
+ total_lines_removed: report.summary.totalLinesRemoved,
1010
+ overlap: report.summary.overlap,
1011
+ },
1012
+ charts: {
1013
+ projects: report.charts.projects.slice(0, 6),
1014
+ models: report.charts.models.slice(0, 4),
1015
+ tools: report.charts.tools.slice(0, 8),
1016
+ tool_failures: report.charts.toolFailures.slice(0, 6),
1017
+ active_hours: compressActiveHours(report.charts.activeHours),
1018
+ },
1019
+ aggregate_facets: {
1020
+ sessions_with_facets: facets.length,
1021
+ goal_categories: goalCategories,
1022
+ outcomes,
1023
+ satisfaction,
1024
+ session_types: sessionTypes,
1025
+ friction,
1026
+ success,
1027
+ },
1028
+ session_summaries: sortedFacets.slice(0, MAX_CONTEXT_FACETS).map(facet => ({
1029
+ thread_id: facet.threadId,
1030
+ title: truncateForContext(facet.title, 80),
1031
+ project: compactProjectPath(facet.cwd),
1032
+ goal: truncateForContext(facet.underlying_goal, 120),
1033
+ outcome: facet.outcome,
1034
+ session_type: facet.session_type,
1035
+ primary_success: facet.primary_success,
1036
+ summary: truncateForContext(facet.brief_summary, 160),
1037
+ friction: compactCountObject(facet.friction_counts, 2),
1038
+ failures: facet.totalCommandFailures,
1039
+ })),
1040
+ friction_details: frictionDetails,
1041
+ user_instructions: userInstructions,
1042
+ recent_threads: threadSummaries.slice(0, MAX_RECENT_THREADS).map(thread => ({
1043
+ id: thread.id,
1044
+ title: truncateForContext(thread.title, 80),
1045
+ project: compactProjectPath(thread.cwd),
1046
+ duration_minutes: thread.durationMinutes,
1047
+ user_messages: thread.userMessages,
1048
+ tool_calls: thread.totalToolCalls,
1049
+ files_modified: thread.filesModified,
1050
+ })),
1051
+ }
1052
+ }
1053
+
1054
+ function buildAtAGlancePrompt(context, insights) {
1055
+ return `You are writing an "At a Glance" summary for a Codex usage insights report.
1056
+
1057
+ Use this 4-part structure:
1058
+ 1. What's working
1059
+ 2. What's hindering you
1060
+ 3. Quick wins to try
1061
+ 4. Ambitious workflows
1062
+
1063
+ Keep each field to 2-3 compact sentences. Be specific, not flattering.
1064
+
1065
+ Additional constraints:
1066
+ - "What's working" should emphasize distinctive strengths, not generic success
1067
+ - "What's hindering you" should include both model-side and workflow-side friction when supported
1068
+ - "Quick wins" should be immediately actionable and high leverage
1069
+ - "Ambitious workflows" should be plausible next-step workflows, not science fiction
1070
+ - Do not repeat the same idea across multiple fields
1071
+
1072
+ REPORT CONTEXT:
1073
+ ${compactJson(context)}
1074
+
1075
+ SECTION DIGEST:
1076
+ ${compactJson(compactInsightDigest(insights))}
1077
+
1078
+ RESPOND WITH ONLY A VALID JSON OBJECT matching the schema.`
1079
+ }
1080
+
1081
+ /**
1082
+ * @param {InsightRunOptions} options
1083
+ * @param {string} provider
1084
+ * @param {(usage: any) => void} onUsage
1085
+ */
1086
+ function buildProviderOptions(options, provider, onUsage) {
1087
+ return {
1088
+ provider,
1089
+ lang: options.lang,
1090
+ apiKey: options.apiKey,
1091
+ apiBase: options.apiBase,
1092
+ codexBin: options.codexBin,
1093
+ cwd: process.cwd(),
1094
+ onUsage,
1095
+ facetEffort: options.facetEffort || DEFAULT_FACET_EFFORT,
1096
+ }
1097
+ }
1098
+
1099
+ function estimateSectionInputs(candidateThreads, facetJobs) {
1100
+ const contextShape = {
1101
+ metadata: {
1102
+ thread_count: candidateThreads.length,
1103
+ },
1104
+ summary: {
1105
+ total_user_messages: candidateThreads.reduce((sum, thread) => sum + Number(thread.userMessages || 0), 0),
1106
+ total_tool_calls: candidateThreads.reduce((sum, thread) => sum + Number(thread.totalToolCalls || 0), 0),
1107
+ total_failures: candidateThreads.reduce((sum, thread) => sum + Number(thread.totalCommandFailures || 0), 0),
1108
+ },
1109
+ charts: {
1110
+ projects: candidateThreads.slice(0, 6).map(thread => compactProjectPath(thread.cwd)),
1111
+ models: Array.from(new Set(candidateThreads.map(thread => thread.model).filter(Boolean))).slice(0, 4),
1112
+ tools: [],
1113
+ },
1114
+ aggregate_facets: {
1115
+ sessions_with_facets: facetJobs.length,
1116
+ },
1117
+ session_summaries: facetJobs.slice(0, MAX_CONTEXT_FACETS).map(job => {
1118
+ if (job.cachedFacet) {
1119
+ return {
1120
+ title: truncateForContext(job.cachedFacet.title, 80),
1121
+ project: compactProjectPath(job.cachedFacet.cwd),
1122
+ goal: truncateForContext(job.cachedFacet.underlying_goal, 120),
1123
+ outcome: job.cachedFacet.outcome,
1124
+ primary_success: job.cachedFacet.primary_success,
1125
+ summary: truncateForContext(job.cachedFacet.brief_summary, 160),
1126
+ friction: compactCountObject(job.cachedFacet.friction_counts, 2),
1127
+ }
1128
+ }
1129
+ return {
1130
+ title: truncateForContext(job.thread.title, 80),
1131
+ project: compactProjectPath(job.thread.cwd),
1132
+ goal: truncateForContext(job.thread.firstUserMessage, 120),
1133
+ outcome: 'unknown',
1134
+ primary_success: 'unknown',
1135
+ summary: truncateForContext(job.thread.firstUserMessage, 160),
1136
+ friction: {},
1137
+ }
1138
+ }),
1139
+ recent_threads: candidateThreads.slice(0, MAX_RECENT_THREADS).map(thread => ({
1140
+ title: truncateForContext(thread.title, 80),
1141
+ project: compactProjectPath(thread.cwd),
1142
+ duration_minutes: thread.durationMinutes,
1143
+ user_messages: thread.userMessages,
1144
+ tool_calls: thread.totalToolCalls,
1145
+ files_modified: thread.filesModified,
1146
+ })),
1147
+ friction_details: [],
1148
+ user_instructions: [],
1149
+ }
1150
+ const sectionKinds = [
1151
+ 'project_areas',
1152
+ 'interaction_style',
1153
+ 'what_works',
1154
+ 'friction_analysis',
1155
+ 'suggestions',
1156
+ 'on_the_horizon',
1157
+ 'fun_ending',
1158
+ 'at_a_glance',
1159
+ ]
1160
+ const estimated = {}
1161
+ for (const kind of sectionKinds) {
1162
+ estimated[kind] = estimateTokensFromChars(compactJson(buildSectionContext(contextShape, kind)).length)
1163
+ }
1164
+ return estimated
1165
+ }
1166
+
1167
+ function resolveSectionModel(section, { fastSectionModel, insightModel }) {
1168
+ return section.modelTier === 'fast' ? fastSectionModel : insightModel
1169
+ }
1170
+
1171
+ function resolveSectionEffort(section, { fastSectionEffort, insightEffort }) {
1172
+ return section.modelTier === 'fast' ? fastSectionEffort : insightEffort
1173
+ }
1174
+
1175
+ function resolveModelFallbacks(model) {
1176
+ if (model === 'gpt-5.3-codex-spark') {
1177
+ return ['gpt-5.4-mini', 'gpt-5.4']
1178
+ }
1179
+ if (model === 'gpt-5.4') {
1180
+ return ['gpt-5.4-mini']
1181
+ }
1182
+ return []
1183
+ }
1184
+
1185
+ function buildEstimateBucket(label, calls, inputTokens, cachedInputTokens, outputTokens) {
1186
+ return {
1187
+ label,
1188
+ calls,
1189
+ inputTokens: Math.round(inputTokens),
1190
+ cachedInputTokens: Math.round(cachedInputTokens),
1191
+ outputTokens: Math.round(outputTokens),
1192
+ totalTokens: Math.round(inputTokens + cachedInputTokens + outputTokens),
1193
+ }
1194
+ }
1195
+
1196
+ function aggregateEstimateByModel(items) {
1197
+ const buckets = {}
1198
+ for (const item of items) {
1199
+ if (!item.calls) continue
1200
+ if (!buckets[item.label]) buckets[item.label] = emptyUsageBucket()
1201
+ buckets[item.label].calls += item.calls
1202
+ buckets[item.label].inputTokens += Math.round(item.inputTokens)
1203
+ buckets[item.label].cachedInputTokens += Math.round(item.cachedInputTokens)
1204
+ buckets[item.label].outputTokens += Math.round(item.outputTokens)
1205
+ buckets[item.label].totalTokens += Math.round(item.totalTokens)
1206
+ }
1207
+ return sortBuckets(buckets)
1208
+ }
1209
+
1210
+ function createUsageTracker(provider) {
1211
+ const totals = {
1212
+ provider,
1213
+ calls: 0,
1214
+ inputTokens: 0,
1215
+ cachedInputTokens: 0,
1216
+ outputTokens: 0,
1217
+ totalTokens: 0,
1218
+ byModel: {},
1219
+ byStage: {},
1220
+ }
1221
+
1222
+ return {
1223
+ add(usage) {
1224
+ totals.calls += 1
1225
+ totals.inputTokens += Number(usage.inputTokens ?? 0)
1226
+ totals.cachedInputTokens += Number(usage.cachedInputTokens ?? 0)
1227
+ totals.outputTokens += Number(usage.outputTokens ?? 0)
1228
+ totals.totalTokens += Number(usage.totalTokens ?? 0)
1229
+
1230
+ const modelKey = usage.model || '(unknown model)'
1231
+ if (!totals.byModel[modelKey]) totals.byModel[modelKey] = emptyUsageBucket()
1232
+ addToBucket(totals.byModel[modelKey], usage)
1233
+
1234
+ const stageKey = usage.stage || 'unspecified'
1235
+ if (!totals.byStage[stageKey]) totals.byStage[stageKey] = emptyUsageBucket()
1236
+ addToBucket(totals.byStage[stageKey], usage)
1237
+ },
1238
+ snapshot() {
1239
+ return {
1240
+ provider: totals.provider,
1241
+ calls: totals.calls,
1242
+ inputTokens: totals.inputTokens,
1243
+ cachedInputTokens: totals.cachedInputTokens,
1244
+ outputTokens: totals.outputTokens,
1245
+ totalTokens: totals.totalTokens,
1246
+ byModel: sortBuckets(totals.byModel),
1247
+ byStage: sortBuckets(totals.byStage),
1248
+ }
1249
+ },
1250
+ }
1251
+ }
1252
+
1253
+ function emptyUsageBucket() {
1254
+ return {
1255
+ calls: 0,
1256
+ inputTokens: 0,
1257
+ cachedInputTokens: 0,
1258
+ outputTokens: 0,
1259
+ totalTokens: 0,
1260
+ }
1261
+ }
1262
+
1263
+ function addToBucket(bucket, usage) {
1264
+ bucket.calls += 1
1265
+ bucket.inputTokens += Number(usage.inputTokens ?? 0)
1266
+ bucket.cachedInputTokens += Number(usage.cachedInputTokens ?? 0)
1267
+ bucket.outputTokens += Number(usage.outputTokens ?? 0)
1268
+ bucket.totalTokens += Number(usage.totalTokens ?? 0)
1269
+ }
1270
+
1271
+ function sortBuckets(buckets) {
1272
+ return Object.entries(buckets)
1273
+ .map(([label, value]) => ({ label, ...value }))
1274
+ .sort((a, b) => b.totalTokens - a.totalTokens)
1275
+ }
1276
+
1277
+ function isWarmupMinimalFacet(facet) {
1278
+ const categories = Object.entries(facet.goal_categories || {}).filter(([, count]) => count > 0)
1279
+ return categories.length === 1 && categories[0][0] === 'warmup_minimal'
1280
+ }
1281
+
1282
+ function addCounts(target, source) {
1283
+ for (const [key, value] of Object.entries(source || {})) {
1284
+ if (value > 0) target[key] = (target[key] || 0) + value
1285
+ }
1286
+ }
1287
+
1288
+ function buildFacetVersionKey(thread, model) {
1289
+ return hashObject({
1290
+ schemaVersion: FACET_SCHEMA_VERSION,
1291
+ id: thread.id,
1292
+ updatedAt: thread.updatedAt,
1293
+ model,
1294
+ transcript: thread.transcriptForAnalysis,
1295
+ })
1296
+ }
1297
+
1298
+ function resolveCacheRoot(explicitDir) {
1299
+ if (explicitDir) return path.resolve(explicitDir)
1300
+ return path.join(os.homedir(), '.codex-insights-cache')
1301
+ }
1302
+
1303
+ function chunkText(text, chunkSize) {
1304
+ const chunks = []
1305
+ let start = 0
1306
+ while (start < text.length) {
1307
+ let end = Math.min(text.length, start + chunkSize)
1308
+ if (end < text.length) {
1309
+ const lastBreak = text.lastIndexOf('\n', end)
1310
+ if (lastBreak > start + chunkSize * 0.6) {
1311
+ end = lastBreak
1312
+ }
1313
+ }
1314
+ chunks.push(text.slice(start, end))
1315
+ start = end
1316
+ }
1317
+ return chunks
1318
+ }
1319
+
1320
+ function estimateTokensFromChars(chars) {
1321
+ return Math.ceil(Number(chars || 0) / 4)
1322
+ }
1323
+
1324
+ function getNarrativeLanguageInstruction(lang) {
1325
+ if (lang === 'zh-CN') {
1326
+ return 'Write all free-text narrative fields in Simplified Chinese.'
1327
+ }
1328
+ return 'Write all free-text narrative fields in English.'
1329
+ }
1330
+
1331
+ function getStructuredLanguageInstruction(lang) {
1332
+ if (lang === 'zh-CN') {
1333
+ return 'Keep keys and enum values unchanged. Write only free-text fields in Simplified Chinese.'
1334
+ }
1335
+ return 'Keep keys and enum values unchanged. Write free-text fields in English.'
1336
+ }
1337
+
1338
+ function langFromProviderOptions(options) {
1339
+ return options?.lang === 'zh-CN' ? 'zh-CN' : 'en'
1340
+ }
1341
+
1342
+ function describeLanguage(lang) {
1343
+ return lang === 'zh-CN' ? 'Simplified Chinese' : 'English'
1344
+ }
1345
+
1346
+ function emitProgress(options, event) {
1347
+ if (typeof options?.onProgress === 'function') {
1348
+ options.onProgress(event)
1349
+ }
1350
+ }
1351
+
1352
+ function sanitizeContextText(value) {
1353
+ return String(value ?? '').replace(/\s+/g, ' ').trim()
1354
+ }
1355
+
1356
+ function truncateForContext(value, limit) {
1357
+ const text = sanitizeContextText(value)
1358
+ if (!text) return ''
1359
+ if (text.length <= limit) return text
1360
+ return `${text.slice(0, limit)}...`
1361
+ }
1362
+
1363
+ function compactProjectPath(value) {
1364
+ const text = String(value ?? '').trim()
1365
+ if (!text) return ''
1366
+ const parts = text.split('/').filter(Boolean)
1367
+ return parts.slice(-2).join('/')
1368
+ }
1369
+
1370
+ function compactCountObject(value, limit) {
1371
+ return Object.fromEntries(
1372
+ Object.entries(value || {})
1373
+ .filter(([, count]) => Number(count) > 0)
1374
+ .sort((a, b) => Number(b[1]) - Number(a[1]))
1375
+ .slice(0, limit),
1376
+ )
1377
+ }
1378
+
1379
+ function compressActiveHours(hourSeries) {
1380
+ return [
1381
+ { label: 'night', value: sumHours(hourSeries, [0, 1, 2, 3, 4, 5]) },
1382
+ { label: 'morning', value: sumHours(hourSeries, [6, 7, 8, 9, 10, 11]) },
1383
+ { label: 'afternoon', value: sumHours(hourSeries, [12, 13, 14, 15, 16, 17]) },
1384
+ { label: 'evening', value: sumHours(hourSeries, [18, 19, 20, 21, 22, 23]) },
1385
+ ]
1386
+ }
1387
+
1388
+ function sumHours(hourSeries, hours) {
1389
+ return hours.reduce((sum, hour) => sum + Number(hourSeries[hour]?.value || 0), 0)
1390
+ }
1391
+
1392
+ function compactJson(value) {
1393
+ return JSON.stringify(value)
1394
+ }
1395
+
1396
+ function buildFullSectionContext(context) {
1397
+ return {
1398
+ metadata: context.metadata,
1399
+ summary: context.summary,
1400
+ charts: context.charts,
1401
+ aggregate_facets: context.aggregate_facets,
1402
+ session_summaries: context.session_summaries,
1403
+ friction_details: context.friction_details,
1404
+ user_instructions: context.user_instructions,
1405
+ recent_threads: context.recent_threads,
1406
+ }
1407
+ }
1408
+
1409
+ function buildSectionContext(context, kind) {
1410
+ if (kind === 'at_a_glance') {
1411
+ return {
1412
+ metadata: context.metadata,
1413
+ summary: context.summary,
1414
+ aggregate_facets: context.aggregate_facets,
1415
+ charts: {
1416
+ projects: context.charts.projects,
1417
+ tools: context.charts.tools,
1418
+ tool_failures: context.charts.tool_failures,
1419
+ },
1420
+ friction_details: context.friction_details.slice(0, 8),
1421
+ recent_threads: context.recent_threads,
1422
+ }
1423
+ }
1424
+
1425
+ return buildFullSectionContext(context)
1426
+ }
1427
+
1428
+ function compactInsightDigest(insights) {
1429
+ return {
1430
+ project_areas: (insights.project_areas?.areas || []).slice(0, 4).map(item => ({
1431
+ name: item.name,
1432
+ sessions: item.session_count,
1433
+ })),
1434
+ interaction_style: {
1435
+ key_pattern: insights.interaction_style?.key_pattern || '',
1436
+ narrative: truncateForContext(insights.interaction_style?.narrative || '', 240),
1437
+ },
1438
+ what_works: (insights.what_works?.impressive_workflows || []).slice(0, 3).map(item => ({
1439
+ title: item.title,
1440
+ description: truncateForContext(item.description, 160),
1441
+ })),
1442
+ friction_analysis: (insights.friction_analysis?.categories || []).slice(0, 3).map(item => ({
1443
+ category: item.category,
1444
+ description: truncateForContext(item.description, 140),
1445
+ })),
1446
+ suggestions: {
1447
+ features: (insights.suggestions?.features_to_try || []).slice(0, 3).map(item => item.feature),
1448
+ usage_patterns: (insights.suggestions?.usage_patterns || []).slice(0, 2).map(item => item.title),
1449
+ agents_md_additions: (insights.suggestions?.agents_md_additions || []).slice(0, 2).map(item =>
1450
+ truncateForContext(item.addition, 120),
1451
+ ),
1452
+ },
1453
+ on_the_horizon: (insights.on_the_horizon?.opportunities || []).slice(0, 3).map(item => ({
1454
+ title: item.title,
1455
+ whats_possible: truncateForContext(item.whats_possible, 140),
1456
+ })),
1457
+ }
1458
+ }
1459
+
1460
+ function hashObject(value) {
1461
+ return crypto.createHash('sha256').update(JSON.stringify(value)).digest('hex')
1462
+ }
1463
+
1464
+ async function readJson(filePath) {
1465
+ try {
1466
+ return JSON.parse(await fs.readFile(filePath, 'utf8'))
1467
+ } catch {
1468
+ return null
1469
+ }
1470
+ }
1471
+
1472
+ async function mapLimit(items, limit, fn) {
1473
+ const results = new Array(items.length)
1474
+ let index = 0
1475
+
1476
+ async function worker() {
1477
+ while (index < items.length) {
1478
+ const current = index
1479
+ index += 1
1480
+ results[current] = await fn(items[current], current)
1481
+ }
1482
+ }
1483
+
1484
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, worker))
1485
+ return results
1486
+ }