codex-work-receipt 0.8.1 → 0.10.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/src/cli.mjs CHANGED
@@ -18,13 +18,19 @@ import {
18
18
  import { generateReceipt } from "./core/generator.mjs";
19
19
  import { promptForGenerationMode } from "./core/mode-selector.mjs";
20
20
  import { printOpenSourcePrompt } from "./core/open-source.mjs";
21
- import { getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
21
+ import { getCustomSummaryNotice, getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
22
+ import { getProjectIdentitySecret, projectDescriptorFromPath } from "./core/project-identity.mjs";
22
23
  import { encodeSingleReceiptQr } from "./core/qr-payload.mjs";
23
- import { promptForRange } from "./core/selector.mjs";
24
+ import {
25
+ promptForCustomRange,
26
+ promptForProjectRange,
27
+ promptForRange,
28
+ promptForSpecificSession,
29
+ } from "./core/selector.mjs";
24
30
  import { installCodexSkill } from "./core/skill-installer.mjs";
25
31
  import { installCodexPet, uninstallCodexPet } from "./core/pet-installer.mjs";
26
32
  import { formatNumber } from "./lib/time.mjs";
27
- import { listRecentCodexSessions } from "./parsers/codex.mjs";
33
+ import { listRecentCodexProjects, listRecentCodexSessions } from "./parsers/codex.mjs";
28
34
 
29
35
  const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url));
30
36
  const PROJECT_DIR = path.dirname(SCRIPT_DIR);
@@ -185,6 +191,49 @@ async function main() {
185
191
  return;
186
192
  }
187
193
 
194
+ if ((options.selectSession || options.selectProject || (options.mode === "custom-range" && !options.from)) && !isInteractive()) {
195
+ throw new Error("交互选择命令需要在终端中运行;也可以使用 --session、--project、--from 和 --to");
196
+ }
197
+
198
+ let projectSecret = null;
199
+ const ensureProjectSecret = () => {
200
+ projectSecret ||= getProjectIdentitySecret({ dataDir: options.dataDir });
201
+ return projectSecret;
202
+ };
203
+ const loadRecentProjects = () => listRecentCodexProjects(10, {
204
+ projectSecret: ensureProjectSecret(),
205
+ });
206
+
207
+ if (options.project) {
208
+ options.projectId = projectDescriptorFromPath(options.project, ensureProjectSecret()).projectId;
209
+ }
210
+ if (options.selectSession) {
211
+ const selected = await promptForSpecificSession({
212
+ locale: options.locale,
213
+ timezone: options.timezone,
214
+ loadRecentSessions: () => listRecentCodexSessions(10),
215
+ });
216
+ options.mode = selected.mode;
217
+ options.sessionId = selected.sessionId;
218
+ options.modeExplicit = true;
219
+ } else if (options.selectProject) {
220
+ const selected = await promptForProjectRange({
221
+ locale: options.locale,
222
+ timezone: options.timezone,
223
+ loadRecentProjects,
224
+ });
225
+ options.mode = selected.mode;
226
+ options.projectId = selected.projectId;
227
+ options.hours = selected.hours || options.hours;
228
+ options.from = selected.from || options.from;
229
+ options.to = selected.to || options.to;
230
+ options.modeExplicit = true;
231
+ } else if (options.mode === "custom-range" && !options.from) {
232
+ const selected = await promptForCustomRange({ locale: options.locale, timezone: options.timezone });
233
+ options.from = selected.from;
234
+ options.to = selected.to;
235
+ }
236
+
188
237
  if (!options.modeExplicit && isInteractive()) {
189
238
  const workReceiptHome = getWorkReceiptHome({ dataDir: options.dataDir });
190
239
  if (!readAutoConfig({ workReceiptHome })) {
@@ -199,10 +248,14 @@ async function main() {
199
248
  locale: options.locale,
200
249
  timezone: options.timezone,
201
250
  loadRecentSessions: () => listRecentCodexSessions(10),
251
+ loadRecentProjects,
202
252
  });
203
253
  options.mode = selected.mode;
204
254
  options.sessionId = selected.sessionId;
205
255
  options.hours = selected.hours || options.hours;
256
+ options.projectId = selected.projectId || options.projectId;
257
+ options.from = selected.from || options.from;
258
+ options.to = selected.to || options.to;
206
259
  }
207
260
 
208
261
  const generated = await generateReceipt(options, {
@@ -235,7 +288,7 @@ async function main() {
235
288
  console.log(`Structured data: ${persisted.companionPath}`);
236
289
  console.log(`WeChat import file: ${persisted.transferPath}`);
237
290
  console.log(`Local history: ${persisted.receiptPath}`);
238
- console.log(`Range: ${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} session(s)`);
291
+ console.log(`Range: ${getScopeLabel(record.source.scope, options.locale, record.source.hours, { rangeKind: record.source.range_kind, filterKind: record.source.filter_kind })} · ${record.stats.session_count} session(s)`);
239
292
  console.log(`Stats: ${record.stats.completed_turns} turns · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Tokens · ${record.stats.tool_calls} tool calls`);
240
293
  console.log(dataQrDataUrl
241
294
  ? `Data QR: available as one code · QR version ${dataQrVersion}`
@@ -246,13 +299,16 @@ async function main() {
246
299
  if (record.source.scope === "last-hours") {
247
300
  console.log(`Note: ${getRollingSummaryNotice(options.locale, record.source.hours)}`);
248
301
  }
302
+ if (record.source.scope === "custom-range" && record.source.range_kind === "exact-time") {
303
+ console.log(`Note: ${getCustomSummaryNotice(options.locale)}`);
304
+ }
249
305
  if (!miniProgramCodeDataUrl) console.log("Mini-program code: not configured; using the explicit placeholder");
250
306
  } else {
251
307
  console.log(`已生成网页:${outputFile}`);
252
308
  console.log(`结构数据:${persisted.companionPath}`);
253
309
  console.log(`微信导入文件:${persisted.transferPath}`);
254
310
  console.log(`本地历史:${persisted.receiptPath}`);
255
- console.log(`统计范围:${getScopeLabel(record.source.scope, options.locale, record.source.hours)} · ${record.stats.session_count} 个会话`);
311
+ console.log(`统计范围:${getScopeLabel(record.source.scope, options.locale, record.source.hours, { rangeKind: record.source.range_kind, filterKind: record.source.filter_kind })} · ${record.stats.session_count} 个会话`);
256
312
  console.log(`统计:${record.stats.completed_turns} 轮 · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Token · ${record.stats.tool_calls} 次工具调用`);
257
313
  console.log(dataQrDataUrl
258
314
  ? `数据二维码:可用 · 单码 · QR version ${dataQrVersion}`
@@ -263,6 +319,9 @@ async function main() {
263
319
  if (record.source.scope === "last-hours") {
264
320
  console.log(`提示:${getRollingSummaryNotice(options.locale, record.source.hours)}`);
265
321
  }
322
+ if (record.source.scope === "custom-range" && record.source.range_kind === "exact-time") {
323
+ console.log(`提示:${getCustomSummaryNotice(options.locale)}`);
324
+ }
266
325
  if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
267
326
  }
268
327
  printOpenSourcePrompt("receipt", options.locale);
package/src/core/args.mjs CHANGED
@@ -11,16 +11,25 @@ Usage:
11
11
  npx codex-work-receipt@latest --today --lang en
12
12
  npx codex-work-receipt@latest --range last-7-days --lang en
13
13
  npx codex-work-receipt@latest --range this-week --lang en
14
+ npx codex-work-receipt@latest --custom-range --lang en
15
+ npx codex-work-receipt@latest --select-session --lang en
16
+ npx codex-work-receipt@latest --select-project --lang en
14
17
  npx codex-work-receipt@latest --install-skill --lang en
15
18
  npx codex-work-receipt@latest --install-companion --lang en
16
19
  npx codex-work-receipt@latest --setup --lang en
17
20
 
18
21
  Options:
19
- --range <name> Range: latest, last-hours, today, last-7-days, this-week
22
+ --range <name> Range: latest, last-hours, custom-range, today, last-7-days, this-week
20
23
  --hours <number> Summarize the last 1-168 hours
24
+ --custom-range Interactively choose a custom date or time range
25
+ --from <value> Custom start: YYYY-MM-DD or YYYY-MM-DDTHH:mm
26
+ --to <value> Custom end: YYYY-MM-DD or YYYY-MM-DDTHH:mm
21
27
  --latest Summarize the latest active Codex session (default)
22
28
  --today Summarize all Codex activity from today
23
29
  --session <id> Summarize one specific Codex session
30
+ --select-session Interactively choose a recent Codex session
31
+ --project <directory> Limit the receipt to one local project
32
+ --select-project Interactively choose a recent project and range
24
33
  --timezone <name> Use an IANA timezone, for example Asia/Shanghai
25
34
  --lang <name> Receipt language: zh-CN, en
26
35
  --theme <name> Default theme: classic, diner, payroll
@@ -46,16 +55,25 @@ Codex AI 打工小票
46
55
  npx codex-work-receipt@latest --today
47
56
  npx codex-work-receipt@latest --range last-7-days
48
57
  npx codex-work-receipt@latest --range this-week
58
+ npx codex-work-receipt@latest --custom-range
59
+ npx codex-work-receipt@latest --select-session
60
+ npx codex-work-receipt@latest --select-project
49
61
  npx codex-work-receipt@latest --install-skill
50
62
  npx codex-work-receipt@latest --install-companion
51
63
  npx codex-work-receipt@latest --setup
52
64
 
53
65
  选项:
54
- --range <name> 统计范围:latest、last-hours、today、last-7-days、this-week
66
+ --range <name> 统计范围:latest、last-hours、custom-range、today、last-7-days、this-week
55
67
  --hours <number> 统计最近 1~168 小时
68
+ --custom-range 交互选择自定义日期或精确时间区间
69
+ --from <value> 自定义开始:YYYY-MM-DD 或 YYYY-MM-DDTHH:mm
70
+ --to <value> 自定义结束:YYYY-MM-DD 或 YYYY-MM-DDTHH:mm
56
71
  --latest 统计最近活跃的 Codex 会话(默认)
57
72
  --today 统计本地时区今天发生的全部 Codex 活动
58
73
  --session <id> 统计指定的 Codex 会话
74
+ --select-session 交互选择最近的 Codex 会话
75
+ --project <directory> 只统计指定的本地项目目录
76
+ --select-project 交互选择最近项目及统计范围
59
77
  --timezone <name> 指定 IANA 时区,例如 Asia/Shanghai
60
78
  --lang <name> 小票语言:zh-CN、en
61
79
  --theme <name> 默认主题:classic、diner、payroll
@@ -79,7 +97,14 @@ export function parseArgs(argv) {
79
97
  mode: "latest",
80
98
  modeExplicit: false,
81
99
  sessionId: null,
100
+ selectSession: false,
101
+ project: null,
102
+ projectId: null,
103
+ selectProject: false,
82
104
  hours: null,
105
+ customRange: false,
106
+ from: null,
107
+ to: null,
83
108
  timezone: Intl.DateTimeFormat().resolvedOptions().timeZone || "Asia/Shanghai",
84
109
  locale: "zh-CN",
85
110
  theme: "classic",
@@ -103,7 +128,10 @@ export function parseArgs(argv) {
103
128
  ["--output", "output"],
104
129
  ["--data-dir", "dataDir"],
105
130
  ["--session", "sessionId"],
131
+ ["--project", "project"],
106
132
  ["--hours", "hours"],
133
+ ["--from", "from"],
134
+ ["--to", "to"],
107
135
  ]);
108
136
 
109
137
  for (let index = 0; index < argv.length; index += 1) {
@@ -114,7 +142,13 @@ export function parseArgs(argv) {
114
142
  } else if (argument === "--today") {
115
143
  result.mode = "today";
116
144
  result.modeExplicit = true;
117
- } else if (argument === "--install-pet") result.installPet = true;
145
+ } else if (argument === "--custom-range") {
146
+ result.mode = "custom-range";
147
+ result.modeExplicit = true;
148
+ result.customRange = true;
149
+ } else if (argument === "--select-session") result.selectSession = true;
150
+ else if (argument === "--select-project") result.selectProject = true;
151
+ else if (argument === "--install-pet") result.installPet = true;
118
152
  else if (argument === "--uninstall-pet") result.uninstallPet = true;
119
153
  else if (argument === "--install-companion") result.installCompanion = true;
120
154
  else if (argument === "--range") {
@@ -142,6 +176,10 @@ export function parseArgs(argv) {
142
176
  } else if (key === "hours") {
143
177
  result.mode = "last-hours";
144
178
  result.modeExplicit = true;
179
+ } else if (key === "from" || key === "to") {
180
+ result.mode = "custom-range";
181
+ result.modeExplicit = true;
182
+ result.customRange = true;
145
183
  }
146
184
  } else throw new Error(`不认识的参数:${argument}`);
147
185
  }
@@ -164,6 +202,18 @@ export function parseArgs(argv) {
164
202
  }
165
203
  }
166
204
  if (result.mode === "last-hours" && result.hours === null) result.hours = 3;
205
+ if (result.mode === "custom-range") result.customRange = true;
206
+ if (Boolean(result.from) !== Boolean(result.to)) throw new Error("--from 和 --to 必须同时使用");
207
+ if (result.selectSession && result.selectProject) throw new Error("不能同时选择会话和项目");
208
+ if ((result.selectSession || result.selectProject) && result.modeExplicit) {
209
+ throw new Error("交互选择参数不能与统计范围参数同时使用");
210
+ }
211
+ if (result.selectSession && result.project) throw new Error("指定会话时不能同时指定项目");
212
+ if (result.selectProject && result.project) throw new Error("不能同时使用 --project 和 --select-project");
213
+ if (result.project && !result.modeExplicit) {
214
+ result.mode = "today";
215
+ result.modeExplicit = true;
216
+ }
167
217
  const managementActions = [
168
218
  result.setup,
169
219
  result.enableAuto,
@@ -172,8 +222,14 @@ export function parseArgs(argv) {
172
222
  ].filter(Boolean).length;
173
223
  if (managementActions > 1) throw new Error("自动保存管理参数不能同时使用");
174
224
  if (managementActions && result.modeExplicit) throw new Error("自动保存管理参数不能与统计范围参数同时使用");
225
+ if (managementActions && (result.selectSession || result.selectProject || result.project)) {
226
+ throw new Error("自动保存管理参数不能与会话或项目选择参数同时使用");
227
+ }
175
228
  if (managementActions && (
176
229
  result.installSkill || result.installPet || result.uninstallPet || result.installCompanion
177
230
  )) throw new Error("自动保存管理参数不能与 Skill 或桌宠管理参数同时使用");
231
+ if ((result.selectSession || result.selectProject || result.project) && (
232
+ result.installSkill || result.installPet || result.uninstallPet || result.installCompanion
233
+ )) throw new Error("会话或项目选择参数不能与 Skill 或桌宠管理参数同时使用");
178
234
  return result;
179
235
  }
@@ -7,7 +7,7 @@ import {
7
7
  METRIC_SCHEMA_VERSION,
8
8
  } from "./fact-identity.mjs";
9
9
  import { dateKey, rowDate } from "../lib/time.mjs";
10
- import { isCalendarScope, isDateInRange, shiftDateKey } from "./range.mjs";
10
+ import { isCalendarRange, isDateInRange, shiftDateKey } from "./range.mjs";
11
11
 
12
12
  const TOKEN_KEYS = [
13
13
  "input_tokens",
@@ -112,7 +112,7 @@ function metricsForRows(sessionRows, bucketRows, localDate, timezone) {
112
112
  }
113
113
 
114
114
  function coverageForRange(range, scanMode, observedAt) {
115
- if (!isCalendarScope(range.scope)) {
115
+ if (!isCalendarRange(range)) {
116
116
  return {
117
117
  kind: "selected_sessions",
118
118
  scan_mode: "none",
@@ -151,7 +151,7 @@ export function buildCanonicalFacts(sessions, range, options = {}) {
151
151
 
152
152
  for (const row of sessionRows) {
153
153
  const date = rowDate(row);
154
- if (!date || (isCalendarScope(range.scope) && !isDateInRange(date, range))) continue;
154
+ if (!date || (isCalendarRange(range) && !isDateInRange(date, range))) continue;
155
155
  const localDate = dateKey(date, accountingTimezone);
156
156
  const rows = buckets.get(localDate) || [];
157
157
  rows.push(row);
@@ -25,16 +25,20 @@ export function buildLogicalReceiptKey(metrics) {
25
25
  const sessionPart = stableId("cwg", "codex-work-receipt/session-group/v1", [
26
26
  ...[...metrics.sessionIds].sort(),
27
27
  ]);
28
+ const projectPart = metrics.projectId ? `:project:${metrics.projectId}` : "";
28
29
  if (metrics.mode === "latest" || metrics.mode === "session") {
29
- return `${metrics.mode}:${sessionPart}:${metrics.timezone}`;
30
+ return `${metrics.mode}:${sessionPart}:${metrics.timezone}${projectPart}`;
30
31
  }
31
32
  if (metrics.mode === "last-hours") {
32
- return `last-hours:${metrics.windowHours}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}`;
33
+ return `last-hours:${metrics.windowHours}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}${projectPart}`;
34
+ }
35
+ if (metrics.mode === "custom-range") {
36
+ return `custom-range:${metrics.boundaryKind}:${metrics.windowStartAt.toISOString()}:${metrics.windowEndAt.toISOString()}:${metrics.timezone}${projectPart}`;
33
37
  }
34
38
  if (metrics.mode === "this-week") {
35
- return `this-week:${metrics.rangeStartDate}:${metrics.timezone}`;
39
+ return `this-week:${metrics.rangeStartDate}:${metrics.timezone}${projectPart}`;
36
40
  }
37
- return `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
41
+ return `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}${projectPart}`;
38
42
  }
39
43
 
40
44
  export function buildSummaryReceiptId(logicalReceiptKey) {
@@ -4,6 +4,7 @@ import path from "node:path";
4
4
  import { buildCanonicalFacts } from "./fact-buckets.mjs";
5
5
  import { createReceiptFile } from "./file-payload.mjs";
6
6
  import { collectMetrics } from "./metrics.mjs";
7
+ import { getProjectIdentitySecret } from "./project-identity.mjs";
7
8
  import { outputSlugForRange, resolveRange } from "./range.mjs";
8
9
  import {
9
10
  buildReceiptRecord,
@@ -39,11 +40,22 @@ export async function generateReceipt(
39
40
  ) {
40
41
  if (!projectDir) throw new Error("生成小票时缺少项目目录");
41
42
 
42
- const range = resolveRange(options.mode, options.timezone, now, options.sessionId, options.hours);
43
- const sessions = loadCodexSessions(range, { codexHome });
43
+ const range = resolveRange(
44
+ options.mode,
45
+ options.timezone,
46
+ now,
47
+ options.sessionId,
48
+ options.hours,
49
+ options.mode === "custom-range" ? { from: options.from, to: options.to } : null,
50
+ options.projectId,
51
+ );
52
+ const projectSecret = range.projectId ? getProjectIdentitySecret({ dataDir: options.dataDir }) : null;
53
+ const sessions = loadCodexSessions(range, { codexHome, projectSecret });
44
54
  const metrics = collectMetrics(sessions, range);
45
55
  const observedAt = now.toISOString();
46
- const canonical = range.scope === "last-hours"
56
+ const summaryOnly = range.scope === "last-hours"
57
+ || (range.scope === "custom-range" && range.boundaryKind === "exact-time");
58
+ const canonical = summaryOnly
47
59
  ? {}
48
60
  : buildCanonicalFacts(sessions, range, { observedAt });
49
61
  const record = buildReceiptRecord(metrics, options.theme, options.locale, canonical);
@@ -1,9 +1,10 @@
1
1
  import { dateKey, rowDate } from "../lib/time.mjs";
2
+ import { toolCategoryForRow } from "../lib/tool-category.mjs";
2
3
  import {
3
4
  calendarDayCount,
4
- isCalendarScope,
5
+ isCalendarRange,
5
6
  isDateInRange,
6
- isTimeWindowScope,
7
+ isTimeWindowRange,
7
8
  } from "./range.mjs";
8
9
  import { selectWorkProfileId } from "./presentation.mjs";
9
10
 
@@ -21,6 +22,83 @@ function addUsage(target, source) {
21
22
  for (const key of Object.keys(target)) target[key] += Number(source[key] || 0);
22
23
  }
23
24
 
25
+ function increment(map, key, amount = 1) {
26
+ if (!key) return;
27
+ map.set(key, Number(map.get(key) || 0) + amount);
28
+ }
29
+
30
+ function roundedRatio(numerator, denominator, digits = 2) {
31
+ if (!denominator) return 0;
32
+ const scale = 10 ** digits;
33
+ return Math.round((Number(numerator || 0) / denominator) * scale) / scale;
34
+ }
35
+
36
+ function percentile(samples, quantile) {
37
+ if (!samples.length) return 0;
38
+ const sorted = [...samples].sort((left, right) => left - right);
39
+ const position = (sorted.length - 1) * quantile;
40
+ const lowerIndex = Math.floor(position);
41
+ const upperIndex = Math.ceil(position);
42
+ const lower = sorted[lowerIndex];
43
+ const upper = sorted[upperIndex];
44
+ return Math.round(lower + (upper - lower) * (position - lowerIndex));
45
+ }
46
+
47
+ function usageRows(map, keyName) {
48
+ return [...map.entries()]
49
+ .map(([name, count]) => ({ [keyName]: name, count }))
50
+ .sort((left, right) => right.count - left.count || left[keyName].localeCompare(right[keyName]));
51
+ }
52
+
53
+ function localHour(date, timezone) {
54
+ if (!date) return null;
55
+ const hour = Number(new Intl.DateTimeFormat("en-GB", {
56
+ timeZone: timezone,
57
+ hour: "2-digit",
58
+ hourCycle: "h23",
59
+ }).format(date));
60
+ return Number.isInteger(hour) && hour >= 0 && hour <= 23 ? hour : null;
61
+ }
62
+
63
+ function buildInsights({
64
+ tokens,
65
+ completedTurns,
66
+ toolCalls,
67
+ completedDurationMs,
68
+ firstTokenSamples,
69
+ turnDurationSamples,
70
+ activityByHour,
71
+ modelUsage,
72
+ toolUsage,
73
+ }) {
74
+ const inputTokens = Math.max(0, Number(tokens.input_tokens || 0));
75
+ const cachedInputTokens = Math.max(0, Number(tokens.cached_input_tokens || 0));
76
+ return {
77
+ cache_hit_rate: inputTokens ? Math.min(1, roundedRatio(cachedInputTokens, inputTokens, 4)) : 0,
78
+ per_turn: {
79
+ total_tokens: roundedRatio(tokens.total_tokens, completedTurns),
80
+ output_tokens: roundedRatio(tokens.output_tokens, completedTurns),
81
+ tool_calls: roundedRatio(toolCalls, completedTurns),
82
+ work_duration_ms: Math.round(roundedRatio(completedDurationMs, completedTurns)),
83
+ },
84
+ latency_ms: {
85
+ first_token: {
86
+ sample_count: firstTokenSamples.length,
87
+ p50: percentile(firstTokenSamples, 0.5),
88
+ p90: percentile(firstTokenSamples, 0.9),
89
+ },
90
+ turn: {
91
+ sample_count: turnDurationSamples.length,
92
+ p50: percentile(turnDurationSamples, 0.5),
93
+ p90: percentile(turnDurationSamples, 0.9),
94
+ },
95
+ },
96
+ activity_by_hour: [...activityByHour],
97
+ model_usage: usageRows(modelUsage, "model"),
98
+ tool_usage: usageRows(toolUsage, "category"),
99
+ };
100
+ }
101
+
24
102
  function sessionTokenUsage(rows, range) {
25
103
  const events = rows
26
104
  .filter((row) => row.type === "event_msg" && row.payload?.type === "token_count")
@@ -33,7 +111,7 @@ function sessionTokenUsage(rows, range) {
33
111
  for (const row of events) {
34
112
  const currentUsage = row.payload.info.total_token_usage;
35
113
  const date = rowDate(row);
36
- const selected = (!isCalendarScope(range.scope) && !isTimeWindowScope(range.scope))
114
+ const selected = (!isCalendarRange(range) && !isTimeWindowRange(range))
37
115
  || isDateInRange(date, range);
38
116
 
39
117
  for (const key of Object.keys(totals)) {
@@ -63,6 +141,7 @@ function calculateWorkPoints(metrics) {
63
141
 
64
142
  function emptyRangeMessage(range) {
65
143
  if (range.scope === "last-hours") return `最近 ${range.hours} 小时没有找到 Codex 活动`;
144
+ if (range.scope === "custom-range") return "自定义时间区间内没有找到 Codex 活动";
66
145
  if (range.scope === "today") return `没有找到 ${range.targetDate} 的 Codex 活动`;
67
146
  if (range.scope === "last-7-days") return `没有找到 ${range.startDate} 至 ${range.endDate} 的 Codex 活动`;
68
147
  if (range.scope === "this-week") return `本周暂时没有找到 Codex 活动`;
@@ -78,14 +157,20 @@ export function collectMetrics(sessions, range) {
78
157
  let toolCalls = 0;
79
158
  let interruptions = 0;
80
159
  let workDurationMs = 0;
160
+ let completedDurationMs = 0;
81
161
  let totalFirstTokenMs = 0;
82
162
  let firstTokenSamples = 0;
163
+ const firstTokenLatencySamples = [];
164
+ const turnDurationSamples = [];
83
165
  const timestamps = [];
84
166
  const activeDateKeys = new Set();
85
167
  const models = new Set();
168
+ const modelUsage = new Map();
169
+ const toolUsage = new Map();
170
+ const activityByHour = Array(24).fill(0);
86
171
 
87
172
  for (const session of sessions) {
88
- const scopedRows = isCalendarScope(range.scope) || isTimeWindowScope(range.scope)
173
+ const scopedRows = isCalendarRange(range) || isTimeWindowRange(range)
89
174
  ? session.rows.filter((row) => isDateInRange(rowDate(row), range))
90
175
  : session.rows;
91
176
 
@@ -94,40 +179,70 @@ export function collectMetrics(sessions, range) {
94
179
  sessionIds.push(session.sessionId);
95
180
  addUsage(tokens, sessionTokenUsage(session.rows, range));
96
181
 
97
- for (const row of scopedRows) {
182
+ let activeModel = null;
183
+ let unattributedModelTurns = 0;
184
+ let sessionHasSelectedModel = false;
185
+ for (const row of session.rows) {
186
+ if (row.type === "turn_context" && row.payload?.model) activeModel = row.payload.model;
187
+ const selected = (!isCalendarRange(range) && !isTimeWindowRange(range))
188
+ || isDateInRange(rowDate(row), range);
189
+ if (!selected) continue;
98
190
  const date = rowDate(row);
99
191
  if (date) {
100
192
  timestamps.push(date);
101
193
  activeDateKeys.add(dateKey(date, range.timezone));
102
194
  }
103
195
 
104
- if (row.type === "turn_context" && row.payload?.model) models.add(row.payload.model);
196
+ if (row.type === "turn_context" && row.payload?.model) {
197
+ models.add(row.payload.model);
198
+ sessionHasSelectedModel = true;
199
+ }
105
200
  if (row.type === "event_msg") {
106
201
  const eventType = row.payload?.type;
107
202
  if (eventType === "task_complete") {
203
+ const hasDuration = Number.isFinite(row.payload.duration_ms);
204
+ const durationMs = hasDuration ? Math.max(0, Number(row.payload.duration_ms)) : 0;
108
205
  completedTurns += 1;
109
- workDurationMs += Number(row.payload.duration_ms || 0);
206
+ workDurationMs += durationMs;
207
+ completedDurationMs += durationMs;
208
+ if (hasDuration) turnDurationSamples.push(durationMs);
209
+ if (activeModel) {
210
+ models.add(activeModel);
211
+ sessionHasSelectedModel = true;
212
+ increment(modelUsage, activeModel);
213
+ }
214
+ else unattributedModelTurns += 1;
215
+ const hour = localHour(date, range.timezone);
216
+ if (hour !== null) activityByHour[hour] += 1;
110
217
  if (Number.isFinite(row.payload.time_to_first_token_ms)) {
111
- totalFirstTokenMs += row.payload.time_to_first_token_ms;
218
+ const firstTokenMs = Math.max(0, Number(row.payload.time_to_first_token_ms));
219
+ totalFirstTokenMs += firstTokenMs;
112
220
  firstTokenSamples += 1;
221
+ firstTokenLatencySamples.push(firstTokenMs);
113
222
  }
114
223
  } else if (eventType === "user_message") userMessages += 1;
115
224
  else if (eventType === "turn_aborted") {
116
225
  interruptions += 1;
117
- workDurationMs += Number(row.payload.duration_ms || 0);
226
+ workDurationMs += Math.max(0, Number(row.payload.duration_ms || 0));
227
+ const hour = localHour(date, range.timezone);
228
+ if (hour !== null) activityByHour[hour] += 1;
118
229
  }
119
230
  }
120
231
 
121
- if (
122
- row.type === "response_item" &&
123
- (row.payload?.type === "custom_tool_call" || row.payload?.type === "function_call")
124
- ) toolCalls += 1;
232
+ const toolCategory = toolCategoryForRow(row);
233
+ if (toolCategory) {
234
+ toolCalls += 1;
235
+ increment(toolUsage, toolCategory);
236
+ }
125
237
  }
126
238
 
127
239
  const fallbackModel = [...session.rows]
128
240
  .reverse()
129
241
  .find((row) => row.type === "turn_context" && row.payload?.model)?.payload?.model;
130
- if (fallbackModel) models.add(fallbackModel);
242
+ if (fallbackModel && (!sessionHasSelectedModel || unattributedModelTurns)) {
243
+ models.add(fallbackModel);
244
+ if (unattributedModelTurns) increment(modelUsage, fallbackModel, unattributedModelTurns);
245
+ }
131
246
  }
132
247
 
133
248
  if (!scopedSessions.length || !timestamps.length) throw new Error(emptyRangeMessage(range));
@@ -144,7 +259,9 @@ export function collectMetrics(sessions, range) {
144
259
  windowStartAt: range.startAt,
145
260
  windowEndAt: range.endAt,
146
261
  windowHours: range.hours,
147
- calendarDayCount: isCalendarScope(range.scope) ? calendarDayCount(range) : 1,
262
+ boundaryKind: range.boundaryKind,
263
+ projectId: range.projectId,
264
+ calendarDayCount: isCalendarRange(range) ? calendarDayCount(range) : 1,
148
265
  activeDayCount: Math.max(1, activeDateKeys.size),
149
266
  sessionIds,
150
267
  sessionCount: scopedSessions.length,
@@ -159,6 +276,17 @@ export function collectMetrics(sessions, range) {
159
276
  tokens,
160
277
  models: [...models],
161
278
  };
279
+ metrics.insights = buildInsights({
280
+ tokens,
281
+ completedTurns,
282
+ toolCalls,
283
+ completedDurationMs,
284
+ firstTokenSamples: firstTokenLatencySamples,
285
+ turnDurationSamples,
286
+ activityByHour,
287
+ modelUsage,
288
+ toolUsage,
289
+ });
162
290
  metrics.workProfileId = selectWorkProfileId(metrics);
163
291
  metrics.workPoints = calculateWorkPoints(metrics);
164
292
  return metrics;