codex-session-insights 0.2.1 → 0.2.3

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, ' ')
@@ -411,6 +411,7 @@ Guardrails:
411
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
412
  - When a capability is already adopted, suggest a deeper refinement or a tighter operating pattern instead of basic adoption
413
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
414
415
  - Write AGENTS.md additions as directly pasteable instruction lines, not commentary about instructions
415
416
  - Make feature examples immediately usable; avoid placeholders like "insert your repo path here" unless unavoidable
416
417
  - Make usage pattern suggestions sound like concrete next actions the user can try today, not abstract best practices`,
@@ -597,6 +598,7 @@ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} })
597
598
  const facetModel = options.facetModel || DEFAULT_FACET_MODEL
598
599
  const fastSectionModel = options.fastSectionModel || DEFAULT_FAST_SECTION_MODEL
599
600
  const insightModel = options.insightModel || DEFAULT_INSIGHT_MODEL
601
+ const provider = options.provider || DEFAULT_PROVIDER
600
602
  const cacheRoot = resolveCacheRoot(options.cacheDir)
601
603
  const facetCacheDir = path.join(cacheRoot, 'facets')
602
604
  await fs.mkdir(facetCacheDir, { recursive: true })
@@ -618,20 +620,35 @@ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} })
618
620
  let combineSummaryCalls = 0
619
621
  let estimatedFacetInputTokens = 0
620
622
  let estimatedFacetOutputTokens = 0
621
- let estimatedSummaryInputTokens = 0
622
- let estimatedSummaryOutputTokens = 0
623
+ let estimatedChunkSummaryInputTokens = 0
624
+ let estimatedChunkSummaryOutputTokens = 0
625
+ let estimatedCombineSummaryInputTokens = 0
626
+ let estimatedCombineSummaryOutputTokens = 0
627
+ const facetSystemPrompt = buildFacetSystemPrompt(options.lang)
623
628
 
624
629
  for (const job of uncachedFacetJobs) {
625
630
  const transcript = String(job.thread.transcriptForAnalysis || '').trim()
626
631
  const transcriptChars = transcript.length
627
632
  if (!transcriptChars) {
628
- estimatedFacetInputTokens += 600
633
+ estimatedFacetInputTokens += estimateModelInputTokens({
634
+ provider,
635
+ systemPrompt: facetSystemPrompt,
636
+ userPrompt: buildFacetExtractionPrompt(job.thread, `${job.thread.title || '(untitled)'}\n${job.thread.firstUserMessage || ''}`.trim(), options.lang),
637
+ schema: FACET_SCHEMA,
638
+ structured: true,
639
+ })
629
640
  estimatedFacetOutputTokens += 350
630
641
  continue
631
642
  }
632
643
 
633
644
  if (transcriptChars <= LONG_TRANSCRIPT_THRESHOLD) {
634
- estimatedFacetInputTokens += estimateTokensFromChars(transcriptChars) + 1200
645
+ estimatedFacetInputTokens += estimateModelInputTokens({
646
+ provider,
647
+ systemPrompt: facetSystemPrompt,
648
+ userPrompt: buildFacetExtractionPrompt(job.thread, transcript, options.lang),
649
+ schema: FACET_SCHEMA,
650
+ structured: true,
651
+ })
635
652
  estimatedFacetOutputTokens += 350
636
653
  continue
637
654
  }
@@ -639,30 +656,65 @@ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} })
639
656
  const chunks = chunkText(transcript, TRANSCRIPT_CHUNK_SIZE)
640
657
  chunkSummaryCalls += chunks.length
641
658
  for (const chunk of chunks) {
642
- estimatedSummaryInputTokens += estimateTokensFromChars(chunk.length) + 220
643
- estimatedSummaryOutputTokens += 260
659
+ estimatedChunkSummaryInputTokens += estimateModelInputTokens({
660
+ provider,
661
+ systemPrompt: `${FACET_TRANSCRIPT_SUMMARY_DIRECTIVE}\n\nPreserve user goal, outcome, friction, command/tool issues, and what the assistant actually achieved.`,
662
+ userPrompt: `Chunk 1 of ${chunks.length}\n\n${chunk}`,
663
+ structured: false,
664
+ })
665
+ estimatedChunkSummaryOutputTokens += 260
644
666
  }
645
667
  const combinedSummaryChars = chunks.length * 1100
646
668
  if (combinedSummaryChars > LONG_TRANSCRIPT_THRESHOLD) {
647
669
  combineSummaryCalls += 1
648
- estimatedSummaryInputTokens += estimateTokensFromChars(combinedSummaryChars) + 180
649
- estimatedSummaryOutputTokens += 320
670
+ estimatedCombineSummaryInputTokens += estimateModelInputTokens({
671
+ provider,
672
+ systemPrompt:
673
+ '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.',
674
+ userPrompt: makePlaceholderText(combinedSummaryChars, 'Chunk summaries'),
675
+ structured: false,
676
+ })
677
+ estimatedCombineSummaryOutputTokens += 320
650
678
  }
651
- estimatedFacetInputTokens += 2400
679
+ estimatedFacetInputTokens += estimateModelInputTokens({
680
+ provider,
681
+ systemPrompt: facetSystemPrompt,
682
+ userPrompt: buildFacetExtractionPrompt(
683
+ job.thread,
684
+ makePlaceholderText(
685
+ combinedSummaryChars > LONG_TRANSCRIPT_THRESHOLD ? 1200 : combinedSummaryChars,
686
+ '[Long transcript summarized before facet extraction]',
687
+ ),
688
+ options.lang,
689
+ ),
690
+ schema: FACET_SCHEMA,
691
+ structured: true,
692
+ })
652
693
  estimatedFacetOutputTokens += 350
653
694
  }
654
695
 
655
- const estimatedSectionInputs = estimateSectionInputs(candidateThreads, facetJobs)
696
+ const estimatedSectionInputs = estimateSectionInputs(candidateThreads, facetJobs, options)
656
697
  const fastSectionCalls = SECTION_DEFS.filter(section => section.modelTier === 'fast').length
657
698
  const fullSectionCalls = SECTION_DEFS.filter(section => section.modelTier !== 'fast').length
658
- const estimatedFastSectionInputTokens = SECTION_DEFS.filter(section => section.modelTier === 'fast')
699
+ let estimatedFastSectionInputTokens = SECTION_DEFS.filter(section => section.modelTier === 'fast')
659
700
  .reduce((sum, section) => sum + estimatedSectionInputs[section.contextKind] + 500, 0)
660
701
  const estimatedFastSectionOutputTokens = fastSectionCalls * 500
661
- const estimatedFullSectionInputTokens = SECTION_DEFS.filter(section => section.modelTier !== 'fast')
702
+ let estimatedFullSectionInputTokens = SECTION_DEFS.filter(section => section.modelTier !== 'fast')
662
703
  .reduce((sum, section) => sum + estimatedSectionInputs[section.contextKind] + 650, 0)
663
704
  const estimatedFullSectionOutputTokens = fullSectionCalls * 700
664
- const estimatedAtAGlanceInputTokens = estimatedSectionInputs.at_a_glance + 2200
705
+ let estimatedAtAGlanceInputTokens = estimatedSectionInputs.at_a_glance + 2200
665
706
  const estimatedAtAGlanceOutputTokens = 260
707
+ const estimatedSummaryInputTokens =
708
+ estimatedChunkSummaryInputTokens + estimatedCombineSummaryInputTokens
709
+ const estimatedSummaryOutputTokens =
710
+ estimatedChunkSummaryOutputTokens + estimatedCombineSummaryOutputTokens
711
+
712
+ estimatedFacetInputTokens += estimateCodexCliFreshOverhead(provider, facetModel, uncachedFacetJobs.length)
713
+ estimatedChunkSummaryInputTokens += estimateCodexCliFreshOverhead(provider, facetModel, chunkSummaryCalls)
714
+ estimatedCombineSummaryInputTokens += estimateCodexCliFreshOverhead(provider, facetModel, combineSummaryCalls)
715
+ estimatedFastSectionInputTokens += estimateCodexCliFreshOverhead(provider, fastSectionModel, fastSectionCalls)
716
+ estimatedFullSectionInputTokens += estimateCodexCliFreshOverhead(provider, insightModel, fullSectionCalls)
717
+ estimatedAtAGlanceInputTokens += estimateCodexCliFreshOverhead(provider, insightModel, 1)
666
718
 
667
719
  const byStage = [
668
720
  buildEstimateBucket(
@@ -675,16 +727,16 @@ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} })
675
727
  buildEstimateBucket(
676
728
  'transcript_summary:chunk',
677
729
  chunkSummaryCalls,
678
- estimatedSummaryInputTokens - (combineSummaryCalls ? estimateTokensFromChars(combineSummaryCalls * 1100) + combineSummaryCalls * 180 : 0),
730
+ estimatedChunkSummaryInputTokens,
679
731
  0,
680
- estimatedSummaryOutputTokens - combineSummaryCalls * 320,
732
+ estimatedChunkSummaryOutputTokens,
681
733
  ),
682
734
  buildEstimateBucket(
683
735
  'transcript_summary:combine',
684
736
  combineSummaryCalls,
685
- combineSummaryCalls ? estimateTokensFromChars(combineSummaryCalls * 1100) + combineSummaryCalls * 180 : 0,
737
+ estimatedCombineSummaryInputTokens,
686
738
  0,
687
- combineSummaryCalls * 320,
739
+ estimatedCombineSummaryOutputTokens,
688
740
  ),
689
741
  buildEstimateBucket(
690
742
  'section:fast',
@@ -784,6 +836,14 @@ export async function estimateLlmAnalysisCost({ threadSummaries, options = {} })
784
836
  }
785
837
  }
786
838
 
839
+ function estimateCodexCliFreshOverhead(provider, model, calls) {
840
+ if (provider !== 'codex-cli' || !calls) return 0
841
+ const normalized = String(model || '').trim()
842
+ if (normalized === 'gpt-5.4') return calls * 25_000
843
+ if (normalized === 'gpt-5.4-mini' || normalized === 'gpt-5.3-codex-spark') return calls * 4_500
844
+ return calls * 8_000
845
+ }
846
+
787
847
  async function planFacetJobs(threadSummaries, { cacheDir, model, uncachedLimit }) {
788
848
  const jobs = []
789
849
  let uncachedCount = 0
@@ -824,61 +884,14 @@ async function getFacetForThread(thread, { cacheDir, model, provider, providerOp
824
884
  provider,
825
885
  providerOptions,
826
886
  })
827
- const prompt = `Analyze this Codex coding session and extract structured facets.
828
-
829
- CRITICAL GUIDELINES:
830
- 1. goal_categories should count only what the user explicitly asked for.
831
- 2. user_satisfaction_counts should rely on explicit user signals or strong transcript evidence.
832
- 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.
833
- 4. If the session is mostly warmup, rehearsal, or cache-filling, use warmup_minimal as the only goal category.
834
- 5. If evidence is insufficient after transcript compression, use conservative values such as unclear_from_transcript rather than guessing.
835
- 6. Do not infer the user's goal from assistant or tool activity alone.
836
- 7. Do not count assistant-led exploration or extra implementation work unless the user clearly asked for it.
837
-
838
- Allowed values:
839
- - outcome: fully_achieved | mostly_achieved | partially_achieved | not_achieved | unclear_from_transcript
840
- - assistant_helpfulness: unhelpful | slightly_helpful | moderately_helpful | very_helpful | essential
841
- - session_type: single_task | multi_task | iterative_refinement | exploration | quick_question
842
- - primary_success: none | fast_accurate_search | correct_code_edits | good_explanations | proactive_help | multi_file_changes | good_debugging
843
-
844
- Language:
845
- - Keep enum values and keys exactly as requested.
846
- - Write free-text fields in ${describeLanguage(langFromProviderOptions(providerOptions))}.
847
-
848
- Transcript:
849
- ${transcript}
850
-
851
- Summary stats:
852
- ${JSON.stringify(
853
- {
854
- title: thread.title,
855
- cwd: thread.cwd,
856
- durationMinutes: thread.durationMinutes,
857
- userMessages: thread.userMessages,
858
- assistantMessages: thread.assistantMessages,
859
- totalToolCalls: thread.totalToolCalls,
860
- totalCommandFailures: thread.totalCommandFailures,
861
- toolCounts: thread.toolCounts,
862
- toolFailures: thread.toolFailures,
863
- userInterruptions: thread.userInterruptions,
864
- usesTaskAgent: thread.usesTaskAgent,
865
- usesMcp: thread.usesMcp,
866
- usesWebSearch: thread.usesWebSearch,
867
- usesWebFetch: thread.usesWebFetch,
868
- },
869
- null,
870
- 2,
871
- )}
872
-
873
- RESPOND WITH ONLY A VALID JSON OBJECT matching the requested schema.`
887
+ const prompt = buildFacetExtractionPrompt(thread, transcript, langFromProviderOptions(providerOptions))
874
888
 
875
889
  const rawFacet = await callStructuredModel({
876
890
  provider,
877
891
  model,
878
892
  schemaName: 'codex_session_facet',
879
893
  schema: FACET_SCHEMA,
880
- systemPrompt:
881
- `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(),
894
+ systemPrompt: buildFacetSystemPrompt(langFromProviderOptions(providerOptions)),
882
895
  userPrompt: prompt,
