codex-session-insights 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/cli.js ADDED
@@ -0,0 +1,1002 @@
1
+ import path from 'node:path'
2
+ import { spawn } from 'node:child_process'
3
+ import { confirm, input, select } from '@inquirer/prompts'
4
+ import ora from 'ora'
5
+ import { collectThreadSummaries, resolveCodexHome } from './codex-data.js'
6
+ import { buildReport, renderTerminalSummary, writeReportFiles } from './report.js'
7
+ import { estimateLlmAnalysisCost, generateLlmInsights } from './llm-insights.js'
8
+
9
+ const DEFAULT_SCOPE_PRESET = 'standard'
10
+ const DEFAULT_QUALITY_PRESET = 'balanced'
11
+
12
+ export async function runCli(argv) {
13
+ const parsed = parseArgs(argv)
14
+ const progress = createProgressUi(parsed.options)
15
+
16
+ if (parsed.help) {
17
+ printHelp()
18
+ return
19
+ }
20
+
21
+ const command = parsed.command ?? 'report'
22
+ if (command !== 'report') {
23
+ throw new Error(`Unsupported command "${command}". Only "report" is available in this build.`)
24
+ }
25
+
26
+ const codexHome = resolveCodexHome(parsed.options.codexHome)
27
+ const outDir = path.resolve(parsed.options.outDir ?? path.join(codexHome, 'usage-data'))
28
+ let threadSummaries
29
+ let estimate
30
+
31
+ if (shouldUseInteractiveMode(parsed.options)) {
32
+ const wizardResult = await runInteractiveWizard({
33
+ options: parsed.options,
34
+ codexHome,
35
+ defaultOutDir: outDir,
36
+ progress,
37
+ })
38
+ if (!wizardResult) return
39
+ parsed.options = wizardResult.options
40
+ threadSummaries = wizardResult.threadSummaries
41
+ estimate = wizardResult.estimate
42
+ }
43
+
44
+ const sinceEpochSeconds =
45
+ parsed.options.days && parsed.options.days > 0
46
+ ? Math.floor(Date.now() / 1000 - parsed.options.days * 24 * 60 * 60)
47
+ : null
48
+ const cacheDir = parsed.options.cacheDir
49
+ ? path.resolve(parsed.options.cacheDir)
50
+ : undefined
51
+
52
+ if (!threadSummaries) {
53
+ progress.startStage(parsed.options, getUiText(parsed.options.lang).loadingIndex)
54
+ threadSummaries = await collectThreadSummaries({
55
+ codexHome,
56
+ sinceEpochSeconds,
57
+ limit: parsed.options.limit,
58
+ includeArchived: parsed.options.includeArchived,
59
+ includeSubagents: parsed.options.includeSubagents,
60
+ cacheDir,
61
+ })
62
+ progress.completeStage(parsed.options, getUiText(parsed.options.lang).loadingIndex)
63
+ }
64
+
65
+ if (!estimate) {
66
+ progress.startStage(parsed.options, getUiText(parsed.options.lang).estimating)
67
+ estimate = await estimateLlmAnalysisCost({
68
+ threadSummaries,
69
+ options: parsed.options,
70
+ })
71
+ progress.completeStage(parsed.options, getUiText(parsed.options.lang).estimating)
72
+ }
73
+
74
+ if (!parsed.options.stdoutJson) {
75
+ process.stdout.write(`${renderEstimateSummary(estimate, parsed.options.lang)}\n\n`)
76
+ }
77
+
78
+ if (parsed.options.estimateOnly) {
79
+ return
80
+ }
81
+
82
+ progress.startStage(parsed.options, getUiText(parsed.options.lang).generating)
83
+ const llmResult = await generateLlmInsights({
84
+ threadSummaries,
85
+ options: {
86
+ ...parsed.options,
87
+ onProgress: event => progress.updateFromEvent(parsed.options, event),
88
+ },
89
+ })
90
+ progress.completeStage(parsed.options, getUiText(parsed.options.lang).generating)
91
+
92
+ const report = buildReport(llmResult.reportThreads, {
93
+ codexHome,
94
+ days: parsed.options.days,
95
+ lang: parsed.options.lang,
96
+ threadPreviewLimit: parsed.options.preview,
97
+ insightsOverride: llmResult.insights,
98
+ facets: llmResult.facets,
99
+ })
100
+ report.analysisMode = 'llm'
101
+ report.provider = parsed.options.provider
102
+ report.analysisUsage = llmResult.analysisUsage
103
+
104
+ progress.startStage(parsed.options, getUiText(parsed.options.lang).writingFiles)
105
+ const { jsonPath, htmlPath } = await writeReportFiles(report, {
106
+ outDir,
107
+ jsonPath: parsed.options.jsonPath ? path.resolve(parsed.options.jsonPath) : undefined,
108
+ htmlPath: parsed.options.htmlPath ? path.resolve(parsed.options.htmlPath) : undefined,
109
+ })
110
+ progress.completeStage(parsed.options, getUiText(parsed.options.lang).writingFiles)
111
+
112
+ if (parsed.options.stdoutJson) {
113
+ process.stdout.write(`${JSON.stringify(report, null, 2)}\n`)
114
+ return
115
+ }
116
+
117
+ process.stdout.write(`${renderTerminalSummary(report)}\n\n`)
118
+ process.stdout.write(`JSON: ${jsonPath}\n`)
119
+ process.stdout.write(`HTML: ${htmlPath}\n`)
120
+
121
+ const shouldOpen = resolveShouldOpenReport(parsed.options)
122
+ if (shouldOpen) {
123
+ progress.startStage(parsed.options, getUiText(parsed.options.lang).openingBrowser)
124
+ const opened = await openReportInBrowser(htmlPath)
125
+ if (opened) {
126
+ progress.completeStage(parsed.options, getUiText(parsed.options.lang).openingBrowser)
127
+ process.stdout.write(`${getUiText(parsed.options.lang).openedInBrowser}\n`)
128
+ return
129
+ }
130
+ progress.failStage(parsed.options, getUiText(parsed.options.lang).openingBrowser)
131
+ }
132
+
133
+ process.stdout.write(`${getUiText(parsed.options.lang).openHint}: ${formatOpenHint(htmlPath)}\n`)
134
+ }
135
+
136
+ function parseArgs(argv) {
137
+ let command = null
138
+ const options = {
139
+ codexHome: null,
140
+ outDir: null,
141
+ jsonPath: null,
142
+ htmlPath: null,
143
+ days: 30,
144
+ limit: 50,
145
+ preview: 50,
146
+ provider: 'codex-cli',
147
+ codexBin: null,
148
+ apiBase: null,
149
+ apiKey: null,
150
+ facetModel: null,
151
+ facetEffort: null,
152
+ fastSectionModel: null,
153
+ fastSectionEffort: null,
154
+ insightModel: null,
155
+ insightEffort: null,
156
+ cacheDir: null,
157
+ facetLimit: 20,
158
+ lang: detectSystemLanguage(),
159
+ includeArchived: false,
160
+ includeSubagents: false,
161
+ stdoutJson: false,
162
+ estimateOnly: false,
163
+ openReport: null,
164
+ yes: false,
165
+ nonInteractive: false,
166
+ }
167
+ let help = false
168
+
169
+ for (let i = 0; i < argv.length; i += 1) {
170
+ const arg = argv[i]
171
+
172
+ if (!arg.startsWith('-') && command === null) {
173
+ command = arg
174
+ continue
175
+ }
176
+
177
+ if (arg === '-h' || arg === '--help') {
178
+ help = true
179
+ continue
180
+ }
181
+ if (arg === '--codex-home') {
182
+ options.codexHome = requireValue(argv, ++i, '--codex-home')
183
+ continue
184
+ }
185
+ if (arg === '--out-dir') {
186
+ options.outDir = requireValue(argv, ++i, '--out-dir')
187
+ continue
188
+ }
189
+ if (arg === '--json-path') {
190
+ options.jsonPath = requireValue(argv, ++i, '--json-path')
191
+ continue
192
+ }
193
+ if (arg === '--html-path') {
194
+ options.htmlPath = requireValue(argv, ++i, '--html-path')
195
+ continue
196
+ }
197
+ if (arg === '--days') {
198
+ options.days = toPositiveInt(requireValue(argv, ++i, '--days'), '--days')
199
+ continue
200
+ }
201
+ if (arg === '--limit') {
202
+ options.limit = toPositiveInt(requireValue(argv, ++i, '--limit'), '--limit')
203
+ continue
204
+ }
205
+ if (arg === '--preview') {
206
+ options.preview = toPositiveInt(requireValue(argv, ++i, '--preview'), '--preview')
207
+ continue
208
+ }
209
+ if (arg === '--provider') {
210
+ options.provider = requireValue(argv, ++i, '--provider')
211
+ continue
212
+ }
213
+ if (arg === '--codex-bin') {
214
+ options.codexBin = requireValue(argv, ++i, '--codex-bin')
215
+ continue
216
+ }
217
+ if (arg === '--api-base') {
218
+ options.apiBase = requireValue(argv, ++i, '--api-base')
219
+ continue
220
+ }
221
+ if (arg === '--api-key') {
222
+ options.apiKey = requireValue(argv, ++i, '--api-key')
223
+ continue
224
+ }
225
+ if (arg === '--facet-model') {
226
+ options.facetModel = requireValue(argv, ++i, '--facet-model')
227
+ continue
228
+ }
229
+ if (arg === '--facet-effort') {
230
+ options.facetEffort = requireValue(argv, ++i, '--facet-effort')
231
+ continue
232
+ }
233
+ if (arg === '--fast-section-model') {
234
+ options.fastSectionModel = requireValue(argv, ++i, '--fast-section-model')
235
+ continue
236
+ }
237
+ if (arg === '--fast-section-effort') {
238
+ options.fastSectionEffort = requireValue(argv, ++i, '--fast-section-effort')
239
+ continue
240
+ }
241
+ if (arg === '--insight-model') {
242
+ options.insightModel = requireValue(argv, ++i, '--insight-model')
243
+ continue
244
+ }
245
+ if (arg === '--insight-effort') {
246
+ options.insightEffort = requireValue(argv, ++i, '--insight-effort')
247
+ continue
248
+ }
249
+ if (arg === '--cache-dir') {
250
+ options.cacheDir = requireValue(argv, ++i, '--cache-dir')
251
+ continue
252
+ }
253
+ if (arg === '--lang') {
254
+ options.lang = normalizeLang(requireValue(argv, ++i, '--lang'))
255
+ continue
256
+ }
257
+ if (arg === '--facet-limit') {
258
+ options.facetLimit = toPositiveInt(requireValue(argv, ++i, '--facet-limit'), '--facet-limit')
259
+ continue
260
+ }
261
+ if (arg === '--include-archived') {
262
+ options.includeArchived = true
263
+ continue
264
+ }
265
+ if (arg === '--include-subagents') {
266
+ options.includeSubagents = true
267
+ continue
268
+ }
269
+ if (arg === '--stdout-json') {
270
+ options.stdoutJson = true
271
+ continue
272
+ }
273
+ if (arg === '--estimate-only') {
274
+ options.estimateOnly = true
275
+ continue
276
+ }
277
+ if (arg === '--open') {
278
+ options.openReport = true
279
+ continue
280
+ }
281
+ if (arg === '--no-open') {
282
+ options.openReport = false
283
+ continue
284
+ }
285
+ if (arg === '--yes') {
286
+ options.yes = true
287
+ continue
288
+ }
289
+ if (arg === '--non-interactive') {
290
+ options.nonInteractive = true
291
+ continue
292
+ }
293
+
294
+ throw new Error(`Unknown argument: ${arg}`)
295
+ }
296
+
297
+ if (!['codex-cli', 'openai'].includes(options.provider)) {
298
+ throw new Error(`Invalid provider "${options.provider}". Expected codex-cli or openai.`)
299
+ }
300
+
301
+ return { command, options, help }
302
+ }
303
+
304
+ function requireValue(argv, index, flag) {
305
+ const value = argv[index]
306
+ if (!value) {
307
+ throw new Error(`${flag} requires a value`)
308
+ }
309
+ return value
310
+ }
311
+
312
+ function toPositiveInt(value, flag) {
313
+ const number = Number.parseInt(value, 10)
314
+ if (!Number.isFinite(number) || number < 0) {
315
+ throw new Error(`${flag} must be a non-negative integer`)
316
+ }
317
+ return number
318
+ }
319
+
320
+ function printHelp() {
321
+ process.stdout.write(`codex-session-insights
322
+
323
+ Usage:
324
+ codex-session-insights report [options]
325
+ codex-session-insights [options]
326
+
327
+ Options:
328
+ --codex-home <path> Override the Codex data directory (default: $CODEX_HOME or ~/.codex)
329
+ --out-dir <path> Directory for generated report files (default: ~/.codex/usage-data)
330
+ --json-path <path> Exact path for report.json
331
+ --html-path <path> Exact path for report.html
332
+ --days <n> Only include threads updated in the last N days (default: 30)
333
+ --limit <n> Max threads to scan from the thread registry (default: 50)
334
+ --preview <n> Number of threads to embed in the HTML report (default: 50)
335
+ --provider <name> Model provider: codex-cli or openai (default: codex-cli)
336
+ --codex-bin <path> Override the Codex CLI binary path for provider=codex-cli
337
+ --api-key <key> OpenAI API key override for provider=openai
338
+ --api-base <url> Responses API base URL override for provider=openai
339
+ --facet-model <name> Model for per-thread facet extraction
340
+ --facet-effort <level> Reasoning effort for facet extraction
341
+ --fast-section-model <name>
342
+ Model for lower-risk report sections
343
+ --fast-section-effort <level>
344
+ Reasoning effort for lower-risk sections
345
+ --insight-model <name> Model for final report generation
346
+ --insight-effort <level> Reasoning effort for higher-risk sections
347
+ --facet-limit <n> Max uncached substantive threads to analyze (default: 20)
348
+ --cache-dir <path> Cache directory for session-meta and facet caches
349
+ --lang <code> Report language: en or zh-CN (default: system language)
350
+ --include-archived Include archived threads
351
+ --include-subagents Include sub-agent threads spawned from parent threads
352
+ --estimate-only Print estimated analysis token usage and exit
353
+ --stdout-json Print the JSON report to stdout instead of a terminal summary
354
+ --open Force opening report.html in your browser after generation
355
+ --no-open Do not auto-open report.html after generation
356
+ --yes Run immediately without interactive confirmation
357
+ --non-interactive Disable TTY wizard mode
358
+ -h, --help Show this help
359
+ `)
360
+ }
361
+
362
+ function renderEstimateSummary(estimate, lang = 'en') {
363
+ const ui = getUiText(lang)
364
+ const lines = []
365
+ lines.push(ui.analysisEstimateTitle)
366
+ lines.push(
367
+ `${formatMillionTokens(estimate.estimatedRange.low)} ${ui.toWord} ${formatMillionTokens(estimate.estimatedRange.high)} ${ui.likelyWord}`,
368
+ )
369
+ lines.push(
370
+ `${ui.plannedCallsLabel}=${formatInteger(estimate.estimatedCalls)} | ${ui.substantiveThreadsLabel}=${formatInteger(estimate.candidateThreads)} | ${ui.uncachedFacetsLabel}=${formatInteger(estimate.uncachedFacetThreads)} | ${ui.longTranscriptsLabel}=${formatInteger(estimate.longTranscriptThreads)}`,
371
+ )
372
+ lines.push(
373
+ `${ui.inputEstimateLabel}≈${formatMillionTokens(estimate.estimatedInputTokens)} | ${ui.outputEstimateLabel}≈${formatMillionTokens(estimate.estimatedOutputTokens)}`,
374
+ )
375
+ return lines.join('\n')
376
+ }
377
+
378
+ function formatInteger(value) {
379
+ return new Intl.NumberFormat('en-US').format(Math.round(Number(value || 0)))
380
+ }
381
+
382
+ function formatMillionTokens(value) {
383
+ const millions = Number(value || 0) / 1_000_000
384
+ if (millions >= 1) return `${millions.toFixed(2)}M tokens`
385
+ return `${(Number(value || 0) / 1_000).toFixed(1)}K tokens`
386
+ }
387
+
388
+ function resolveShouldOpenReport(options) {
389
+ if (options.stdoutJson || options.estimateOnly) return false
390
+ if (typeof options.openReport === 'boolean') return options.openReport
391
+ if (!process.stdout.isTTY) return false
392
+ if (process.env.CI) return false
393
+ return true
394
+ }
395
+
396
+ function shouldUseInteractiveMode(options) {
397
+ if (options.yes || options.nonInteractive || options.estimateOnly || options.stdoutJson) return false
398
+ if (process.env.CI) return false
399
+ return Boolean(process.stdin.isTTY && process.stdout.isTTY)
400
+ }
401
+
402
+ async function openReportInBrowser(filePath) {
403
+ const command = getOpenCommand(filePath)
404
+ if (!command) return false
405
+
406
+ return new Promise(resolve => {
407
+ let settled = false
408
+ const child = spawn(command.bin, command.args, {
409
+ detached: true,
410
+ stdio: 'ignore',
411
+ })
412
+ child.once('error', () => {
413
+ if (settled) return
414
+ settled = true
415
+ resolve(false)
416
+ })
417
+ child.once('spawn', () => {
418
+ if (settled) return
419
+ settled = true
420
+ child.unref()
421
+ resolve(true)
422
+ })
423
+ })
424
+ }
425
+
426
+ function getOpenCommand(filePath) {
427
+ if (process.platform === 'darwin') {
428
+ return { bin: 'open', args: [filePath] }
429
+ }
430
+ if (process.platform === 'win32') {
431
+ return { bin: 'cmd', args: ['/c', 'start', '', filePath] }
432
+ }
433
+ if (process.platform === 'linux') {
434
+ return { bin: 'xdg-open', args: [filePath] }
435
+ }
436
+ return null
437
+ }
438
+
439
+ function formatOpenHint(filePath) {
440
+ if (process.platform === 'darwin') return `open ${shellQuote(filePath)}`
441
+ if (process.platform === 'win32') return `start "" ${shellQuote(filePath)}`
442
+ if (process.platform === 'linux') return `xdg-open ${shellQuote(filePath)}`
443
+ return filePath
444
+ }
445
+
446
+ function shellQuote(value) {
447
+ const text = String(value)
448
+ if (/^[A-Za-z0-9_./:-]+$/.test(text)) return text
449
+ return `'${text.replace(/'/g, `'\\''`)}'`
450
+ }
451
+
452
+ async function runInteractiveWizard({ options, codexHome, defaultOutDir, progress }) {
453
+ let current = {
454
+ ...options,
455
+ outDir: options.outDir ? path.resolve(options.outDir) : defaultOutDir,
456
+ lang: normalizeLang(options.lang),
457
+ }
458
+ let ui = getUiText(current.lang)
459
+
460
+ if (!hasCustomModelOverrides(current)) {
461
+ Object.assign(current, applyQualityPreset(current, DEFAULT_QUALITY_PRESET))
462
+ }
463
+
464
+ while (true) {
465
+ ui = getUiText(current.lang)
466
+ process.stdout.write(`${ui.wizardTitle}\n`)
467
+ const { threadSummaries, estimate } = await collectEstimateForOptions({
468
+ current,
469
+ codexHome,
470
+ ui,
471
+ progress,
472
+ })
473
+
474
+ process.stdout.write(`\n${renderPlanSummary(current, estimate, ui)}\n`)
475
+ process.stdout.write(`${ui.equivalentCommandLabel}\n${buildEquivalentCommand(current)}\n\n`)
476
+
477
+ const action = await promptChoice(
478
+ ui.confirmQuestion,
479
+ [
480
+ { key: 'start', label: ui.startAnalysis },
481
+ { key: 'adjust', label: ui.adjustSettings },
482
+ { key: 'exit', label: ui.exitAction },
483
+ ],
484
+ 'start',
485
+ )
486
+
487
+ if (action === 'start') {
488
+ current.yes = true
489
+ return { options: current, threadSummaries, estimate }
490
+ }
491
+ if (action === 'exit') {
492
+ process.stdout.write(`${ui.cancelled}\n`)
493
+ return null
494
+ }
495
+
496
+ current = await runAdjustFlow(current, defaultOutDir, ui, {
497
+ allowQualityAdjust: !hasCustomModelOverrides(options),
498
+ })
499
+ process.stdout.write(`\n`)
500
+ }
501
+ }
502
+
503
+ async function collectEstimateForOptions({ current, codexHome, ui, progress }) {
504
+ const sinceEpochSeconds =
505
+ current.days && current.days > 0
506
+ ? Math.floor(Date.now() / 1000 - current.days * 24 * 60 * 60)
507
+ : null
508
+ const cacheDir = current.cacheDir ? path.resolve(current.cacheDir) : undefined
509
+
510
+ progress.startStage(current, ui.loadingIndex)
511
+ const threadSummaries = await collectThreadSummaries({
512
+ codexHome,
513
+ sinceEpochSeconds,
514
+ limit: current.limit,
515
+ includeArchived: current.includeArchived,
516
+ includeSubagents: current.includeSubagents,
517
+ cacheDir,
518
+ })
519
+ progress.completeStage(current, ui.loadingIndex)
520
+
521
+ progress.startStage(current, ui.estimating)
522
+ const estimate = await estimateLlmAnalysisCost({
523
+ threadSummaries,
524
+ options: current,
525
+ })
526
+ progress.completeStage(current, ui.estimating)
527
+
528
+ return { threadSummaries, estimate }
529
+ }
530
+
531
+ async function runAdjustFlow(current, defaultOutDir, ui, config = {}) {
532
+ current.days = await promptScopeDays(current.days, ui)
533
+ Object.assign(current, await promptDepthPreset(current, ui))
534
+ current.lang = await promptLanguage(current.lang, ui)
535
+ ui = getUiText(current.lang)
536
+ current.outDir = await promptOutputDir(current.outDir, defaultOutDir, ui)
537
+ current.openReport = await promptYesNo(
538
+ ui.openBrowserQuestion,
539
+ resolveShouldOpenReport(current),
540
+ )
541
+
542
+ if (config.allowQualityAdjust) {
543
+ Object.assign(current, await promptQualityPreset(current, ui))
544
+ }
545
+
546
+ return current
547
+ }
548
+
549
+ async function promptScopeDays(currentDays, ui) {
550
+ const preset = inferScopePreset(currentDays)
551
+ const scope = await promptChoice(
552
+ ui.scopeQuestion,
553
+ [
554
+ { key: '7', label: ui.scope7 },
555
+ { key: '30', label: ui.scope30 },
556
+ { key: '90', label: ui.scope90 },
557
+ { key: 'custom', label: ui.scopeCustom },
558
+ ],
559
+ preset,
560
+ )
561
+ if (scope === 'custom') {
562
+ return promptIntegerInput(ui.customDaysQuestion, currentDays || 30)
563
+ }
564
+ return Number(scope)
565
+ }
566
+
567
+ async function promptDepthPreset(current, ui) {
568
+ const preset = inferDepthPreset(current)
569
+ const choice = await promptChoice(
570
+ ui.depthQuestion,
571
+ [
572
+ { key: 'conservative', label: ui.depthConservative },
573
+ { key: 'standard', label: ui.depthStandard },
574
+ { key: 'deep', label: ui.depthDeep },
575
+ { key: 'custom', label: ui.depthCustom },
576
+ ],
577
+ preset,
578
+ )
579
+ if (choice === 'custom') {
580
+ const limit = await promptIntegerInput(ui.limitQuestion, current.limit)
581
+ const facetLimit = await promptIntegerInput(ui.facetLimitQuestion, current.facetLimit)
582
+ return { limit, facetLimit, preset: 'custom' }
583
+ }
584
+ return { ...applyScopePreset(current, choice), preset: choice }
585
+ }
586
+
587
+ async function promptLanguage(currentLang, ui) {
588
+ return promptChoice(
589
+ ui.languageQuestion,
590
+ [
591
+ { key: 'en', label: 'English' },
592
+ { key: 'zh-CN', label: '简体中文' },
593
+ ],
594
+ normalizeLang(currentLang),
595
+ )
596
+ }
597
+
598
+ async function promptOutputDir(currentOutDir, defaultOutDir, ui) {
599
+ const answer = await input({
600
+ message: ui.outputDirQuestion,
601
+ default: currentOutDir || defaultOutDir,
602
+ })
603
+ return answer?.trim() ? path.resolve(answer.trim()) : currentOutDir || defaultOutDir
604
+ }
605
+
606
+ async function promptQualityPreset(current, ui) {
607
+ const preset = inferQualityPreset(current)
608
+ const choice = await promptChoice(
609
+ ui.qualityQuestion,
610
+ [
611
+ { key: 'cheaper', label: ui.qualityCheaper },
612
+ { key: 'balanced', label: ui.qualityBalanced },
613
+ { key: 'higher', label: ui.qualityHigher },
614
+ ],
615
+ preset,
616
+ )
617
+ return { ...applyQualityPreset(current, choice), qualityPreset: choice }
618
+ }
619
+
620
+ async function promptChoice(question, choices, defaultKey) {
621
+ return select({
622
+ message: question,
623
+ default: defaultKey,
624
+ choices: choices.map(choice => ({
625
+ value: choice.key,
626
+ name: choice.label,
627
+ })),
628
+ })
629
+ }
630
+
631
+ async function promptYesNo(question, defaultValue) {
632
+ return confirm({
633
+ message: question,
634
+ default: defaultValue,
635
+ })
636
+ }
637
+
638
+ async function promptIntegerInput(question, defaultValue) {
639
+ const answer = await input({
640
+ message: question,
641
+ default: String(defaultValue),
642
+ validate(value) {
643
+ const number = Number.parseInt(String(value).trim(), 10)
644
+ if (Number.isFinite(number) && number >= 0) return true
645
+ return 'Please enter a non-negative integer.'
646
+ },
647
+ })
648
+ return Number.parseInt(String(answer).trim(), 10)
649
+ }
650
+
651
+ function inferScopePreset(days) {
652
+ if (days === 7) return '7'
653
+ if (days === 90) return '90'
654
+ if (days === 30) return '30'
655
+ return 'custom'
656
+ }
657
+
658
+ function inferDepthPreset(options) {
659
+ if (options.limit === 20 && options.facetLimit === 8) return 'conservative'
660
+ if (options.limit === 50 && options.facetLimit === 20) return 'standard'
661
+ if (options.limit === 200 && options.facetLimit === 50) return 'deep'
662
+ return 'custom'
663
+ }
664
+
665
+ function inferQualityPreset(options) {
666
+ if (
667
+ options.facetModel === 'gpt-5.4-mini' &&
668
+ options.fastSectionModel === 'gpt-5.4-mini' &&
669
+ options.insightModel === 'gpt-5.4-mini'
670
+ ) {
671
+ return 'cheaper'
672
+ }
673
+ if (
674
+ options.facetModel === 'gpt-5.4' &&
675
+ options.fastSectionModel === 'gpt-5.4' &&
676
+ options.insightModel === 'gpt-5.4'
677
+ ) {
678
+ return 'higher'
679
+ }
680
+ return 'balanced'
681
+ }
682
+
683
+ function applyScopePreset(options, preset) {
684
+ if (preset === 'conservative') return { ...options, limit: 20, facetLimit: 8 }
685
+ if (preset === 'deep') return { ...options, limit: 200, facetLimit: 50 }
686
+ return { ...options, limit: 50, facetLimit: 20 }
687
+ }
688
+
689
+ function applyQualityPreset(options, preset) {
690
+ if (preset === 'cheaper') {
691
+ return {
692
+ ...options,
693
+ facetModel: 'gpt-5.4-mini',
694
+ fastSectionModel: 'gpt-5.4-mini',
695
+ insightModel: 'gpt-5.4-mini',
696
+ facetEffort: 'low',
697
+ fastSectionEffort: 'low',
698
+ insightEffort: 'low',
699
+ }
700
+ }
701
+ if (preset === 'higher') {
702
+ return {
703
+ ...options,
704
+ facetModel: 'gpt-5.4',
705
+ fastSectionModel: 'gpt-5.4',
706
+ insightModel: 'gpt-5.4',
707
+ facetEffort: 'low',
708
+ fastSectionEffort: 'medium',
709
+ insightEffort: 'high',
710
+ }
711
+ }
712
+ return {
713
+ ...options,
714
+ facetModel: 'gpt-5.4-mini',
715
+ fastSectionModel: 'gpt-5.4-mini',
716
+ insightModel: 'gpt-5.4',
717
+ facetEffort: 'low',
718
+ fastSectionEffort: 'low',
719
+ insightEffort: 'high',
720
+ }
721
+ }
722
+
723
+ function hasCustomModelOverrides(options) {
724
+ return Boolean(
725
+ options.facetModel ||
726
+ options.fastSectionModel ||
727
+ options.insightModel ||
728
+ options.facetEffort ||
729
+ options.fastSectionEffort ||
730
+ options.insightEffort,
731
+ )
732
+ }
733
+
734
+ function renderPlanSummary(options, estimate, ui) {
735
+ const lines = []
736
+ lines.push(ui.planSummaryTitle)
737
+ lines.push(
738
+ `${options.days} ${ui.daysLabel}, ${formatDepthPresetLabel(inferDepthPreset(options), ui)}, ${options.lang === 'zh-CN' ? '简体中文' : 'English'}`,
739
+ )
740
+ lines.push(`${ui.outputLabel}: ${options.outDir}`)
741
+ lines.push(`${ui.providerLabel}: ${options.provider}`)
742
+ lines.push('')
743
+ lines.push(renderEstimateSummary(estimate, options.lang))
744
+ return lines.join('\n')
745
+ }
746
+
747
+ function formatDepthPresetLabel(preset, ui) {
748
+ if (preset === 'conservative') return ui.depthConservative
749
+ if (preset === 'deep') return ui.depthDeep
750
+ if (preset === 'custom') return ui.depthCustom
751
+ return ui.depthStandard
752
+ }
753
+
754
+ function buildEquivalentCommand(options) {
755
+ const args = [
756
+ 'codex-session-insights',
757
+ 'report',
758
+ '--days',
759
+ String(options.days),
760
+ '--limit',
761
+ String(options.limit),
762
+ '--facet-limit',
763
+ String(options.facetLimit),
764
+ '--lang',
765
+ normalizeLang(options.lang),
766
+ '--yes',
767
+ ]
768
+ if (typeof options.openReport === 'boolean') {
769
+ args.push(options.openReport ? '--open' : '--no-open')
770
+ }
771
+ if (options.includeArchived) args.push('--include-archived')
772
+ if (options.includeSubagents) args.push('--include-subagents')
773
+ if (options.outDir) args.push('--out-dir', shellQuote(options.outDir))
774
+ if (options.provider !== 'codex-cli') args.push('--provider', options.provider)
775
+ if (options.facetModel) args.push('--facet-model', options.facetModel)
776
+ if (options.fastSectionModel) args.push('--fast-section-model', options.fastSectionModel)
777
+ if (options.insightModel) args.push('--insight-model', options.insightModel)
778
+ if (options.facetEffort) args.push('--facet-effort', options.facetEffort)
779
+ if (options.fastSectionEffort) args.push('--fast-section-effort', options.fastSectionEffort)
780
+ if (options.insightEffort) args.push('--insight-effort', options.insightEffort)
781
+ return args.join(' ')
782
+ }
783
+
784
+ function normalizeLang(value) {
785
+ if (!value) return 'en'
786
+ const normalized = String(value).trim()
787
+ if (
788
+ normalized === 'zh' ||
789
+ normalized === 'zh-CN' ||
790
+ normalized === 'zh-Hans' ||
791
+ normalized.startsWith('zh_') ||
792
+ normalized.startsWith('zh-')
793
+ ) {
794
+ return 'zh-CN'
795
+ }
796
+ return 'en'
797
+ }
798
+
799
+ function detectSystemLanguage() {
800
+ if (process.env.CODEX_REPORT_LANG) {
801
+ return normalizeLang(process.env.CODEX_REPORT_LANG)
802
+ }
803
+
804
+ const intlLocale = Intl.DateTimeFormat().resolvedOptions().locale
805
+ const normalizedIntl = normalizeLang(intlLocale)
806
+ if (normalizedIntl === 'zh-CN') return normalizedIntl
807
+
808
+ const candidates = [process.env.LC_ALL, process.env.LC_MESSAGES, process.env.LANG].filter(Boolean)
809
+
810
+ for (const value of candidates) {
811
+ const normalized = normalizeLang(String(value).split('.')[0])
812
+ if (normalized === 'zh-CN') return normalized
813
+ }
814
+
815
+ return 'en'
816
+ }
817
+
818
+ function getUiText(lang) {
819
+ if (normalizeLang(lang) === 'zh-CN') {
820
+ return {
821
+ wizardTitle: '\nCodex Session Insights 配置向导\n',
822
+ loadingIndex: '正在读取线程索引...',
823
+ estimating: '正在预估分析成本...',
824
+ generating: '正在生成报告...',
825
+ writingFiles: '正在写入报告文件...',
826
+ openingBrowser: '正在打开浏览器...',
827
+ openedInBrowser: '已在浏览器中打开报告。',
828
+ openHint: '可用以下命令打开',
829
+ scopeQuestion: '选择分析时间范围:',
830
+ scope7: '最近 7 天',
831
+ scope30: '最近 30 天',
832
+ scope90: '最近 90 天',
833
+ scopeCustom: '自定义天数',
834
+ customDaysQuestion: '输入要分析的天数',
835
+ depthQuestion: '选择分析深度:',
836
+ depthConservative: '保守(20 threads / 8 facets)',
837
+ depthStandard: '标准(50 threads / 20 facets)',
838
+ depthDeep: '深度(200 threads / 50 facets)',
839
+ depthCustom: '自定义',
840
+ limitQuestion: '输入最大 thread 数',
841
+ facetLimitQuestion: '输入最大 facet 提取数',
842
+ languageQuestion: '选择报告语言:',
843
+ outputDirQuestion: '输出目录',
844
+ openBrowserQuestion: '生成后自动打开浏览器?',
845
+ qualityQuestion: '选择分析质量预设:',
846
+ qualityCheaper: '更省(尽量用 spark)',
847
+ qualityBalanced: '平衡',
848
+ qualityHigher: '更高质量(更多使用 gpt-5.4)',
849
+ yesDefault: '[Y/n]',
850
+ noDefault: '[y/N]',
851
+ invalidYesNo: '输入无效,使用默认值。',
852
+ confirmQuestion: '确认这次分析计划:',
853
+ startAnalysis: '开始分析',
854
+ adjustSettings: '重新调整设置',
855
+ exitAction: '退出',
856
+ cancelled: '已取消。',
857
+ equivalentCommandLabel: '等价命令:',
858
+ planSummaryTitle: '计划摘要',
859
+ analysisEstimateTitle: '分析预估',
860
+ toWord: '到',
861
+ likelyWord: '左右',
862
+ plannedCallsLabel: '预计调用数',
863
+ substantiveThreadsLabel: '有效线程',
864
+ uncachedFacetsLabel: '未缓存 facets',
865
+ longTranscriptsLabel: '长 transcript',
866
+ inputEstimateLabel: '输入',
867
+ outputEstimateLabel: '输出',
868
+ daysLabel: '天',
869
+ depthLabel: '深度',
870
+ outputLabel: '输出目录',
871
+ providerLabel: 'Provider',
872
+ facetProgress: '提取 facets',
873
+ sectionProgress: '生成 sections',
874
+ modelFallback: '模型降级',
875
+ }
876
+ }
877
+
878
+ return {
879
+ wizardTitle: '\nCodex Session Insights Setup\n',
880
+ loadingIndex: 'Loading thread index...',
881
+ estimating: 'Estimating analysis cost...',
882
+ generating: 'Generating report...',
883
+ writingFiles: 'Writing report files...',
884
+ openingBrowser: 'Opening browser...',
885
+ openedInBrowser: 'Opened report in your browser.',
886
+ openHint: 'Open it with',
887
+ scopeQuestion: 'Choose analysis range:',
888
+ scope7: 'Last 7 days',
889
+ scope30: 'Last 30 days',
890
+ scope90: 'Last 90 days',
891
+ scopeCustom: 'Custom days',
892
+ customDaysQuestion: 'Enter number of days to analyze',
893
+ depthQuestion: 'Choose analysis depth:',
894
+ depthConservative: 'Conservative (20 threads / 8 facets)',
895
+ depthStandard: 'Standard (50 threads / 20 facets)',
896
+ depthDeep: 'Deep (200 threads / 50 facets)',
897
+ depthCustom: 'Custom',
898
+ limitQuestion: 'Enter max thread count',
899
+ facetLimitQuestion: 'Enter max facet extraction count',
900
+ languageQuestion: 'Choose report language:',
901
+ outputDirQuestion: 'Output directory',
902
+ openBrowserQuestion: 'Open the report in your browser after generation?',
903
+ qualityQuestion: 'Choose quality preset:',
904
+ qualityCheaper: 'Cheaper (more spark)',
905
+ qualityBalanced: 'Balanced',
906
+ qualityHigher: 'Higher quality (more gpt-5.4)',
907
+ yesDefault: '[Y/n]',
908
+ noDefault: '[y/N]',
909
+ invalidYesNo: 'Invalid input, using default.',
910
+ confirmQuestion: 'Confirm this analysis plan:',
911
+ startAnalysis: 'Start analysis',
912
+ adjustSettings: 'Adjust settings',
913
+ exitAction: 'Exit',
914
+ cancelled: 'Cancelled.',
915
+ equivalentCommandLabel: 'Equivalent command:',
916
+ planSummaryTitle: 'Plan Summary',
917
+ analysisEstimateTitle: 'Analysis Estimate',
918
+ toWord: 'to',
919
+ likelyWord: 'likely',
920
+ plannedCallsLabel: 'planned calls',
921
+ substantiveThreadsLabel: 'substantive threads',
922
+ uncachedFacetsLabel: 'uncached facets',
923
+ longTranscriptsLabel: 'long transcripts',
924
+ inputEstimateLabel: 'input',
925
+ outputEstimateLabel: 'output',
926
+ daysLabel: 'days',
927
+ depthLabel: 'depth',
928
+ outputLabel: 'Output',
929
+ providerLabel: 'Provider',
930
+ facetProgress: 'Extracting facets',
931
+ sectionProgress: 'Generating sections',
932
+ modelFallback: 'Model fallback',
933
+ }
934
+ }
935
+
936
+ function logStage(options, message) {
937
+ if (options.stdoutJson) return
938
+ process.stdout.write(`${message}\n`)
939
+ }
940
+
941
+ function createProgressUi(initialOptions = {}) {
942
+ const spinner =
943
+ process.stdout.isTTY && !initialOptions.stdoutJson && !process.env.CI
944
+ ? ora({ isSilent: false })
945
+ : null
946
+
947
+ return {
948
+ startStage(options, message) {
949
+ if (spinner) {
950
+ spinner.start(message)
951
+ return
952
+ }
953
+ logStage(options, message)
954
+ },
955
+ completeStage(options, message) {
956
+ if (spinner) {
957
+ spinner.succeed(message)
958
+ return
959
+ }
960
+ logStage(options, message)
961
+ },
962
+ failStage(options, message) {
963
+ if (spinner) {
964
+ spinner.fail(message)
965
+ return
966
+ }
967
+ logStage(options, message)
968
+ },
969
+ updateFromEvent(options, event) {
970
+ if (!spinner || !event) return
971
+ const ui = getUiText(options.lang)
972
+ if (event.kind === 'facets:planned') {
973
+ spinner.text = `${ui.facetProgress}: 0/${event.total}`
974
+ return
975
+ }
976
+ if (event.kind === 'facets:progress') {
977
+ spinner.text = `${ui.facetProgress}: ${event.completed}/${event.total}`
978
+ return
979
+ }
980
+ if (event.kind === 'sections:planned') {
981
+ spinner.text = `${ui.sectionProgress}: 0/${event.total}`
982
+ return
983
+ }
984
+ if (event.kind === 'sections:progress') {
985
+ spinner.text = `${ui.sectionProgress}: ${event.completed}/${event.total} (${event.section})`
986
+ return
987
+ }
988
+ if (event.kind === 'model:fallback') {
989
+ spinner.text = `${ui.modelFallback}: ${event.fromModel} -> ${event.toModel}`
990
+ }
991
+ },
992
+ }
993
+ }
994
+
995
+ export const __test = {
996
+ shouldUseInteractiveMode,
997
+ applyScopePreset,
998
+ applyQualityPreset,
999
+ buildEquivalentCommand,
1000
+ normalizeLang,
1001
+ detectSystemLanguage,
1002
+ }