codex-session-insights 0.2.0 → 0.2.2

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/README.md CHANGED
@@ -60,6 +60,12 @@ Default run:
60
60
  npx codex-session-insights
61
61
  ```
62
62
 
63
+ Lite local run for prompt and layout testing:
64
+
65
+ ```bash
66
+ npx codex-session-insights --preset lite
67
+ ```
68
+
63
69
  Estimate first, then decide:
64
70
 
65
71
  ```bash
@@ -120,6 +126,7 @@ Current default analysis plan:
120
126
 
121
127
  Important behavior defaults:
122
128
 
129
+ - `--preset lite` maps to `days=7`, `limit=20`, `facet-limit=8`, `preview=10`
123
130
  - `limit` means the target number of substantive threads to include in the report, not just the first 50 indexed threads
124
131
  - `facet-limit` means the max number of uncached per-thread facet analyses to run in a single report
125
132
  - Report language follows a best-effort system locale check
@@ -195,7 +202,9 @@ Useful local commands:
195
202
  npm install
196
203
  npm test
197
204
  npm run check
205
+ npm run report:lite
198
206
  npm run generate:test-report
199
207
  ```
200
208
 
209
+ `npm run report:lite` runs a smaller local analysis preset for testing prompt and layout changes without paying the full 200/50 default cost.
201
210
  `npm run generate:test-report` writes a deterministic sample report page to `test-artifacts/sample-report/`.
package/lib/cli.js CHANGED
@@ -99,6 +99,7 @@ export async function runCli(argv) {
99
99
  })
100
100
  report.analysisMode = 'llm'
101
101
  report.provider = parsed.options.provider
102
+ report.analysisEstimate = estimate
102
103
  report.analysisUsage = llmResult.analysisUsage
103
104
 
104
105
  progress.startStage(parsed.options, getUiText(parsed.options.lang).writingFiles)
@@ -135,11 +136,18 @@ export async function runCli(argv) {
135
136
 
136
137
  function parseArgs(argv) {
137
138
  let command = null
139
+ const explicit = {
140
+ days: false,
141
+ limit: false,
142
+ preview: false,
143
+ facetLimit: false,
144
+ }
138
145
  const options = {
139
146
  codexHome: null,
140
147
  outDir: null,
141
148
  jsonPath: null,
142
149
  htmlPath: null,
150
+ preset: DEFAULT_SCOPE_PRESET,
143
151
  days: 30,
144
152
  limit: 200,
145
153
  preview: 50,
@@ -195,14 +203,21 @@ function parseArgs(argv) {
195
203
  continue
196
204
  }
197
205
  if (arg === '--days') {
206
+ explicit.days = true
198
207
  options.days = toPositiveInt(requireValue(argv, ++i, '--days'), '--days')
199
208
  continue
200
209
  }
210
+ if (arg === '--preset') {
211
+ options.preset = normalizeScopePreset(requireValue(argv, ++i, '--preset'))
212
+ continue
213
+ }
201
214
  if (arg === '--limit') {
215
+ explicit.limit = true
202
216
  options.limit = toPositiveInt(requireValue(argv, ++i, '--limit'), '--limit')
203
217
  continue
204
218
  }
205
219
  if (arg === '--preview') {
220
+ explicit.preview = true
206
221
  options.preview = toPositiveInt(requireValue(argv, ++i, '--preview'), '--preview')
207
222
  continue
208
223
  }
@@ -255,6 +270,7 @@ function parseArgs(argv) {
255
270
  continue
256
271
  }
257
272
  if (arg === '--facet-limit') {
273
+ explicit.facetLimit = true
258
274
  options.facetLimit = toPositiveInt(requireValue(argv, ++i, '--facet-limit'), '--facet-limit')
259
275
  continue
260
276
  }
@@ -298,6 +314,19 @@ function parseArgs(argv) {
298
314
  throw new Error(`Invalid provider "${options.provider}". Expected codex-cli or openai.`)
299
315
  }
300
316
 
317
+ options.preset = normalizeScopePreset(options.preset)
318
+ const explicitValues = {
319
+ days: options.days,
320
+ limit: options.limit,
321
+ preview: options.preview,
322
+ facetLimit: options.facetLimit,
323
+ }
324
+ Object.assign(options, applyScopePreset(options, options.preset))
325
+ if (explicit.days) options.days = explicitValues.days
326
+ if (explicit.limit) options.limit = explicitValues.limit
327
+ if (explicit.preview) options.preview = explicitValues.preview
328
+ if (explicit.facetLimit) options.facetLimit = explicitValues.facetLimit
329
+
301
330
  return { command, options, help }
302
331
  }
303
332
 
@@ -330,6 +359,7 @@ Options:
330
359
  --json-path <path> Exact path for report.json
331
360
  --html-path <path> Exact path for report.html
332
361
  --days <n> Only include threads updated in the last N days (default: 30)
362
+ --preset <name> Scope preset: lite, standard, or deep (default: standard)
333
363
  --limit <n> Target number of substantive threads to include (default: 200)
334
364
  --preview <n> Number of threads to embed in the HTML report (default: 50)
335
365
  --provider <name> Model provider: codex-cli or openai (default: codex-cli)
@@ -681,11 +711,22 @@ function inferQualityPreset(options) {
681
711
  }
682
712
 
683
713
  function applyScopePreset(options, preset) {
684
- if (preset === 'conservative') return { ...options, limit: 20, facetLimit: 8 }
714
+ if (preset === 'lite' || preset === 'conservative') {
715
+ return { ...options, days: 7, limit: 20, facetLimit: 8, preview: 10 }
716
+ }
685
717
  if (preset === 'deep') return { ...options, limit: 400, facetLimit: 50 }
686
718
  return { ...options, limit: 200, facetLimit: 50 }
687
719
  }
688
720
 
721
+ function normalizeScopePreset(value) {
722
+ const preset = String(value || '').trim().toLowerCase()
723
+ if (preset === 'lite') return 'lite'
724
+ if (preset === 'conservative') return 'conservative'
725
+ if (preset === 'deep') return 'deep'
726
+ if (preset === 'standard' || !preset) return 'standard'
727
+ throw new Error(`Invalid preset "${value}". Expected lite, standard, or deep.`)
728
+ }
729
+
689
730
  function applyQualityPreset(options, preset) {
690
731
  if (preset === 'cheaper') {
691
732
  return {
@@ -997,6 +1038,8 @@ export const __test = {
997
1038
  applyScopePreset,
998
1039
  applyQualityPreset,
999
1040
  buildEquivalentCommand,
1041
+ parseArgs,
1042
+ normalizeScopePreset,
1000
1043
  normalizeLang,
1001
1044
  detectSystemLanguage,
1002
1045
  }
package/lib/codex-data.js CHANGED
@@ -8,8 +8,16 @@ import { promisify } from 'node:util'
8
8
  const execFileAsync = promisify(execFile)
9
9
  const MAX_TRANSCRIPT_CHARS = 30000
10
10
  const USER_TRANSCRIPT_LIMIT = 500
11
- const ASSISTANT_TRANSCRIPT_LIMIT = 180
11
+ const ASSISTANT_TRANSCRIPT_LIMIT = 300
12
12
  const FAILURE_SUMMARY_LIMIT = 120
13
+ const MAX_TOOL_LINES_PER_BURST = 2
14
+ const LOW_SIGNAL_ASSISTANT_PATTERNS = [
15
+ /^i('| a)?m\s+(checking|looking|reviewing|reading|investigating)\b/i,
16
+ /^i('| wi)?ll\s+(check|look|review|inspect|read|investigate|start by)\b/i,
17
+ /^let me\b/i,
18
+ /^(checking|reviewing|reading|looking at|investigating)\b/i,
19
+ /^(next|now)\b/i,
20
+ ]
13
21
  const TASK_AGENT_TOOLS = new Set([
14
22
  'spawn_agent',
15
23
  'send_input',
@@ -422,7 +430,7 @@ export function summarizeThread(thread, events) {
422
430
  averageResponseTimeSeconds: average(responseTimesSeconds),
423
431
  activeHours,
424
432
  userMessageTimestamps: sortedUserTs.map(tsMs => new Date(tsMs).toISOString()),
425
- transcriptForAnalysis: clampTranscript(transcriptLines.join('\n')),
433
+ transcriptForAnalysis: clampTranscript(compactTranscriptLines(transcriptLines).join('\n')),
426
434
  gitCommits,
427
435
  gitPushes,
428
436
  userInterruptions,
@@ -627,6 +635,38 @@ function clampTranscript(text) {
627
635
  return `${clean.slice(0, MAX_TRANSCRIPT_CHARS)}\n[Transcript truncated]`
628
636
  }
629
637
 
638
+ function compactTranscriptLines(lines) {
639
+ const compacted = []
640
+ let pendingToolBurst = []
641
+
642
+ const flushToolBurst = () => {
643
+ if (!pendingToolBurst.length) return
644
+ compacted.push(...pendingToolBurst.slice(0, MAX_TOOL_LINES_PER_BURST))
645
+ const remaining = pendingToolBurst.length - MAX_TOOL_LINES_PER_BURST
646
+ if (remaining > 0) {
647
+ compacted.push(`[Tool activity truncated: ${remaining} more tool calls]`)
648
+ }
649
+ pendingToolBurst = []
650
+ }
651
+
652
+ for (const line of lines) {
653
+ if (isLowSignalAssistantLine(line)) continue
654
+
655
+ if (isToolLine(line)) {
656
+ pendingToolBurst.push(line)
657
+ continue
658
+ }
659
+
660
+ flushToolBurst()
661
+ if (!compacted.length || compacted[compacted.length - 1] !== line) {
662
+ compacted.push(line)
663
+ }
664
+ }
665
+
666
+ flushToolBurst()
667
+ return compacted
668
+ }
669
+
630
670
  function appendTranscriptLine(lines, line, setLastLine, lastLine) {
631
671
  const clean = String(line ?? '').trim()
632
672
  if (!clean || clean === lastLine) return
@@ -634,6 +674,20 @@ function appendTranscriptLine(lines, line, setLastLine, lastLine) {
634
674
  setLastLine(clean)
635
675
  }
636
676
 
677
+ function isToolLine(line) {
678
+ const text = String(line || '')
679
+ return text.startsWith('[Tool: ')
680
+ }
681
+
682
+ function isLowSignalAssistantLine(line) {
683
+ const text = String(line || '')
684
+ if (!text.startsWith('[Assistant] ')) return false
685
+ const body = text.slice('[Assistant] '.length).trim()
686
+ if (!body) return true
687
+ if (body.length > 60) return false
688
+ return LOW_SIGNAL_ASSISTANT_PATTERNS.some(pattern => pattern.test(body))
689
+ }
690
+
637
691
  function sanitizeTranscriptText(value, limit) {
638
692
  const text = String(value ?? '')
639
693
  .replace(/<system_instruction>[\s\S]*?<\/system_instruction>/g, ' ')
@@ -276,24 +276,27 @@ const SECTION_DEFS = [
276
276
  contextKind: 'project_areas',
277
277
  schemaName: 'codex_project_areas',
278
278
  schema: PROJECT_AREAS_SCHEMA,
279
- prompt: `Analyze this Codex usage data and identify project areas.
279
+ prompt: `Analyze this Codex usage data and identify the user's main workstreams.
280
280
 
281
281
  RESPOND WITH ONLY A VALID JSON OBJECT:
282
282
  {
283
283
  "areas": [
284
- {"name": "Area name", "session_count": 0, "description": "2-3 sentences about what was worked on and how Codex was used."}
284
+ {"name": "Area name", "session_count": 0, "description": "2-3 sentences describing the workstream, its recurring tasks, and why it matters."}
285
285
  ]
286
286
  }
287
287
 
288
- Include 4-5 areas. Skip Codex self-hosting/meta work unless it is a dominant project area.
288
+ Include 3-4 areas. Skip Codex self-hosting/meta work unless it is a dominant project area.
289
289
 
290
290
  Guardrails:
291
291
  - Use concrete project or workstream names, not generic labels like "coding" or "development"
292
292
  - Base areas on repeated evidence across summaries, not one-off threads
293
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`,
294
+ - Group related tasks into a coherent long-running workstream instead of listing each task separately
295
+ - Prefer fewer, broader areas that still feel accurate over a more complete but fragmented list
296
+ - Do not turn recent sub-tasks, bugfixes, or cleanup passes into separate areas unless they clearly form their own repeated stream
297
+ - Each description should read like a workstream summary, not a changelog
298
+ - Mention representative tasks, artifacts, or decisions so the area feels concrete without enumerating every thread
299
+ - Keep the focus on what the user was trying to accomplish; mention Codex only lightly when it clarifies the shape of the work`,
297
300
  },
298
301
  {
299
302
  name: 'interaction_style',
@@ -311,9 +314,12 @@ RESPOND WITH ONLY A VALID JSON OBJECT:
311
314
 
312
315
  Guardrails:
313
316
  - Focus on stable interaction patterns, not isolated moments
314
- - Talk about how the user scopes work, interrupts, redirects, or trusts execution
317
+ - Talk about how the user scopes work, redirects goals, sets acceptance bars, or trusts execution
318
+ - Prefer evidence from user requests, follow-up corrections, repeated constraints, and outcome patterns over implementation telemetry
315
319
  - 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
320
  - Treat shell usage, file reads, and verification commands as weak evidence unless the user explicitly asked for that working style
321
+ - Do not infer style from repository type, documentation volume, or language mix alone
322
+ - Avoid turning a single repo's workflow shape into a personality claim about the user
317
323
  - If evidence is mixed, describe the tension instead of forcing one clean story`,
