codex-work-receipt 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,10 +1,25 @@
1
1
  # Changelog
2
2
 
3
- ## 0.7.1 - 2026-07-19
3
+ ## 0.7.3
4
+
5
+ - Codex 会话日志改为分块逐行解析,避免超大 `.jsonl` 在转成单个 JavaScript 字符串时触发 V8 长度上限。
6
+ - 会话行解析后立即丢弃 Prompt、回复、图片和工具输出,只在内存中保留统计所需的时间、事件、模型与 Token 字段。
7
+ - 单行异常过大时带文件路径和行号跳过;文件读取失败时补充路径与体积,便于定位具体日志。
8
+ - 本地 `history.jsonl` 改为逐条写入,避免历史增长后形成同类的大字符串拼接风险。
9
+ - 小票生成或票仔安装成功后在终端展示开源仓库地址,并用对应的中英文文案邀请用户为项目点 Star。
10
+ - 修正 npm 包的 repository、homepage 和 issues 地址,使其统一指向 `a-bai-2026/codex-work-receipt`。
11
+
12
+ ## 0.7.2
13
+
14
+ - 修复同一 Codex 会话存在多份 append-only 日志修订时生成重复 canonical factId、导致小程序云端入库拒绝的问题。
15
+ - cwr2 manifest 输出前新增 factId 唯一性防线,避免无效二维码进入扫码链路。
16
+ - “最近 N 小时”在交互选择、终端输出和 HTML 中明确标记为私人滚动摘要,并提示其不参与 AI 供销社统计。
17
+
18
+ ## 0.7.1
4
19
 
5
20
  - 修正 HTML 长图导出:PNG 只保留完整小票和微信小程序码,不再包含数据二维码或分片数据码。
6
21
 
7
- ## 0.7.0 - 2026-07-19
22
+ ## 0.7.0
8
23
 
9
24
  - 新增 Codex 原生桌宠“票仔 · AI 小票工”,提供夜班绿票风格的核心状态与左右移动动画,并支持通过 `--install-pet`、`--uninstall-pet` 和 `--install-companion` 安装管理。
10
25
  - AI 打工小票 Skill 新增“票仔,开今天的票”等自然语言入口。
package/README.en.md CHANGED
@@ -54,6 +54,8 @@ Summarize the last 3 hours:
54
54
  npx codex-work-receipt@latest --hours 3 --lang en
55
55
  ```
56
56
 
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
+
57
59
  HTML and structured data are written to `./codex-work-receipt-output/` by default. The generated page can save a high-resolution PNG containing only the full receipt and WeChat mini-program code; data QR codes are excluded. Non-interactive options such as `--latest` and `--today` remain available; see the [CLI guide](docs/cli.en.md).
58
60
 
59
61
  ## Ask Codex directly
@@ -99,9 +101,9 @@ The desktop page generates one or more privacy-safe data QR codes. The companion
99
101
  ## Sponsor
100
102
 
101
103
  <p align="center">
102
- <a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
103
- <a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
104
- <sub>Helping independent developers access leading AI model APIs reliably and at a very low cost, so they can quickly build their own products with advanced model capabilities.</sub>
104
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
105
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL">modelflare.dev</a><br>
106
+ <sub>Affordable and reliable access to leading AI model APIs, helping independent developers validate ideas faster and turn them into real products.</sub>
105
107
  </p>
106
108
 
107
109
  ## License
package/README.md CHANGED
@@ -54,6 +54,8 @@ npx codex-work-receipt@latest --today
54
54
  npx codex-work-receipt@latest --hours 3
55
55
  ```
56
56
 
57
+ “最近 N 小时”是滚动摘要,只用于查看和保存私人历史,不参与 AI 供销社的去重统计。需要进入供销社时,请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。
58
+
57
59
  网页和结构数据默认保存在 `./codex-work-receipt-output/`。生成的网页支持一键保存只包含完整小票和微信小程序码的高清长图,数据二维码不会进入图片。也可以继续使用 `--latest`、`--today` 等非交互参数,详见 [CLI 使用文档](docs/cli.md)。
58
60
 
59
61
  ## 直接跟 Codex 说