883
896
  options: {
884
897
  ...providerOptions,
@@ -1154,6 +1167,124 @@ ${compactJson(compactInsightDigest(insights))}
1154
1167
  RESPOND WITH ONLY A VALID JSON OBJECT matching the schema.`
1155
1168
  }
1156
1169
 
1170
+ function buildFacetSystemPrompt(lang) {
1171
+ 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()
1172
+ }
1173
+
1174
+ function buildFacetExtractionPrompt(thread, transcript, lang) {
1175
+ return `Analyze this Codex coding session and extract structured facets.
1176
+
1177
+ CRITICAL GUIDELINES:
1178
+ 1. goal_categories should count only what the user explicitly asked for.
1179
+ 2. user_satisfaction_counts should rely on explicit user signals or strong transcript evidence.
1180
+ 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.
1181
+ 4. If the session is mostly warmup, rehearsal, or cache-filling, use warmup_minimal as the only goal category.
1182
+ 5. If evidence is insufficient after transcript compression, use conservative values such as unclear_from_transcript rather than guessing.
1183
+ 6. Do not infer the user's goal from assistant or tool activity alone.
1184
+ 7. Do not count assistant-led exploration or extra implementation work unless the user clearly asked for it.
1185
+
1186
+ Allowed values:
1187
+ - outcome: fully_achieved | mostly_achieved | partially_achieved | not_achieved | unclear_from_transcript
1188
+ - assistant_helpfulness: unhelpful | slightly_helpful | moderately_helpful | very_helpful | essential
1189
+ - session_type: single_task | multi_task | iterative_refinement | exploration | quick_question
1190
+ - primary_success: none | fast_accurate_search | correct_code_edits | good_explanations | proactive_help | multi_file_changes | good_debugging
1191
+
1192
+ Language:
1193
+ - Keep enum values and keys exactly as requested.
1194
+ - Write free-text fields in ${describeLanguage(lang)}.
1195
+
1196
+ Transcript:
1197
+ ${transcript}
1198
+
1199
+ Summary stats:
1200
+ ${JSON.stringify(
1201
+ {
1202
+ title: thread.title,
1203
+ cwd: thread.cwd,
1204
+ durationMinutes: thread.durationMinutes,
1205
+ userMessages: thread.userMessages,
1206
+ assistantMessages: thread.assistantMessages,
1207
+ totalToolCalls: thread.totalToolCalls,
1208
+ totalCommandFailures: thread.totalCommandFailures,
1209
+ toolCounts: thread.toolCounts,
1210
+ toolFailures: thread.toolFailures,
1211
+ userInterruptions: thread.userInterruptions,
1212
+ usesTaskAgent: thread.usesTaskAgent,
1213
+ usesMcp: thread.usesMcp,
1214
+ usesWebSearch: thread.usesWebSearch,
1215
+ usesWebFetch: thread.usesWebFetch,
1216
+ },
1217
+ null,
1218
+ 2,
1219
+ )}
1220
+
1221
+ RESPOND WITH ONLY A VALID JSON OBJECT matching the requested schema.`
1222
+ }
1223
+
1224
+ function buildEstimatedFacet(thread) {
1225
+ return {
1226
+ threadId: thread.id,
1227
+ title: thread.title,
1228
+ cwd: thread.cwd,
1229
+ updatedAt: thread.updatedAt,
1230
+ durationMinutes: thread.durationMinutes,
1231
+ userMessages: thread.userMessages,
1232
+ assistantMessages: thread.assistantMessages,
1233
+ totalToolCalls: thread.totalToolCalls,
1234
+ totalCommandFailures: thread.totalCommandFailures,
1235
+ underlying_goal: truncateForContext(thread.firstUserMessage || thread.title, 160),
1236
+ goal_categories: {},
1237
+ outcome: thread.totalCommandFailures > 0 ? 'partially_achieved' : 'unclear_from_transcript',
1238
+ user_satisfaction_counts: {},
1239
+ assistant_helpfulness: 'moderately_helpful',
1240
+ session_type: thread.userMessages > 2 ? 'iterative_refinement' : 'single_task',
1241
+ friction_counts: thread.totalCommandFailures > 0 ? { tool_failed: 1 } : {},
1242
+ friction_detail: 'none',
1243
+ primary_success: 'none',
1244
+ brief_summary: truncateForContext(thread.firstUserMessage || thread.title, 180),
1245
+ user_instructions: [],
1246
+ }
1247
+ }
1248
+
1249
+ function buildEstimatedInsightsPlaceholder(context) {
1250
+ return {
1251
+ project_areas: {
1252
+ areas: (context.session_summaries || []).slice(0, 3).map((item, index) => ({
1253
+ name: item.project || item.title || `Workstream ${index + 1}`,
1254
+ session_count: 1,
1255
+ description: truncateForContext(item.summary || item.goal || '', 120),
1256
+ })),
1257
+ },
1258
+ interaction_style: {
1259
+ key_pattern: 'Tight scope before execution',
1260
+ narrative: truncateForContext('You first align scope and constraints, then execute and verify against explicit acceptance bars.', 180),
1261
+ },
1262
+ what_works: {
1263
+ impressive_workflows: [
1264
+ { title: 'Scope first', description: 'You repeatedly tighten scope before execution.' },
1265
+ { title: 'Verification loop', description: 'You use evidence to confirm changes before closing.' },
1266
+ ],
1267
+ },
1268
+ friction_analysis: {
1269
+ categories: [
1270
+ { category: 'Direction drift', description: 'Some sessions need scope tightening.', examples: ['Scope had to be pulled back.'] },
1271
+ ],
1272
+ },
1273
+ suggestions: {
1274
+ features_to_try: [{ feature: 'AGENTS.md' }],
1275
+ usage_patterns: [{ title: 'Split review and execution' }],
1276
+ agents_md_additions: [{ addition: 'Read existing docs before editing.' }],
1277
+ },
1278
+ on_the_horizon: {
1279
+ opportunities: [{ title: 'Longer workflows', how_to_try: 'Add staged execution.' }],
1280
+ },
1281
+ fun_ending: {
1282
+ headline: 'Memorable moment',
1283
+ detail: 'A compact placeholder for estimate sizing.',
1284
+ },
1285
+ }
1286
+ }
1287
+
1157
1288
  /**
1158
1289
  * @param {InsightRunOptions} options
1159
1290
  * @param {string} provider
@@ -1172,71 +1303,36 @@ function buildProviderOptions(options, provider, onUsage) {
1172
1303
  }
1173
1304
  }
1174
1305
 
1175
- function estimateSectionInputs(candidateThreads, facetJobs) {
1176
- const contextShape = {
1177
- metadata: {
1178
- thread_count: candidateThreads.length,
1179
- },
1180
- summary: {
1181
- total_user_messages: candidateThreads.reduce((sum, thread) => sum + Number(thread.userMessages || 0), 0),
1182
- total_tool_calls: candidateThreads.reduce((sum, thread) => sum + Number(thread.totalToolCalls || 0), 0),
1183
- total_failures: candidateThreads.reduce((sum, thread) => sum + Number(thread.totalCommandFailures || 0), 0),
1184
- },
1185
- charts: {
1186
- projects: candidateThreads.slice(0, 6).map(thread => compactProjectPath(thread.cwd)),
1187
- models: Array.from(new Set(candidateThreads.map(thread => thread.model).filter(Boolean))).slice(0, 4),
1188
- tools: [],
1189
- },
1190
- aggregate_facets: {
1191
- sessions_with_facets: facetJobs.length,
1192
- },
1193
- session_summaries: facetJobs.slice(0, MAX_CONTEXT_FACETS).map(job => {
1194
- if (job.cachedFacet) {
1195
- return {
1196
- title: truncateForContext(job.cachedFacet.title, 80),
1197
- project: compactProjectPath(job.cachedFacet.cwd),
1198
- goal: truncateForContext(job.cachedFacet.underlying_goal, 120),
1199
- outcome: job.cachedFacet.outcome,
1200
- primary_success: job.cachedFacet.primary_success,
1201
- summary: truncateForContext(job.cachedFacet.brief_summary, 160),
1202
- friction: compactCountObject(job.cachedFacet.friction_counts, 2),
1203
- }
1204
- }
1205
- return {
1206
- title: truncateForContext(job.thread.title, 80),
1207
- project: compactProjectPath(job.thread.cwd),
1208
- goal: truncateForContext(job.thread.firstUserMessage, 120),
1209
- outcome: 'unknown',
1210
- primary_success: 'unknown',
1211
- summary: truncateForContext(job.thread.firstUserMessage, 160),
1212
- friction: {},
1213
- }
1214
- }),
1215
- recent_threads: candidateThreads.slice(0, MAX_RECENT_THREADS).map(thread => ({
1216
- title: truncateForContext(thread.title, 80),
1217
- project: compactProjectPath(thread.cwd),
1218
- duration_minutes: thread.durationMinutes,
1219
- user_messages: thread.userMessages,
1220
- tool_calls: thread.totalToolCalls,
1221
- files_modified: thread.filesModified,
1222
- })),
1223
- friction_details: [],
1224
- user_instructions: [],
1225
- }
1226
- const sectionKinds = [
1227
- 'project_areas',
1228
- 'interaction_style',
1229
- 'what_works',
1230
- 'friction_analysis',
1231
- 'suggestions',
1232
- 'on_the_horizon',
1233
- 'fun_ending',
1234
- 'at_a_glance',
1235
- ]
1306
+ function estimateSectionInputs(candidateThreads, facetJobs, options = {}) {
1307
+ const provider = options.provider || DEFAULT_PROVIDER
1308
+ const lang = options.lang || 'en'
1309
+ const estimatedFacets = facetJobs.map(job => job.cachedFacet || buildEstimatedFacet(job.thread))
1310
+ const report = buildReport(candidateThreads, { facets: estimatedFacets })
1311
+ const context = buildInsightContext(report, candidateThreads, estimatedFacets)
1236
1312
  const estimated = {}
1237
- for (const kind of sectionKinds) {
1238
- estimated[kind] = estimateTokensFromChars(compactJson(buildSectionContext(contextShape, kind)).length)
1313
+
1314
+ for (const section of SECTION_DEFS) {
1315
+ const systemPrompt = `${SECTION_SYSTEM_PROMPT} ${getNarrativeLanguageInstruction(lang)}`.trim()
1316
+ const sectionContext = buildSectionContext(context, section.contextKind)
1317
+ const userPrompt = `${section.prompt}\n\n${getNarrativeLanguageInstruction(lang)}\n\nDATA:\n${compactJson(sectionContext)}`
1318
+ estimated[section.contextKind] = estimateModelInputTokens({
1319
+ provider,
1320
+ systemPrompt,
1321
+ userPrompt,
1322
+ schema: section.schema,
1323
+ structured: true,
1324
+ })
1239
1325
  }
1326
+
1327
+ const placeholderInsights = buildEstimatedInsightsPlaceholder(context)
1328
+ estimated.at_a_glance = estimateModelInputTokens({
1329
+ provider,
1330
+ systemPrompt: `${AT_A_GLANCE_SYSTEM_PROMPT} ${getNarrativeLanguageInstruction(lang)}`.trim(),
1331
+ userPrompt: buildAtAGlancePrompt(buildSectionContext(context, 'at_a_glance'), placeholderInsights),
1332
+ schema: AT_A_GLANCE_SCHEMA,
1333
+ structured: true,
1334
+ })
1335
+
1240
1336
  return estimated
1241
1337
  }
1242
1338
 
@@ -1397,6 +1493,36 @@ function estimateTokensFromChars(chars) {
1397
1493
  return Math.ceil(Number(chars || 0) / 4)
1398
1494
  }
1399
1495
 
1496
+ function estimateModelInputTokens({ provider, systemPrompt, userPrompt, schema = null, structured }) {
1497
+ const promptText =
1498
+ provider === 'codex-cli'
1499
+ ? structured
1500
+ ? buildStructuredEstimatePrompt(systemPrompt, userPrompt, schema)
1501
+ : buildPlainEstimatePrompt(systemPrompt, userPrompt)
1502
+ : buildApiEstimatePrompt(systemPrompt, userPrompt, schema, structured)
1503
+ return estimateTokensFromChars(promptText.length)
1504
+ }
1505
+
1506
+ function buildPlainEstimatePrompt(systemPrompt, userPrompt) {
1507
+ return `${String(systemPrompt || '').trim()}\n\n${String(userPrompt || '').trim()}`
1508
+ }
1509
+
1510
+ function buildStructuredEstimatePrompt(systemPrompt, userPrompt, schema) {
1511
+ return `${buildPlainEstimatePrompt(systemPrompt, userPrompt)}\n\nRESPOND WITH ONLY A VALID JSON OBJECT matching this schema:\n${JSON.stringify(schema, null, 2)}`
1512
+ }
1513
+
1514
+ function buildApiEstimatePrompt(systemPrompt, userPrompt, schema, structured) {
1515
+ if (!structured) return buildPlainEstimatePrompt(systemPrompt, userPrompt)
1516
+ return `${buildPlainEstimatePrompt(systemPrompt, userPrompt)}\n\nJSON schema:\n${JSON.stringify(schema, null, 2)}`
1517
+ }
1518
+
1519
+ function makePlaceholderText(length, prefix = '') {
1520
+ const target = Math.max(0, Number(length || 0))
1521
+ const seed = prefix ? `${prefix}\n` : ''
1522
+ if (seed.length >= target) return seed.slice(0, target)
1523
+ return `${seed}${'x'.repeat(Math.max(0, target - seed.length))}`
1524
+ }
1525
+
1400
1526
  function getNarrativeLanguageInstruction(lang) {
1401
1527
  if (lang === 'zh-CN') {
1402
1528
  return 'Write all free-text narrative fields in Simplified Chinese.'
package/lib/report.js CHANGED
@@ -178,6 +178,7 @@ export async function writeReportFiles(report, options) {
178
178
 
179
179
  export function renderTerminalSummary(report) {
180
180
  const text = getReportText(report.metadata.language)
181
+ const estimateComparison = buildEstimateComparison(report)
181
182
  const lines = []
182
183
  lines.push(text.reportTitle)
183
184
  lines.push(
@@ -190,6 +191,11 @@ export function renderTerminalSummary(report) {
190
191
  lines.push(
191
192
  ` ${text.inputLabel}=${formatMillionTokens(report.analysisUsage.inputTokens)} | ${text.cachedLabel}=${formatMillionTokens(report.analysisUsage.cachedInputTokens)} | ${text.outputLabel}=${formatMillionTokens(report.analysisUsage.outputTokens)}`,
192
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
+ }
193
199
  }
194
200
  if (report.metadata.dateRange.start && report.metadata.dateRange.end) {
195
201
  lines.push(`${report.metadata.dateRange.start} -> ${report.metadata.dateRange.end}`)
@@ -214,8 +220,12 @@ function renderHtmlReport(report) {
214
220
  const analysisUsage = report.analysisUsage || null
215
221
  const topProjects = renderBarList(report.charts.projects, { formatLabel: formatProjectLabel })
216
222
  const modelMix = renderBarList(report.charts.models)
217
- const sessionTypes = renderBarList(report.charts.sessionTypes)
218
- const outcomes = renderBarList(report.charts.outcomes)
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
+ })
219
229
  const capabilitySignals = renderBarList(report.charts.capabilities)
220
230
  const toolFailures = renderBarList(report.charts.toolFailures)
221
231
  const toolErrorCategories = renderBarList(report.charts.toolErrorCategories)
@@ -793,7 +803,7 @@ function renderProjectAreas(insights) {
793
803
  <div class="project-area">
794
804
  <div class="area-header">
795
805
  <span class="area-name">${escapeHtml(area.name)}</span>
796
- <span class="area-count">~${formatNumber(area.session_count)} ${escapeHtml(text.sessionsLabel)}</span>
806
+ <span class="area-count">${escapeHtml(text.workstreamBadge)}</span>
797
807
  </div>
798
808
  <div class="area-desc">${escapeHtml(area.description)}</div>
799
809
  </div>
@@ -875,9 +885,9 @@ function renderSuggestions(insights) {
875
885
  const suggestions = insights.suggestions
876
886
  if (!suggestions) return ''
877
887
  const text = getReportText(insights.__lang)
878
- const agentItems = suggestions.agents_md_additions || []
879
- const featureItems = suggestions.features_to_try || []
880
- 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)
881
891
 
882
892
  return `
883
893
  <section class="panel">
@@ -995,6 +1005,7 @@ function renderReportMeta(report, context = {}) {
995
1005
  const text = getReportText(report.metadata.language)
996
1006
  const usage = report.analysisUsage
997
1007
  const hasUsage = Boolean(usage?.totalTokens)
1008
+ const estimateComparison = buildEstimateComparison(report)
998
1009
  const analysisByStage = context.analysisByStage || ''
999
1010
  const analysisByModel = context.analysisByModel || ''
1000
1011
 
@@ -1010,6 +1021,7 @@ function renderReportMeta(report, context = {}) {
1010
1021
  ${hasUsage ? renderStat(text.analysisTokens, formatMillionTokens(usage.totalTokens)) : ''}
1011
1022
  ${hasUsage ? renderStat(text.modelCalls, formatNumber(usage.calls)) : ''}
1012
1023
  ${hasUsage ? renderStat(text.cachedInput, formatMillionTokens(usage.cachedInputTokens)) : ''}
1024
+ ${estimateComparison ? renderStat(text.estimateDelta, `${formatSignedMillionTokens(estimateComparison.deltaTokens)} (${formatSignedPercent(estimateComparison.deltaPercent)})`) : ''}
1013
1025
  ${renderStat(text.historicalSessionTokens, formatNumber(report.summary.totalTokens))}
1014
1026
  </div>
1015
1027
  ${
@@ -1035,6 +1047,14 @@ function renderReportMeta(report, context = {}) {
1035
1047
  })}
1036
1048
  <p class="meta">${escapeHtml(text.freshInput)}: ${formatMillionTokens(freshInputTokens)}. ${escapeHtml(text.analysisCostFootnote)}</p>
1037
1049
  </div>
1050
+ ${
1051
+ estimateComparison
1052
+ ? `<div class="usage-breakdown">
1053
+ <h3>${escapeHtml(text.estimateVsActualHeading)}</h3>
1054
+ ${renderEstimateComparisonCard(estimateComparison, text)}
1055
+ </div>`
1056
+ : ''
1057
+ }
1038
1058
  </div>`
1039
1059
  : ''
1040
1060
  }
@@ -1079,6 +1099,29 @@ function renderUsageCard(item) {
1079
1099
  `
1080
1100
  }
1081
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
+
1082
1125
  function renderStat(label, value) {
1083
1126
  return `<div class="stat"><div class="value">${escapeHtml(String(value))}</div><div>${escapeHtml(label)}</div></div>`
1084
1127
  }
@@ -1147,6 +1190,20 @@ function renderCopyRow(value, text) {
1147
1190
  `
1148
1191
  }
1149
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
+
1150
1207
  function formatAgentInstruction(item) {
1151
1208
  const placement = String(item?.prompt_scaffold || '').trim()
1152
1209
  const addition = String(item?.addition || '').trim()
@@ -1171,6 +1228,44 @@ function formatProjectLabel(value) {
1171
1228
  return formatDisplayPath(value, { tailSegments: 2, preferHomeAlias: false, ellipsis: true })
1172
1229
  }
1173
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
+
1174
1269
  function formatDisplayPath(value, options = {}) {
1175
1270
  const text = String(value || '').trim()
1176
1271
  if (!text) return '(unknown)'
@@ -1225,6 +1320,12 @@ function formatMillionTokens(value) {
1225
1320
  return `${round(number / 1_000)}K tokens`
1226
1321
  }
1227
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
+
1228
1329
  function formatPercent(value, total) {
1229
1330
  const numerator = Number(value || 0)
1230
1331
  const denominator = Number(total || 0)
@@ -1232,6 +1333,12 @@ function formatPercent(value, total) {
1232
1333
  return `${round((numerator / denominator) * 100)}%`
1233
1334
  }
1234
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
+
1235
1342
  function average(values) {
1236
1343
  if (!values.length) return 0
1237
1344
  return round(values.reduce((sum, value) => sum + value, 0) / values.length)
@@ -1286,6 +1393,41 @@ function escapeAttribute(value) {
1286
1393
  return escapeHtml(String(value)).replaceAll("'", '&#39;').replaceAll('\n', '&#10;')
1287
1394
  }
1288
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
+
1289
1431
  function getReportText(lang) {
1290
1432
  if (lang === 'zh-CN') {
1291
1433
  return {
@@ -1304,7 +1446,7 @@ function getReportText(lang) {
1304
1446
  filesModified: '修改文件',
1305
1447
  toolErrors: '工具错误',
1306
1448
  avgResponse: '平均响应',
1307
- topProjects: 'Top Projects',
1449
+ topProjects: '项目分布',
1308
1450
  modelMix: '模型分布',
1309
1451
  sessionTypes: '会话类型',
1310
1452
  outcomes: '结果分布',
@@ -1318,6 +1460,7 @@ function getReportText(lang) {
1318
1460
  quickWins: '可以立刻尝试的优化',
1319
1461
  ambitiousWorkflows: '值得尝试的更强工作流',
1320
1462
  sessionsLabel: '次会话',
1463
+ workstreamBadge: '代表性工作流',
1321
1464
  impressiveThingsLink: '做得好的地方',
1322
1465
  whereThingsGoWrongLink: '容易出问题的地方',
1323
1466
  whatYouWorkOn: '你主要在做什么',
@@ -1348,6 +1491,15 @@ function getReportText(lang) {
1348
1491
  dateRange: '时间范围',
1349
1492
  modelCalls: '模型调用',
1350
1493
  cachedInput: '缓存输入',
1494
+ estimatedLabel: '预估',
1495
+ actualLabel: '实际',
1496
+ actualFreshLabel: '实际(不含缓存)',
1497
+ actualFreshSuffix: '(不含缓存)',
1498
+ estimateRangeLabel: '预估区间',
1499
+ estimateVsActualHeading: '预估与实际',
1500
+ estimateVsActualLabel: '预估 vs 实际',
1501
+ estimateDelta: '偏差',
1502
+ estimateError: '偏差比例',
1351
1503
  historicalSessionTokens: '历史会话 Tokens',
1352
1504
  analysisCostByStage: '按阶段拆分的分析成本',
1353
1505
  analysisCostByModel: '按模型拆分的分析成本',
@@ -1397,6 +1549,7 @@ function getReportText(lang) {
1397
1549
  quickWins: 'Quick wins to try',
1398
1550
  ambitiousWorkflows: 'Ambitious workflows',
1399
1551
  sessionsLabel: 'sessions',
1552
+ workstreamBadge: 'Representative workstream',
1400
1553
  impressiveThingsLink: 'Impressive Things You Did',
1401
1554
  whereThingsGoWrongLink: 'Where Things Go Wrong',
1402
1555
  whatYouWorkOn: 'What You Work On',
@@ -1427,6 +1580,15 @@ function getReportText(lang) {
1427
1580
  dateRange: 'Date Range',
1428
1581
  modelCalls: 'Model Calls',
1429
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',
1430
1592
  historicalSessionTokens: 'Historical Session Tokens',
1431
1593
  analysisCostByStage: 'Analysis Cost by Stage',
1432
1594
  analysisCostByModel: 'Analysis Cost by Model',
package/package.json CHANGED
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "codex-session-insights",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Generate a report analyzing your Codex sessions.",
5
5
  "type": "module",
6
6
  "bin": {
7
- "codex-session-insights": "./bin/codex-insights.js"
7
+ "codex-session-insights": "bin/codex-insights.js"
8
8
  },
9
9
  "files": [
10
10
  "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",