318
324
  },
319
325
  {
@@ -360,6 +366,7 @@ Include 3 friction categories with 2 examples each.
360
366
  Guardrails:
361
367
  - Separate model-side friction from user/workflow-side friction when useful
362
368
  - Examples must be concrete and tied to the supplied evidence
369
+ - Treat overlap or concurrency metrics as weak supporting evidence unless the summaries or friction details also show real switching pain
363
370
  - Do not invent root causes that are not visible in the data`,
364
371
  },
365
372
  {
@@ -401,6 +408,10 @@ Guardrails:
401
408
  - Suggest only actions that clearly connect to repeated evidence
402
409
  - Avoid generic advice like "give more context" unless it is overwhelmingly justified
403
410
  - Prefer changes with strong leverage: repo memory, repeatable workflows, automation, or parallelism
411
+ - Do not recommend first-time adoption of AGENTS.md, Skills, codex exec, Sub-agents, or MCP Servers when the capability_adoption evidence shows the user already uses them in a moderate or strong way
412
+ - When a capability is already adopted, suggest a deeper refinement or a tighter operating pattern instead of basic adoption
413
+ - Distinguish "you should start using this" from "you should formalize or deepen how you already use this"
414
+ - Use AGENTS.md as the canonical repo instruction filename in examples; do not mention CLAUDE.md
404
415
  - Write AGENTS.md additions as directly pasteable instruction lines, not commentary about instructions
405
416
  - Make feature examples immediately usable; avoid placeholders like "insert your repo path here" unless unavoidable
406
417
  - Make usage pattern suggestions sound like concrete next actions the user can try today, not abstract best practices`,
@@ -610,18 +621,31 @@ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} })
610
621
  let estimatedFacetOutputTokens = 0
611
622
  let estimatedSummaryInputTokens = 0
612
623
  let estimatedSummaryOutputTokens = 0
624
+ const facetSystemPrompt = buildFacetSystemPrompt(options.lang)
613
625
 
