codex-work-receipt 0.5.0 → 0.6.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,15 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.6.0
4
+
5
+ - 新增 cwr2 协议:按“会话 × 记账自然日”生成稳定的 canonical facts,重叠的今日、近 7 日和本周小票可由接收端可靠去重。
6
+ - 新增 fact、session、receipt 的稳定匿名身份,以及 manifest、content hash 和 source revision,便于幂等导入和追加式会话更新。
7
+ - Token 统计改为按来源顺序处理累计值重置,避免重置后出现负数或漏计。
8
+ - 新增 `cwr2p` 分片二维码;单个 cwr2 超出安全二维码容量时自动拆分,最多 12 片,并支持乱序重组和分片/总校验。
9
+ - 网页导入联可展示多张数据二维码;命令行输出二维码数量和 canonical fact 数量。
10
+ - 保留 cwr1 解码兼容;旧版小票仍可作为私人历史导入,但不包含精确去重所需的 canonical facts。
11
+ - 更新中英文手机导入、数据协议和隐私说明。
12
+
3
13
  ## 0.5.0
4
14
 
5
15
  - 新增网页版“保存完整长图”功能。
package/README.en.md CHANGED
@@ -22,6 +22,8 @@
22
22
 
23
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.
24
24
 
25
+ Version `0.6.0` uses the cwr2 protocol to create stable privacy-safe facts for each session and calendar day. Overlapping today, last-seven-days, and this-week receipts can be deduplicated by the receiver, and oversized payloads are automatically split into reorderable multipart QR codes.
26
+
25
27
  ## Quickstart
26
28
 
27
29
  Requires Node.js 20+ and local Codex session records. No clone required; choose today, the last 7 days, this week, or a specific session:
@@ -54,7 +56,7 @@ Codex will choose the range and theme, run the CLI, and open the receipt. See th
54
56
 
55
57
  ## Desktop to mobile
56
58
 
57
- The desktop page generates a privacy-safe data QR code. The companion WeChat mini program supports QR import, local history, themes, and image export. See [mobile import](docs/mobile-import.en.md).
59
+ The desktop page generates one or more privacy-safe data QR codes. The companion WeChat mini program supports multipart scanning, private history, themes, and image export. See [mobile import](docs/mobile-import.en.md).
58
60
 
59
61
  ## Docs
60
62
 
package/README.md CHANGED
@@ -22,6 +22,8 @@
22
22
 
23
23
  它会统计 Codex 会话中的轮次、工具调用、Token、时长和模型,生成带有 AI 工分、今日工种和点评的小票。不会把 Prompt、回复正文、代码、项目路径或文件名写入小票。
24
24
 
25
+ `0.6.0` 使用 cwr2 协议,按“会话 × 自然日”生成稳定的脱敏事实。今日、近 7 日和本周小票即使范围重叠,接收端也能识别相同工作;数据过大时会自动生成多张可重组的分片二维码。
26
+
25
27
  ## Quickstart
26
28
 
27
29
  需要 Node.js 20 或更高版本,并且本机已经使用过 Codex。无需克隆仓库,运行后选择“今天 / 近 7 日 / 本周 / 指定会话”:
