codex-work-receipt 0.8.0 → 0.9.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/CHANGELOG.md CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.9.0
4
+
5
+ - 新增缓存命中率、每轮 Token / 输出 Token / 工具调用 / 完成耗时等效率指标。
6
+ - 新增首 Token 与整轮耗时的 P50 / P90 分位数,以及按本地时区生成的 24 小时工作热力图。
7
+ - 新增模型使用轮次和脱敏工具类别结构;解析器不保留原始工具名、调用参数、命令或工具输出。
8
+ - HTML 与本地完整 JSON 展示详细效率洞察;微信 `cwr1` / `cwr2` 紧凑载荷保持不变,继续兼容现有小程序。
9
+ - HTML 赞助商下方新增“更多小票功能”,集中展示生成范围、自动 / 手动模式、Skill 与票仔的 12 条常用命令,并支持一键复制。
10
+ - 功能命令区采用独立的浅色纸张样式,记忆展开状态,适配桌面和移动端,并继续排除在长图导出范围之外。
11
+
3
12
  ## 0.8.0
4
13
 
5
14
  - 首次交互式运行新增“自动保存 / 仅手动”选择;显式范围命令继续保持非交互兼容。
@@ -11,7 +20,7 @@
11
20
  - 数据二维码降级为可选单码:只有完整载荷能放进安全 QR version 时才显示扫码入口,不再生成或轮播新的 multipart 二维码。
12
21
  - 保留 `cwr1`、`cwr2` 和历史 `cwr2p` 解码兼容;二维码容量不足不再阻止 HTML、JSON、导入文件或本地历史生成。
13
22
  - 修复 HTML 长图导出时文字节点被固化宽高后发生的数值换行与错位问题。
14
- - 更新 HTML 工具栏并新增响应式侧栏,集中展示 GitHub、更新日志和赞助伙伴入口;这些网页控件不会进入导出图片。
23
+ - 更新 HTML 工具栏并新增响应式侧栏,改善主题切换、长图导出和相关信息浏览体验;这些网页控件不会进入导出图片。
15
24
  - 更新中英文 CLI、手机导入、协议、隐私和 Codex Skill 文档。
16
25
 
17
26
  ## 0.7.4
package/README.en.md CHANGED
@@ -20,7 +20,7 @@
20
20
  <img src="docs/images/readme-hero.jpg" alt="Three Codex Work Receipt themes" width="920">
21
21
  </p>
22
22
 
23
- Codex Work Receipt summarizes turns, tool calls, Tokens, duration, and models, then turns them into playful AI work points, a job title, and a review. Receipts never include prompts, responses, source code, project paths, or file names.
23
+ Codex Work Receipt summarizes turns, tool calls, Tokens, duration, and models. It also reports cache hit rate, per-turn efficiency, P50 / P90 latency, a work-time heatmap, and model/tool structure, then turns the result into playful AI work points, a job title, and a review. Receipts never include prompts, responses, source code, project paths, file names, tool arguments, or tool output.
24
24
 
25
25
  The cwr2 protocol creates stable privacy-safe facts for each session and calendar day, allowing overlapping today, last-seven-days, and this-week receipts to be deduplicated. Every receipt also produces one `.cwr.json` WeChat import file. Scan import appears only when the complete payload fits in one data QR code.
26
26
 
@@ -56,7 +56,7 @@ npx codex-work-receipt@latest --hours 3 --lang en
56
56
 
57
57
  “Last N hours” is a rolling summary for private history only. It does not participate in AI Work Cooperative deduplicated accounting. Use today, this week, the last seven days, or a specific session when you want accountable canonical facts.
58
58
 
59
- HTML, structured data, and the WeChat import file are written to `./codex-work-receipt-output/` by default. The page can download its `.cwr.json` file and save a high-resolution PNG containing only the full receipt and WeChat mini-program code. See the [CLI guide](docs/cli.en.md).
59
+ HTML, structured data, and the WeChat import file are written to `./codex-work-receipt-output/` by default. The page can download its `.cwr.json` file and save a high-resolution PNG containing only the full receipt and WeChat mini-program code. A “More receipt features” section below the sponsor provides 12 copyable commands for receipt ranges, automatic mode, the Codex skill, and Ticket Buddy. The web controls stay out of the exported image. See the [CLI guide](docs/cli.en.md).
60
60
 
61
61
  ## Automatic saving or manual only
62
62
 
package/README.md CHANGED
@@ -20,7 +20,7 @@
20
20
  <img src="docs/images/readme-hero.jpg" alt="Codex AI 打工小票三种主题效果" width="920">
21
21
  </p>
22
22
 
23
- 它会统计 Codex 会话中的轮次、工具调用、Token、时长和模型,生成带有 AI 工分、今日工种和点评的小票。不会把 Prompt、回复正文、代码、项目路径或文件名写入小票。
23
+ 它会统计 Codex 会话中的轮次、工具调用、Token、时长和模型,并提供缓存命中率、每轮效率、P50 / P90 延迟、工作时间热力图、模型与工具结构,生成带有 AI 工分、今日工种和点评的小票。不会把 Prompt、回复正文、代码、项目路径、文件名、工具参数或工具输出写入小票。
24
24
 
25
25
  cwr2 协议按“会话 × 自然日”生成稳定的脱敏事实。今日、近 7 日和本周小票即使范围重叠,接收端也能识别相同工作。每张小票还会生成一个 `.cwr.json` 微信导入文件;只有完整数据能放进一个二维码时,网页才额外提供扫码导入。
26
26
 
@@ -56,7 +56,7 @@ npx codex-work-receipt@latest --hours 3
56
56
 
57
57
  “最近 N 小时”是滚动摘要,只用于查看和保存私人历史,不参与 AI 供销社的去重统计。需要进入供销社时,请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。
58
58
 
59
- 网页、结构数据和微信导入文件默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键下载 `.cwr.json`,也可以保存只包含完整小票和微信小程序码的高清长图。数据二维码不会进入图片。详见 [CLI 使用文档](docs/cli.md)。
59
+ 网页、结构数据和微信导入文件默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键下载 `.cwr.json`,也可以保存只包含完整小票和微信小程序码的高清长图。赞助商下方的“更多小票功能”还提供 12 条常用命令及复制按钮,帮助用户发现不同统计范围、自动模式、Skill 和票仔。数据二维码和网页功能区不会进入图片。详见 [CLI 使用文档](docs/cli.md)。
60
60
 
61
61
  ## 自动保存或仅手动
62
62
 
@@ -12,12 +12,26 @@ The full receipt schema is currently version `2`; rolling-hour summaries remain
12
12
  - `generated_at`: generation time
13
13
  - `source`: data source, selected `scope`, and collector version
14
14
  - `period`: actual activity times, timezone, and `range_start_date` / `range_end_date` calendar boundaries
15
- - `stats`: turns, messages, tools, Tokens, duration, and related metrics
15
+ - `stats`: turns, messages, tools, Tokens, duration, and detailed local efficiency insights
16
16
  - `presentation`: default theme, language-neutral `work_profile`, localized role, review, and AI work points
17
17
  - `privacy`: explicit declaration of excluded sensitive content
18
18
 
19
19
  The `id` remains stable for the same session, day, or calendar-boundary pair. Regeneration updates the record rather than creating duplicate history. `source.snapshot_hash` detects metric changes.
20
20
 
21
+ ### Detailed local efficiency insights
22
+
23
+ `stats.insights` in the full JSON contains:
24
+
25
+ - `cache_hit_rate`: `cached_input_tokens / input_tokens`, or `0` when input is zero
26
+ - `per_turn`: total Tokens, output Tokens, tool calls, and average completion duration per completed turn
27
+ - `latency_ms.first_token`: sample count plus P50 and P90 first-token latency
28
+ - `latency_ms.turn`: sample count plus P50 and P90 completed-turn duration
29
+ - `activity_by_hour`: 24 hourly buckets in the receipt timezone; each completed or interrupted turn adds one
30
+ - `model_usage`: completed-turn counts attributed to each model
31
+ - `tool_usage`: privacy-safe category counts for terminal, file editing, browser, research, media, multi-agent, planning, integrations, and other tools
32
+
33
+ Percentiles use linear interpolation between adjacent samples and are rounded to milliseconds. Tool classification retains only stable categories, not raw tool names, arguments, commands, or output.
34
+
21
35
  ## WeChat import file