614
626
  for (const job of uncachedFacetJobs) {
615
627
  const transcript = String(job.thread.transcriptForAnalysis || '').trim()
616
628
  const transcriptChars = transcript.length
617
629
  if (!transcriptChars) {
618
- estimatedFacetInputTokens += 600
630
+ estimatedFacetInputTokens += estimateModelInputTokens({
631
+ provider: options.provider || DEFAULT_PROVIDER,
632
+ systemPrompt: facetSystemPrompt,
633
+ userPrompt: buildFacetExtractionPrompt(job.thread, `${job.thread.title || '(untitled)'}\n${job.thread.firstUserMessage || ''}`.trim(), options.lang),
634
+ schema: FACET_SCHEMA,
635
+ structured: true,
636
+ })
619
637
  estimatedFacetOutputTokens += 350
620
638
  continue
621
639
  }
622
640
 
623
641
  if (transcriptChars <= LONG_TRANSCRIPT_THRESHOLD) {
624
- estimatedFacetInputTokens += estimateTokensFromChars(transcriptChars) + 1200
642
+ estimatedFacetInputTokens += estimateModelInputTokens({
643
+ provider: options.provider || DEFAULT_PROVIDER,
644
+ systemPrompt: facetSystemPrompt,
645
+ userPrompt: buildFacetExtractionPrompt(job.thread, transcript, options.lang),
646
+ schema: FACET_SCHEMA,
647
+ structured: true,
648
+ })
625
649
  estimatedFacetOutputTokens += 350
626
650
  continue
627
651
  }
@@ -629,20 +653,44 @@ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} })
629
653
  const chunks = chunkText(transcript, TRANSCRIPT_CHUNK_SIZE)
630
654
  chunkSummaryCalls += chunks.length
631
655
  for (const chunk of chunks) {
632
- estimatedSummaryInputTokens += estimateTokensFromChars(chunk.length) + 220
656
+ estimatedSummaryInputTokens += estimateModelInputTokens({
657
+ provider: options.provider || DEFAULT_PROVIDER,
658
+ systemPrompt: `${FACET_TRANSCRIPT_SUMMARY_DIRECTIVE}\n\nPreserve user goal, outcome, friction, command/tool issues, and what the assistant actually achieved.`,
659
+ userPrompt: `Chunk 1 of ${chunks.length}\n\n${chunk}`,
660
+ structured: false,
661
+ })
633
662
  estimatedSummaryOutputTokens += 260
634
663
  }
635
664
  const combinedSummaryChars = chunks.length * 1100
636
665
  if (combinedSummaryChars > LONG_TRANSCRIPT_THRESHOLD) {
637
666
  combineSummaryCalls += 1
638
- estimatedSummaryInputTokens += estimateTokensFromChars(combinedSummaryChars) + 180
667
+ estimatedSummaryInputTokens += estimateModelInputTokens({
668
+ provider: options.provider || DEFAULT_PROVIDER,
669
+ systemPrompt:
670
+ '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.',
671
+ userPrompt: makePlaceholderText(combinedSummaryChars, 'Chunk summaries'),
672
+ structured: false,
673
+ })
639
674
  estimatedSummaryOutputTokens += 320
640
675
  }
641
- estimatedFacetInputTokens += 2400
676
+ estimatedFacetInputTokens += estimateModelInputTokens({
677
+ provider: options.provider || DEFAULT_PROVIDER,
678
+ systemPrompt: facetSystemPrompt,
679
+ userPrompt: buildFacetExtractionPrompt(
680
+ job.thread,
681
+ makePlaceholderText(
682
+ combinedSummaryChars > LONG_TRANSCRIPT_THRESHOLD ? 1200 : combinedSummaryChars,
683
+ '[Long transcript summarized before facet extraction]',
684
+ ),
685
+ options.lang,
686
+ ),
687
+ schema: FACET_SCHEMA,
688
+ structured: true,
689
+ })
642
690
  estimatedFacetOutputTokens += 350
643
691
  }
644
692
 
645
- const estimatedSectionInputs = estimateSectionInputs(candidateThreads, facetJobs)
693
+ const estimatedSectionInputs = estimateSectionInputs(candidateThreads, facetJobs, options)
646
694
  const fastSectionCalls = SECTION_DEFS.filter(section => section.modelTier === 'fast').length
647
695
  const fullSectionCalls = SECTION_DEFS.filter(section => section.modelTier !== 'fast').length
648
696
  const estimatedFastSectionInputTokens = SECTION_DEFS.filter(section => section.modelTier === 'fast')
@@ -814,61 +862,14 @@ async function getFacetForThread(thread, { cacheDir, model, provider, providerOp
814
862
  provider,
815
863
  providerOptions,
816
864
  })
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.`
865
+ const prompt = buildFacetExtractionPrompt(thread, transcript, langFromProviderOptions(providerOptions))
864
866
 
865
867
  const rawFacet = await callStructuredModel({
866
868
  provider,
867
869
  model,
868
870
  schemaName: 'codex_session_facet',
869
871
  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
+ systemPrompt: buildFacetSystemPrompt(langFromProviderOptions(providerOptions)),
872
873
  userPrompt: prompt,
873
874
  options: {
874
875
  ...providerOptions,
@@ -989,6 +990,8 @@ function buildInsightContext(report, threadSummaries, facets) {
989
990
  ),
990
991
  ).slice(0, MAX_USER_INSTRUCTIONS)
991
992
 
993
+ const capabilityAdoption = summarizeCapabilityAdoption(report, threadSummaries, facets)
994
+
992
995
  return {
993
996
  metadata: {
994
997
  generated_at: report.metadata.generatedAt,
@@ -1025,6 +1028,7 @@ function buildInsightContext(report, threadSummaries, facets) {
1025
1028
  friction,
1026
1029
  success,
1027
1030
  },
1031
+ capability_adoption: capabilityAdoption,
1028
1032
  session_summaries: sortedFacets.slice(0, MAX_CONTEXT_FACETS).map(facet => ({
1029
1033
  thread_id: facet.threadId,
1030
1034
  title: truncateForContext(facet.title, 80),
@@ -1051,6 +1055,69 @@ function buildInsightContext(report, threadSummaries, facets) {
1051
1055
  }
1052
1056
  }
1053
1057
 
1058
+ function summarizeCapabilityAdoption(report, threadSummaries, facets) {
1059
+ const textByThread = new Map()
1060
+ for (const thread of threadSummaries) {
1061
+ textByThread.set(
1062
+ thread.id,
1063
+ [thread.title, thread.firstUserMessage]
1064
+ .map(value => String(value || ''))
1065
+ .join('\n')
1066
+ .toLowerCase(),
1067
+ )
1068
+ }
1069
+
1070
+ for (const facet of facets) {
1071
+ const existing = textByThread.get(facet.threadId) || ''
1072
+ const facetText = [
1073
+ facet.underlying_goal,
1074
+ facet.brief_summary,
1075
+ ...(facet.user_instructions || []),
1076
+ ]
1077
+ .map(value => String(value || ''))
1078
+ .join('\n')
1079
+ .toLowerCase()
1080
+ textByThread.set(facet.threadId, `${existing}\n${facetText}`.trim())
1081
+ }
1082
+
1083
+ const detectMentionedThreads = regex => {
1084
+ let count = 0
1085
+ for (const text of textByThread.values()) {
1086
+ if (regex.test(text)) count += 1
1087
+ }
1088
+ return count
1089
+ }
1090
+
1091
+ const totalThreads = Math.max(1, Number(report.metadata.threadCount || threadSummaries.length || 0))
1092
+ const signals = {
1093
+ agents_md: detectMentionedThreads(/\bagents\.md\b/i),
1094
+ skills: detectMentionedThreads(/\bskills?\b/i),
1095
+ codex_exec: detectMentionedThreads(/\bcodex exec\b/i),
1096
+ subagents: Number(report.summary.sessionsUsingTaskAgent || 0),
1097
+ mcp_servers: Number(report.summary.sessionsUsingMcp || 0),
1098
+ web_search: Number(report.summary.sessionsUsingWebSearch || 0),
1099
+ web_fetch: Number(report.summary.sessionsUsingWebFetch || 0),
1100
+ }
1101
+
1102
+ return Object.fromEntries(
1103
+ Object.entries(signals).map(([key, count]) => [
1104
+ key,
1105
+ {
1106
+ count,
1107
+ status: classifyCapabilityAdoption(count, totalThreads),
1108
+ },
1109
+ ]),
1110
+ )
1111
+ }
1112
+
1113
+ function classifyCapabilityAdoption(count, totalThreads) {
1114
+ const share = Number(count || 0) / Math.max(1, Number(totalThreads || 0))
1115
+ if (count >= 10 || share >= 0.25) return 'strong'
1116
+ if (count >= 4 || share >= 0.1) return 'moderate'
1117
+ if (count > 0) return 'light'
1118
+ return 'none'
1119
+ }
1120
+
1054
1121
  function buildAtAGlancePrompt(context, insights) {
1055
1122
  return `You are writing an "At a Glance" summary for a Codex usage insights report.