@@ -54,7 +56,7 @@ Codex 会选择统计范围和主题、执行命令并打开小票。详见 [Cod
54
56
 
55
57
  ## 从电脑到手机
56
58
 
57
- 桌面网页会生成脱敏的数据二维码。配套微信小程序支持扫码导入、本地历史、主题切换和保存图片。详见 [手机扫码导入](docs/mobile-import.md)。
59
+ 桌面网页会生成一张或多张脱敏数据二维码。配套微信小程序支持分片扫码、私人历史、主题切换和保存图片。详见 [手机扫码导入](docs/mobile-import.md)。
58
60
 
59
61
  ## 文档
60
62
 
package/docs/cli.en.md CHANGED
@@ -79,7 +79,7 @@ Default output:
79
79
 
80
80
  Default filenames include the calendar range. Latest-session and selected-session receipts include a short identifier so receipts from different dates or sessions do not overwrite one another.
81
81
 
82
- Open the generated HTML and click “Save full PNG” to download a high-resolution image. It includes the main receipt, mini-program code, and data QR import stub in the currently selected theme. Theme controls, the page background, and the footer note are excluded.
82
+ Open the generated HTML and click “Save full PNG” to download a high-resolution image. It includes the main receipt, mini-program code, and data QR import stub in the currently selected theme. Theme controls, the page background, and the footer note are excluded. When cwr2 data exceeds the safe capacity of one QR code, the import stub automatically shows numbered multipart codes that must all be scanned in the mini program.
83
83
 
84
84
  Set a timezone and output path:
85
85
 
package/docs/cli.md CHANGED
@@ -79,7 +79,7 @@ npx codex-work-receipt@latest --latest --theme diner
79
79
 
80
80
  默认文件名会携带统计日期范围;最近会话和指定会话则携带短编号,避免不同日期或会话的小票互相覆盖。
81
81
 
82
- 打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。
82
+ 打开生成的 HTML 后,点击“保存完整长图”即可下载高清 PNG。图片包含主小票、小程序码和数据二维码导入联,并自动使用当前选择的主题;主题按钮、网页背景和底部说明不会进入图片。cwr2 数据超过单个二维码的安全容量时,导入联会自动展示多张带序号的分片二维码,需在小程序中全部扫完。
83
83
 
84
84
  指定时区和输出路径:
85
85
 
@@ -2,17 +2,19 @@
2
2
 
3
3
  <p><a href="./mobile-import.md">中文</a> · <strong>English</strong> · <a href="../README.en.md">Back to README</a></p>
4
4
 
5
- The desktop page shows both the fixed mini-program code and a data QR code for the current receipt. The companion mini program can complete the MVP import without a server:
5
+ The desktop page shows both the fixed mini-program code and a data QR code for the current receipt. The companion mini program validates the protocol and privacy-safe fields on the phone, then saves the private receipt to the user's anonymous account database:
6
6
 
7
7
  1. Scan the fixed mini-program code in WeChat and open the companion mini program.
8
8
  2. Tap the desktop-import action.
9
9
  3. Scan the adjacent data QR code.
10
- 4. Validate the `cwr1` prefix, checksum, and schema version.
10
+ 4. Validate the `cwr1`, `cwr2`, or multipart `cwr2p` prefix, checksum, and schema version.
11
11
  5. Decompress and parse the privacy-safe metrics.
12
- 6. Recognize a session, today, last-seven-days, or this-week range, then deduplicate by receipt ID and store it locally.
12
+ 6. Recognize a session, today, last-seven-days, or this-week range. New receipts use canonical facts for deduplication and are stored in the anonymous account database by default.
13
13
  7. Render the selected template with Canvas and save it to the phone.
14
14
 
15
- The QR code does not transfer an image. The desktop page and mini program independently render the same structured receipt data. Older QR codes without an explicit range field remain supported through the legacy label fallback.
15
+ The phone keeps only multipart staging, pending database writes, and necessary caches. Saving a private receipt does not automatically join public statistics; participation in the AI Cooperative is a separate choice inside the companion mini program.
16
+
17
+ The QR code does not transfer an image. The desktop page and mini program independently render the same structured receipt data. Older QR codes without an explicit range field remain supported through the legacy label fallback. QR payloads exclude prompts, response text, source code, project paths, file names, and original session IDs.
16
18
 
17
19
  The companion mini program is a separate product. This repository does not contain its source code, AppID, backend code, or server credentials.
18
20
 
@@ -2,17 +2,19 @@
2
2
 
3
3
  <p><strong>中文</strong> · <a href="./mobile-import.en.md">English</a> · <a href="../README.md">返回 README</a></p>
4
4
 
5
- 桌面网页会同时展示固定小程序码和当前小票的数据二维码。配套小程序无需服务器即可完成 MVP 导入:
5
+ 桌面网页会同时展示固定小程序码和当前小票的数据二维码。配套小程序会先在手机端完成协议和隐私字段校验,再把私人小票保存到用户的匿名账号数据库:
6
6
 
7
7
  1. 微信扫描固定小程序码并打开配套小程序。
8
8
  2. 点击“从电脑导入”。
9
9
  3. 扫描网页旁边的数据二维码。
10
- 4. 小程序校验 `cwr1` 前缀、checksum 和结构版本。
10
+ 4. 小程序校验 `cwr1`、`cwr2` 或 `cwr2p` 分片前缀、checksum 和结构版本。
11
11
  5. 解压并解析脱敏统计数据。
12
- 6. 识别单会话、今日、近七日或本周范围,并按小票 ID 去重写入本地历史。
12
+ 6. 识别单会话、今日、近七日或本周范围;新版小票按规范事实去重,完整记录默认保存到匿名账号数据库。
13
13
  7. 使用 Canvas 重新绘制所选模板并保存到手机相册。
14
14
 
15
- 二维码不会传输图片。桌面端和小程序端根据同一份结构数据分别渲染小票;旧版二维码没有显式范围字段时仍按原有文案兼容识别。
15
+ 手机本地只保留扫码分片、待入库记录和必要缓存。私人小票入库不等于自动加入公开统计;是否参与 AI 供销社匿名汇总由用户在配套小程序中单独决定。
16
+
17
+ 二维码不会传输图片。桌面端和小程序端根据同一份结构数据分别渲染小票;旧版二维码没有显式范围字段时仍按原有文案兼容识别。二维码不包含 Prompt、回复正文、代码、项目路径、文件名或原始会话 ID。
16
18
 
17
19
  配套小程序是独立产品,本仓库不包含其源码、AppID、后台代码或服务端密钥。
18
20
 
@@ -23,6 +23,8 @@ Generated HTML and JSON files are written to `codex-work-receipt-output/` by def
23
23
 
24
24
  The runtime does not upload Codex session data to a project server. The initial or updated package download performed by `npx` is a normal package-manager network request and does not contain local session data.
25
25
 
26
+ If the user actively scans a data QR code, the companion mini program saves the privacy-safe receipt to the user's anonymous account database for history recovery and canonical deduplication. This happens inside the companion product and does not upload prompts, response text, source code, project paths, file names, or original session IDs. Joining AI Cooperative aggregate statistics remains a separate user choice.
27
+
26
28
  ## QR payload
27
29
 
28
30
  The QR code contains versioned, privacy-safe receipt metrics only. It does not contain the original session or an image. Treat the QR code as a scannable data file and share it only with people you trust.
package/docs/privacy.md CHANGED
@@ -23,6 +23,8 @@ AI 打工小票在本机读取 Codex 会话,并在本机完成统计和渲染
23
23
 
24
24
  运行期间不会把 Codex 会话数据上传到本项目的服务器。通过 `npx` 首次下载或更新 npm 包属于包管理器的正常网络请求,不包含本地会话内容。
25
25
 
26
+ 如果用户主动扫描数据二维码,配套小程序会把二维码中的脱敏小票保存到用户的匿名账号数据库,用于历史恢复和规范去重。这一步发生在配套小程序中,不会上传 Prompt、回复正文、代码、项目路径、文件名或原始会话 ID;是否参与 AI 供销社匿名统计仍由用户单独决定。
27
+
26
28
  ## 二维码
27
29
 
28
30
  二维码只包含当前小票的版本化脱敏统计,不包含原始会话或图片。二维码等同于一份可扫描的数据文件,请只向信任的人展示。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.5.0",
3
+ "version": "0.6.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
@@ -7,9 +7,10 @@ import { fileURLToPath } from "node:url";
7
7
  import QRCode from "qrcode";
8
8
 
9
9
  import { parseArgs, printHelp } from "./core/args.mjs";
10
+ import { buildCanonicalFacts } from "./core/fact-buckets.mjs";
10
11
  import { collectMetrics } from "./core/metrics.mjs";
11
12
  import { getReceiptCopy } from "./core/presentation.mjs";
12
- import { encodeReceiptPayload } from "./core/qr-payload.mjs";
13
+ import { encodeReceiptPayloads } from "./core/qr-payload.mjs";
13
14
  import { outputSlugForRange, resolveRange } from "./core/range.mjs";
14
15
  import { buildReceiptRecord, persistReceiptRecord } from "./core/receipt-record.mjs";
15
16
  import { promptForRange } from "./core/selector.mjs";
@@ -77,14 +78,16 @@ async function main() {
77
78
  const range = resolveRange(options.mode, options.timezone, new Date(), options.sessionId);
78
79
  const sessions = loadCodexSessions(range);
79
80
  const metrics = collectMetrics(sessions, range);
80
- const record = buildReceiptRecord(metrics, options.theme, options.locale);
81
- const qrPayload = encodeReceiptPayload(record);
82
- const dataQrDataUrl = await QRCode.toDataURL(qrPayload, {
81
+ const observedAt = new Date().toISOString();
82
+ const canonical = buildCanonicalFacts(sessions, range, { observedAt });
83
+ const record = buildReceiptRecord(metrics, options.theme, options.locale, canonical);
84
+ const qrPayloads = encodeReceiptPayloads(record);
85
+ const dataQrDataUrls = await Promise.all(qrPayloads.map((payload) => QRCode.toDataURL(payload, {
83
86
  errorCorrectionLevel: "M",
84
87
  margin: 2,
85
88
  width: 360,
86
89
  color: { dark: "#171713", light: "#ffffff" },
87
- });
90
+ })));
88
91
 
89
92
  const requestedOutput = options.output || path.join(
90
93
  DEFAULT_OUTPUT_DIR,
@@ -97,7 +100,7 @@ async function main() {
97
100
 
98
101
  fs.writeFileSync(
99
102
  outputFile,
100
- renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl }),
103
+ renderHtml({ record, dataQrDataUrls, miniProgramCodeDataUrl }),
101
104
  "utf8",
102
105
  );
103
106
  const persisted = persistReceiptRecord(record, outputFile, options.dataDir);
@@ -108,7 +111,7 @@ async function main() {
108
111
  console.log(`Local history: ${persisted.receiptPath}`);
109
112
  console.log(`Range: ${getReceiptCopy(options.locale).scope[record.source.scope]} · ${record.stats.session_count} session(s)`);
110
113
  console.log(`Stats: ${record.stats.completed_turns} turns · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Tokens · ${record.stats.tool_calls} tool calls`);
111
- console.log(`Data QR: ${qrPayload.length} characters · schema v${record.schema_version}`);
114
+ console.log(`Data QR: ${qrPayloads.length} code(s) · ${record.manifest.fact_count} canonical fact(s) · schema v${record.schema_version}`);
112
115
  if (!miniProgramCodeDataUrl) console.log("Mini-program code: not configured; using the explicit placeholder");
113
116
  } else {
114
117
  console.log(`已生成网页:${outputFile}`);
@@ -116,7 +119,7 @@ async function main() {
116
119
  console.log(`本地历史:${persisted.receiptPath}`);
117
120
  console.log(`统计范围:${getReceiptCopy(options.locale).scope[record.source.scope]} · ${record.stats.session_count} 个会话`);
118
121
  console.log(`统计:${record.stats.completed_turns} 轮 · ${formatNumber(record.stats.tokens.total_tokens, options.locale)} Token · ${record.stats.tool_calls} 次工具调用`);
119
- console.log(`数据二维码:${qrPayload.length} 字符 · schema v${record.schema_version}`);
122
+ console.log(`数据二维码:${qrPayloads.length} · ${record.manifest.fact_count} 条规范事实 · schema v${record.schema_version}`);
120
123
  if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
121
124
  }
122
125
  if (options.open) openFile(outputFile);
@@ -0,0 +1,27 @@
1
+ import crypto from "node:crypto";
2
+
3
+ function normalize(value) {
4
+ if (Array.isArray(value)) return value.map(normalize);
5
+ if (!value || typeof value !== "object") return value;
6
+
7
+ return Object.fromEntries(
8
+ Object.keys(value)
9
+ .sort()
10
+ .map((key) => [key, normalize(value[key])]),
11
+ );
12
+ }
13
+
14
+ export function canonicalStringify(value) {
15
+ return JSON.stringify(normalize(value));
16
+ }
17
+
18
+ export function sha256Hex(value) {
19
+ const input = Buffer.isBuffer(value) || value instanceof Uint8Array
20
+ ? value
21
+ : Buffer.from(typeof value === "string" ? value : canonicalStringify(value), "utf8");
22
+ return crypto.createHash("sha256").update(input).digest("hex");
23
+ }
24
+
25
+ export function stableId(prefix, domain, parts) {
26
+ return `${prefix}_${sha256Hex([domain, ...parts])}`;
27
+ }
@@ -0,0 +1,205 @@
1
+ import { canonicalStringify, sha256Hex } from "./canonical.mjs";
2
+ import {
3
+ ACCOUNTING_TIMEZONE,
4
+ buildFactId,
5
+ buildSessionId,
6
+ FACT_SCHEMA_VERSION,
7
+ METRIC_SCHEMA_VERSION,
8
+ } from "./fact-identity.mjs";
9
+ import { dateKey, rowDate } from "../lib/time.mjs";
10
+ import { isCalendarScope, isDateInRange, shiftDateKey } from "./range.mjs";
11
+
12
+ const TOKEN_KEYS = [
13
+ "input_tokens",
14
+ "cached_input_tokens",
15
+ "output_tokens",
16
+ "reasoning_output_tokens",
17
+ "total_tokens",
18
+ ];
19
+
20
+ function zeroUsage() {
21
+ return Object.fromEntries(TOKEN_KEYS.map((key) => [key, 0]));
22
+ }
23
+
24
+ function tokenUsage(row) {
25
+ return row.type === "event_msg" && row.payload?.type === "token_count"
26
+ ? row.payload?.info?.total_token_usage || null
27
+ : null;
28
+ }
29
+
30
+ function tokenDeltaForDate(rows, localDate, timezone) {
31
+ const totals = zeroUsage();
32
+ const previous = zeroUsage();
33
+ let resetCount = 0;
34
+
35
+ for (const row of rows) {
36
+ const usage = tokenUsage(row);
37
+ const date = rowDate(row);
38
+ if (!usage || !date) continue;
39
+ const key = dateKey(date, timezone);
40
+ if (key > localDate) break;
41
+ let rowReset = false;
42
+
43
+ for (const tokenKey of TOKEN_KEYS) {
44
+ const current = Math.max(0, Number(usage[tokenKey] || 0));
45
+ if (key === localDate) {
46
+ if (current >= previous[tokenKey]) totals[tokenKey] += current - previous[tokenKey];
47
+ else {
48
+ totals[tokenKey] += current;
49
+ rowReset = true;
50
+ }
51
+ }
52
+ previous[tokenKey] = current;
53
+ }
54
+ if (key === localDate && rowReset) resetCount += 1;
55
+ }
56
+
57
+ return { totals, resetCount };
58
+ }
59
+
60
+ function metricsForRows(sessionRows, bucketRows, localDate, timezone) {
61
+ let completedTurns = 0;
62
+ let userMessages = 0;
63
+ let toolCalls = 0;
64
+ let interruptions = 0;
65
+ let workDurationMs = 0;
66
+ let firstTokenTotalMs = 0;
67
+ let firstTokenSampleCount = 0;
68
+ const models = new Set();
69
+
70
+ for (const row of bucketRows) {
71
+ if (row.type === "turn_context" && row.payload?.model) models.add(row.payload.model);
72
+ if (row.type === "event_msg") {
73
+ if (row.payload?.type === "task_complete") {
74
+ completedTurns += 1;
75
+ workDurationMs += Math.max(0, Number(row.payload.duration_ms || 0));
76
+ if (Number.isFinite(row.payload.time_to_first_token_ms)) {
77
+ firstTokenTotalMs += Math.max(0, Number(row.payload.time_to_first_token_ms));
78
+ firstTokenSampleCount += 1;
79
+ }
80
+ } else if (row.payload?.type === "user_message") userMessages += 1;
81
+ else if (row.payload?.type === "turn_aborted") {
82
+ interruptions += 1;
83
+ workDurationMs += Math.max(0, Number(row.payload.duration_ms || 0));
84
+ }
85
+ }
86
+ if (
87
+ row.type === "response_item" &&
88
+ (row.payload?.type === "custom_tool_call" || row.payload?.type === "function_call")
89
+ ) toolCalls += 1;
90
+ }
91
+
92
+ if (!models.size) {
93
+ const fallback = [...sessionRows]
94
+ .reverse()
95
+ .find((row) => row.type === "turn_context" && row.payload?.model)?.payload?.model;
96
+ if (fallback) models.add(fallback);
97
+ }
98
+
99
+ const tokenResult = tokenDeltaForDate(sessionRows, localDate, timezone);
100
+ return {
101
+ completed_turns: completedTurns,
102
+ user_messages: userMessages,
103
+ tool_calls: toolCalls,
104
+ interruptions,
105
+ work_duration_ms: Math.round(workDurationMs),
106
+ first_token_total_ms: Math.round(firstTokenTotalMs),
107
+ first_token_sample_count: firstTokenSampleCount,
108
+ ...tokenResult.totals,
109
+ token_reset_count: tokenResult.resetCount,
110
+ models: [...models].sort(),
111
+ };
112
+ }
113
+
114
+ function coverageForRange(range, scanMode, observedAt) {
115
+ if (!isCalendarScope(range.scope)) {
116
+ return {
117
+ kind: "selected_sessions",
118
+ scan_mode: "none",
119
+ start_date: range.startDate,
120
+ end_date: range.endDate,
121
+ complete_through_date: null,
122
+ observed_through_at: observedAt,
123
+ };
124
+ }
125
+
126
+ const full = scanMode === "full";
127
+ return {
128
+ kind: "calendar_range",
129
+ scan_mode: full ? "full" : "best_effort",
130
+ start_date: range.startDate,
131
+ end_date: range.endDate,
132
+ complete_through_date: full ? shiftDateKey(range.endDate, -1) : null,
133
+ observed_through_at: observedAt,
134
+ };
135
+ }
136
+
137
+ export function buildCanonicalFacts(sessions, range, options = {}) {
138
+ const sourceType = options.sourceType || "codex";
139
+ const accountingTimezone = options.accountingTimezone || ACCOUNTING_TIMEZONE;
140
+ const collectorVersion = options.collectorVersion || "0.6.0";
141
+ const observedAt = options.observedAt || new Date().toISOString();
142
+ const facts = [];
143
+
144
+ for (const session of sessions) {
145
+ const identityQuality = session.identityQuality || "metadata";
146
+ const sessionId = buildSessionId(sourceType, session.sessionId, identityQuality);
147
+ const sessionRows = [...session.rows].sort(
148
+ (left, right) => Number(left.__sourceLine || 0) - Number(right.__sourceLine || 0),
149
+ );
150
+ const buckets = new Map();
151
+
152
+ for (const row of sessionRows) {
153
+ const date = rowDate(row);
154
+ if (!date || (isCalendarScope(range.scope) && !isDateInRange(date, range))) continue;
155
+ const localDate = dateKey(date, accountingTimezone);
156
+ const rows = buckets.get(localDate) || [];
157
+ rows.push(row);
158
+ buckets.set(localDate, rows);
159
+ }
160
+
161
+ for (const [localDate, bucketRows] of buckets) {
162
+ const timestamps = bucketRows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
163
+ if (!timestamps.length) continue;
164
+ const stats = metricsForRows(sessionRows, bucketRows, localDate, accountingTimezone);
165
+ const factId = buildFactId({ sourceType, sessionId, accountingTimezone, localDate });
166
+ const sourceRevision = session.sourceRevision || {
167
+ kind: "append-only-jsonl-v1",
168
+ row_count: sessionRows.length,
169
+ byte_length: 0,
170
+ tail_hash: sha256Hex(sessionRows.map((row) => row.__sourceLine || 0)),
171
+ };
172
+ const content = {
173
+ fact_id: factId,
174
+ session_id: sessionId,
175
+ source_type: sourceType,
176
+ accounting_timezone: accountingTimezone,
177
+ local_date: localDate,
178
+ bucket_start_at: timestamps[0].toISOString(),
179
+ bucket_end_at: timestamps.at(-1).toISOString(),
180
+ stats,
181
+ };
182
+
183
+ facts.push({
184
+ ...content,
185
+ identity_quality: identityQuality,
186
+ fact_schema_version: FACT_SCHEMA_VERSION,
187
+ metric_schema_version: METRIC_SCHEMA_VERSION,
188
+ collector_version: collectorVersion,
189
+ source_watermark_at: timestamps.at(-1).toISOString(),
190
+ source_revision: sourceRevision,
191
+ observed_at: observedAt,
192
+ content_hash: sha256Hex(canonicalStringify(content)),
193
+ });
194
+ }
195
+ }
196
+
197
+ facts.sort((left, right) => (
198
+ left.local_date.localeCompare(right.local_date) || left.fact_id.localeCompare(right.fact_id)
199
+ ));
200
+
201
+ return {
202
+ facts,
203
+ coverage: coverageForRange(range, sessions.scanMode || options.scanMode || "best_effort", observedAt),
204
+ };
205
+ }
@@ -0,0 +1,39 @@
1
+ import { stableId } from "./canonical.mjs";
2
+
3
+ export const ACCOUNTING_TIMEZONE = "Asia/Shanghai";
4
+ export const FACT_SCHEMA_VERSION = 1;
5
+ export const METRIC_SCHEMA_VERSION = 1;
6
+
7
+ export function buildSessionId(sourceType, rawSessionId, identityQuality = "metadata") {
8
+ return stableId("cws", "codex-work-receipt/session/v1", [
9
+ sourceType,
10
+ identityQuality,
11
+ rawSessionId,
12
+ ]);
13
+ }
14
+
15
+ export function buildFactId({ sourceType, sessionId, accountingTimezone, localDate }) {
16
+ return stableId("cwf", "codex-work-receipt/fact/session-day/v1", [
17
+ sourceType,
18
+ sessionId,
19
+ accountingTimezone,
20
+ localDate,
21
+ ]);
22
+ }
23
+
24
+ export function buildLogicalReceiptKey(metrics) {
25
+ const sessionPart = stableId("cwg", "codex-work-receipt/session-group/v1", [
26
+ ...[...metrics.sessionIds].sort(),
27
+ ]);
28
+ if (metrics.mode === "latest" || metrics.mode === "session") {
29
+ return `${metrics.mode}:${sessionPart}:${metrics.timezone}`;
30
+ }
31
+ if (metrics.mode === "this-week") {
32
+ return `this-week:${metrics.rangeStartDate}:${metrics.timezone}`;
33
+ }
34
+ return `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
35
+ }
36
+
37
+ export function buildProtocolReceiptId(sourceVersion, logicalReceiptKey) {
38
+ return stableId("cwr2", "codex-work-receipt/receipt/v2", [sourceVersion, logicalReceiptKey]);
39
+ }
@@ -12,14 +12,6 @@ function zeroUsage() {
12
12
  };
13
13
  }
14
14
 
15
- function subtractUsage(after = {}, before = {}) {
16
- const result = zeroUsage();
17
- for (const key of Object.keys(result)) {
18
- result[key] = Math.max(0, Number(after[key] || 0) - Number(before[key] || 0));
19
- }
20
- return result;
21
- }
22
-
23
15
  function addUsage(target, source) {
24
16
  for (const key of Object.keys(target)) target[key] += Number(source[key] || 0);
25
17
  }
@@ -27,23 +19,25 @@ function addUsage(target, source) {
27
19
  function sessionTokenUsage(rows, range) {
28
20
  const events = rows
29
21
  .filter((row) => row.type === "event_msg" && row.payload?.type === "token_count")
30
- .filter((row) => row.payload?.info?.total_token_usage)
31
- .sort((left, right) => (rowDate(left)?.getTime() || 0) - (rowDate(right)?.getTime() || 0));
22
+ .filter((row) => row.payload?.info?.total_token_usage);
32
23
 
33
24
  if (!events.length) return zeroUsage();
34
- if (!isCalendarScope(range.scope)) {
35
- return { ...zeroUsage(), ...events.at(-1).payload.info.total_token_usage };
36
- }
25
+ const totals = zeroUsage();
26
+ const previous = zeroUsage();
37
27
 
38
- const lastInRange = events.filter((row) => isDateInRange(rowDate(row), range)).at(-1);
39
- if (!lastInRange) return zeroUsage();
40
-
41
- const baseline = events.filter((row) => {
28
+ for (const row of events) {
29
+ const currentUsage = row.payload.info.total_token_usage;
42
30
  const date = rowDate(row);
43
- return date && dateKey(date, range.timezone) < range.startDate;
44
- }).at(-1)?.payload.info.total_token_usage || zeroUsage();
31
+ const selected = !isCalendarScope(range.scope) || isDateInRange(date, range);
32
+
33
+ for (const key of Object.keys(totals)) {
34
+ const current = Math.max(0, Number(currentUsage[key] || 0));
35
+ if (selected) totals[key] += current >= previous[key] ? current - previous[key] : current;
36
+ previous[key] = current;
37
+ }
38
+ }
45
39
 
46
- return subtractUsage(lastInRange.payload.info.total_token_usage, baseline);
40
+ return totals;
47
41
  }
48
42
 
49
43
  function calculateWorkPoints(metrics) {
@@ -1,9 +1,17 @@
1
1
  import crypto from "node:crypto";
2
2
  import zlib from "node:zlib";
3
+ import QRCode from "qrcode";
3
4
 
4
5
  import { buildCompensation, getWorkProfileCopy } from "./presentation.mjs";
5
6
 
6
- export function compactReceipt(record) {
7
+ const MAX_QR_VERSION = 25;
8
+ const MAX_MULTIPART_PARTS = 12;
9
+
10
+ function checksum(value, length = 8) {
11
+ return crypto.createHash("sha256").update(value).digest("hex").slice(0, length);
12
+ }
13
+
14
+ function compactPresentation(record) {
7
15
  const profileId = record.presentation.work_profile;
8
16
  const scope = record.source?.scope || (record.presentation.compensation?.label === "本日工资" ? "today" : "latest");
9
17
  const mobileProfile = profileId
@@ -13,11 +21,21 @@ export function compactReceipt(record) {
13
21
  ? buildCompensation(scope, record.presentation.compensation?.amount, "zh-CN")
14
22
  : record.presentation.compensation;
15
23
 
24
+ return {
25
+ profileId,
26
+ scope,
27
+ mobileProfile,
28
+ mobileCompensation,
29
+ };
30
+ }
31
+
32
+ function compactBase(record) {
33
+ const presentation = compactPresentation(record);
16
34
  return {
17
35
  v: record.schema_version,
18
36
  i: record.id,
19
37
  g: record.generated_at,
20
- o: scope,
38
+ o: presentation.scope,
21
39
  d: [
22
40
  record.period.start_at,
23
41
  record.period.end_at,
@@ -43,35 +61,206 @@ export function compactReceipt(record) {
43
61
  ],
44
62
  m: record.stats.models,
45
63
  l: record.locale || "zh-CN",
46
- r: profileId || null,
64
+ r: presentation.profileId || null,
47
65
  p: [
48
66
  record.presentation.default_theme,
49
- mobileProfile.title,
50
- mobileProfile.review,
51
- mobileCompensation
67
+ presentation.mobileProfile.title,
68
+ presentation.mobileProfile.review,
69
+ presentation.mobileCompensation
52
70
  ? [
53
- mobileCompensation.label,
54
- mobileCompensation.amount,
55
- mobileCompensation.unit,
56
- mobileCompensation.note,
57
- mobileCompensation.formula_version,
71
+ presentation.mobileCompensation.label,
72
+ presentation.mobileCompensation.amount,
73
+ presentation.mobileCompensation.unit,
74
+ presentation.mobileCompensation.note,
75
+ presentation.mobileCompensation.formula_version,
58
76
  ]
59
77
  : null,
60
78
  ],
61
79
  };
62
80
  }
63
81
 
82
+ function compactFact(fact) {
83
+ const stats = fact.stats;
84
+ return [
85
+ fact.fact_id,
86
+ fact.session_id,
87
+ fact.identity_quality,
88
+ fact.source_type,
89
+ fact.local_date,
90
+ fact.bucket_start_at,
91
+ fact.bucket_end_at,
92
+ fact.source_watermark_at,
93
+ fact.observed_at,
94
+ [
95
+ fact.source_revision.kind,
96
+ fact.source_revision.row_count,
97
+ fact.source_revision.byte_length,
98
+ fact.source_revision.tail_hash,
99
+ ],
100
+ fact.content_hash,
101
+ [
102
+ stats.completed_turns,
103
+ stats.user_messages,
104
+ stats.tool_calls,
105
+ stats.interruptions,
106
+ stats.work_duration_ms,
107
+ stats.first_token_total_ms,
108
+ stats.first_token_sample_count,
109
+ stats.input_tokens,
110
+ stats.cached_input_tokens,
111
+ stats.output_tokens,
112
+ stats.reasoning_output_tokens,
113
+ stats.total_tokens,
114
+ stats.token_reset_count,
115
+ stats.models,
116
+ ],
117
+ ];
118
+ }
119
+
120
+ export function compactReceipt(record) {
121
+ const compact = compactBase(record);
122
+ if (record.schema_version !== 2) return compact;
123
+
124
+ const coverage = record.manifest.coverage;
125
+ return {
126
+ ...compact,
127
+ k: record.source.logical_key,
128
+ h: record.source.snapshot_hash,
129
+ a: [
130
+ record.manifest.version,
131
+ record.manifest.fact_schema_version,
132
+ record.manifest.metric_schema_version,
133
+ record.manifest.accounting_timezone,
134
+ record.manifest.fact_count,
135
+ record.manifest.fact_ids,
136
+ [
137
+ coverage.kind,
138
+ coverage.scan_mode,
139
+ coverage.start_date || null,
140
+ coverage.end_date || null,
141
+ coverage.complete_through_date || null,
142
+ coverage.observed_through_at,
143
+ ],
144
+ record.manifest.manifest_hash,
145
+ ],
146
+ f: record.facts.map(compactFact),
147
+ };
148
+ }
149
+
150
+ function encodeCompact(prefix, compact) {
151
+ const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compact), "utf8"));
152
+ return `${prefix}.${checksum(compressed)}.${compressed.toString("base64url")}`;
153
+ }
154
+
64
155
  export function encodeReceiptPayload(record) {
65
- const compressed = zlib.deflateRawSync(Buffer.from(JSON.stringify(compactReceipt(record)), "utf8"));
66
- const checksum = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
67
- return `cwr1.${checksum}.${compressed.toString("base64url")}`;
156
+ return encodeCompact(record.schema_version === 2 ? "cwr2" : "cwr1", compactReceipt(record));
68
157
  }
69
158
 
70
- export function decodeReceiptPayload(payload) {
71
- const [prefix, checksum, encoded] = String(payload).split(".");
72
- if (prefix !== "cwr1" || !checksum || !encoded) throw new Error("无效的打工小票二维码");
159
+ function qrVersion(payload) {
160
+ return QRCode.create(payload, { errorCorrectionLevel: "M" }).version;
161
+ }
162
+
163
+ function fitsVersion(payload, maxVersion) {
164
+ try {
165
+ return qrVersion(payload) <= maxVersion;
166
+ } catch {
167
+ return false;
168
+ }
169
+ }
170
+
171
+ function multipartPayload(transferId, partIndex, partCount, totalChecksum, chunk) {
172
+ return `cwr2p.${transferId}.${partIndex}.${partCount}.${totalChecksum}.${checksum(chunk)}.${chunk}`;
173
+ }
174
+
175
+ function maxChunkSize(singlePayload, maxVersion) {
176
+ const transferId = checksum(singlePayload, 12);
177
+ const totalChecksum = checksum(singlePayload);
178
+ let low = 32;
179
+ let high = singlePayload.length;
180
+
181
+ while (low < high) {
182
+ const middle = Math.ceil((low + high) / 2);
183
+ const candidate = multipartPayload(transferId, 12, 12, totalChecksum, singlePayload.slice(0, middle));
184
+ if (fitsVersion(candidate, maxVersion)) low = middle;
185
+ else high = middle - 1;
186
+ }
187
+ return low;
188
+ }
189
+
190
+ export function encodeReceiptPayloads(record, options = {}) {
191
+ const maxVersion = options.maxVersion || MAX_QR_VERSION;
192
+ const maxParts = options.maxParts || MAX_MULTIPART_PARTS;
193
+ const singlePayload = encodeReceiptPayload(record);
194
+ if (fitsVersion(singlePayload, maxVersion)) return [singlePayload];
195
+ if (!singlePayload.startsWith("cwr2.")) {
196
+ throw new Error("旧版小票数据超过单个二维码容量");
197
+ }
198
+
199
+ const chunkSize = maxChunkSize(singlePayload, maxVersion);
200
+ const chunks = [];
201
+ for (let offset = 0; offset < singlePayload.length; offset += chunkSize) {
202
+ chunks.push(singlePayload.slice(offset, offset + chunkSize));
203
+ }
204
+ if (chunks.length > maxParts) {
205
+ throw new Error(`小票数据需要 ${chunks.length} 个二维码,超过 ${maxParts} 个上限,请缩小统计范围`);
206
+ }
207
+
208
+ const transferId = checksum(singlePayload, 12);
209
+ const totalChecksum = checksum(singlePayload);
210
+ return chunks.map((chunk, index) => (
211
+ multipartPayload(transferId, index + 1, chunks.length, totalChecksum, chunk)
212
+ ));
213
+ }
214
+
215
+ function decodeSinglePayload(payload) {
216
+ const [prefix, expectedChecksum, encoded] = String(payload).split(".");
217
+ if ((prefix !== "cwr1" && prefix !== "cwr2") || !expectedChecksum || !encoded) {
218
+ throw new Error("无效的打工小票二维码");
219
+ }
73
220
  const compressed = Buffer.from(encoded, "base64url");
74
- const actual = crypto.createHash("sha256").update(compressed).digest("hex").slice(0, 8);
75
- if (actual !== checksum) throw new Error("二维码数据校验失败");
221
+ if (checksum(compressed) !== expectedChecksum) throw new Error("二维码数据校验失败");
76
222
  return JSON.parse(zlib.inflateRawSync(compressed).toString("utf8"));
77
223
  }
224
+
225
+ export function decodeReceiptPayload(payload) {
226
+ return decodeSinglePayload(payload);
227
+ }
228
+
229
+ export function decodeMultipartReceiptPayloads(payloads) {
230
+ const parts = payloads.map((payload) => {
231
+ const fields = String(payload).split(".");
232
+ const [prefix, transferId, indexText, countText, totalChecksum, partChecksum] = fields;
233
+ const chunk = fields.slice(6).join(".");
234
+ const index = Number(indexText);
235
+ const count = Number(countText);
236
+ if (
237
+ prefix !== "cwr2p" || !transferId || !totalChecksum || !chunk ||
238
+ !Number.isInteger(index) || !Number.isInteger(count) || index < 1 || index > count || count > MAX_MULTIPART_PARTS ||
239
+ checksum(chunk) !== partChecksum
240
+ ) throw new Error("无效的分片二维码");
241
+ return { transferId, index, count, totalChecksum, chunk };
242
+ });
243
+ if (!parts.length) throw new Error("缺少分片二维码");
244
+ const first = parts[0];
245
+ if (parts.some((part) => (
246
+ part.transferId !== first.transferId || part.count !== first.count || part.totalChecksum !== first.totalChecksum
247
+ ))) throw new Error("分片二维码不属于同一张小票");
248
+ const unique = new Map(parts.map((part) => [part.index, part]));
249
+ if (unique.size !== first.count) throw new Error("分片二维码尚未集齐");
250
+ const singlePayload = [...unique.values()].sort((left, right) => left.index - right.index).map((part) => part.chunk).join("");
251
+ if (checksum(singlePayload) !== first.totalChecksum) throw new Error("分片二维码总校验失败");
252
+ return decodeSinglePayload(singlePayload);
253
+ }
254
+
255
+ export function inspectReceiptPayloadPart(payload) {
256
+ const fields = String(payload).split(".");
257
+ const [prefix, transferId, indexText, countText, totalChecksum, partChecksum] = fields;
258
+ const chunk = fields.slice(6).join(".");
259
+ if (prefix !== "cwr2p") return null;
260
+ const index = Number(indexText);
261
+ const count = Number(countText);
262
+ if (!transferId || !chunk || checksum(chunk) !== partChecksum || !Number.isInteger(index) || !Number.isInteger(count)) {
263
+ throw new Error("无效的分片二维码");
264
+ }
265
+ return { transferId, index, count, totalChecksum };
266
+ }
@@ -3,28 +3,27 @@ import fs from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
 
6
+ import { canonicalStringify, sha256Hex } from "./canonical.mjs";
7
+ import { buildLogicalReceiptKey, buildProtocolReceiptId } from "./fact-identity.mjs";
6
8
  import {
7
9
  buildCompensation,
8
10
  DEFAULT_LOCALE,
9
11
  getWorkProfileCopy,
10
12
  } from "./presentation.mjs";
11
13
 
12
- const SCHEMA_VERSION = 1;
13
- const COLLECTOR_VERSION = "0.5.0";
14
+ const SCHEMA_VERSION = 2;
15
+ const SOURCE_VERSION = "cwr2";
16
+ const COLLECTOR_VERSION = "0.6.0";
14
17
 
15
18
  function fingerprintSessionIds(sessionIds) {
16
19
  return crypto.createHash("sha256").update([...sessionIds].sort().join("|")).digest("hex").slice(0, 16);
17
20
  }
18
21
 
19
- export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE) {
22
+ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = DEFAULT_LOCALE, canonical = {}) {
20
23
  const sessionFingerprint = fingerprintSessionIds(metrics.sessionIds);
21
- const logicalKey = metrics.mode === "latest" || metrics.mode === "session"
22
- ? `latest:${sessionFingerprint}`
23
- : metrics.mode === "this-week"
24
- ? `this-week:${metrics.rangeStartDate}:${metrics.timezone}`
25
- : `${metrics.mode}:${metrics.rangeStartDate}:${metrics.rangeEndDate}:${metrics.timezone}`;
26
- const id = `cwr_${crypto.createHash("sha256").update(logicalKey).digest("hex").slice(0, 16)}`;
27
- const snapshotHash = crypto.createHash("sha256").update(JSON.stringify({
24
+ const logicalKey = buildLogicalReceiptKey(metrics);
25
+ const id = buildProtocolReceiptId(SOURCE_VERSION, logicalKey);
26
+ const snapshotHash = sha256Hex({
28
27
  start: metrics.startAt.toISOString(),
29
28
  end: metrics.endAt.toISOString(),
30
29
  tokens: metrics.tokens,
@@ -34,9 +33,31 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
34
33
  scope: metrics.mode,
35
34
  rangeStartDate: metrics.rangeStartDate,
36
35
  rangeEndDate: metrics.rangeEndDate,
37
- })).digest("hex").slice(0, 16);
36
+ });
38
37
  const workProfileId = metrics.workProfileId || "temporary-hire";
39
38
  const workProfile = getWorkProfileCopy(workProfileId, locale);
39
+ const facts = Array.isArray(canonical.facts) ? canonical.facts : [];
40
+ const coverage = canonical.coverage || {
41
+ kind: "selected_sessions",
42
+ scan_mode: "none",
43
+ start_date: metrics.rangeStartDate,
44
+ end_date: metrics.rangeEndDate,
45
+ complete_through_date: null,
46
+ observed_through_at: new Date().toISOString(),
47
+ };
48
+ const manifestCore = {
49
+ version: 1,
50
+ fact_schema_version: 1,
51
+ metric_schema_version: 1,
52
+ accounting_timezone: "Asia/Shanghai",
53
+ fact_count: facts.length,
54
+ fact_ids: facts.map((fact) => fact.fact_id),
55
+ coverage,
56
+ };
57
+ const manifest = {
58
+ ...manifestCore,
59
+ manifest_hash: sha256Hex(canonicalStringify(manifestCore)),
60
+ };
40
61
 
41
62
  return {
42
63
  schema_version: SCHEMA_VERSION,
@@ -45,6 +66,7 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
45
66
  generated_at: new Date().toISOString(),
46
67
  source: {
47
68
  type: "codex",
69
+ version: SOURCE_VERSION,
48
70
  scope: metrics.mode,
49
71
  collector_version: COLLECTOR_VERSION,
50
72
  logical_key: logicalKey,
@@ -68,6 +90,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
68
90
  average_first_token_ms: Math.round(metrics.averageFirstTokenMs),
69
91
  tokens: { ...metrics.tokens },
70
92
  models: [...metrics.models],
93
+ receipt_work_points: metrics.workPoints,
94
+ receipt_formula_version: "receipt_work_points_v1",
71
95
  },
72
96
  presentation: {
73
97
  default_theme: defaultTheme,
@@ -83,6 +107,8 @@ export function buildReceiptRecord(metrics, defaultTheme = "classic", locale = D
83
107
  contains_paths: false,
84
108
  contains_filenames: false,
85
109
  },
110
+ manifest,
111
+ facts,
86
112
  };
87
113
  }
88
114
 
@@ -0,0 +1,17 @@
1
+ export function compareSourceRevision(candidate, current) {
2
+ if (!current) return "newer";
3
+ if (!candidate) return "conflict";
4
+
5
+ const candidateRows = Number(candidate.row_count ?? candidate.rowCount ?? 0);
6
+ const currentRows = Number(current.row_count ?? current.rowCount ?? 0);
7
+ const candidateBytes = Number(candidate.byte_length ?? candidate.byteLength ?? 0);
8
+ const currentBytes = Number(current.byte_length ?? current.byteLength ?? 0);
9
+ const candidateTail = String(candidate.tail_hash ?? candidate.tailHash ?? "");
10
+ const currentTail = String(current.tail_hash ?? current.tailHash ?? "");
11
+
12
+ if (candidateRows === currentRows && candidateBytes === currentBytes) {
13
+ return candidateTail === currentTail ? "same" : "conflict";
14
+ }
15
+ if (candidateRows >= currentRows && candidateBytes >= currentBytes) return "newer";
16
+ return "stale";
17
+ }
@@ -1,3 +1,4 @@
1
+ import crypto from "node:crypto";
1
2
  import fs from "node:fs";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
@@ -20,24 +21,33 @@ function readJsonl(filePath) {
20
21
  for (const [lineIndex, line] of source.split("\n").entries()) {
21
22
  if (!line.trim()) continue;
22
23
  try {
23
- rows.push(JSON.parse(line));
24
+ rows.push({ ...JSON.parse(line), __sourceLine: lineIndex + 1 });
24
25
  } catch {
25
26
  console.warn(`跳过无法解析的记录:${path.basename(filePath)}:${lineIndex + 1}`);
26
27
  }
27
28
  }
28
- return rows;
29
+ return { rows, source };
29
30
  }
30
31
 
31
32
  function sessionFromFile(file) {
32
- const rows = readJsonl(file.filePath);
33
+ const { rows, source } = readJsonl(file.filePath);
33
34
  const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
35
+ const metadataSessionId = meta.session_id || meta.id || null;
34
36
  const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
35
37
  const fallbackDate = new Date(file.modifiedAt);
38
+ const sourceBuffer = Buffer.from(source, "utf8");
36
39
  return {
37
40
  rows,
38
41
  filePath: file.filePath,
39
42
  modifiedAt: file.modifiedAt,
40
- sessionId: meta.session_id || meta.id || path.basename(file.filePath, ".jsonl"),
43
+ sessionId: metadataSessionId || path.basename(file.filePath, ".jsonl"),
44
+ identityQuality: metadataSessionId ? "metadata" : "filename_fallback",
45
+ sourceRevision: {
46
+ kind: "append-only-jsonl-v1",
47
+ row_count: rows.length,
48
+ byte_length: sourceBuffer.byteLength,
49
+ tail_hash: crypto.createHash("sha256").update(sourceBuffer.subarray(-4096)).digest("hex"),
50
+ },
41
51
  startAt: timestamps[0] || fallbackDate,
42
52
  endAt: timestamps.at(-1) || fallbackDate,
43
53
  };
@@ -62,6 +72,7 @@ function calendarCandidates(files, startDate) {
62
72
  export function loadCodexSessions(range) {
63
73
  const files = codexSessionFiles();
64
74
  let candidates = files;
75
+ let scanMode = "none";
65
76
 
66
77
  if (range.scope === "latest") {
67
78
  candidates = files.slice(0, 40);
@@ -69,19 +80,28 @@ export function loadCodexSessions(range) {
69
80
  const filenameMatches = files.filter((file) => path.basename(file.filePath).includes(range.sessionId));
70
81
  candidates = filenameMatches.length ? filenameMatches : files;
71
82
  } else {
72
- candidates = calendarCandidates(files, range.startDate);
83
+ const fullScan = process.env.CODEX_WORK_RECEIPT_FULL_SCAN === "1";
84
+ candidates = fullScan ? files : calendarCandidates(files, range.startDate);
85
+ scanMode = fullScan ? "full" : "best_effort";
73
86
  }
74
87
 
75
88
  const sessions = candidates
76
89
  .map(sessionFromFile)
77
90
  .sort((left, right) => right.endAt - left.endAt);
78
91
 
79
- if (range.scope === "latest") return sessions.slice(0, 1);
92
+ if (range.scope === "latest") {
93
+ const selected = sessions.slice(0, 1);
94
+ selected.scanMode = scanMode;
95
+ return selected;
96
+ }
80
97
  if (range.scope === "session") {
81
98
  const selected = sessions.find((session) => session.sessionId === range.sessionId);
82
99
  if (!selected) throw new Error(`没有找到指定的 Codex 会话:${range.sessionId}`);
83
- return [selected];
100
+ const result = [selected];
101
+ result.scanMode = scanMode;
102
+ return result;
84
103
  }
104
+ sessions.scanMode = scanMode;
85
105
  return sessions;
86
106
  }
87
107
 
@@ -36,7 +36,7 @@ function formatDateKey(value, locale) {
36
36
  return locale === "en" ? `${match[2]}/${match[3]}/${match[1]}` : `${match[1]}/${match[2]}/${match[3]}`;
37
37
  }
38
38
 
39
- export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = null }) {
39
+ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null, miniProgramCodeDataUrl = null }) {
40
40
  const locale = record.locale || DEFAULT_LOCALE;
41
41
  const copy = getReceiptCopy(locale);
42
42
  const startAt = new Date(record.period.start_at);
@@ -85,6 +85,17 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
85
85
  const miniProgramVisual = miniProgramCodeDataUrl
86
86
  ? `<img src="${miniProgramCodeDataUrl}" alt="${escapeHtml(copy.miniProgramAlt)}">`
87
87
  : `<div class="mini-placeholder" role="img" aria-label="${escapeHtml(copy.placeholderAria)}"><span>${escapeHtml(copy.placeholderLabel)}</span><strong>${escapeHtml(copy.placeholderValue)}</strong></div>`;
88
+ const qrUrls = Array.isArray(dataQrDataUrls) && dataQrDataUrls.length
89
+ ? dataQrDataUrls
90
+ : dataQrDataUrl
91
+ ? [dataQrDataUrl]
92
+ : [];
93
+ const dataQrItems = qrUrls.map((url, index) => `
94
+ <div class="qr-item">
95
+ <div class="qr-frame"><img src="${url}" alt="${escapeHtml(copy.dataQrAlt)} ${index + 1}/${qrUrls.length}"></div>
96
+ <strong>${escapeHtml(copy.importData)}${qrUrls.length > 1 ? ` ${index + 1}/${qrUrls.length}` : ""}</strong>
97
+ <span>${escapeHtml(copy.importDataHint)}</span>
98
+ </div>`).join("");
88
99
 
89
100
  return `<!doctype html>
90
101
  <html lang="${escapeHtml(copy.htmlLang)}" data-theme="${escapeHtml(record.presentation.default_theme)}">
@@ -443,11 +454,7 @@ export function renderHtml({ record, dataQrDataUrl, miniProgramCodeDataUrl = nul
443
454
  <strong>${escapeHtml(copy.openMiniProgram)}</strong>
444
455
  <span>${escapeHtml(copy.openMiniProgramHint)}</span>
445
456
  </div>
446
- <div class="qr-item">
447
- <div class="qr-frame"><img src="${dataQrDataUrl}" alt="${escapeHtml(copy.dataQrAlt)}"></div>
448
- <strong>${escapeHtml(copy.importData)}</strong>
449
- <span>${escapeHtml(copy.importDataHint)}</span>
450
- </div>
457
+ ${dataQrItems}
451
458
  </div>
452
459
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
453
460
  </section>