22
36
 
23
37
  Each run writes a `*.cwr.json` file beside the HTML:
@@ -37,6 +51,8 @@ Each run writes a `*.cwr.json` file beside the HTML:
37
51
 
38
52
  `payload` is exactly the compact structure obtained after decoding a QR payload. `integrity.digest` is a full SHA-256 over canonical payload JSON. It detects corruption but is not a source signature. The mini program must treat every file as untrusted input, enforce a size limit, validate versions, fields, numeric bounds, unique fact IDs, manifests, and content hashes, then persist atomically only after confirmation.
39
53
 
54
+ To preserve compatibility with existing mini programs, `stats.insights` currently stays in the local full JSON and HTML and is not added to the compact `cwr1` / `cwr2` payload. The compact payload continues to carry the existing accounting metrics.
55
+
40
56
  Canonicalization recursively sorts every object's keys using JavaScript's default string order (UTF-16 code units), preserves array order, uses JSON primitive types, and emits no whitespace or trailing newline. Calculate SHA-256 over the resulting UTF-8 bytes. Use the digest in `docs/fixtures/cwr-file-v1.json` to cross-check another implementation.
41
57
 
42
58
  A cross-repository compatibility fixture is available at `docs/fixtures/cwr-file-v1.json`.
@@ -12,12 +12,26 @@
12
12
  - `generated_at`:生成时间
13
13
  - `source`:数据来源、`scope` 统计范围和采集器版本
14
14
  - `period`:实际活动起止时间、时区,以及 `range_start_date` / `range_end_date` 自然日边界
15
- - `stats`:轮次、消息、工具、Token、时长等统计
15
+ - `stats`:轮次、消息、工具、Token、时长,以及本地详细效率洞察
16
16
  - `presentation`:默认主题、语言无关的 `work_profile`、本地化工种、点评和 AI 工分
17
17
  - `privacy`:明确声明不包含的敏感内容
18
18
 
19
19
  同一个会话、同一天或同一组自然日边界的 `id` 保持稳定;重复生成会更新该记录。`source.snapshot_hash` 用于识别统计内容是否发生变化。
20
20
 
21
+ ### 本地详细效率洞察
22
+
23
+ 完整 JSON 的 `stats.insights` 包含:
24
+
25
+ - `cache_hit_rate`:`cached_input_tokens / input_tokens`,输入为零时记为 `0`
26
+ - `per_turn`:按已完成轮次计算的总 Token、输出 Token、工具调用和平均完成耗时
27
+ - `latency_ms.first_token`:首 Token 延迟的样本数、P50 和 P90
28
+ - `latency_ms.turn`:已完成整轮耗时的样本数、P50 和 P90
29
+ - `activity_by_hour`:按小票时区统计的 24 个小时槽;每个完成或中断轮次计一次
30
+ - `model_usage`:模型对应的完成轮次数量
31
+ - `tool_usage`:脱敏工具类别及数量,包括终端、文件编辑、浏览器、检索、媒体、多代理、规划、外部集成和其他类别
32
+
33
+ 百分位使用相邻样本线性插值后取整。工具分类只保留稳定类别,不保留原始工具名、参数、命令或输出。
34
+
21
35
  ## 微信导入文件
22
36
 
23
37
  每次生成都会在 HTML 旁边写入一个 `*.cwr.json`:
@@ -37,6 +51,8 @@
37
51
 
38
52
  `payload` 与二维码解压后的精简结构完全相同。`integrity.digest` 是对规范化 payload JSON 计算的完整 SHA-256,用于发现损坏,但不是来源签名。小程序必须把文件视为不可信输入,限制文件大小,校验版本、字段、数值范围、factId 唯一性、manifest 和 content hash,并在用户确认后原子入库。
39
53
 
54
+ 为保持现有小程序兼容,`stats.insights` 当前只存在于本地完整 JSON 和 HTML,不进入 `cwr1` / `cwr2` 精简载荷。精简载荷继续携带原有记账统计。
55
+
40
56
  规范化规则是:递归按 JavaScript 默认字符串排序(UTF-16 code unit 顺序)排列每个对象的键,保持数组元素顺序,使用 JSON 原始类型,不添加空白或换行,再对生成的 UTF-8 字节计算 SHA-256。兼容实现可用 `docs/fixtures/cwr-file-v1.json` 的 digest 做交叉验证。
41
57
 
42
58
  兼容测试夹具见 `docs/fixtures/cwr-file-v1.json`。
@@ -7,10 +7,10 @@ AI Work Receipt reads Codex sessions, computes metrics, and renders receipts loc
7
7
  ## What it reads
8
8
 
9
9
  - `~/.codex/sessions/**/*.jsonl` by default
10
- - Event types, timestamps, model names, and numerical metadata
11
- - Metrics such as turns, tool calls, Tokens, duration, and interruptions
10
+ - Event types, timestamps, model names, privacy-safe tool categories, and numerical metadata
11
+ - Metrics such as turns, tool calls, Tokens, duration, interruptions, cache hit rate, latency percentiles, and hourly distribution
12
12
 
13
- Session logs are parsed locally in bounded chunks, one JSONL row at a time. After each row is parsed, only fields needed for metrics remain in memory; prompts, responses, images, and tool output are discarded from the in-memory statistics representation.
13
+ Session logs are parsed locally in bounded chunks, one JSONL row at a time. Raw tool names are immediately mapped to stable categories. Prompts, responses, images, raw tool names, tool arguments, commands, and tool output are discarded from the in-memory statistics representation.
14
14
 
15
15
  ## What receipts exclude
16
16
 
package/docs/privacy.md CHANGED
@@ -7,10 +7,10 @@ AI 打工小票在本机读取 Codex 会话,并在本机完成统计和渲染
7
7
  ## 会读取什么
8
8
 
9
9
  - 默认读取 `~/.codex/sessions/**/*.jsonl`
10
- - 提取事件类型、时间、模型名称和数字统计
11
- - 计算轮次、工具调用、Token、时长和中断次数等指标
10
+ - 提取事件类型、时间、模型名称、脱敏工具类别和数字统计
11
+ - 计算轮次、工具调用、Token、时长、中断次数、缓存命中率、延迟分位与小时分布等指标
12
12
 
13
- 会话日志会在本机分块逐行解析。每行解析后只保留统计所需字段,Prompt、回复、图片和工具输出不会保留在内存统计对象中。
13
+ 会话日志会在本机分块逐行解析。每行解析后只保留统计所需字段;工具名会立即映射为稳定类别。Prompt、回复、图片、原始工具名、工具参数、命令和工具输出不会保留在内存统计对象中。
14
14
 
15
15
  ## 不会写入小票什么
16
16
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Generate a privacy-first Codex work receipt from local session metadata.",
6
6
  "author": "a-bai-2026",
package/src/cli.mjs CHANGED
@@ -77,10 +77,11 @@ function enableAuto(options) {
77
77
  });
78
78
  startAutomaticRun(result.config);
79
79
  printAutomaticEnabled(result, options.locale);
80
+ printOpenSourcePrompt("receipt", options.locale);
80
81
  return result;
81
82
  }
82
83
 