1056
1123
 
@@ -1078,6 +1145,124 @@ ${compactJson(compactInsightDigest(insights))}
1078
1145
  RESPOND WITH ONLY A VALID JSON OBJECT matching the schema.`
1079
1146
  }
1080
1147
 
1148
+ function buildFacetSystemPrompt(lang) {
1149
+ return `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(lang)}`.trim()
1150
+ }
1151
+
1152
+ function buildFacetExtractionPrompt(thread, transcript, lang) {
1153
+ return `Analyze this Codex coding session and extract structured facets.
1154
+
1155
+ CRITICAL GUIDELINES:
1156
+ 1. goal_categories should count only what the user explicitly asked for.
1157
+ 2. user_satisfaction_counts should rely on explicit user signals or strong transcript evidence.
1158
+ 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.
1159
+ 4. If the session is mostly warmup, rehearsal, or cache-filling, use warmup_minimal as the only goal category.
1160
+ 5. If evidence is insufficient after transcript compression, use conservative values such as unclear_from_transcript rather than guessing.
1161
+ 6. Do not infer the user's goal from assistant or tool activity alone.
1162
+ 7. Do not count assistant-led exploration or extra implementation work unless the user clearly asked for it.
1163
+
1164
+ Allowed values:
1165
+ - outcome: fully_achieved | mostly_achieved | partially_achieved | not_achieved | unclear_from_transcript
1166
+ - assistant_helpfulness: unhelpful | slightly_helpful | moderately_helpful | very_helpful | essential
1167
+ - session_type: single_task | multi_task | iterative_refinement | exploration | quick_question
1168
+ - primary_success: none | fast_accurate_search | correct_code_edits | good_explanations | proactive_help | multi_file_changes | good_debugging
1169
+
1170
+ Language:
1171
+ - Keep enum values and keys exactly as requested.
1172
+ - Write free-text fields in ${describeLanguage(lang)}.
1173
+
1174
+ Transcript:
1175
+ ${transcript}
1176
+
1177
+ Summary stats:
1178
+ ${JSON.stringify(
1179
+ {
1180
+ title: thread.title,
1181
+ cwd: thread.cwd,
1182
+ durationMinutes: thread.durationMinutes,
1183
+ userMessages: thread.userMessages,
1184
+ assistantMessages: thread.assistantMessages,
1185
+ totalToolCalls: thread.totalToolCalls,
1186
+ totalCommandFailures: thread.totalCommandFailures,
1187
+ toolCounts: thread.toolCounts,
1188
+ toolFailures: thread.toolFailures,
1189
+ userInterruptions: thread.userInterruptions,
1190
+ usesTaskAgent: thread.usesTaskAgent,
1191
+ usesMcp: thread.usesMcp,
1192
+ usesWebSearch: thread.usesWebSearch,
1193
+ usesWebFetch: thread.usesWebFetch,
1194
+ },
1195
+ null,
1196
+ 2,
1197
+ )}
1198
+
1199
+ RESPOND WITH ONLY A VALID JSON OBJECT matching the requested schema.`
1200
+ }
1201
+
1202
+ function buildEstimatedFacet(thread) {
1203
+ return {
1204
+ threadId: thread.id,
1205
+ title: thread.title,
1206
+ cwd: thread.cwd,
1207
+ updatedAt: thread.updatedAt,
1208
+ durationMinutes: thread.durationMinutes,
1209
+ userMessages: thread.userMessages,
1210
+ assistantMessages: thread.assistantMessages,
1211
+ totalToolCalls: thread.totalToolCalls,
1212
+ totalCommandFailures: thread.totalCommandFailures,
1213
+ underlying_goal: truncateForContext(thread.firstUserMessage || thread.title, 160),
1214
+ goal_categories: {},
1215
+ outcome: thread.totalCommandFailures > 0 ? 'partially_achieved' : 'unclear_from_transcript',
1216
+ user_satisfaction_counts: {},
1217
+ assistant_helpfulness: 'moderately_helpful',
1218
+ session_type: thread.userMessages > 2 ? 'iterative_refinement' : 'single_task',
1219
+ friction_counts: thread.totalCommandFailures > 0 ? { tool_failed: 1 } : {},
1220
+ friction_detail: 'none',
1221
+ primary_success: 'none',
1222
+ brief_summary: truncateForContext(thread.firstUserMessage || thread.title, 180),
1223
+ user_instructions: [],
1224
+ }
1225
+ }
1226
+
1227
+ function buildEstimatedInsightsPlaceholder(context) {
1228
+ return {
1229
+ project_areas: {
1230
+ areas: (context.session_summaries || []).slice(0, 3).map((item, index) => ({
1231
+ name: item.project || item.title || `Workstream ${index + 1}`,
1232
+ session_count: 1,
1233
+ description: truncateForContext(item.summary || item.goal || '', 120),
1234
+ })),
1235
+ },
1236
+ interaction_style: {
1237
+ key_pattern: 'Tight scope before execution',
1238
+ narrative: truncateForContext('You first align scope and constraints, then execute and verify against explicit acceptance bars.', 180),
1239
+ },
1240
+ what_works: {
1241
+ impressive_workflows: [
1242
+ { title: 'Scope first', description: 'You repeatedly tighten scope before execution.' },
1243
+ { title: 'Verification loop', description: 'You use evidence to confirm changes before closing.' },
1244
+ ],
1245
+ },
1246
+ friction_analysis: {
1247
+ categories: [
1248
+ { category: 'Direction drift', description: 'Some sessions need scope tightening.', examples: ['Scope had to be pulled back.'] },
1249
+ ],
1250
+ },
1251
+ suggestions: {
1252
+ features_to_try: [{ feature: 'AGENTS.md' }],
1253
+ usage_patterns: [{ title: 'Split review and execution' }],
1254
+ agents_md_additions: [{ addition: 'Read existing docs before editing.' }],
1255
+ },
1256
+ on_the_horizon: {
1257
+ opportunities: [{ title: 'Longer workflows', how_to_try: 'Add staged execution.' }],
1258
+ },
1259
+ fun_ending: {
1260
+ headline: 'Memorable moment',
1261
+ detail: 'A compact placeholder for estimate sizing.',
1262
+ },
1263
+ }
1264
+ }
1265
+
1081
1266
  /**
1082
1267
  * @param {InsightRunOptions} options
1083
1268
  * @param {string} provider
@@ -1096,71 +1281,36 @@ function buildProviderOptions(options, provider, onUsage) {
1096
1281
  }
1097
1282
  }
1098
1283
 
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
- ]
1284
+ function estimateSectionInputs(candidateThreads, facetJobs, options = {}) {
1285
+ const provider = options.provider || DEFAULT_PROVIDER
1286
+ const lang = options.lang || 'en'
1287
+ const estimatedFacets = facetJobs.map(job => job.cachedFacet || buildEstimatedFacet(job.thread))
1288
+ const report = buildReport(candidateThreads, { facets: estimatedFacets })
1289
+ const context = buildInsightContext(report, candidateThreads, estimatedFacets)
1160
1290
  const estimated = {}
1161
- for (const kind of sectionKinds) {
1162
- estimated[kind] = estimateTokensFromChars(compactJson(buildSectionContext(contextShape, kind)).length)
1291
+
1292
+ for (const section of SECTION_DEFS) {
1293
+ const systemPrompt = `${SECTION_SYSTEM_PROMPT} ${getNarrativeLanguageInstruction(lang)}`.trim()
1294
+ const sectionContext = buildSectionContext(context, section.contextKind)
1295
+ const userPrompt = `${section.prompt}\n\n${getNarrativeLanguageInstruction(lang)}\n\nDATA:\n${compactJson(sectionContext)}`
1296
+ estimated[section.contextKind] = estimateModelInputTokens({
1297
+ provider,
1298
+ systemPrompt,
1299
+ userPrompt,
1300
+ schema: section.schema,
1301
+ structured: true,
1302
+ })
1163
1303
  }
1304
+
1305
+ const placeholderInsights = buildEstimatedInsightsPlaceholder(context)
1306
+ estimated.at_a_glance = estimateModelInputTokens({
1307
+ provider,
1308
+ systemPrompt: `${AT_A_GLANCE_SYSTEM_PROMPT} ${getNarrativeLanguageInstruction(lang)}`.trim(),
1309
+ userPrompt: buildAtAGlancePrompt(buildSectionContext(context, 'at_a_glance'), placeholderInsights),
1310
+ schema: AT_A_GLANCE_SCHEMA,
1311
+ structured: true,
1312
+ })
1313
+
1164
1314
  return estimated
1165
1315
  }
1166
1316
 
@@ -1321,6 +1471,36 @@ function estimateTokensFromChars(chars) {
1321
1471
  return Math.ceil(Number(chars || 0) / 4)
1322
1472
  }
1323
1473
 
1474
+ function estimateModelInputTokens({ provider, systemPrompt, userPrompt, schema = null, structured }) {
1475
+ const promptText =
1476
+ provider === 'codex-cli'
1477
+ ? structured
1478
+ ? buildStructuredEstimatePrompt(systemPrompt, userPrompt, schema)
1479
+ : buildPlainEstimatePrompt(systemPrompt, userPrompt)
1480
+ : buildApiEstimatePrompt(systemPrompt, userPrompt, schema, structured)
1481
+ return estimateTokensFromChars(promptText.length)
1482
+ }
1483
+
1484
+ function buildPlainEstimatePrompt(systemPrompt, userPrompt) {
1485
+ return `${String(systemPrompt || '').trim()}\n\n${String(userPrompt || '').trim()}`
1486
+ }
1487
+
1488
+ function buildStructuredEstimatePrompt(systemPrompt, userPrompt, schema) {
1489
+ return `${buildPlainEstimatePrompt(systemPrompt, userPrompt)}\n\nRESPOND WITH ONLY A VALID JSON OBJECT matching this schema:\n${JSON.stringify(schema, null, 2)}`
1490
+ }
1491
+
1492
+ function buildApiEstimatePrompt(systemPrompt, userPrompt, schema, structured) {
1493
+ if (!structured) return buildPlainEstimatePrompt(systemPrompt, userPrompt)
1494
+ return `${buildPlainEstimatePrompt(systemPrompt, userPrompt)}\n\nJSON schema:\n${JSON.stringify(schema, null, 2)}`
1495
+ }
1496
+
1497
+ function makePlaceholderText(length, prefix = '') {
1498
+ const target = Math.max(0, Number(length || 0))
1499
+ const seed = prefix ? `${prefix}\n` : ''
1500
+ if (seed.length >= target) return seed.slice(0, target)
1501
+ return `${seed}${'x'.repeat(Math.max(0, target - seed.length))}`
1502
+ }
1503
+
1324
1504
  function getNarrativeLanguageInstruction(lang) {
1325
1505
  if (lang === 'zh-CN') {
1326
1506
  return 'Write all free-text narrative fields in Simplified Chinese.'
package/lib/report.js CHANGED
@@ -1,3 +1,4 @@
1
+ import os from 'node:os'
1
2
  import path from 'node:path'
2
3
  import { promises as fs } from 'node:fs'
3
4
 
@@ -6,6 +7,9 @@ export function buildReport(threadSummaries, options = {}) {
6
7
  const modelCounts = {}
7
8
  const toolCounts = {}
8
9
  const commandKindCounts = {}
10
+ const capabilityCounts = {}
11
+ const outcomeCounts = {}
12
+ const sessionTypeCounts = {}
9
13
  const toolFailureCounts = {}
10
14
  const activeHourCounts = {}
11
15
  const responseTimes = []
@@ -81,8 +85,20 @@ export function buildReport(threadSummaries, options = {}) {
81
85
  if (thread.usesMcp) sessionsUsingMcp += 1
82
86
  if (thread.usesWebSearch) sessionsUsingWebSearch += 1
83
87
  if (thread.usesWebFetch) sessionsUsingWebFetch += 1
88
+ if (thread.filesModified > 0) increment(capabilityCounts, 'Repo edits')
89
+ if (thread.gitCommits > 0 || thread.gitPushes > 0) increment(capabilityCounts, 'Git activity')
84
90
  }
85
91
 
92
+ for (const facet of options.facets || []) {
93
+ if (facet.outcome) increment(outcomeCounts, facet.outcome)
94
+ if (facet.session_type) increment(sessionTypeCounts, facet.session_type)
95
+ }
96
+
97
+ if (sessionsUsingTaskAgent > 0) increment(capabilityCounts, 'Sub-agents', sessionsUsingTaskAgent)
98
+ if (sessionsUsingMcp > 0) increment(capabilityCounts, 'MCP servers', sessionsUsingMcp)
99
+ if (sessionsUsingWebSearch > 0) increment(capabilityCounts, 'Web search', sessionsUsingWebSearch)
100
+ if (sessionsUsingWebFetch > 0) increment(capabilityCounts, 'Web fetch', sessionsUsingWebFetch)
101
+
86
102
  const sortedThreads = [...threadSummaries].sort(
87
103
  (a, b) => Date.parse(b.updatedAt) - Date.parse(a.updatedAt),
88
104
  )
@@ -130,6 +146,9 @@ export function buildReport(threadSummaries, options = {}) {
130
146
  models: topEntries(modelCounts, 10),
131
147
  tools: topEntries(toolCounts, 12),
132
148
  commandKinds: topEntries(commandKindCounts, 12),
149
+ capabilities: topEntries(capabilityCounts, 8),
150
+ outcomes: topEntries(outcomeCounts, 8),
151
+ sessionTypes: topEntries(sessionTypeCounts, 8),
133
152
  toolFailures: topEntries(toolFailureCounts, 8),
134
153
  toolErrorCategories: topEntries(toolErrorCategoryCounts, 8),
135
154
  activeHours: buildHourSeries(activeHourCounts),
@@ -159,6 +178,7 @@ export async function writeReportFiles(report, options) {
159
178
 
160
179
  export function renderTerminalSummary(report) {
161
180
  const text = getReportText(report.metadata.language)
181
+ const estimateComparison = buildEstimateComparison(report)
162
182
  const lines = []
163
183
  lines.push(text.reportTitle)
164
184
  lines.push(
@@ -171,18 +191,23 @@ export function renderTerminalSummary(report) {
171
191
  lines.push(
172
192
  ` ${text.inputLabel}=${formatMillionTokens(report.analysisUsage.inputTokens)} | ${text.cachedLabel}=${formatMillionTokens(report.analysisUsage.cachedInputTokens)} | ${text.outputLabel}=${formatMillionTokens(report.analysisUsage.outputTokens)}`,
173
193
  )
194
+ if (estimateComparison) {
195
+ lines.push(
196
+ ` ${text.estimateVsActualLabel}: ${formatMillionTokens(estimateComparison.estimatedTotalTokens)} -> ${formatMillionTokens(estimateComparison.actualTotalTokens)} ${text.actualFreshSuffix} (${formatSignedMillionTokens(estimateComparison.deltaTokens)}, ${formatSignedPercent(estimateComparison.deltaPercent)})`,
197
+ )
198
+ }
174
199
  }
175
200
  if (report.metadata.dateRange.start && report.metadata.dateRange.end) {
176
201
  lines.push(`${report.metadata.dateRange.start} -> ${report.metadata.dateRange.end}`)
177
202
  }
178
203
  lines.push('')
179
- lines.push(`${text.topTools}:`)
180
- for (const item of report.charts.tools.slice(0, 5)) {
181
- lines.push(` ${item.label}: ${item.value}`)
182
- }
183
- lines.push('')
184
204
  lines.push(`${text.topProjects}:`)
185
205
  for (const item of report.charts.projects.slice(0, 5)) {
206
+ lines.push(` ${formatProjectLabel(item.label)}: ${item.value}`)
207
+ }
208
+ lines.push('')
209
+ lines.push(`${text.modelMix}:`)
210
+ for (const item of report.charts.models.slice(0, 5)) {
186
211
  lines.push(` ${item.label}: ${item.value}`)
187
212
  }
188
213
  return lines.join('\n')
@@ -193,9 +218,15 @@ function renderHtmlReport(report) {
193
218
  const insights = report.insights
194
219
  if (insights && !insights.__lang) insights.__lang = report.metadata.language
195
220
  const analysisUsage = report.analysisUsage || null
196
- const topTools = renderBarList(report.charts.tools)
197
- const topProjects = renderBarList(report.charts.projects)
198
- const commandKinds = renderBarList(report.charts.commandKinds)
221
+ const topProjects = renderBarList(report.charts.projects, { formatLabel: formatProjectLabel })
222
+ const modelMix = renderBarList(report.charts.models)
223
+ const sessionTypes = renderBarList(report.charts.sessionTypes, {
224
+ formatLabel: value => formatSessionTypeLabel(value, report.metadata.language),
225
+ })
226
+ const outcomes = renderBarList(report.charts.outcomes, {
227
+ formatLabel: value => formatOutcomeLabel(value, report.metadata.language),
228
+ })
229
+ const capabilitySignals = renderBarList(report.charts.capabilities)
199
230
  const toolFailures = renderBarList(report.charts.toolFailures)
200
231
  const toolErrorCategories = renderBarList(report.charts.toolErrorCategories)
201
232
  const activeHours = renderHourHistogram(report.charts.activeHours)
@@ -639,7 +670,7 @@ function renderHtmlReport(report) {
639
670
  <section class="hero">
640
671
  <span class="eyebrow">${escapeHtml(text.eyebrow)}</span>
641
672
  <h1>${escapeHtml(text.reportTitle)}</h1>
642
- <p class="meta">${escapeHtml(text.generatedLabel)} ${escapeHtml(report.metadata.generatedAt)} ${escapeHtml(text.generatedFrom)} ${report.metadata.threadCount} ${escapeHtml(text.substantiveThreads)} ${escapeHtml(text.inCodexHome)} ${escapeHtml(report.metadata.codexHome)}.</p>
673
+ <p class="meta">${escapeHtml(text.generatedLabel)} ${escapeHtml(report.metadata.generatedAt)} ${escapeHtml(text.generatedFrom)} ${report.metadata.threadCount} ${escapeHtml(text.substantiveThreads)} ${escapeHtml(text.inCodexHome)} ${escapeHtml(formatCodexHome(report.metadata.codexHome))}.</p>
643
674
  <div class="summary-grid">
644
675
  ${renderStat(text.userMessages, formatNumber(report.summary.totalUserMessages))}
645
676
  ${renderStat(text.toolCalls, formatNumber(report.summary.totalToolCalls))}
@@ -672,17 +703,25 @@ function renderHtmlReport(report) {
672
703
  ${renderOnTheHorizon(insights)}
673
704
  </div>
674
705
  <aside class="side-column">
675
- <section class="chart-panel">
676
- <h2>${escapeHtml(text.topTools)}</h2>
677
- ${topTools}
678
- </section>
679
706
  <section class="chart-panel">
680
707
  <h2>${escapeHtml(text.topProjects)}</h2>
681
708
  ${topProjects}
682
709
  </section>
683
710
  <section class="chart-panel">
684
- <h2>${escapeHtml(text.commandKinds)}</h2>
685
- ${commandKinds}
711
+ <h2>${escapeHtml(text.modelMix)}</h2>
712
+ ${modelMix}
713
+ </section>
714
+ <section class="chart-panel">
715
+ <h2>${escapeHtml(text.sessionTypes)}</h2>
716
+ ${sessionTypes}
717
+ </section>
718
+ <section class="chart-panel">
719
+ <h2>${escapeHtml(text.outcomes)}</h2>
720
+ ${outcomes}
721
+ </section>
722
+ <section class="chart-panel">
723
+ <h2>${escapeHtml(text.capabilitySignals)}</h2>
724
+ ${capabilitySignals}
686
725
  </section>
687
726
  <section class="chart-panel">
688
727
  <h2>${escapeHtml(text.failureHotspots)}</h2>
@@ -764,7 +803,7 @@ function renderProjectAreas(insights) {
764
803
  <div class="project-area">
765
804
  <div class="area-header">
766
805
  <span class="area-name">${escapeHtml(area.name)}</span>
767
- <span class="area-count">~${formatNumber(area.session_count)} ${escapeHtml(text.sessionsLabel)}</span>
806
+ <span class="area-count">${escapeHtml(text.workstreamBadge)}</span>
768
807
  </div>
769
808
  <div class="area-desc">${escapeHtml(area.description)}</div>
770
809
  </div>
@@ -846,9 +885,9 @@ function renderSuggestions(insights) {
846
885
  const suggestions = insights.suggestions
847
886
  if (!suggestions) return ''
848
887
  const text = getReportText(insights.__lang)
849
- const agentItems = suggestions.agents_md_additions || []
850
- const featureItems = suggestions.features_to_try || []
851
- const patternItems = suggestions.usage_patterns || []
888
+ const agentItems = (suggestions.agents_md_additions || []).map(normalizeSuggestionTextItem)
889
+ const featureItems = (suggestions.features_to_try || []).map(normalizeSuggestionTextItem)
890
+ const patternItems = (suggestions.usage_patterns || []).map(normalizeSuggestionTextItem)
852
891
 
853
892
  return `