@@ -99,9 +101,9 @@ Codex 会选择统计范围和主题、执行命令并打开小票。详见 [Cod
99
101
  ## 赞助商
100
102
 
101
103
  <p align="center">
102
- <a href="https://modelflare.dev/"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
103
- <a href="https://modelflare.dev/"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/">modelflare.dev</a><br>
104
- <sub>让个人开发者也可以以很低的成本、稳定地接入前沿 AI 模型的 API,借助模型能力快速构建自己的产品。</sub>
104
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><img src="docs/images/sponsors/modelflare-logo.png" alt="ModelFlare Logo" width="56"></a><br>
105
+ <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL"><strong>ModelFlare</strong></a> · <a href="https://modelflare.dev/sign-up?partner=OB9YXNSEEGOL">modelflare.dev</a><br>
106
+ <sub>为个人开发者提供价格友好、稳定便捷的前沿 AI 模型 API 接入,帮助你更快完成原型验证,把想法变成真正可用的产品。</sub>
105
107
  </p>
106
108
 
107
109
  ## License
package/docs/cli.en.md CHANGED
@@ -39,7 +39,7 @@ Summarize the last 3 hours:
39
39
  npx codex-work-receipt@latest --hours 3 --lang en
40
40
  ```
41
41
 
42
- `--hours` accepts an integer from 1 to 168 and filters exact timestamps, including windows that cross midnight. Rolling-hour receipts use the compatible cwr1 summary protocol and do not create cwr2 session-day canonical facts.
42
+ `--hours` accepts an integer from 1 to 168 and filters exact timestamps, including windows that cross midnight. Rolling-hour receipts use the compatible cwr1 summary protocol, stay in private history, and do not participate in AI Work Cooperative accounting. Use today, this week, the last seven days, or a specific session for cwr2 session-day canonical facts.
43
43
 
44
44
  Last 7 calendar days, including today:
45
45
 
package/docs/cli.md CHANGED
@@ -39,7 +39,7 @@ npx codex-work-receipt@latest --today
39
39
  npx codex-work-receipt@latest --hours 3
40
40
  ```
41
41
 
42
- `--hours` 支持 1~168 的整数,并按精确时间戳筛选,可以跨越午夜。滚动小时小票使用兼容的 cwr1 摘要协议,不会写入 cwr2 的“会话 × 自然日”规范事实。
42
+ `--hours` 支持 1~168 的整数,并按精确时间戳筛选,可以跨越午夜。滚动小时小票使用兼容的 cwr1 摘要协议,不会写入 cwr2 的“会话 × 自然日”规范事实,只保存到私人历史,不参与 AI 供销社统计。需要进入供销社时,请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。
43
43
 
44
44
  最近 7 个自然日(含今天):
45
45
 
package/docs/codex-pet.md CHANGED
@@ -30,6 +30,8 @@ npx codex-work-receipt@latest --install-companion
30
30
 
31
31
  “最近几个小时”默认表示最近 3 小时。
32
32
 
33
+ 最近 N 小时小票是私人滚动摘要,不进入 AI 供销社;需要参与供销社统计时,请让票仔生成今日、本周、近七日或指定会话小票。
34
+
33
35
  ## 只安装或卸载桌宠
34
36
 
35
37
  只安装票仔:
@@ -38,6 +38,8 @@ Skill 会安装到:
38
38
 
39
39
  Codex 会根据表达选择单次会话、最近 1~168 小时、今天、近七日或本周,以及语言和主题,然后执行命令并打开生成的网页。“最近几个小时”默认统计最近 3 小时。
40
40
 
41
+ “最近 N 小时”生成的是只用于私人历史的滚动摘要,不进入 AI 供销社;如果用户明确希望参与供销社统计,应改用今日、本周、近七日或指定会话范围。
42
+
41
43
  如果当前 Codex 任务仍在运行,而你希望立刻查看小票,建议新开一个本地 Codex 会话再说“开今天的票”;在当前运行会话中发送的消息可能根据 Follow-up behavior 被用于 Steer 或 Queue。
42
44
 
43
45
  也可以通过 `$ai-work-receipt` 显式调用这个 Skill。
@@ -32,7 +32,9 @@ cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk
32
32
 
33
33
  The mini program validates the prefix and checksum before decompressing and parsing the payload. Future schema versions use the compact `v` field for compatibility.
34
34
 
35
- Compact field `o` explicitly carries `latest`, `session`, `last-hours`, `today`, `last-7-days`, or `this-week`. `d[3]` and `d[4]` carry date boundaries. Today, last-seven-days, and this-week receipts continue to use cwr2 canonical facts. `last-hours` is a rolling window, so it uses the compatible cwr1 summary payload to avoid colliding with session-day fact identities. The updated mini program can still import older QR codes.
35
+ Compact field `o` explicitly carries `latest`, `session`, `last-hours`, `today`, `last-7-days`, or `this-week`. `d[3]` and `d[4]` carry date boundaries. Today, last-seven-days, and this-week receipts continue to use cwr2 canonical facts. `last-hours` is a rolling window, so it uses the compatible cwr1 summary payload to avoid colliding with session-day fact identities; it stays in private history and does not participate in AI Work Cooperative accounting. The updated mini program can still import older QR codes.
36
+
37
+ When the same Codex session has multiple append-only log revisions, the generator keeps the more complete revision. Fact IDs in a cwr2 manifest must be unique; an identity collision stops QR generation instead of emitting an invalid payload.
36
38
 
37
39
  `presentation.compensation` contains playful AI work points, not real API cost. For compatibility with the current Chinese mini program, QR display copy remains Chinese while compact fields `l` and `r` carry the desktop locale and language-neutral role ID. English HTML and local JSON remain fully localized.
38
40
 
@@ -32,7 +32,9 @@ cwr2p.<transferId>.<partIndex>.<partCount>.<totalChecksum>.<partChecksum>.<chunk
32
32
 
33
33
  小程序应检查前缀和校验值,再解压并解析数据。未来结构升级通过 `v` 字段兼容。
34
34
 
35
- 精简字段 `o` 显式携带 `latest`、`session`、`last-hours`、`today`、`last-7-days` 或 `this-week`;`d[3]` 和 `d[4]` 携带日期边界。今日、近 7 日和本周继续使用 cwr2 canonical facts。`last-hours` 是滚动时间窗,为避免与“会话 × 自然日”事实发生身份冲突,使用兼容的 cwr1 摘要载荷。旧二维码仍可由新版小程序导入。
35
+ 精简字段 `o` 显式携带 `latest`、`session`、`last-hours`、`today`、`last-7-days` 或 `this-week`;`d[3]` 和 `d[4]` 携带日期边界。今日、近 7 日和本周继续使用 cwr2 canonical facts。`last-hours` 是滚动时间窗,为避免与“会话 × 自然日”事实发生身份冲突,使用兼容的 cwr1 摘要载荷,只进入私人历史、不参与 AI 供销社统计。旧二维码仍可由新版小程序导入。
36
+
37
+ 同一 Codex 会话出现多份 append-only 日志修订时,生成器只采用更完整的修订;cwr2 manifest 中的 factId 必须唯一,检测到身份冲突时不会输出可扫码二维码。
36
38
 
37
39
  `presentation.compensation` 是娱乐化 AI 工分,不代表真实 API 费用。为兼容当前中文小程序,二维码展示文案继续使用中文,并通过精简字段 `l` 和 `r` 携带桌面语言及工种语义 ID;英文 HTML 和本地 JSON 不受影响。
38
40
 
@@ -10,6 +10,8 @@ AI Work Receipt reads Codex sessions, computes metrics, and renders receipts loc
10
10
  - Event types, timestamps, model names, and numerical metadata
11
11
  - Metrics such as turns, tool calls, Tokens, duration, and interruptions
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.
14
+
13
15
  ## What receipts exclude
14
16
 
15
17
  - Prompts and response text
package/docs/privacy.md CHANGED
@@ -10,6 +10,8 @@ AI 打工小票在本机读取 Codex 会话,并在本机完成统计和渲染
10
10
  - 提取事件类型、时间、模型名称和数字统计
11
11
  - 计算轮次、工具调用、Token、时长和中断次数等指标
12
12
 
13
+ 会话日志会在本机分块逐行解析。每行解析后只保留统计所需字段,Prompt、回复、图片和工具输出不会保留在内存统计对象中。
14
+
13
15
  ## 不会写入小票什么
14
16
 
15
17
  - Prompt 和回复正文
package/package.json CHANGED
@@ -1,16 +1,16 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "description": "Generate a privacy-first Codex work receipt from local session metadata.",
6
6
  "author": "a-bai-2026",
7
7
  "repository": {
8
8
  "type": "git",
9
- "url": "git+https://github.com/a-bai-2026/ai-work-receipt.git"
9
+ "url": "git+https://github.com/a-bai-2026/codex-work-receipt.git"
10
10
  },
11
- "homepage": "https://github.com/a-bai-2026/ai-work-receipt#readme",
11
+ "homepage": "https://github.com/a-bai-2026/codex-work-receipt#readme",
12
12
  "bugs": {
13
- "url": "https://github.com/a-bai-2026/ai-work-receipt/issues"
13
+ "url": "https://github.com/a-bai-2026/codex-work-receipt/issues"
14
14
  },
15
15
  "license": "GPL-3.0-only",
16
16
  "keywords": [
@@ -13,6 +13,7 @@ Use the published `codex-work-receipt` CLI as the only statistics and rendering
13
13
  - Use `--hours <N>` for “最近 N 小时”“过去 N 小时” or “近 N 小时”. Accept integers from 1 to 168.
14
14
  - Use `--hours 3` for an unspecified “最近几个小时”“过去几个小时” request.
15
15
  - Use `--hours 12` for “最近半天”.
16
+ - Treat `--hours` receipts as rolling summaries for private history only. They do not participate in AI Work Cooperative accounting; if the user asks for an accountable cooperative receipt, use today, this week, the last seven days, or a specific session instead.
16
17
  - Use `--today` for “今天”“今日”“今天全部工作” or a daily summary.
17
18
  - Use `--range last-7-days` for “最近七天”“近 7 日” or a rolling seven-calendar-day summary.
18
19
  - Use `--range this-week` for “本周”“这周” or a Monday-to-now summary.
@@ -39,6 +40,7 @@ Replace only the mode and optional flags according to the request.
39
40
 
40
41
  3. Let the CLI open the generated HTML unless the user requested `--no-open`.
41
42
  4. On success, report the HTML path and tell the user to scan the official mini-program code first, then the adjacent data QR code.
43
+ 5. When `--hours` was used, explicitly state that the receipt is a private rolling summary and will not enter AI Work Cooperative statistics.
42
44
 
43
45
  ## Privacy and failures
44
46
 
package/src/cli.mjs CHANGED
@@ -9,7 +9,8 @@ import QRCode from "qrcode";
9
9
  import { parseArgs, printHelp } from "./core/args.mjs";
10
10
  import { buildCanonicalFacts } from "./core/fact-buckets.mjs";
11
11
  import { collectMetrics } from "./core/metrics.mjs";
12
- import { getScopeLabel } from "./core/presentation.mjs";
12
+ import { printOpenSourcePrompt } from "./core/open-source.mjs";
13
+ import { getRollingSummaryNotice, getScopeLabel } from "./core/presentation.mjs";
13
14
  import { encodeReceiptPayloads } from "./core/qr-payload.mjs";
14
15
  import { outputSlugForRange, resolveRange } from "./core/range.mjs";
15
16
  import { buildReceiptRecord, persistReceiptRecord } from "./core/receipt-record.mjs";
@@ -81,6 +82,7 @@ async function main() {
81
82
  console.log("请重启 Codex,打开 Settings > Pets,点击 Refresh 后选择“票仔 · AI 小票工”。");
82
83
  console.log("输入 /pet 唤醒票仔;以后可以说:票仔,开今天的票。");
83
84
  }
85
+ printOpenSourcePrompt("pet", options.locale);
84
86
  return;
85
87
  }
86
88
  if (options.uninstallPet) {
@@ -145,6 +147,9 @@ async function main() {
145
147
  console.log(record.manifest
146
148
  ? `Data QR: ${qrPayloads.length} code(s) · ${record.manifest.fact_count} canonical fact(s) · schema v${record.schema_version}`
147
149
  : `Data QR: ${qrPayloads.length} code(s) · rolling summary · schema v${record.schema_version}`);
150
+ if (record.source.scope === "last-hours") {
151
+ console.log(`Note: ${getRollingSummaryNotice(options.locale, record.source.hours)}`);
152
+ }
148
153
  if (!miniProgramCodeDataUrl) console.log("Mini-program code: not configured; using the explicit placeholder");
149
154
  } else {
150
155
  console.log(`已生成网页:${outputFile}`);
@@ -155,8 +160,12 @@ async function main() {
155
160
  console.log(record.manifest
156
161
  ? `数据二维码:${qrPayloads.length} 个 · ${record.manifest.fact_count} 条规范事实 · schema v${record.schema_version}`
157
162
  : `数据二维码:${qrPayloads.length} 个 · 滚动摘要 · schema v${record.schema_version}`);
163
+ if (record.source.scope === "last-hours") {
164
+ console.log(`提示:${getRollingSummaryNotice(options.locale, record.source.hours)}`);
165
+ }
158
166
  if (!miniProgramCodeDataUrl) console.log("小程序码:尚未配置,页面使用明确占位符");
159
167
  }
168
+ printOpenSourcePrompt("receipt", options.locale);
160
169
  if (options.open) openFile(outputFile);
161
170
  }
162
171
 
@@ -198,6 +198,11 @@ export function buildCanonicalFacts(sessions, range, options = {}) {
198
198
  left.local_date.localeCompare(right.local_date) || left.fact_id.localeCompare(right.fact_id)
199
199
  ));
200
200
 
201
+ const uniqueFactIds = new Set(facts.map((fact) => fact.fact_id));
202
+ if (uniqueFactIds.size !== facts.length) {
203
+ throw new Error("检测到重复的规范事实,请更新生成器后重新生成小票");
204
+ }
205
+
201
206
  return {
202
207
  facts,
203
208
  coverage: coverageForRange(range, sessions.scanMode || options.scanMode || "best_effort", observedAt),
@@ -0,0 +1,43 @@
1
+ export const OPEN_SOURCE_REPOSITORY_URL = "https://github.com/a-bai-2026/codex-work-receipt";
2
+
3
+ const PROMPTS = {
4
+ "zh-CN": {
5
+ receipt: {
6
+ label: "开源项目",
7
+ message: "如果你也喜欢这个 AI 小票工具,欢迎来 GitHub 给我点个 Star ⭐",
8
+ },
9
+ pet: {
10
+ label: "票仔开源地址",
11
+ message: "如果你也喜欢票仔,欢迎来 GitHub 给我点个 Star ⭐",
12
+ },
13
+ },
14
+ en: {
15
+ receipt: {
16
+ label: "Open-source project",
17
+ message: "If you enjoy AI Work Receipt, a GitHub Star would mean a lot ⭐",
18
+ },
19
+ pet: {
20
+ label: "Ticket Buddy source",
21
+ message: "If you enjoy Ticket Buddy, a GitHub Star would mean a lot ⭐",
22
+ },
23
+ },
24
+ };
25
+
26
+ export function getOpenSourcePrompt(kind = "receipt", locale = "zh-CN") {
27
+ const normalizedLocale = locale === "en" ? "en" : "zh-CN";
28
+ const localized = PROMPTS[normalizedLocale];
29
+ const copy = localized[kind] || localized.receipt;
30
+ return {
31
+ url: OPEN_SOURCE_REPOSITORY_URL,
32
+ label: copy.label,
33
+ message: copy.message,
34
+ separator: normalizedLocale === "en" ? ": " : ":",
35
+ };
36
+ }
37
+
38
+ export function printOpenSourcePrompt(kind, locale, writeLine = console.log) {
39
+ const prompt = getOpenSourcePrompt(kind, locale);
40
+ writeLine("");
41
+ writeLine(`${prompt.label}${prompt.separator}${prompt.url}`);
42
+ writeLine(prompt.message);
43
+ }
@@ -147,6 +147,7 @@ const RECEIPT_COPY = {
147
147
  placeholderValue: "待接入",
148
148
  placeholderAria: "小程序码待接入",
149
149
  transferNote: "数据码只包含时间、轮次、Token和工具调用等统计,不包含Prompt、回复正文、代码、项目路径或文件名。",
150
+ rollingSummaryNotice: "最近 {hours} 小时属于滚动摘要,只保存到私人历史,不参与 AI 供销社统计。需要统计时请生成“今日 / 本周 / 近 7 日 / 指定会话”小票。",
150
151
  privacy: "结构数据同时保存在本机,未来可以批量导入小程序历史记录。",
151
152
  },
152
153
  en: {
@@ -221,6 +222,7 @@ const RECEIPT_COPY = {
221
222
  placeholderValue: "Pending",
222
223
  placeholderAria: "Mini-program code pending",
223
224
  transferNote: "The data code contains only statistics such as time, turns, Tokens, and tool calls. It does not contain prompts, responses, code, project paths, or file names.",
225
+ rollingSummaryNotice: "The last {hours} hours is a rolling summary for private history only. It does not participate in AI Work Cooperative accounting. Use today, this week, the last seven days, or a specific session for accountable facts.",
224
226
  privacy: "Structured data is also stored locally for future batch import into your mini-program history.",
225
227
  },
226
228
  };
@@ -273,6 +275,10 @@ export function getScopeLabel(scope, locale = DEFAULT_LOCALE, hours = null) {
273
275
  return String(template).replaceAll("{hours}", String(hours || 3));
274
276
  }
275
277
 
278
+ export function getRollingSummaryNotice(locale = DEFAULT_LOCALE, hours = null) {
279
+ return String(getReceiptCopy(locale).rollingSummaryNotice).replaceAll("{hours}", String(hours || 3));
280
+ }
281
+
276
282
  export function buildCompensation(scope, amount, locale = DEFAULT_LOCALE) {
277
283
  const copy = COMPENSATION_COPY[SUPPORTED_LOCALES.has(locale) ? locale : DEFAULT_LOCALE];
278
284
  return {
@@ -155,11 +155,13 @@ export function persistReceiptRecord(record, outputHtmlPath, requestedDataDir =
155
155
  }
156
156
  const history = [...deduplicated.values()]
157
157
  .sort((left, right) => String(left.period?.end_at).localeCompare(String(right.period?.end_at)));
158
- fs.writeFileSync(
159
- path.join(dataDir, "history.jsonl"),
160
- history.length ? `${history.map((item) => JSON.stringify(item)).join("\n")}\n` : "",
161
- "utf8",
162
- );
158
+ const historyPath = path.join(dataDir, "history.jsonl");
159
+ const historyDescriptor = fs.openSync(historyPath, "w");
160
+ try {
161
+ for (const item of history) fs.writeSync(historyDescriptor, `${JSON.stringify(item)}\n`, null, "utf8");
162
+ } finally {
163
+ fs.closeSync(historyDescriptor);
164
+ }
163
165
 
164
166
  const companionPath = /\.html?$/i.test(outputHtmlPath)
165
167
  ? outputHtmlPath.replace(/\.html?$/i, ".json")
@@ -37,7 +37,9 @@ export async function promptForRange({ locale, timezone, loadRecentSessions }) {
37
37
  try {
38
38
  console.log(isEnglish ? "\nChoose a receipt range:\n" : "\n请选择小票统计范围:\n");
39
39
  console.log(isEnglish ? "1. All activity today (recommended)" : "1. 今天全部活动(推荐)");
40
- console.log(isEnglish ? "2. Last 3 hours" : "2. 最近 3 小时");
40
+ console.log(isEnglish
41
+ ? "2. Last 3 hours (private rolling summary; not counted by the cooperative)"
42
+ : "2. 最近 3 小时(私人滚动摘要,不计入供销社)");
41
43
  console.log(isEnglish ? "3. Last 7 calendar days" : "3. 最近 7 个自然日");
42
44
  console.log(isEnglish ? "4. This week (Monday to now)" : "4. 本周(周一至今)");
43
45
  console.log(isEnglish ? "5. Choose a specific session" : "5. 选择一个具体会话");
@@ -5,6 +5,17 @@ import path from "node:path";
5
5
 
6
6
  import { rowDate } from "../lib/time.mjs";
7
7
 
8
+ const READ_CHUNK_BYTES = 256 * 1024;
9
+ const MAX_JSONL_ROW_BYTES = 64 * 1024 * 1024;
10
+ const TAIL_HASH_BYTES = 4096;
11
+ const TOKEN_KEYS = [
12
+ "input_tokens",
13
+ "cached_input_tokens",
14
+ "output_tokens",
15
+ "reasoning_output_tokens",
16
+ "total_tokens",
17
+ ];
18
+
8
19
  function walkJsonlFiles(directory, accumulator = []) {
9
20
  if (!fs.existsSync(directory)) return accumulator;
10
21
  for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
@@ -15,27 +26,183 @@ function walkJsonlFiles(directory, accumulator = []) {
15
26
  return accumulator;
16
27
  }
17
28
 
18
- function readJsonl(filePath) {
29
+ function shortString(value, maximumLength = 512) {
30
+ return typeof value === "string" && value.length <= maximumLength ? value : null;
31
+ }
32
+
33
+ function finiteNumber(value) {
34
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
35
+ }
36
+
37
+ function compactTokenUsage(value) {
38
+ if (!value || typeof value !== "object") return null;
39
+ const usage = {};
40
+ for (const key of TOKEN_KEYS) {
41
+ const amount = finiteNumber(value[key]);
42
+ if (amount !== null) usage[key] = amount;
43
+ }
44
+ return Object.keys(usage).length ? usage : null;
45
+ }
46
+
47
+ function compactPayload(rowType, value) {
48
+ if (!value || typeof value !== "object") return null;
49
+ const payload = {};
50
+ for (const key of ["completed_at", "started_at", "timestamp"]) {
51
+ const timestamp = shortString(value[key], 128);
52
+ if (timestamp !== null) payload[key] = timestamp;
53
+ }
54
+
55
+ const payloadType = shortString(value.type, 128);
56
+ if (payloadType !== null) payload.type = payloadType;
57
+
58
+ if (rowType === "session_meta") {
59
+ const sessionId = shortString(value.session_id);
60
+ const fallbackId = shortString(value.id);
61
+ if (sessionId !== null) payload.session_id = sessionId;
62
+ if (fallbackId !== null) payload.id = fallbackId;
63
+ } else if (rowType === "turn_context") {
64
+ const model = shortString(value.model);
65
+ if (model !== null) payload.model = model;
66
+ } else if (rowType === "event_msg") {
67
+ const durationMs = finiteNumber(value.duration_ms);
68
+ const firstTokenMs = finiteNumber(value.time_to_first_token_ms);
69
+ if (durationMs !== null) payload.duration_ms = durationMs;
70
+ if (firstTokenMs !== null) payload.time_to_first_token_ms = firstTokenMs;
71
+ const totalTokenUsage = compactTokenUsage(value.info?.total_token_usage);
72
+ if (totalTokenUsage) payload.info = { total_token_usage: totalTokenUsage };
73
+ }
74
+
75
+ return Object.keys(payload).length ? payload : null;
76
+ }
77
+
78
+ function compactRow(value, sourceLine) {
79
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
80
+ const row = { __sourceLine: sourceLine };
81
+ const timestamp = shortString(value.timestamp, 128);
82
+ const type = shortString(value.type, 128);
83
+ if (timestamp !== null) row.timestamp = timestamp;
84
+ if (type !== null) row.type = type;
85
+ const payload = compactPayload(type, value.payload);
86
+ if (payload) row.payload = payload;
87
+ return row;
88
+ }
89
+
90
+ function formatByteSize(value) {
91
+ const bytes = Math.max(0, Number(value || 0));
92
+ if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MiB`;
93
+ if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KiB`;
94
+ return `${bytes} B`;
95
+ }
96
+
97
+ function appendTail(current, chunk) {
98
+ if (chunk.length >= TAIL_HASH_BYTES) return Buffer.from(chunk.subarray(chunk.length - TAIL_HASH_BYTES));
99
+ const combined = Buffer.concat([current, chunk]);
100
+ return combined.length > TAIL_HASH_BYTES
101
+ ? Buffer.from(combined.subarray(combined.length - TAIL_HASH_BYTES))
102
+ : combined;
103
+ }
104
+
105
+ function sessionReadError(file, error) {
106
+ return new Error(
107
+ `无法读取 Codex 会话:${file.filePath}(${formatByteSize(file.size)}):${error.message}`,
108
+ { cause: error },
109
+ );
110
+ }
111
+
112
+ function readJsonl(file) {
19
113
  const rows = [];
20
- const source = fs.readFileSync(filePath, "utf8");
21
- for (const [lineIndex, line] of source.split("\n").entries()) {
22
- if (!line.trim()) continue;
23
- try {
24
- rows.push({ ...JSON.parse(line), __sourceLine: lineIndex + 1 });
25
- } catch {
26
- console.warn(`跳过无法解析的记录:${path.basename(filePath)}:${lineIndex + 1}`);
114
+ const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
115
+ const lineParts = [];
116
+ let lineBytes = 0;
117
+ let lineIndex = 0;
118
+ let offset = 0;
119
+ let tail = Buffer.alloc(0);
120
+ let skippingOversizedLine = false;
121
+ let fileDescriptor = null;
122
+
123
+ const resetLine = () => {
124
+ lineParts.length = 0;
125
+ lineBytes = 0;
126
+ skippingOversizedLine = false;
127
+ };
128
+
129
+ const appendLinePart = (part) => {
130
+ lineBytes += part.length;
131
+ if (skippingOversizedLine) return;
132
+ if (lineBytes > MAX_JSONL_ROW_BYTES) {
133
+ skippingOversizedLine = true;
134
+ lineParts.length = 0;
135
+ return;
136
+ }
137
+ if (part.length) lineParts.push(Buffer.from(part));
138
+ };
139
+
140
+ const finishLine = (finalPart = Buffer.alloc(0)) => {
141
+ lineIndex += 1;
142
+ const totalBytes = lineBytes + finalPart.length;
143
+ if (skippingOversizedLine || totalBytes > MAX_JSONL_ROW_BYTES) {
144
+ console.warn(
145
+ `跳过过大的 Codex 记录:${file.filePath}:${lineIndex}(${formatByteSize(totalBytes)})`,
146
+ );
147
+ resetLine();
148
+ return;
149
+ }
150
+
151
+ const lineBuffer = lineParts.length
152
+ ? Buffer.concat([...lineParts, finalPart], totalBytes)
153
+ : finalPart;
154
+ const line = lineBuffer.toString("utf8").replace(/\r$/, "");
155
+ if (line.trim()) {
156
+ try {
157
+ const compact = compactRow(JSON.parse(line), lineIndex);
158
+ if (compact) rows.push(compact);
159
+ } catch {
160
+ console.warn(`跳过无法解析的记录:${file.filePath}:${lineIndex}`);
161
+ }
27
162
  }
163
+ resetLine();
164
+ };
165
+
166
+ try {
167
+ fileDescriptor = fs.openSync(file.filePath, "r");
168
+ const snapshotBytes = Math.max(0, Number(file.size || 0));
169
+ while (offset < snapshotBytes) {
170
+ const requestedBytes = Math.min(buffer.length, snapshotBytes - offset);
171
+ const bytesRead = fs.readSync(fileDescriptor, buffer, 0, requestedBytes, offset);
172
+ if (!bytesRead) break;
173
+ const chunk = buffer.subarray(0, bytesRead);
174
+ tail = appendTail(tail, chunk);
175
+ offset += bytesRead;
176
+
177
+ let start = 0;
178
+ let newlineIndex = chunk.indexOf(10, start);
179
+ while (newlineIndex !== -1) {
180
+ finishLine(chunk.subarray(start, newlineIndex));
181
+ start = newlineIndex + 1;
182
+ newlineIndex = chunk.indexOf(10, start);
183
+ }
184
+ appendLinePart(chunk.subarray(start));
185
+ }
186
+ if (lineBytes || skippingOversizedLine) finishLine();
187
+ } catch (error) {
188
+ throw sessionReadError(file, error);
189
+ } finally {
190
+ if (fileDescriptor !== null) fs.closeSync(fileDescriptor);
28
191
  }
29
- return { rows, source };
192
+
193
+ return {
194
+ rows,
195
+ byteLength: offset,
196
+ tailHash: crypto.createHash("sha256").update(tail).digest("hex"),
197
+ };
30
198
  }
31
199
 
32
200
  function sessionFromFile(file) {
33
- const { rows, source } = readJsonl(file.filePath);
201
+ const { rows, byteLength, tailHash } = readJsonl(file);
34
202
  const meta = rows.find((row) => row.type === "session_meta")?.payload || {};
35
203
  const metadataSessionId = meta.session_id || meta.id || null;
36
204
  const timestamps = rows.map(rowDate).filter(Boolean).sort((left, right) => left - right);
37
205
  const fallbackDate = new Date(file.modifiedAt);
38
- const sourceBuffer = Buffer.from(source, "utf8");
39
206
  return {
40
207
  rows,
41
208
  filePath: file.filePath,
@@ -45,8 +212,8 @@ function sessionFromFile(file) {
45
212
  sourceRevision: {
46
213
  kind: "append-only-jsonl-v1",
47
214
  row_count: rows.length,
48
- byte_length: sourceBuffer.byteLength,
49
- tail_hash: crypto.createHash("sha256").update(sourceBuffer.subarray(-4096)).digest("hex"),
215
+ byte_length: byteLength,
216
+ tail_hash: tailHash,
50
217
  },
51
218
  startAt: timestamps[0] || fallbackDate,
52
219
  endAt: timestamps.at(-1) || fallbackDate,
@@ -57,7 +224,14 @@ function codexSessionFiles() {
57
224
  const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), ".codex");
58
225
  const sessionsDirectory = path.join(codexHome, "sessions");
59
226
  const files = walkJsonlFiles(sessionsDirectory)
60
- .map((filePath) => ({ filePath, modifiedAt: fs.statSync(filePath).mtimeMs }))
227
+ .map((filePath) => {
228
+ try {
229
+ const stats = fs.statSync(filePath);
230
+ return { filePath, modifiedAt: stats.mtimeMs, size: stats.size };
231
+ } catch (error) {
232
+ throw sessionReadError({ filePath, size: 0 }, error);
233
+ }
234
+ })
61
235
  .sort((left, right) => right.modifiedAt - left.modifiedAt);
62
236
  if (!files.length) throw new Error(`没有在 ${sessionsDirectory} 找到 Codex 会话记录`);
63
237
  return files;
@@ -69,6 +243,38 @@ function calendarCandidates(files, startDate) {
69
243
  return files.filter((file) => file.modifiedAt >= approximateStart);
70
244
  }
71
245
 
246
+ function revisionDominates(candidate, current) {
247
+ const candidateRevision = candidate.sourceRevision || {};
248
+ const currentRevision = current.sourceRevision || {};
249
+ const candidateRows = Number(candidateRevision.row_count || 0);
250
+ const currentRows = Number(currentRevision.row_count || 0);
251
+ const candidateBytes = Number(candidateRevision.byte_length || 0);
252
+ const currentBytes = Number(currentRevision.byte_length || 0);
253
+ return candidateRows >= currentRows && candidateBytes >= currentBytes
254
+ && (candidateRows > currentRows || candidateBytes > currentBytes);
255
+ }
256
+
257
+ function preferSession(candidate, current) {
258
+ if (revisionDominates(candidate, current)) return true;
259
+ if (revisionDominates(current, candidate)) return false;
260
+
261
+ const candidateModifiedAt = Number(candidate.modifiedAt || candidate.endAt || 0);
262
+ const currentModifiedAt = Number(current.modifiedAt || current.endAt || 0);
263
+ if (candidateModifiedAt !== currentModifiedAt) return candidateModifiedAt > currentModifiedAt;
264
+
265
+ return String(candidate.filePath || "").localeCompare(String(current.filePath || "")) > 0;
266
+ }
267
+
268
+ export function deduplicateCodexSessions(sessions) {
269
+ const selected = new Map();
270
+ for (const session of sessions) {
271
+ const key = `${session.identityQuality || "metadata"}:${session.sessionId}`;
272
+ const current = selected.get(key);
273
+ if (!current || preferSession(session, current)) selected.set(key, session);
274
+ }
275
+ return [...selected.values()];
276
+ }
277
+
72
278
  export function loadCodexSessions(range) {
73
279
  const files = codexSessionFiles();
74
280
  let candidates = files;
@@ -85,8 +291,7 @@ export function loadCodexSessions(range) {
85
291
  scanMode = fullScan ? "full" : "best_effort";
86
292
  }
87
293
 
88
- const sessions = candidates
89
- .map(sessionFromFile)
294
+ const sessions = deduplicateCodexSessions(candidates.map(sessionFromFile))
90
295
  .sort((left, right) => right.endAt - left.endAt);
91
296
 
92
297
  if (range.scope === "latest") {
@@ -106,10 +311,10 @@ export function loadCodexSessions(range) {
106
311
  }
107
312
 
108
313
  export function listRecentCodexSessions(limit = 10) {
109
- const sessions = codexSessionFiles()
314
+ const sessions = deduplicateCodexSessions(codexSessionFiles()
110
315
  .slice(0, Math.max(40, limit * 3))
111
316
  .map(sessionFromFile)
112
- .filter((session) => session.rows.length)
317
+ .filter((session) => session.rows.length))
113
318
  .sort((left, right) => right.endAt - left.endAt)
114
319
  .slice(0, limit);
115
320
 
@@ -7,6 +7,7 @@ import {
7
7
  buildCompensation,
8
8
  DEFAULT_LOCALE,
9
9
  getReceiptCopy,
10
+ getRollingSummaryNotice,
10
11
  getScopeLabel,
11
12
  } from "../core/presentation.mjs";
12
13
 
@@ -71,6 +72,9 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
71
72
  const isCalendarScope = new Set(["today", "last-7-days", "this-week"]).has(record.source.scope);
72
73
  const spansMultipleDates = rangeStartDate !== rangeEndDate;
73
74
  const scopeLabel = getScopeLabel(record.source.scope, locale, record.source.hours);
75
+ const rollingSummaryNotice = record.source.scope === "last-hours"
76
+ ? getRollingSummaryNotice(locale, record.source.hours)
77
+ : null;
74
78
  const dateRange = spansMultipleDates
75
79
  ? `${formatDateKey(rangeStartDate, locale)}—${formatDateKey(rangeEndDate, locale)}`
76
80
  : formatDateKey(rangeEndDate, locale);
@@ -493,6 +497,13 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
493
497
  line-height: 1.65;
494
498
  text-align: center;
495
499
  }
500
+ .transfer-note--warning {
501
+ padding: 12px 14px;
502
+ border: 1px solid color-mix(in srgb, var(--ink) 28%, transparent);
503
+ background: color-mix(in srgb, var(--accent) 76%, var(--paper));
504
+ color: var(--ink);
505
+ font-weight: 700;
506
+ }
496
507
  .privacy {
497
508
  margin: 24px auto 0;
498
509
  max-width: 440px;
@@ -569,6 +580,7 @@ export function renderHtml({ record, dataQrDataUrl = null, dataQrDataUrls = null
569
580
  <p>${escapeHtml(copy.transferDescription)}</p>
570
581
  </header>
571
582
  ${transferVisual}
583
+ ${rollingSummaryNotice ? `<p class="transfer-note transfer-note--warning">${escapeHtml(rollingSummaryNotice)}</p>` : ""}
572
584
  <p class="transfer-note">${escapeHtml(copy.transferNote)}</p>
573
585
  </section>
574
586
  </div>