83
- function enableManual(options) {
84
+ function enableManual(options, { showOpenSourcePrompt = true } = {}) {
84
85
  const result = configureManualMode({
85
86
  dataDir: options.dataDir,
86
87
  locale: options.locale,
@@ -88,6 +89,7 @@ function enableManual(options) {
88
89
  theme: options.theme,
89
90
  });
90
91
  printManualEnabled(result, options.locale);
92
+ if (showOpenSourcePrompt) printOpenSourcePrompt("receipt", options.locale);
91
93
  return result;
92
94
  }
93
95
 
@@ -191,7 +193,7 @@ async function main() {
191
193
  enableAuto(options);
192
194
  return;
193
195
  }
194
- enableManual(options);
196
+ enableManual(options, { showOpenSourcePrompt: false });
195
197
  }
196
198
  const selected = await promptForRange({
197
199
  locale: options.locale,
@@ -1,4 +1,5 @@
1
1
  import { dateKey, rowDate } from "../lib/time.mjs";
2
+ import { toolCategoryForRow } from "../lib/tool-category.mjs";
2
3
  import {
3
4
  calendarDayCount,
4
5
  isCalendarScope,
@@ -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")
@@ -78,11 +156,17 @@ export function collectMetrics(sessions, range) {
78
156
  let toolCalls = 0;
79
157
  let interruptions = 0;
80
158
  let workDurationMs = 0;
159
+ let completedDurationMs = 0;
81
160
  let totalFirstTokenMs = 0;
82
161
  let firstTokenSamples = 0;
162
+ const firstTokenLatencySamples = [];
163
+ const turnDurationSamples = [];
83
164
  const timestamps = [];
84
165
  const activeDateKeys = new Set();
85
166
  const models = new Set();
167
+ const modelUsage = new Map();
168
+ const toolUsage = new Map();
169
+ const activityByHour = Array(24).fill(0);
86
170
 
87
171
  for (const session of sessions) {
88
172
  const scopedRows = isCalendarScope(range.scope) || isTimeWindowScope(range.scope)
@@ -94,40 +178,70 @@ export function collectMetrics(sessions, range) {
94
178
  sessionIds.push(session.sessionId);
95
179
  addUsage(tokens, sessionTokenUsage(session.rows, range));
96
180
 
97
- for (const row of scopedRows) {
181
+ let activeModel = null;
182
+ let unattributedModelTurns = 0;
183
+ let sessionHasSelectedModel = false;
184
+ for (const row of session.rows) {
185
+ if (row.type === "turn_context" && row.payload?.model) activeModel = row.payload.model;
186
+ const selected = (!isCalendarScope(range.scope) && !isTimeWindowScope(range.scope))
187
+ || isDateInRange(rowDate(row), range);
188
+ if (!selected) continue;
98
189
  const date = rowDate(row);
99
190
  if (date) {
100
191
  timestamps.push(date);
101
192
  activeDateKeys.add(dateKey(date, range.timezone));
102
193
  }
103
194
 
104
- if (row.type === "turn_context" && row.payload?.model) models.add(row.payload.model);
195
+ if (row.type === "turn_context" && row.payload?.model) {
196
+ models.add(row.payload.model);
197
+ sessionHasSelectedModel = true;
198
+ }
105
199
  if (row.type === "event_msg") {
106
200
  const eventType = row.payload?.type;
107
201
  if (eventType === "task_complete") {
202
+ const hasDuration = Number.isFinite(row.payload.duration_ms);
203
+ const durationMs = hasDuration ? Math.max(0, Number(row.payload.duration_ms)) : 0;
108
204
  completedTurns += 1;
109
- workDurationMs += Number(row.payload.duration_ms || 0);
205
+ workDurationMs += durationMs;
206
+ completedDurationMs += durationMs;
207
+ if (hasDuration) turnDurationSamples.push(durationMs);
208
+ if (activeModel) {
209
+ models.add(activeModel);
210
+ sessionHasSelectedModel = true;
211
+ increment(modelUsage, activeModel);
212
+ }
213
+ else unattributedModelTurns += 1;
214
+ const hour = localHour(date, range.timezone);
215
+ if (hour !== null) activityByHour[hour] += 1;
110
216
  if (Number.isFinite(row.payload.time_to_first_token_ms)) {
111
- totalFirstTokenMs += row.payload.time_to_first_token_ms;
217
+ const firstTokenMs = Math.max(0, Number(row.payload.time_to_first_token_ms));
218
+ totalFirstTokenMs += firstTokenMs;
112
219
  firstTokenSamples += 1;
220
+ firstTokenLatencySamples.push(firstTokenMs);
113
221
  }
114
222
  } else if (eventType === "user_message") userMessages += 1;
115
223
  else if (eventType === "turn_aborted") {
116
224
  interruptions += 1;
117
- workDurationMs += Number(row.payload.duration_ms || 0);
225
+ workDurationMs += Math.max(0, Number(row.payload.duration_ms || 0));
226
+ const hour = localHour(date, range.timezone);
227
+ if (hour !== null) activityByHour[hour] += 1;
118
228
  }
119
229
  }
120
230
 
121
- if (
122
- row.type === "response_item" &&
123
- (row.payload?.type === "custom_tool_call" || row.payload?.type === "function_call")
124
- ) toolCalls += 1;
231
+ const toolCategory = toolCategoryForRow(row);
232
+ if (toolCategory) {
233
+ toolCalls += 1;
234
+ increment(toolUsage, toolCategory);
235
+ }
125
236
  }
126
237
 
127
238
  const fallbackModel = [...session.rows]
128
239
  .reverse()
129
240
  .find((row) => row.type === "turn_context" && row.payload?.model)?.payload?.model;
130
- if (fallbackModel) models.add(fallbackModel);
241
+ if (fallbackModel && (!sessionHasSelectedModel || unattributedModelTurns)) {
242
+ models.add(fallbackModel);
243
+ if (unattributedModelTurns) increment(modelUsage, fallbackModel, unattributedModelTurns);
244
+ }
131
245
  }
132
246
 
133
247
  if (!scopedSessions.length || !timestamps.length) throw new Error(emptyRangeMessage(range));
@@ -159,6 +273,17 @@ export function collectMetrics(sessions, range) {
159
273
  tokens,
160
274
  models: [...models],
161
275
  };
276
+ metrics.insights = buildInsights({
277
+ tokens,
278
+ completedTurns,
279
+ toolCalls,
280
+ completedDurationMs,
281
+ firstTokenSamples: firstTokenLatencySamples,
282
+ turnDurationSamples,
283
+ activityByHour,
284
+ modelUsage,
285
+ toolUsage,
286
+ });
162
287
  metrics.workProfileId = selectWorkProfileId(metrics);
163
288
  metrics.workPoints = calculateWorkPoints(metrics);
164
289
  return metrics;
@@ -93,6 +93,45 @@ const RECEIPT_COPY = {
93
93
  changelogLink: "查看更新日志 →",
94
94
  sponsorLabel: "赞助伙伴",
95
95
  sponsorAlt: "ModelFlare 标志",
96
+ features: {
97
+ title: "更多小票功能",
98
+ countLabel: "{count} 项",
99
+ description: "复制命令后,在终端中运行即可。",
100
+ copyLabel: "复制",
101
+ copiedLabel: "已复制",
102
+ copyErrorLabel: "复制失败",
103
+ copiedStatus: "命令已复制到剪贴板",
104
+ copyErrorStatus: "无法自动复制,请手动选择命令",
105
+ groups: [
106
+ {
107
+ title: "生成小票",
108
+ commands: [
109
+ { label: "生成最近一次小票", args: "--latest" },
110
+ { label: "生成最近 3 小时小票", args: "--hours 3" },
111
+ { label: "生成今日小票", args: "--today" },
112
+ { label: "生成最近 7 天小票", args: "--range last-7-days" },
113
+ { label: "生成本周小票", args: "--range this-week" },
114
+ ],
115
+ },
116
+ {
117
+ title: "自动与手动",
118
+ commands: [
119
+ { label: "重新选择工作模式", args: "--setup" },
120
+ { label: "开启自动保存", args: "--enable-auto" },
121
+ { label: "切换为仅手动", args: "--disable-auto" },
122
+ { label: "查看自动保存状态", args: "--auto-status" },
123
+ ],
124
+ },
125
+ {
126
+ title: "票仔与对话开票",
127
+ commands: [
128
+ { label: "安装票仔和对话开票", args: "--install-companion" },
129
+ { label: "仅安装票仔", args: "--install-pet" },
130
+ { label: "仅安装对话开票", args: "--install-skill" },
131
+ ],
132
+ },
133
+ ],
134
+ },
96
135
  },
97
136
  themes: {
98
137
  classic: "经典白票",
@@ -135,6 +174,36 @@ const RECEIPT_COPY = {
135
174
  interruptions: ["次", "次"],
136
175
  seconds: ["秒", "秒"],
137
176
  },
177
+ insights: {
178
+ title: "效率与工作结构",
179
+ cacheHit: "缓存命中率",
180
+ perTurn: "每轮效率",
181
+ totalTokens: "总 Token",
182
+ outputTokens: "输出 Token",
183
+ toolCalls: "工具调用",
184
+ firstTokenLatency: "首次响应延迟",
185
+ turnLatency: "整轮耗时",
186
+ p50: "P50",
187
+ p90: "P90",
188
+ noSamples: "暂无样本",
189
+ heatmap: "工作时间热力图",
190
+ heatmapCell: "{hour}:00 · {count} 轮",
191
+ models: "模型结构",
192
+ tools: "工具结构",
193
+ turnsUnit: "轮",
194
+ callsUnit: "次",
195
+ toolCategories: {
196
+ terminal: "终端命令",
197
+ "file-edit": "文件编辑",
198
+ browser: "浏览器操作",
199
+ research: "检索研究",
200
+ media: "图像与媒体",
201
+ agents: "多代理协作",
202
+ planning: "规划与确认",
203
+ integrations: "外部集成",
204
+ other: "其他工具",
205
+ },
206
+ },
138
207
  footerThanks: "谢谢惠顾,欢迎明天继续改需求",
139
208
  transferAria: "微信小程序导入联",
140
209
  transferTitle: "导入到微信小程序",
@@ -186,6 +255,45 @@ const RECEIPT_COPY = {
186
255
  changelogLink: "View the changelog →",
187
256
  sponsorLabel: "Sponsored by",
188
257
  sponsorAlt: "ModelFlare logo",
258
+ features: {
259
+ title: "More receipt features",
260
+ countLabel: "{count} commands",
261
+ description: "Copy a command, then run it in your terminal.",
262
+ copyLabel: "Copy",
263
+ copiedLabel: "Copied",
264
+ copyErrorLabel: "Copy failed",
265
+ copiedStatus: "Command copied to the clipboard",
266
+ copyErrorStatus: "Could not copy automatically. Select the command manually.",
267
+ groups: [
268
+ {
269
+ title: "Generate receipts",
270
+ commands: [
271
+ { label: "Generate the latest receipt", args: "--latest" },
272
+ { label: "Generate the last 3 hours", args: "--hours 3" },
273
+ { label: "Generate today's receipt", args: "--today" },
274
+ { label: "Generate the last 7 days", args: "--range last-7-days" },
275
+ { label: "Generate this week's receipt", args: "--range this-week" },
276
+ ],
277
+ },
278
+ {
279
+ title: "Automatic and manual",
280
+ commands: [
281
+ { label: "Choose a working mode", args: "--setup" },
282
+ { label: "Enable automatic saving", args: "--enable-auto" },
283
+ { label: "Switch to manual only", args: "--disable-auto" },
284
+ { label: "Check automatic saving", args: "--auto-status" },
285
+ ],
286
+ },
287
+ {
288
+ title: "Ticket Buddy and chat commands",
289
+ commands: [
290
+ { label: "Install Ticket Buddy and chat commands", args: "--install-companion" },
291
+ { label: "Install Ticket Buddy only", args: "--install-pet" },
292
+ { label: "Install chat commands only", args: "--install-skill" },
293
+ ],
294
+ },
295
+ ],
296
+ },
189
297
  },
190
298
  themes: {
191
299
  classic: "Classic White",
@@ -228,6 +336,36 @@ const RECEIPT_COPY = {
228
336
  interruptions: ["time", "times"],
229
337
  seconds: ["sec", "sec"],
230
338
  },
339
+ insights: {
340
+ title: "Efficiency & work structure",
341
+ cacheHit: "Cache hit rate",
342
+ perTurn: "Per-turn efficiency",
343
+ totalTokens: "total Tokens",
344
+ outputTokens: "output Tokens",
345
+ toolCalls: "tool calls",
346
+ firstTokenLatency: "First-token latency",
347
+ turnLatency: "Full-turn duration",
348
+ p50: "P50",
349
+ p90: "P90",
350
+ noSamples: "No samples",
351
+ heatmap: "Work-time heatmap",
352
+ heatmapCell: "{hour}:00 · {count} turns",
353
+ models: "Model structure",
354
+ tools: "Tool structure",
355
+ turnsUnit: "turns",
356
+ callsUnit: "calls",
357
+ toolCategories: {
358
+ terminal: "Terminal",
359
+ "file-edit": "File editing",
360
+ browser: "Browser",
361
+ research: "Research",
362
+ media: "Image & media",
363
+ agents: "Multi-agent",
364
+ planning: "Planning",
365
+ integrations: "Integrations",
366
+ other: "Other tools",
367
+ },
368
+ },
231
369
  footerThanks: "Thanks for your business. More revisions welcome tomorrow.",
232
370
  transferAria: "WeChat mini-program import stub",
233
371
  transferTitle: "IMPORT INTO WECHAT",
@@ -21,6 +21,30 @@ const SCHEMA_VERSION = 2;
21
21
  const SOURCE_VERSION = "cwr2";
22
22
  const COLLECTOR_VERSION = "0.6.0";
23
23
 
24
+ function fallbackInsights(metrics) {
25
+ const completedTurns = Math.max(0, Number(metrics.completedTurns || 0));
26
+ const ratio = (value) => completedTurns ? Number(value || 0) / completedTurns : 0;
27
+ const inputTokens = Math.max(0, Number(metrics.tokens?.input_tokens || 0));
28
+ return {
29
+ cache_hit_rate: inputTokens
30
+ ? Math.min(1, Math.max(0, Number(metrics.tokens?.cached_input_tokens || 0) / inputTokens))
31
+ : 0,
32
+ per_turn: {
33
+ total_tokens: ratio(metrics.tokens?.total_tokens),
34
+ output_tokens: ratio(metrics.tokens?.output_tokens),
35
+ tool_calls: ratio(metrics.toolCalls),
36
+ work_duration_ms: Math.round(ratio(metrics.workDurationMs)),
37
+ },
38
+ latency_ms: {
39
+ first_token: { sample_count: 0, p50: 0, p90: 0 },
40
+ turn: { sample_count: 0, p50: 0, p90: 0 },
41
+ },
42
+ activity_by_hour: Array(24).fill(0),
43
+ model_usage: (metrics.models || []).map((model) => ({ model, count: 0 })),
44
+ tool_usage: metrics.toolCalls ? [{ category: "other", count: metrics.toolCalls }] : [],
45
+ };
46
+ }
47
+
24
48
  function fingerprintSessionIds(sessionIds) {
25
49
  return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
26
50
  }
@@ -29,6 +53,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
29
53
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
30
54
  const logicalKey = buildLogicalReceiptKey(metrics);
31
55
  const summaryOnly = metrics.mode === "last-hours";
56
+ const insights = metrics.insights || fallbackInsights(metrics);
32
57
  const schemaVersion = summaryOnly ? 1 : SCHEMA_VERSION;
33
58
  const sourceVersion = summaryOnly ? "cwr1" : SOURCE_VERSION;
34
59
  const id = summaryOnly
@@ -41,6 +66,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
41
66
  tools: metrics.toolCalls,
42
67
  turns: metrics.completedTurns,
43
68
  interruptions: metrics.interruptions,
69
+ insights,
44
70
  scope: metrics.mode,
45
71
  rangeStartDate: metrics.rangeStartDate,
46
72
  rangeEndDate: metrics.rangeEndDate,
@@ -104,6 +130,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
104
130
  average_first_token_ms: Math.round(metrics.averageFirstTokenMs),
105
131
  tokens: { ...metrics.tokens },
106
132
  models: [...metrics.models],
133
+ insights,
107
134
  receipt_work_points: metrics.workPoints,
108
135
  receipt_formula_version: "receipt_work_points_v1",
109
136
  },
@@ -120,6 +147,9 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
120
147
  contains_code: false,
121
148
  contains_paths: false,
122
149
  contains_filenames: false,
150
+ contains_tool_names: false,
151
+ contains_tool_arguments: false,
152
+ contains_tool_output: false,
123
153
  },
124
154
  };
125
155
  if (!summaryOnly) {
@@ -0,0 +1,36 @@
1
+ const TOOL_CATEGORIES = new Set([
2
+ "terminal",
3
+ "file-edit",
4
+ "browser",
5
+ "research",
6
+ "media",
7
+ "agents",
8
+ "planning",
9
+ "integrations",
10
+ "other",
11
+ ]);
12
+
13
+ export function normalizeToolCategory(value) {
14
+ return TOOL_CATEGORIES.has(value) ? value : "other";
15
+ }
16
+
17
+ export function classifyToolName(value) {
18
+ const name = String(value || "").trim().toLowerCase();
19
+ if (!name) return "other";
20
+ if (/(^|[_.-])(apply[_-]?patch|edit(?:[_-]?file)?|write(?:[_-]?file)?)$/.test(name)) return "file-edit";
21
+ if (/browser|playwright|computer|chrome|navigate|screenshot/.test(name)) return "browser";
22
+ if (/web[_-]?search|search[_-]?docs|fetch[_-]?doc|openai.*docs/.test(name)) return "research";
23
+ if (/image|audio|video|canvas|view[_-]?image/.test(name)) return "media";
24
+ if (/spawn[_-]?agent|send[_-]?message|followup[_-]?task|wait[_-]?agent|collaboration/.test(name)) return "agents";
25
+ if (/update[_-]?plan|request[_-]?user[_-]?input|create[_-]?goal|update[_-]?goal/.test(name)) return "planning";
26
+ if (/exec|shell|bash|terminal|write[_-]?stdin|command/.test(name)) return "terminal";
27
+ if (/^mcp__|connector|plugin|slack|github|notion|linear|figma/.test(name)) return "integrations";
28
+ return "other";
29
+ }
30
+
31
+ export function toolCategoryForRow(row) {
32
+ if (row?.type !== "response_item") return null;
33
+ const payloadType = row.payload?.type;
34
+ if (payloadType !== "custom_tool_call" && payloadType !== "function_call") return null;
35
+ return normalizeToolCategory(row.payload?.tool_category);
36
+ }
@@ -4,6 +4,7 @@ import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
6
  import { rowDate } from "../lib/time.mjs";
7
+ import { classifyToolName } from "../lib/tool-category.mjs";
7
8
 
8
9
  const READ_CHUNK_BYTES = 256 * 1024;
9
10
  const MAX_JSONL_ROW_BYTES = 64 * 1024 * 1024;
@@ -70,6 +71,11 @@ function compactPayload(rowType, value) {
70
71
  if (firstTokenMs !== null) payload.time_to_first_token_ms = firstTokenMs;
71
72
  const totalTokenUsage = compactTokenUsage(value.info?.total_token_usage);
72
73
  if (totalTokenUsage) payload.info = { total_token_usage: totalTokenUsage };
74
+ } else if (
75
+ rowType === "response_item" &&
76
+ (value.type === "custom_tool_call" || value.type === "function_call")
77
+ ) {
78
+ payload.tool_category = classifyToolName(value.name);
73
79
  }
74
80
 
75
81
  return Object.keys(payload).length ? payload : null;
@@ -62,6 +62,151 @@ function formatDateKey(value, locale) {
62
62
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
63
63
  }
64
64
 
65
+ function formatDecimal(value, locale, maximumFractionDigits = 1) {
66
+ return new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
67
+ maximumFractionDigits,
68
+ }).format(Math.max(0, Number(value || 0)));
69
+ }
70
+
71
+ function formatPercent(value, locale) {
72
+ return new Intl.NumberFormat(locale === "en" ? "en-US" : "zh-CN", {
73
+ style: "percent",
74
+ maximumFractionDigits: 1,
75
+ }).format(Math.min(1, Math.max(0, Number(value || 0))));
76
+ }
77
+
78
+ function formatSeconds(milliseconds, locale) {
79
+ return `${formatDecimal(Number(milliseconds || 0) / 1000, locale)}s`;
80
+ }
81
+
82
+ function renderBreakdown(items, { key, labels = {}, unit, emptyLabel, locale }) {
83
+ if (!items.length) return `<p class="structure-empty">${escapeHtml(emptyLabel)}</p>`;
84
+ const maximum = Math.max(1, ...items.map((item) => Number(item.count || 0)));
85
+ return items.slice(0, 6).map((item) => {
86
+ const name = String(item[key] || "");
87
+ const label = labels[name] || name;
88
+ const count = Math.max(0, Number(item.count || 0));
89
+ const width = count ? Math.max(6, Math.round((count / maximum) * 100)) : 0;
90
+ return `
91
+ <div class="structure-row">
92
+ <span class="structure-name" title="${escapeHtml(label)}">${escapeHtml(label)}</span>
93
+ <span class="structure-bar" aria-hidden="true"><i style="width:${width}%"></i></span>
94
+ <strong>${escapeHtml(`${formatDecimal(count, locale, 0)} ${unit}`)}</strong>
95
+ </div>`;
96
+ }).join("");
97
+ }
98
+
99
+ function renderInsights(record, copy, locale) {
100
+ const insights = record.stats.insights || {};
101
+ const perTurn = insights.per_turn || {};
102
+ const firstToken = insights.latency_ms?.first_token || {};
103
+ const turnLatency = insights.latency_ms?.turn || {};
104
+ const activity = Array.from({ length: 24 }, (_, hour) => Number(insights.activity_by_hour?.[hour] || 0));
105
+ const activityPeak = Math.max(0, ...activity);
106
+ const heatmapCells = activity.map((count, hour) => {
107
+ const strength = count && activityPeak ? 16 + Math.round((count / activityPeak) * 84) : 4;
108
+ const aria = copy.insights.heatmapCell
109
+ .replaceAll("{hour}", String(hour).padStart(2, "0"))
110
+ .replaceAll("{count}", formatDecimal(count, locale, 0));
111
+ return `<span class="heatmap-cell" style="--heat:${strength}%" role="img" aria-label="${escapeHtml(aria)}" title="${escapeHtml(aria)}"></span>`;
112
+ }).join("");
113
+ const firstTokenValue = firstToken.sample_count
114
+ ? `${copy.insights.p50} ${formatSeconds(firstToken.p50, locale)}`
115
+ : copy.insights.noSamples;
116
+ const firstTokenDetail = firstToken.sample_count
117
+ ? `${copy.insights.p90} ${formatSeconds(firstToken.p90, locale)}`
118
+ : "—";
119
+ const turnValue = turnLatency.sample_count
120
+ ? `${copy.insights.p50} ${formatSeconds(turnLatency.p50, locale)}`
121
+ : copy.insights.noSamples;
122
+ const turnDetail = turnLatency.sample_count
123
+ ? `${copy.insights.p90} ${formatSeconds(turnLatency.p90, locale)}`
124
+ : "—";
125
+ const modelItems = insights.model_usage?.length
126
+ ? insights.model_usage
127
+ : (record.stats.models || []).map((model) => ({ model, count: 0 }));
128
+ const toolItems = insights.tool_usage || [];
129
+ const modelRows = renderBreakdown(modelItems, {
130
+ key: "model",
131
+ unit: copy.insights.turnsUnit,
132
+ emptyLabel: copy.insights.noSamples,
133
+ locale,
134
+ });
135
+ const toolRows = renderBreakdown(toolItems, {
136
+ key: "category",
137
+ labels: copy.insights.toolCategories,
138
+ unit: copy.insights.callsUnit,
139
+ emptyLabel: copy.insights.noSamples,
140
+ locale,
141
+ });
142
+
143
+ return `
144
+ <section class="insights" aria-label="${escapeHtml(copy.insights.title)}">
145
+ <h2 class="insights-title">${escapeHtml(copy.insights.title)}</h2>
146
+ <div class="insight-metrics">
147
+ <div class="insight-metric">
148
+ <span>${escapeHtml(copy.insights.cacheHit)}</span>
149
+ <strong>${escapeHtml(formatPercent(insights.cache_hit_rate, locale))}</strong>
150
+ <small>${escapeHtml(formatNumber(record.stats.tokens.cached_input_tokens, locale))} / ${escapeHtml(formatNumber(record.stats.tokens.input_tokens, locale))}</small>
151
+ </div>
152
+ <div class="insight-metric">
153
+ <span>${escapeHtml(copy.insights.perTurn)}</span>
154
+ <strong>${escapeHtml(`${formatDecimal(perTurn.total_tokens, locale)} Token`)}</strong>
155
+ <small>${escapeHtml(`${formatDecimal(perTurn.output_tokens, locale)} ${copy.insights.outputTokens} · ${formatDecimal(perTurn.tool_calls, locale)} ${copy.insights.toolCalls}`)}</small>
156
+ </div>
157
+ <div class="insight-metric">
158
+ <span>${escapeHtml(copy.insights.firstTokenLatency)}</span>
159
+ <strong>${escapeHtml(firstTokenValue)}</strong>
160
+ <small>${escapeHtml(firstTokenDetail)}</small>
161
+ </div>
162
+ <div class="insight-metric">
163
+ <span>${escapeHtml(copy.insights.turnLatency)}</span>
164
+ <strong>${escapeHtml(turnValue)}</strong>
165
+ <small>${escapeHtml(turnDetail)}</small>
166
+ </div>
167
+ </div>
168
+ <div class="heatmap-block">
169
+ <h3>${escapeHtml(copy.insights.heatmap)}</h3>
170
+ <div class="heatmap-grid">${heatmapCells}</div>
171
+ <div class="heatmap-axis" aria-hidden="true"><span>00</span><span>06</span><span>12</span><span>18</span><span>23</span></div>
172
+ </div>
173
+ <div class="structure-grid">
174
+ <section class="structure-group">
175
+ <h3>${escapeHtml(copy.insights.models)}</h3>${modelRows}
176
+ </section>
177
+ <section class="structure-group">
178
+ <h3>${escapeHtml(copy.insights.tools)}</h3>${toolRows}
179
+ </section>
180
+ </div>
181
+ </section>`;
182
+ }
183
+
184
+ function renderFeatureCommands(featureCopy, locale) {
185
+ const languageArgument = locale === "en" ? " --lang en" : "";
186
+ return featureCopy.groups.map((group) => {
187
+ const commands = group.commands.map((item) => {
188
+ const command = `npx codex-work-receipt@latest ${item.args}${languageArgument}`;
189
+ return `
190
+ <li class="feature-command">
191
+ <strong class="feature-command__name">${escapeHtml(item.label)}</strong>
192
+ <div class="feature-command__action">
193
+ <code tabindex="0">${escapeHtml(command)}</code>
194
+ <button class="feature-copy-button" type="button" data-copy-command="${escapeHtml(command)}" aria-label="${escapeHtml(`${featureCopy.copyLabel}: ${item.label}`)}" title="${escapeHtml(featureCopy.copyLabel)}">
195
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect width="14" height="14" x="8" y="8" rx="2" ry="2"></rect><path d="M4 16c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h10c1.1 0 2 .9 2 2"></path></svg>
196
+ <span data-copy-label>${escapeHtml(featureCopy.copyLabel)}</span>
197
+ </button>
198
+ </div>
199
+ </li>`;
200
+ }).join("");
201
+ return `
202
+ <section class="feature-group">
203
+ <h4>${escapeHtml(group.title)}</h4>
204
+ <ul>${commands}
205
+ </ul>
206
+ </section>`;
207
+ }).join("");
208
+ }
209
+
65
210
  export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUrl = null, transferFile = null }) {
66
211
  const locale = record.locale || DEFAULT_LOCALE;
67
212
  const copy = getReceiptCopy(locale);
@@ -114,6 +259,19 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
114
259
  successLabel: copy.downloadSuccess,
115
260
  errorLabel: copy.downloadError,
116
261
  }).replaceAll("<", "\\u003c");
262
+ const featureConfig = JSON.stringify({
263
+ copyLabel: copy.sidebar.features.copyLabel,
264
+ copiedLabel: copy.sidebar.features.copiedLabel,
265
+ copyErrorLabel: copy.sidebar.features.copyErrorLabel,
266
+ copiedStatus: copy.sidebar.features.copiedStatus,
267
+ copyErrorStatus: copy.sidebar.features.copyErrorStatus,
268
+ }).replaceAll("<", "\\u003c");
269
+ const featureCommands = renderFeatureCommands(copy.sidebar.features, locale);
270
+ const featureCommandCount = copy.sidebar.features.groups
271
+ .reduce((total, group) => total + group.commands.length, 0);
272
+ const featureCountLabel = copy.sidebar.features.countLabel
273
+ .replace("{count}", formatNumber(featureCommandCount, locale));
274
+ const insights = renderInsights(record, copy, locale);
117
275
  const rows = [
118
276
  receiptRow(copy.rows.scope, scopeLabel),
119
277
  receiptRow(copy.rows.sessions, formatCount(record.stats.session_count, copy.units.sessions, locale)),
@@ -312,6 +470,199 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
312
470
  font-weight: 600;
313
471
  line-height: 1.4;
314
472
  }
473
+ .sidebar-features {
474
+ --feature-ink: #282620;
475
+ --feature-muted: #756f65;
476
+ --feature-line: #c9c2b6;
477
+ --feature-soft-line: #ded7cc;
478
+ --feature-paper: #f6f2e9;
479
+ --feature-paper-soft: #fbfaf7;
480
+ --feature-command: #ece8df;
481
+ padding: 0;
482
+ overflow: hidden;
483
+ border-color: var(--feature-line);
484
+ background: var(--feature-paper);
485
+ color: var(--feature-ink);
486
+ text-align: left;
487
+ transition: background .15s ease, border-color .15s ease, box-shadow .15s ease;
488
+ }
489
+ .sidebar-features[open] {
490
+ background: var(--feature-paper-soft);
491
+ box-shadow: 0 10px 28px rgba(53, 48, 39, .1);
492
+ }
493
+ .sidebar-features__summary {
494
+ display: flex;
495
+ align-items: center;
496
+ justify-content: space-between;
497
+ gap: 8px;
498
+ padding: 12px;
499
+ cursor: pointer;
500
+ list-style: none;
501
+ user-select: none;
502
+ transition: background .15s ease;
503
+ }
504
+ .sidebar-features__summary::-webkit-details-marker { display: none; }
505
+ .sidebar-features__summary:hover { background: #eee8dc; }
506
+ .sidebar-features__summary .sidebar-card__title {
507
+ margin: 0;
508
+ color: var(--feature-ink);
509
+ }
510
+ .sidebar-features__summary .sidebar-card__title svg { color: var(--feature-muted); }
511
+ .sidebar-features__summary:focus-visible {
512
+ outline: 2px solid var(--feature-ink);
513
+ outline-offset: -3px;
514
+ }
515
+ .sidebar-features__meta {
516
+ display: inline-flex;
517
+ align-items: center;
518
+ gap: 5px;
519
+ flex-shrink: 0;
520
+ }
521
+ .sidebar-features__count {
522
+ color: var(--feature-muted);
523
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
524
+ font-size: 9px;
525
+ white-space: nowrap;
526
+ }
527
+ .sidebar-features__chevron {
528
+ width: 14px;
529
+ height: 14px;
530
+ flex-shrink: 0;
531
+ color: var(--feature-muted);
532
+ transition: transform .18s ease;
533
+ }
534
+ .sidebar-features[open] .sidebar-features__chevron { transform: rotate(180deg); }
535
+ .sidebar-features__body {
536
+ padding: 0 12px 12px;
537
+ border-top: 1px solid var(--feature-soft-line);
538
+ background: var(--feature-paper-soft);
539
+ animation: feature-reveal .15s ease-out;
540
+ }
541
+ .sidebar-features .sidebar-features__description {
542
+ margin: 10px 0 12px;
543
+ color: var(--feature-muted);
544
+ }
545
+ .feature-group + .feature-group {
546
+ margin-top: 14px;
547
+ padding-top: 13px;
548
+ border-top: 1px solid var(--feature-soft-line);
549
+ }
550
+ .feature-group h4 {
551
+ margin: 0 0 8px;
552
+ color: var(--feature-muted);
553
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
554
+ font-size: 9px;
555
+ font-weight: 700;
556
+ letter-spacing: .08em;
557
+ text-transform: uppercase;
558
+ }
559
+ .feature-group ul {
560
+ margin: 0;
561
+ padding: 0;
562
+ list-style: none;
563
+ }
564
+ .feature-command + .feature-command {
565
+ margin-top: 10px;
566
+ padding-top: 10px;
567
+ border-top: 1px dashed var(--feature-soft-line);
568
+ }
569
+ .feature-command__name {
570
+ display: block;
571
+ margin-bottom: 5px;
572
+ color: var(--feature-ink);
573
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
574
+ font-size: 10px;
575
+ font-weight: 600;
576
+ line-height: 1.4;
577
+ }
578
+ .feature-command__action {
579
+ display: grid;
580
+ grid-template-columns: minmax(0, 1fr) auto;
581
+ gap: 5px;
582
+ align-items: stretch;
583
+ }
584
+ .feature-command code {
585
+ display: block;
586
+ min-width: 0;
587
+ padding: 6px;
588
+ overflow-x: auto;
589
+ border: 1px solid #d5cfc4;
590
+ border-radius: 4px;
591
+ background: var(--feature-command);
592
+ color: #34312c;
593
+ font-size: 9px;
594
+ line-height: 1.35;
595
+ white-space: nowrap;
596
+ scrollbar-width: thin;
597
+ scrollbar-color: #b8b0a4 transparent;
598
+ }
599
+ .feature-command code:focus-visible {
600
+ outline: 2px solid #81786c;
601
+ outline-offset: 1px;
602
+ }
603
+ .feature-copy-button {
604
+ display: inline-flex;
605
+ align-items: center;
606
+ justify-content: center;
607
+ gap: 3px;
608
+ min-width: 48px;
609
+ padding: 5px 6px;
610
+ border: 1px solid #bdb5a9;
611
+ border-radius: 4px;
612
+ background: #fff;
613
+ color: #4c4942;
614
+ cursor: pointer;
615
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, "PingFang SC", sans-serif;
616
+ font-size: 9px;
617
+ font-weight: 600;
618
+ }
619
+ .feature-copy-button:hover { background: #e5dfd4; }
620
+ .feature-copy-button:focus-visible {
621
+ outline: 2px solid var(--feature-ink);
622
+ outline-offset: 2px;
623
+ }
624
+ .feature-copy-button svg {
625
+ width: 11px;
626
+ height: 11px;
627
+ flex-shrink: 0;
628
+ }
629
+ .feature-copy-button[data-state="success"] {
630
+ border-color: #78a782;
631
+ background: #eef7ef;
632
+ color: #276036;
633
+ }
634
+ .feature-copy-button[data-state="error"] {
635
+ border-color: #c88c84;
636
+ background: #fff1ef;
637
+ color: #8b3028;
638
+ }
639
+ .feature-copy-status {
640
+ position: absolute;
641
+ width: 1px;
642
+ height: 1px;
643
+ padding: 0;
644
+ margin: -1px;
645
+ overflow: hidden;
646
+ clip: rect(0, 0, 0, 0);
647
+ white-space: nowrap;
648
+ border: 0;
649
+ }
650
+ @keyframes feature-reveal {
651
+ from { opacity: 0; transform: translateY(-3px); }
652
+ to { opacity: 1; transform: translateY(0); }
653
+ }
654
+ @media (min-width: 1021px) {
655
+ .sidebar-features[open] {
656
+ width: 300px;
657
+ margin-left: -120px;
658
+ }
659
+ }
660
+ @media (prefers-reduced-motion: reduce) {
661
+ .sidebar-features,
662
+ .sidebar-features__summary,
663
+ .sidebar-features__chevron { transition: none; }
664
+ .sidebar-features__body { animation: none; }
665
+ }
315
666
  .toolbar {
316
667
  display: flex;
317
668
  width: 100%;
@@ -452,6 +803,110 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
452
803
  padding: 8px 6px;
453
804
  background: var(--accent);
454
805
  }
806
+ .insights-title {
807
+ margin: 0 0 12px;
808
+ font-size: 14px;
809
+ letter-spacing: 0;
810
+ text-align: center;
811
+ }
812
+ .insight-metrics {
813
+ display: grid;
814
+ grid-template-columns: repeat(2, minmax(0, 1fr));
815
+ border-top: 1px solid var(--line);
816
+ border-left: 1px solid var(--line);
817
+ }
818
+ .insight-metric {
819
+ min-width: 0;
820
+ padding: 10px;
821
+ border-right: 1px solid var(--line);
822
+ border-bottom: 1px solid var(--line);
823
+ }
824
+ .insight-metric span,
825
+ .insight-metric strong,
826
+ .insight-metric small { display: block; }
827
+ .insight-metric span {
828
+ color: var(--muted);
829
+ font-size: 10px;
830
+ line-height: 1.35;
831
+ }
832
+ .insight-metric strong {
833
+ margin-top: 5px;
834
+ font-size: 16px;
835
+ line-height: 1.2;
836
+ }
837
+ .insight-metric small {
838
+ margin-top: 4px;
839
+ overflow: hidden;
840
+ color: var(--muted);
841
+ font-size: 9px;
842
+ line-height: 1.35;
843
+ text-overflow: ellipsis;
844
+ white-space: nowrap;
845
+ }
846
+ .heatmap-block,
847
+ .structure-grid {
848
+ margin-top: 16px;
849
+ padding-top: 14px;
850
+ border-top: 1px dashed var(--line);
851
+ }
852
+ .heatmap-block h3,
853
+ .structure-group h3 {
854
+ margin: 0 0 9px;
855
+ color: var(--muted);
856
+ font-size: 10px;
857
+ letter-spacing: 0;
858
+ text-transform: uppercase;
859
+ }
860
+ .heatmap-grid {
861
+ display: grid;
862
+ grid-template-columns: repeat(24, minmax(0, 1fr));
863
+ gap: 2px;
864
+ }
865
+ .heatmap-cell {
866
+ display: block;
867
+ height: 22px;
868
+ background: color-mix(in srgb, var(--ink) var(--heat), transparent);
869
+ }
870
+ .heatmap-axis {
871
+ display: flex;
872
+ justify-content: space-between;
873
+ margin-top: 5px;
874
+ color: var(--muted);
875
+ font-size: 8px;
876
+ }
877
+ .structure-grid {
878
+ display: grid;
879
+ grid-template-columns: repeat(2, minmax(0, 1fr));
880
+ gap: 18px;
881
+ }
882
+ .structure-group { min-width: 0; }
883
+ .structure-row {
884
+ display: grid;
885
+ grid-template-columns: minmax(0, 1fr) minmax(32px, .65fr) auto;
886
+ gap: 6px;
887
+ align-items: center;
888
+ min-width: 0;
889
+ padding: 4px 0;
890
+ font-size: 9px;
891
+ }
892
+ .structure-name {
893
+ min-width: 0;
894
+ overflow: hidden;
895
+ text-overflow: ellipsis;
896
+ white-space: nowrap;
897
+ }
898
+ .structure-bar {
899
+ display: block;
900
+ height: 4px;
901
+ background: color-mix(in srgb, var(--ink) 10%, transparent);
902
+ }
903
+ .structure-bar i {
904
+ display: block;
905
+ height: 100%;
906
+ background: var(--ink);
907
+ }
908
+ .structure-row strong { font-size: 8px; white-space: nowrap; }
909
+ .structure-empty { margin: 0; color: var(--muted); font-size: 9px; }
455
910
  .verdict { text-align: center; }
456
911
  .verdict h2 { margin: 0; font-size: 22px; letter-spacing: .04em; }
457
912
  .review {
@@ -663,6 +1118,7 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
663
1118
  flex: 1 1 160px;
664
1119
  min-width: 0;
665
1120
  }
1121
+ .sidebar-features { flex-basis: 100%; }
666
1122
  }
667
1123
  @media (max-width: 420px) {
668
1124
  .layout { padding-inline: 10px; }
@@ -674,6 +1130,7 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
674
1130
  .meta { grid-template-columns: 1fr; }
675
1131
  .meta div:nth-child(even) { text-align: left; }
676
1132
  .transfer-layout { grid-template-columns: 1fr; }
1133
+ .structure-grid { grid-template-columns: 1fr; }
677
1134
  .sidebar { flex-direction: column; }
678
1135
  .sidebar-card { flex-basis: auto; }
679
1136
  }
@@ -713,6 +1170,10 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
713
1170
  <section>${rows}</section>
714
1171
  <div class="divider"></div>
715
1172
 
1173
+ ${insights}
1174
+
1175
+ <div class="divider"></div>
1176
+
716
1177
  <section class="verdict">
717
1178
  <h2>${escapeHtml(record.presentation.work_title)}</h2>
718
1179
  <p class="review">${escapeHtml(record.presentation.review)}</p>
@@ -768,12 +1229,29 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
768
1229
  <span class="sidebar-sponsor__name">ModelFlare<br>modelflare.dev</span>
769
1230
  </a>
770
1231
  </div>
1232
+ <details class="sidebar-card sidebar-features" data-feature-details>
1233
+ <summary class="sidebar-features__summary">
1234
+ <span class="sidebar-card__title">
1235
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><polyline points="4 17 10 11 4 5"></polyline><line x1="12" x2="20" y1="19" y2="19"></line></svg>
1236
+ ${escapeHtml(copy.sidebar.features.title)}
1237
+ </span>
1238
+ <span class="sidebar-features__meta">
1239
+ <span class="sidebar-features__count">${escapeHtml(featureCountLabel)}</span>
1240
+ <svg class="sidebar-features__chevron" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="m6 9 6 6 6-6"></path></svg>
1241
+ </span>
1242
+ </summary>
1243
+ <div class="sidebar-features__body">
1244
+ <p class="sidebar-features__description">${escapeHtml(copy.sidebar.features.description)}</p>${featureCommands}
1245
+ <span class="feature-copy-status" data-feature-copy-status role="status" aria-live="polite"></span>
1246
+ </div>
1247
+ </details>
771
1248
  </aside>
772
1249
  </div>
773
1250
  <script>${inlineScript(DOM_TO_IMAGE_SOURCE)}</script>
774
1251
  <script>
775
1252
  const exportConfig = ${exportConfig};
776
1253
  const transferConfig = ${transferConfig};
1254
+ const featureConfig = ${featureConfig};
777
1255
  const themes = new Set(["classic", "diner", "payroll"]);
778
1256
  const buttons = [...document.querySelectorAll("[data-theme-value]")];
779
1257
  function applyTheme(theme) {
@@ -787,6 +1265,75 @@ export function renderHtml({ record, dataQrDataUrl = null, miniProgramCodeDataUr
787
1265
  try { savedTheme = localStorage.getItem("codex-work-receipt-theme"); } catch {}
788
1266
  applyTheme(savedTheme || document.documentElement.dataset.theme);
789
1267
 
1268
+ const featureDetails = document.querySelector("[data-feature-details]");
1269
+ if (featureDetails) {
1270
+ try {
1271
+ const savedFeatureState = localStorage.getItem("codex-work-receipt-features-open");
1272
+ if (savedFeatureState === "true") featureDetails.open = true;
1273
+ if (savedFeatureState === "false") featureDetails.open = false;
1274
+ } catch {}
1275
+ featureDetails.addEventListener("toggle", () => {
1276
+ try { localStorage.setItem("codex-work-receipt-features-open", String(featureDetails.open)); } catch {}
1277
+ });
1278
+ }
1279
+
1280
+ function fallbackCopyText(value) {
1281
+ const textarea = document.createElement("textarea");
1282
+ textarea.value = value;
1283
+ textarea.setAttribute("readonly", "");
1284
+ Object.assign(textarea.style, {
1285
+ position: "fixed",
1286
+ left: "-10000px",
1287
+ top: "0",
1288
+ opacity: "0",
1289
+ pointerEvents: "none",
1290
+ });
1291
+ document.body.appendChild(textarea);
1292
+ textarea.focus();
1293
+ textarea.select();
1294
+ textarea.setSelectionRange(0, textarea.value.length);
1295
+ const copied = document.execCommand("copy");
1296
+ textarea.remove();
1297
+ if (!copied) throw new Error("Clipboard copy was rejected");
1298
+ }
1299
+
1300
+ async function copyFeatureCommand(value) {
1301
+ if (navigator.clipboard?.writeText) {
1302
+ try {
1303
+ await navigator.clipboard.writeText(value);
1304
+ return;
1305
+ } catch {}
1306
+ }
1307
+ fallbackCopyText(value);
1308
+ }
1309
+
1310
+ const featureCopyStatus = document.querySelector("[data-feature-copy-status]");
1311
+ document.querySelectorAll("[data-copy-command]").forEach((button) => {
1312
+ let resetTimer = null;
1313
+ button.addEventListener("click", async () => {
1314
+ const label = button.querySelector("[data-copy-label]");
1315
+ clearTimeout(resetTimer);
1316
+ button.disabled = true;
1317
+ try {
1318
+ await copyFeatureCommand(button.dataset.copyCommand || "");
1319
+ button.dataset.state = "success";
1320
+ if (label) label.textContent = featureConfig.copiedLabel;
1321
+ if (featureCopyStatus) featureCopyStatus.textContent = featureConfig.copiedStatus;
1322
+ } catch (error) {
1323
+ console.error(error);
1324
+ button.dataset.state = "error";
1325
+ if (label) label.textContent = featureConfig.copyErrorLabel;
1326
+ if (featureCopyStatus) featureCopyStatus.textContent = featureConfig.copyErrorStatus;
1327
+ } finally {
1328
+ button.disabled = false;
1329
+ resetTimer = setTimeout(() => {
1330
+ delete button.dataset.state;
1331
+ if (label) label.textContent = featureConfig.copyLabel;
1332
+ }, 1800);
1333
+ }
1334
+ });
1335
+ });
1336
+
790
1337
  const downloadFileButton = document.getElementById("download-import-file");
791
1338
  const downloadStatus = document.getElementById("download-status");
792
1339
  if (downloadFileButton && downloadStatus) {