854
893
  <section class="panel">
@@ -966,6 +1005,7 @@ function renderReportMeta(report, context = {}) {
966
1005
  const text = getReportText(report.metadata.language)
967
1006
  const usage = report.analysisUsage
968
1007
  const hasUsage = Boolean(usage?.totalTokens)
1008
+ const estimateComparison = buildEstimateComparison(report)
969
1009
  const analysisByStage = context.analysisByStage || ''
970
1010
  const analysisByModel = context.analysisByModel || ''
971
1011
 
@@ -981,6 +1021,7 @@ function renderReportMeta(report, context = {}) {
981
1021
  ${hasUsage ? renderStat(text.analysisTokens, formatMillionTokens(usage.totalTokens)) : ''}
982
1022
  ${hasUsage ? renderStat(text.modelCalls, formatNumber(usage.calls)) : ''}
983
1023
  ${hasUsage ? renderStat(text.cachedInput, formatMillionTokens(usage.cachedInputTokens)) : ''}
1024
+ ${estimateComparison ? renderStat(text.estimateDelta, `${formatSignedMillionTokens(estimateComparison.deltaTokens)} (${formatSignedPercent(estimateComparison.deltaPercent)})`) : ''}
984
1025
  ${renderStat(text.historicalSessionTokens, formatNumber(report.summary.totalTokens))}
985
1026
  </div>
986
1027
  ${
@@ -1006,6 +1047,14 @@ function renderReportMeta(report, context = {}) {
1006
1047
  })}
1007
1048
  <p class="meta">${escapeHtml(text.freshInput)}: ${formatMillionTokens(freshInputTokens)}. ${escapeHtml(text.analysisCostFootnote)}</p>
1008
1049
  </div>
1050
+ ${
1051
+ estimateComparison
1052
+ ? `<div class="usage-breakdown">
1053
+ <h3>${escapeHtml(text.estimateVsActualHeading)}</h3>
1054
+ ${renderEstimateComparisonCard(estimateComparison, text)}
1055
+ </div>`
1056
+ : ''
1057
+ }
1009
1058
  </div>`
1010
1059
  : ''
1011
1060
  }
@@ -1050,19 +1099,43 @@ function renderUsageCard(item) {
1050
1099
  `
1051
1100
  }
1052
1101
 
1102
+ function renderEstimateComparisonCard(comparison, text) {
1103
+ return `
1104
+ <div class="usage-card">
1105
+ <div class="topline">
1106
+ <h3>${escapeHtml(text.estimateVsActualLabel)}</h3>
1107
+ <span class="pill">${escapeHtml(comparison.verdictLabel)}</span>
1108
+ </div>
1109
+ <div class="meta-row">
1110
+ <span>${escapeHtml(text.estimatedLabel)}: ${formatMillionTokens(comparison.estimatedTotalTokens)}</span>
1111
+ <span>${escapeHtml(text.actualFreshLabel)}: ${formatMillionTokens(comparison.actualTotalTokens)}</span>
1112
+ <span>${escapeHtml(text.estimateDelta)}: ${formatSignedMillionTokens(comparison.deltaTokens)} (${formatSignedPercent(comparison.deltaPercent)})</span>
1113
+ </div>
1114
+ <div class="token-row">
1115
+ <div class="token-box"><strong>${escapeHtml(text.estimatedLabel)}</strong><br>${formatMillionTokens(comparison.estimatedTotalTokens)}</div>
1116
+ <div class="token-box"><strong>${escapeHtml(text.actualFreshLabel)}</strong><br>${formatMillionTokens(comparison.actualTotalTokens)}</div>
1117
+ <div class="token-box"><strong>${escapeHtml(text.estimateRangeLabel)}</strong><br>${formatMillionTokens(comparison.lowEstimate)} -> ${formatMillionTokens(comparison.highEstimate)}</div>
1118
+ <div class="token-box"><strong>${escapeHtml(text.estimateDelta)}</strong><br>${formatSignedMillionTokens(comparison.deltaTokens)}</div>
1119
+ <div class="token-box"><strong>${escapeHtml(text.estimateError)}</strong><br>${formatSignedPercent(comparison.deltaPercent)}</div>
1120
+ </div>
1121
+ </div>
1122
+ `
1123
+ }
1124
+
1053
1125
  function renderStat(label, value) {
1054
1126
  return `<div class="stat"><div class="value">${escapeHtml(String(value))}</div><div>${escapeHtml(label)}</div></div>`
1055
1127
  }
1056
1128
 
1057
- function renderBarList(items) {
1129
+ function renderBarList(items, options = {}) {
1058
1130
  if (!items.length) return '<p class="meta">No data available.</p>'
1131
+ const formatLabel = options.formatLabel || (value => value)
1059
1132
  const maxValue = Math.max(...items.map(item => item.value), 1)
1060
1133
  return `<div class="bar-list">${items
1061
1134
  .map(
1062
1135
  item => `
1063
1136
  <div class="bar-row">
1064
1137
  <div class="bar-label">
1065
- <span>${escapeHtml(item.label)}</span>
1138
+ <span>${escapeHtml(formatLabel(item.label))}</span>
1066
1139
  <strong>${formatNumber(item.value)}</strong>
1067
1140
  </div>
1068
1141
  <div class="bar-track"><div class="bar-fill" style="width:${Math.max(6, (item.value / maxValue) * 100)}%"></div></div>
@@ -1117,6 +1190,20 @@ function renderCopyRow(value, text) {
1117
1190
  `
1118
1191
  }
1119
1192
 
1193
+ function normalizeSuggestionTextItem(item) {
1194
+ if (!item || typeof item !== 'object') return item
1195
+ return Object.fromEntries(
1196
+ Object.entries(item).map(([key, value]) => [key, normalizeSuggestionText(value)]),
1197
+ )
1198
+ }
1199
+
1200
+ function normalizeSuggestionText(value) {
1201
+ if (typeof value !== 'string') return value
1202
+ return value
1203
+ .replaceAll('CLAUDE.md / AGENTS.md', 'AGENTS.md')
1204
+ .replaceAll('CLAUDE.md', 'AGENTS.md')
1205
+ }
1206
+
1120
1207
  function formatAgentInstruction(item) {
1121
1208
  const placement = String(item?.prompt_scaffold || '').trim()
1122
1209
  const addition = String(item?.addition || '').trim()
@@ -1133,6 +1220,74 @@ function topEntries(map, limit) {
1133
1220
  .map(([label, value]) => ({ label, value }))
1134
1221
  }
1135
1222
 
1223
+ function formatCodexHome(value) {
1224
+ return formatDisplayPath(value, { tailSegments: 2, preferHomeAlias: true, ellipsis: false })
1225
+ }
1226
+
1227
+ function formatProjectLabel(value) {
1228
+ return formatDisplayPath(value, { tailSegments: 2, preferHomeAlias: false, ellipsis: true })
1229
+ }
1230
+
1231
+ function formatSessionTypeLabel(value, lang) {
1232
+ const key = String(value || '')
1233
+ const zh = {
1234
+ iterative_refinement: '反复收敛',
1235
+ exploration: '探索调研',
1236
+ single_task: '单任务推进',
1237
+ multi_task: '多任务并行',
1238
+ quick_question: '快速提问',
1239
+ }
1240
+ const en = {
1241
+ iterative_refinement: 'Iterative refinement',
1242
+ exploration: 'Exploration',
1243
+ single_task: 'Single-task execution',
1244
+ multi_task: 'Multi-task coordination',
1245
+ quick_question: 'Quick question',
1246
+ }
1247
+ return lang === 'zh-CN' ? zh[key] || key : en[key] || key
1248
+ }
1249
+
1250
+ function formatOutcomeLabel(value, lang) {
1251
+ const key = String(value || '')
1252
+ const zh = {
1253
+ fully_achieved: '完全达成',
1254
+ mostly_achieved: '基本达成',
1255
+ partially_achieved: '部分达成',
1256
+ not_achieved: '未达成',
1257
+ unclear_from_transcript: '从记录中无法判断',
1258
+ }
1259
+ const en = {
1260
+ fully_achieved: 'Fully achieved',
1261
+ mostly_achieved: 'Mostly achieved',
1262
+ partially_achieved: 'Partially achieved',
1263
+ not_achieved: 'Not achieved',
1264
+ unclear_from_transcript: 'Unclear from transcript',
1265
+ }
1266
+ return lang === 'zh-CN' ? zh[key] || key : en[key] || key
1267
+ }
1268
+
1269
+ function formatDisplayPath(value, options = {}) {
1270
+ const text = String(value || '').trim()
1271
+ if (!text) return '(unknown)'
1272
+
1273
+ const normalized = text.replace(/\\/g, '/')
1274
+ const home = os.homedir().replace(/\\/g, '/')
1275
+ if (options.preferHomeAlias !== false && normalized === home) return '~'
1276
+ if (options.preferHomeAlias !== false && normalized.startsWith(`${home}/`)) {
1277
+ return `~/${normalized.slice(home.length + 1)}`
1278
+ }
1279
+
1280
+ const parts = normalized.split('/').filter(Boolean)
1281
+ const tailSegments = Math.max(1, Number(options.tailSegments || 2))
1282
+ if (parts.length <= tailSegments) {
1283
+ return normalized.startsWith('/') ? `/${parts.join('/')}` : parts.join('/')
1284
+ }
1285
+
1286
+ const tail = parts.slice(-tailSegments).join('/')
1287
+ if (options.ellipsis === false) return tail
1288
+ return `…/${tail}`
1289
+ }
1290
+
1136
1291
  function buildHourSeries(hourMap) {
1137
1292
  return Array.from({ length: 24 }, (_, hour) => ({
1138
1293
  hour,
@@ -1165,6 +1320,12 @@ function formatMillionTokens(value) {
1165
1320
  return `${round(number / 1_000)}K tokens`
1166
1321
  }
1167
1322
 
1323
+ function formatSignedMillionTokens(value) {
1324
+ const number = Number(value || 0)
1325
+ const prefix = number > 0 ? '+' : number < 0 ? '-' : ''
1326
+ return `${prefix}${formatMillionTokens(Math.abs(number))}`
1327
+ }
1328
+
1168
1329
  function formatPercent(value, total) {
1169
1330
  const numerator = Number(value || 0)
1170
1331
  const denominator = Number(total || 0)
@@ -1172,6 +1333,12 @@ function formatPercent(value, total) {
1172
1333
  return `${round((numerator / denominator) * 100)}%`
1173
1334
  }
1174
1335
 
1336
+ function formatSignedPercent(value) {
1337
+ const number = Number(value || 0)
1338
+ const prefix = number > 0 ? '+' : number < 0 ? '-' : ''
1339
+ return `${prefix}${round(Math.abs(number))}%`
1340
+ }
1341
+
1175
1342
  function average(values) {
1176
1343
  if (!values.length) return 0
1177
1344
  return round(values.reduce((sum, value) => sum + value, 0) / values.length)
@@ -1226,6 +1393,41 @@ function escapeAttribute(value) {
1226
1393
  return escapeHtml(String(value)).replaceAll("'", '&#39;').replaceAll('\n', '&#10;')
1227
1394
  }
1228
1395
 
1396
+ function buildEstimateComparison(report) {
1397
+ const estimate = report.analysisEstimate
1398
+ const usage = report.analysisUsage
1399
+ if (!estimate?.estimatedTotalTokens || !usage?.totalTokens) return null
1400
+
1401
+ const estimated = Number(estimate.estimatedTotalTokens || 0)
1402
+ const actualFresh =
1403
+ Math.max(0, Number(usage.inputTokens || 0) - Number(usage.cachedInputTokens || 0)) +
1404
+ Number(usage.outputTokens || 0)
1405
+ const delta = actualFresh - estimated
1406
+ const deltaPercent = estimated > 0 ? (delta / estimated) * 100 : 0
1407
+
1408
+ return {
1409
+ estimatedTotalTokens: estimated,
1410
+ actualTotalTokens: actualFresh,
1411
+ lowEstimate: Number(estimate.estimatedRange?.low || estimated),
1412
+ highEstimate: Number(estimate.estimatedRange?.high || estimated),
1413
+ deltaTokens: delta,
1414
+ deltaPercent,
1415
+ verdictLabel: classifyEstimateDelta(deltaPercent, report.metadata.language),
1416
+ }
1417
+ }
1418
+
1419
+ function classifyEstimateDelta(deltaPercent, lang) {
1420
+ const abs = Math.abs(Number(deltaPercent || 0))
1421
+ if (lang === 'zh-CN') {
1422
+ if (abs <= 10) return '估算接近'
1423
+ if (deltaPercent > 0) return '实际偏高'
1424
+ return '实际偏低'
1425
+ }
1426
+ if (abs <= 10) return 'Close estimate'
1427
+ if (deltaPercent > 0) return 'Actual higher'
1428
+ return 'Actual lower'
1429
+ }
1430
+
1229
1431
  function getReportText(lang) {
1230
1432
  if (lang === 'zh-CN') {
1231
1433
  return {
@@ -1244,9 +1446,11 @@ function getReportText(lang) {
1244
1446
  filesModified: '修改文件',
1245
1447
  toolErrors: '工具错误',
1246
1448
  avgResponse: '平均响应',
1247
- topTools: 'Top Tools',
1248
- topProjects: 'Top Projects',
1249
- commandKinds: '命令类型',
1449
+ topProjects: '项目分布',
1450
+ modelMix: '模型分布',
1451
+ sessionTypes: '会话类型',
1452
+ outcomes: '结果分布',
1453
+ capabilitySignals: '能力信号',
1250
1454
  failureHotspots: '失败热点',
1251
1455
  errorCategories: '错误分类',
1252
1456
  timeOfDay: '活跃时段',
@@ -1256,6 +1460,7 @@ function getReportText(lang) {
1256
1460
  quickWins: '可以立刻尝试的优化',
1257
1461
  ambitiousWorkflows: '值得尝试的更强工作流',
1258
1462
  sessionsLabel: '次会话',
1463
+ workstreamBadge: '代表性工作流',
1259
1464
  impressiveThingsLink: '做得好的地方',
1260
1465
  whereThingsGoWrongLink: '容易出问题的地方',
1261
1466
  whatYouWorkOn: '你主要在做什么',
@@ -1286,6 +1491,15 @@ function getReportText(lang) {
1286
1491
  dateRange: '时间范围',
1287
1492
  modelCalls: '模型调用',
1288
1493
  cachedInput: '缓存输入',
1494
+ estimatedLabel: '预估',
1495
+ actualLabel: '实际',
1496
+ actualFreshLabel: '实际(不含缓存)',
1497
+ actualFreshSuffix: '(不含缓存)',
1498
+ estimateRangeLabel: '预估区间',
1499
+ estimateVsActualHeading: '预估与实际',
1500
+ estimateVsActualLabel: '预估 vs 实际',
1501
+ estimateDelta: '偏差',
1502
+ estimateError: '偏差比例',
1289
1503
  historicalSessionTokens: '历史会话 Tokens',
1290
1504
  analysisCostByStage: '按阶段拆分的分析成本',
1291
1505
  analysisCostByModel: '按模型拆分的分析成本',
@@ -1321,9 +1535,11 @@ function getReportText(lang) {
1321
1535
  filesModified: 'Files Modified',
1322
1536
  toolErrors: 'Tool Errors',
1323
1537
  avgResponse: 'Avg Response',
1324
- topTools: 'Top Tools',
1325
1538
  topProjects: 'Top Projects',
1326
- commandKinds: 'Command Kinds',
1539
+ modelMix: 'Model Mix',
1540
+ sessionTypes: 'Session Types',
1541
+ outcomes: 'Outcomes',
1542
+ capabilitySignals: 'Capability Signals',
1327
1543
  failureHotspots: 'Failure Hotspots',
1328
1544
  errorCategories: 'Error Categories',
1329
1545
  timeOfDay: 'Time of Day',
@@ -1333,6 +1549,7 @@ function getReportText(lang) {
1333
1549
  quickWins: 'Quick wins to try',
1334
1550
  ambitiousWorkflows: 'Ambitious workflows',
1335
1551
  sessionsLabel: 'sessions',
1552
+ workstreamBadge: 'Representative workstream',
1336
1553
  impressiveThingsLink: 'Impressive Things You Did',
1337
1554
  whereThingsGoWrongLink: 'Where Things Go Wrong',
1338
1555
  whatYouWorkOn: 'What You Work On',
@@ -1363,6 +1580,15 @@ function getReportText(lang) {
1363
1580
  dateRange: 'Date Range',
1364
1581
  modelCalls: 'Model Calls',
1365
1582
  cachedInput: 'Cached Input',
1583
+ estimatedLabel: 'Estimated',
1584
+ actualLabel: 'Actual',
1585
+ actualFreshLabel: 'Actual (fresh)',
1586
+ actualFreshSuffix: '(fresh)',
1587
+ estimateRangeLabel: 'Estimate range',
1588
+ estimateVsActualHeading: 'Estimate vs Actual',
1589
+ estimateVsActualLabel: 'Estimate vs Actual',
1590
+ estimateDelta: 'Delta',
1591
+ estimateError: 'Error',
1366
1592
  historicalSessionTokens: 'Historical Session Tokens',
1367
1593
  analysisCostByStage: 'Analysis Cost by Stage',
1368
1594
  analysisCostByModel: 'Analysis Cost by Model',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-session-insights",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Generate a report analyzing your Codex sessions.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -25,6 +25,7 @@
25
25
  },
26
26
  "scripts": {
27
27
  "report": "node ./bin/codex-insights.js report",
28
+ "report:lite": "node ./bin/codex-insights.js --preset lite --yes",
28
29
  "generate:test-report": "node ./scripts/generate-test-report.mjs",
29
30
  "test": "node --test",
30
31
  "check": "node --check ./bin/codex-insights.js && node --check ./lib/cli.js && node --check ./lib/codex-data.js && node --check ./lib/report.js && node --check ./lib/llm-insights.js && node --check ./lib/model-provider.js && npm run typecheck",