codex-work-receipt 0.7.4 → 0.8.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.
@@ -0,0 +1,67 @@
1
+ # 小程序文件导入实现
2
+
3
+ <p><strong>中文</strong> · <a href="./miniprogram-file-import.en.md">English</a> · <a href="./mobile-import.md">返回手机导入</a></p>
4
+
5
+ 配套小程序源码不在本仓库中。本页定义 `cwr-file-v1` 的移动端接入边界;兼容夹具位于 `docs/fixtures/cwr-file-v1.json`。
6
+
7
+ ## 选择与读取
8
+
9
+ ```js
10
+ const MAX_IMPORT_BYTES = 2 * 1024 * 1024;
11
+
12
+ wx.chooseMessageFile({
13
+ count: 1,
14
+ type: "file",
15
+ extension: ["json", "cwr"],
16
+ success({ tempFiles }) {
17
+ const file = tempFiles[0];
18
+ if (!file || file.size > MAX_IMPORT_BYTES) return showFileTooLarge();
19
+ wx.getFileSystemManager().readFile({
20
+ filePath: file.path,
21
+ encoding: "utf8",
22
+ success: ({ data }) => previewReceiptFile(data),
23
+ fail: showReadError,
24
+ });
25
+ },
26
+ });
27
+ ```
28
+
29
+ 用户取消选择不应显示错误。扩展名只用于过滤,不能代替内容校验。官方接口参考:[`wx.chooseMessageFile`](https://developers.weixin.qq.com/miniprogram/dev/api/media/image/wx.chooseMessageFile.html) 和 [`FileSystemManager.readFile`](https://developers.weixin.qq.com/miniprogram/dev/api/file/FileSystemManager.readFile.html)。
30
+
31
+ ## 校验顺序
32
+
33
+ 1. UTF-8 JSON 能够解析,顶层是普通对象。
34
+ 2. `format === "codex-work-receipt"`。
35
+ 3. `file_version === 1`;更高版本提示升级小程序。
36
+ 4. `payload_schema` 为 `cwr1` 或 `cwr2`,并与 `payload.v` 一致。
37
+ 5. 对规范化 payload JSON 计算 SHA-256,与 `integrity.digest` 比较。
38
+ 6. 严格校验 compact payload 的字段类型、数组长度、日期、时区和非负数值范围。
39
+ 7. cwr2 校验 `fact_count === facts.length`、factId 唯一性、manifest hash 和每个 content hash。
40
+ 8. 禁止把不可信对象直接合并到应用状态或对象原型。
41
+
42
+ SHA-256 只校验文件是否被损坏,不代表官方签名。公开统计必须继续把客户端数据视为不可信数据。
43
+
44
+ 规范化必须与桌面端一致:递归按 JavaScript 默认字符串排序(UTF-16 code unit 顺序)排列对象键,保持数组顺序,用无空白 JSON 编码为 UTF-8 后计算 SHA-256。请先用 `docs/fixtures/cwr-file-v1.json` 验证实现得到相同 digest。
45
+
46
+ ## 预览与入库
47
+
48
+ 文件和二维码解码后都应得到同一种 compact payload,并进入同一个业务管线:
49
+
50
+ ```text
51
+ validate → preview → confirm → deduplicate → persist
52
+ ```
53
+
54
+ 确认页至少展示:
55
+
56
+ - `payload.o` 与 `payload.d` 对应的统计范围
57
+ - `payload.s[0]` 会话数
58
+ - cwr2 的 `payload.a[4]` canonical fact 数量;cwr1 标记为滚动摘要
59
+ - 可以预计算时展示新增、更新、已存在数量
60
+
61
+ 用户确认前不得写入数据库。入库必须原子化;任一 fact 失败时整次导入失败,不留下部分记录。
62
+
63
+ ## 二维码兼容
64
+
65
+ 新版桌面端只生成完整的单个 `cwr1` 或 `cwr2` 数据码。小程序仍需保留 `cwr2p` 收集和重组能力,以导入旧版本已经生成的分片二维码。
66
+
67
+ 建议覆盖 iOS、Android、Windows 微信和 macOS 微信的文件传输助手流程,并测试取消、超大文件、损坏 JSON、错误 digest、未来版本、重复导入、重叠范围和 append-only 更新。
@@ -1,21 +1,29 @@
1
- # Mobile QR import
1
+ # Mobile file and single-QR import
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 validates the protocol and privacy-safe fields on the phone, then saves the private receipt to the user's anonymous account database:
5
+ The desktop page always provides one `.cwr.json` WeChat import file. The fixed mini-program code only opens the companion app. A data QR appears only when the complete receipt safely fits in one code.
6
6
 
7
7
  1. Scan the fixed mini-program code in WeChat and open the companion mini program.
8
- 2. Tap the desktop-import action.
9
- 3. Scan the adjacent data QR code. Multipart receipts rotate one part at a time on the computer, while the mini program automatically reopens scanning until all parts are collected.
10
- 4. Validate the `cwr1`, `cwr2`, or multipart `cwr2p` prefix, checksum, and schema version.
11
- 5. Single codes decompress immediately. Multipart codes can arrive out of order, and missed parts are collected on the next rotation before the privacy-safe metrics are parsed.
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
- 7. Render the selected template with Canvas and save it to the phone.
8
+ 2. Select “Download WeChat import file” in the desktop page.
9
+ 3. Send the `.cwr.json` file to WeChat File Transfer or one of your own chats.
10
+ 4. Tap “Import from chat file” in the mini program and select that file.
11
+ 5. Validate the file format, version, SHA-256 digest, payload schema, and privacy-safe fields.
12
+ 6. Preview the range, session count, and canonical fact count. Only deduplicate and persist after user confirmation.
13
+ 7. Render the selected template on Canvas and save it to the photo library.
14
14
 
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.
15
+ The mini program should use `wx.chooseMessageFile` to select the chat file and `FileSystemManager.readFile` to read it as UTF-8. It must enforce its own product size limit, validate all untrusted input, and avoid partial writes before confirmation.
16
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.
17
+ ## Optional single-code import
18
+
19
+ When the complete `cwr1` or `cwr2` payload safely fits in one data QR, the page offers “Or import by scanning.” The single data code replaces the mini-program code, so two scannable codes are never shown at once. New `cwr2p` multipart codes are no longer generated.
20
+
21
+ The updated mini program should continue decoding `cwr1`, `cwr2`, and historical `cwr2p` receipts for backwards compatibility.
22
+
23
+ Neither files nor QR codes transfer an image. Desktop and mobile render the same privacy-safe structured data independently. Import data excludes prompts, response text, source code, project paths, file names, and original session IDs. Sending the file to a chat explicitly uses WeChat's file-transfer system, so send it only to a chat you trust.
24
+
25
+ Saving a private receipt does not automatically join public statistics; participation in the AI Cooperative remains a separate choice.
18
26
 
19
27
  The companion mini program is a separate product. This repository does not contain its source code, AppID, backend code, or server credentials.
20
28
 
21
- See the [data schema and QR protocol](data-schema.en.md).
29
+ See the [data schema, file, and QR protocol](data-schema.en.md). For implementation details, see [mini-program file import](miniprogram-file-import.en.md).
@@ -1,21 +1,29 @@
1
- # 手机扫码导入
1
+ # 手机文件与单码导入
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
- 桌面网页会同时展示固定小程序码和当前小票的数据二维码。配套小程序会先在手机端完成协议和隐私字段校验,再把私人小票保存到用户的匿名账号数据库:
5
+ 桌面网页始终提供一个 `.cwr.json` 微信导入文件。固定小程序码只负责打开配套小程序;数据足够小时,网页才额外提供一个完整的数据二维码。
6
6
 
7
7
  1. 微信扫描固定小程序码并打开配套小程序。
8
- 2. 点击“从电脑导入”。
9
- 3. 扫描网页旁边的数据二维码;多分片小票会在电脑上自动逐张轮播,小程序在未集齐时自动继续打开扫码。
10
- 4. 小程序校验 `cwr1`、`cwr2` 或 `cwr2p` 分片前缀、checksum 和结构版本。
11
- 5. 单码立即解压;多分片允许乱序、漏扫后等待下一轮补齐,全部到齐再解析脱敏统计数据。
12
- 6. 识别单会话、今日、近七日或本周范围;新版小票按规范事实去重,完整记录默认保存到匿名账号数据库。
8
+ 2. 在桌面网页点击“下载微信导入文件”。
9
+ 3. 把 `.cwr.json` 发送到“文件传输助手”或自己的任意微信聊天。
10
+ 4. 在小程序中点击“从聊天文件导入”,选择刚才发送的文件。
11
+ 5. 小程序校验文件格式、版本、SHA-256、数据协议和隐私字段。
12
+ 6. 小程序先展示统计范围、会话数和 canonical fact 数量;用户确认后再按规范事实去重并入库。
13
13
  7. 使用 Canvas 重新绘制所选模板并保存到手机相册。
14
14
 
15
- 手机本地只保留扫码分片、待入库记录和必要缓存。私人小票入库不等于自动加入公开统计;是否参与 AI 供销社匿名汇总由用户在配套小程序中单独决定。
15
+ 文件导入使用微信官方 `wx.chooseMessageFile` 选择聊天文件,再通过 `FileSystemManager.readFile` 以 UTF-8 读取。小程序应设置独立的产品大小上限、严格校验内容,并在确认前不写入任何数据。
16
16
 
17
- 二维码不会传输图片。桌面端和小程序端根据同一份结构数据分别渲染小票;旧版二维码没有显式范围字段时仍按原有文案兼容识别。二维码不包含 Prompt、回复正文、代码、项目路径、文件名或原始会话 ID。
17
+ ## 可选的单码导入
18
+
19
+ 如果完整的 `cwr1` 或 `cwr2` 数据能安全放进一个二维码,网页会显示“也可以扫码导入”。点击后,唯一的数据二维码会替换小程序码;页面不会同时显示多个可扫描二维码,也不会生成新的 `cwr2p` 分片。
20
+
21
+ 新版小程序仍应保留 `cwr1`、`cwr2` 和历史 `cwr2p` 解码能力,确保旧小票能够继续导入。
22
+
23
+ 导入文件和二维码都不传输图片。桌面端和小程序端根据同一份脱敏结构数据分别渲染小票;它们不包含 Prompt、回复正文、代码、项目路径、文件名或原始会话 ID。把文件发送到聊天属于用户主动使用微信文件传输体系的行为,请只发送到可信聊天。
24
+
25
+ 私人小票入库不等于自动加入公开统计;是否参与 AI 供销社匿名汇总由用户在配套小程序中单独决定。
18
26
 
19
27
  配套小程序是独立产品,本仓库不包含其源码、AppID、后台代码或服务端密钥。
20
28
 
21
- 协议细节见 [数据结构与二维码协议](data-schema.md)。
29
+ 协议细节见 [数据结构、文件与二维码协议](data-schema.md)。小程序开发接入见 [小程序文件导入实现](miniprogram-file-import.md)
@@ -21,14 +21,29 @@ Session logs are parsed locally in bounded chunks, one JSONL row at a time. Afte
21
21
 
22
22
  ## Where data is saved
23
23
 
24
- Generated HTML and JSON files are written to `codex-work-receipt-output/` by default. Deduplicated history is stored under `~/.codex-work-receipt/`. Use `--output` and `--data-dir` to change these locations.
24
+ Generated HTML, full JSON, and privacy-safe `.cwr.json` WeChat import files are written to `codex-work-receipt-output/` by default. Deduplicated history is stored under `~/.codex-work-receipt/`. Use `--output` and `--data-dir` to change these locations.
25
25
 
26
26
  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.
27
27
 
28
- 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.
28
+ If the user actively sends a `.cwr.json` file to a WeChat chat, that file passes through WeChat's file-transfer system. The user can then select it in the companion mini program, preview it, and save it to the anonymous account database for history recovery and canonical deduplication. The file excludes prompts, response text, source code, project paths, file names, and original session IDs. Joining AI Cooperative aggregate statistics remains a separate user choice.
29
29
 
30
- ## QR payload
30
+ ## Automatic saving
31
31
 
32
- 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.
32
+ Automatic saving is an explicit opt-in local feature. Enabling it:
33
33
 
34
- See the [data schema and QR protocol](data-schema.en.md).
34
+ - safely merges one Codex `Stop` hook into `~/.codex/hooks.json`
35
+ - stores a stable offline runtime under `~/.codex-work-receipt/runtime/`
36
+ - quietly refreshes the daily HTML, full JSON, and `.cwr.json` under `~/.codex-work-receipt/auto/YYYY-MM-DD/`
37
+ - stores the selected mode and latest run state in `config.json` and `auto-state.json`
38
+
39
+ The hook input includes the session identifier and event name supplied by Codex. The trigger script uses them only to confirm a valid `Stop` event; receipt statistics still use the existing local privacy-filtering parser. The hook does not upload sessions, send WeChat files, or continuously monitor other applications. Each trigger starts one short-lived local process that exits after generation.
40
+
41
+ Switching to manual-only mode removes this tool's hook without deleting receipt history or changing unrelated hooks. An existing `hooks.json` is backed up before changes. If it is not valid JSON, setup stops instead of replacing it. Codex also asks users to review non-managed hooks through its own hook trust flow.
42
+
43
+ ## Import files and QR payloads
44
+
45
+ The import file and optional single data QR contain versioned, privacy-safe receipt metrics only. They do not contain the original session or an image. Scan import appears only when the complete payload fits in one code; the desktop no longer creates new multipart QR codes. Send files only to chats you trust and show data codes only to people you trust.
46
+
47
+ Automatic mode does not generate a data QR. It maintains only the `.cwr.json` import file that the user may choose to send through WeChat.
48
+
49
+ See the [data schema, file, and QR protocol](data-schema.en.md).
package/docs/privacy.md CHANGED
@@ -21,14 +21,29 @@ AI 打工小票在本机读取 Codex 会话,并在本机完成统计和渲染
21
21
 
22
22
  ## 会保存在哪里
23
23
 
24
- 生成的 HTML JSON 默认位于当前目录的 `codex-work-receipt-output/`,去重历史保存在 `~/.codex-work-receipt/`。可以使用 `--output` 和 `--data-dir` 修改位置。
24
+ 生成的 HTML、完整 JSON 和脱敏 `.cwr.json` 微信导入文件默认位于当前目录的 `codex-work-receipt-output/`,去重历史保存在 `~/.codex-work-receipt/`。可以使用 `--output` 和 `--data-dir` 修改位置。
25
25
 
26
26
  运行期间不会把 Codex 会话数据上传到本项目的服务器。通过 `npx` 首次下载或更新 npm 包属于包管理器的正常网络请求,不包含本地会话内容。
27
27
 
28
- 如果用户主动扫描数据二维码,配套小程序会把二维码中的脱敏小票保存到用户的匿名账号数据库,用于历史恢复和规范去重。这一步发生在配套小程序中,不会上传 Prompt、回复正文、代码、项目路径、文件名或原始会话 ID;是否参与 AI 供销社匿名统计仍由用户单独决定。
28
+ 如果用户主动把 `.cwr.json` 发送到微信聊天,文件会经过微信的文件传输体系。随后用户可在配套小程序中选择该聊天文件、确认内容并保存到匿名账号数据库,用于历史恢复和规范去重。文件不会包含 Prompt、回复正文、代码、项目路径、文件名或原始会话 ID;是否参与 AI 供销社匿名统计仍由用户单独决定。
29
29
 
30
- ## 二维码
30
+ ## 自动保存
31
31
 
32
- 二维码只包含当前小票的版本化脱敏统计,不包含原始会话或图片。二维码等同于一份可扫描的数据文件,请只向信任的人展示。
32
+ 自动保存是明确选择加入的本地功能。启用后会:
33
33
 
34
- 格式说明见 [数据结构与二维码协议](data-schema.md)。
34
+ - 在 `~/.codex/hooks.json` 中安全合并一个 Codex `Stop` Hook
35
+ - 在 `~/.codex-work-receipt/runtime/` 保存一份稳定的离线运行器
36
+ - 在 `~/.codex-work-receipt/auto/YYYY-MM-DD/` 静默刷新当天的 HTML、完整 JSON 和 `.cwr.json`
37
+ - 在 `config.json` 和 `auto-state.json` 中保存模式与最近运行状态
38
+
39
+ Hook 输入包含 Codex 提供的会话标识和事件名称;触发脚本只用它确认这是一次有效的 `Stop` 事件,实际统计仍通过本机已有的隐私过滤解析器完成。Hook 不会上传会话、自动发送微信文件或持续监控其他应用。每次触发只启动短生命周期的本地进程,完成后退出。
40
+
41
+ 切换到仅手动模式会移除本工具添加的 Hook,不会删除历史小票或修改其他 Hook。已有 `hooks.json` 在变更前会备份;如果它不是有效 JSON,安装会停止而不是覆盖文件。Codex 还会要求用户按自身 Hook 信任机制审查非托管 Hook。
42
+
43
+ ## 导入文件与二维码
44
+
45
+ 导入文件和可选的单个数据二维码只包含当前小票的版本化脱敏统计,不包含原始会话或图片。只有完整载荷能放进一个二维码时才提供扫码导入;桌面端不再生成新的多分片二维码。请只把导入文件发送到可信聊天,也只向信任的人展示数据二维码。
46
+
47
+ 自动模式不生成数据二维码,只维护可由用户主动发送到微信的 `.cwr.json` 导入文件。
48
+
49
+ 格式说明见 [数据结构、文件与二维码协议](data-schema.md)。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codex-work-receipt",
3
- "version": "0.7.4",
3
+ "version": "0.8.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",
@@ -27,7 +27,7 @@
27
27
  "scripts": {
28
28
  "receipt": "node src/cli.mjs",
29
29
  "test": "node --test",
30
- "check": "node --check src/cli.mjs && node --check src/core/skill-installer.mjs && node --check src/core/pet-installer.mjs && node --check src/core/range.mjs",
30
+ "check": "node --check src/cli.mjs && node --check src/auto-hook.mjs && node --check src/auto-runner.mjs && node --check src/core/auto-mode.mjs && node --check src/core/generator.mjs && node --check src/core/mode-selector.mjs && node --check src/lib/files.mjs && node --check src/core/skill-installer.mjs && node --check src/core/pet-installer.mjs && node --check src/core/range.mjs",
31
31
  "prepublishOnly": "npm test && npm run check"
32
32
  },
33
33
  "files": [
@@ -39,13 +39,13 @@ npx --yes codex-work-receipt@latest --latest --lang zh-CN --theme classic
39
39
  Replace only the mode and optional flags according to the request.
40
40
 
41
41
  3. Let the CLI open the generated HTML unless the user requested `--no-open`.
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.
42
+ 4. On success, report both the HTML path and the `.cwr.json` WeChat import file path. Tell the user to send the import file to WeChat File Transfer, open the official mini program, and choose “Import from chat file”. If the CLI reports that a single data QR is available, mention scanning as an optional shortcut; never instruct the user to scan multipart codes.
43
43
  5. When `--hours` was used, explicitly state that the receipt is a private rolling summary and will not enter AI Work Cooperative statistics.
44
44
 
45
45
  ## Privacy and failures
46
46
 
47
47
  - Do not read, quote, summarize, or expose prompt text, response text, code, project paths, or file names from Codex sessions.
48
- - Do not upload session data or add custom mini-program codes. The CLI reads numerical metadata locally and uses the fixed official mini-program code.
48
+ - Do not upload session data or add custom mini-program codes. The CLI reads numerical metadata locally and uses the fixed official mini-program code. Sending the privacy-safe `.cwr.json` file through WeChat is an explicit user action, not an automatic CLI upload.
49
49
  - If `npx` needs network permission to fetch the package, request the narrow permission needed to run it.
50
50
  - If no Codex session is found, report that the user must complete at least one Codex task locally before generating a receipt.
51
51
  - If generation fails, return the CLI error concisely and do not claim that a receipt was created.
@@ -0,0 +1,46 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn } from "node:child_process";
4
+ import fs from "node:fs";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+
8
+ function argumentValue(name) {
9
+ const index = process.argv.indexOf(name);
10
+ return index >= 0 ? process.argv[index + 1] : null;
11
+ }
12
+
13
+ async function readStdin() {
14
+ let value = "";
15
+ for await (const chunk of process.stdin) value += chunk;
16
+ return value;
17
+ }
18
+
19
+ async function main() {
20
+ try {
21
+ const workReceiptHome = argumentValue("--work-receipt-home");
22
+ if (!workReceiptHome) return;
23
+ const input = JSON.parse(await readStdin());
24
+ if (input?.hook_event_name !== "Stop" || !input?.session_id) return;
25
+
26
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
27
+ const runnerEntry = path.join(scriptDir, "auto-runner.mjs");
28
+ if (!fs.existsSync(runnerEntry)) return;
29
+ const child = spawn(process.execPath, [
30
+ runnerEntry,
31
+ "--work-receipt-home",
32
+ workReceiptHome,
33
+ "--triggered-at",
34
+ new Date().toISOString(),
35
+ ], {
36
+ detached: true,
37
+ stdio: "ignore",
38
+ env: process.env,
39
+ });
40
+ child.unref();
41
+ } catch {
42
+ // Automatic receipt failures must never interrupt the Codex turn.
43
+ }
44
+ }
45
+
46
+ await main();
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env node
2
+
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import {
8
+ automaticOutputPath,
9
+ readAutoConfig,
10
+ readAutoState,
11
+ writeAutoState,
12
+ } from "./core/auto-mode.mjs";
13
+ import { dateKey } from "./lib/time.mjs";
14
+
15
+ const LOCK_STALE_MS = 10 * 60 * 1000;
16
+
17
+ function argumentValue(name) {
18
+ const index = process.argv.indexOf(name);
19
+ return index >= 0 ? process.argv[index + 1] : null;
20
+ }
21
+
22
+ function delay(milliseconds) {
23
+ return new Promise((resolve) => setTimeout(resolve, milliseconds));
24
+ }
25
+
26
+ function emptyActivityError(error) {
27
+ return /没有找到|No Codex sessions found/.test(String(error?.message || ""));
28
+ }
29
+
30
+ function pendingTime(pendingPath, fallback = new Date()) {
31
+ try {
32
+ const value = new Date(fs.readFileSync(pendingPath, "utf8").trim());
33
+ return Number.isNaN(value.getTime()) ? fallback : value;
34
+ } catch {
35
+ return fallback;
36
+ }
37
+ }
38
+
39
+ function crossesConfiguredDay(workReceiptHome, left, right) {
40
+ const config = readAutoConfig({ workReceiptHome });
41
+ const timezone = config?.preferences?.timezone || "Asia/Shanghai";
42
+ return dateKey(left, timezone) !== dateKey(right, timezone);
43
+ }
44
+
45
+ function acquireLock(lockPath, pendingPath) {
46
+ fs.mkdirSync(path.dirname(lockPath), { recursive: true });
47
+ try {
48
+ const descriptor = fs.openSync(lockPath, "wx");
49
+ fs.writeFileSync(descriptor, `${JSON.stringify({ pid: process.pid, created_at: new Date().toISOString() })}\n`);
50
+ fs.closeSync(descriptor);
51
+ return true;
52
+ } catch (error) {
53
+ if (error.code !== "EEXIST") throw error;
54
+ try {
55
+ const age = Date.now() - fs.statSync(lockPath).mtimeMs;
56
+ if (age > LOCK_STALE_MS) {
57
+ fs.rmSync(lockPath, { force: true });
58
+ return acquireLock(lockPath, pendingPath);
59
+ }
60
+ } catch {
61
+ // Another worker may have released the lock between checks.
62
+ }
63
+ fs.writeFileSync(pendingPath, `${new Date().toISOString()}\n`, "utf8");
64
+ return false;
65
+ }
66
+ }
67
+
68
+ async function generateOnce({ workReceiptHome, now = new Date() }) {
69
+ const config = readAutoConfig({ workReceiptHome });
70
+ if (!config || config.mode !== "automatic") return { status: "disabled" };
71
+
72
+ const scriptDir = path.dirname(fileURLToPath(import.meta.url));
73
+ const projectDir = path.dirname(scriptDir);
74
+ const vendorScript = path.join(projectDir, "vendor", "dom-to-image-more.min.js");
75
+ if (fs.existsSync(vendorScript)) process.env.CODEX_WORK_RECEIPT_DOM_TO_IMAGE = vendorScript;
76
+ process.env.CODEX_WORK_RECEIPT_HOME = workReceiptHome;
77
+
78
+ const previousState = readAutoState({ workReceiptHome }) || {};
79
+ const attemptedAt = new Date().toISOString();
80
+ try {
81
+ const { generateReceipt } = await import("./core/generator.mjs");
82
+ const outputFile = automaticOutputPath(config, now);
83
+ const generated = await generateReceipt({
84
+ mode: "today",
85
+ sessionId: null,
86
+ hours: null,
87
+ timezone: config.preferences?.timezone || "Asia/Shanghai",
88
+ locale: config.preferences?.locale || "zh-CN",
89
+ theme: config.preferences?.theme || "classic",
90
+ output: outputFile,
91
+ dataDir: workReceiptHome,
92
+ }, {
93
+ projectDir,
94
+ now,
95
+ codexHome: config.hook?.codex_home || null,
96
+ });
97
+ const state = {
98
+ state_version: 1,
99
+ status: "ok",
100
+ last_attempt_at: attemptedAt,
101
+ last_success_at: new Date().toISOString(),
102
+ output_file: generated.outputFile,
103
+ structured_file: generated.persisted.companionPath,
104
+ import_file: generated.persisted.transferPath,
105
+ receipt_id: generated.record.id,
106
+ snapshot_hash: generated.record.source.snapshot_hash,
107
+ error: null,
108
+ };
109
+ writeAutoState({ workReceiptHome }, state);
110
+ return state;
111
+ } catch (error) {
112
+ const state = {
113
+ ...previousState,
114
+ state_version: 1,
115
+ status: emptyActivityError(error) ? "waiting" : "error",
116
+ last_attempt_at: attemptedAt,
117
+ error: String(error?.message || error),
118
+ };
119
+ writeAutoState({ workReceiptHome }, state);
120
+ return state;
121
+ }
122
+ }
123
+
124
+ export async function runAutomaticReceipt({ workReceiptHome, now = new Date(), retryAfterCoalesce = true }) {
125
+ const lockPath = path.join(workReceiptHome, "auto.lock");
126
+ const pendingPath = path.join(workReceiptHome, "auto.pending");
127
+ if (!acquireLock(lockPath, pendingPath)) {
128
+ if (!retryAfterCoalesce) return { status: "coalesced" };
129
+ await delay(800);
130
+ if (fs.existsSync(lockPath)) return { status: "coalesced" };
131
+ return runAutomaticReceipt({
132
+ workReceiptHome,
133
+ now: pendingTime(pendingPath),
134
+ retryAfterCoalesce: false,
135
+ });
136
+ }
137
+
138
+ try {
139
+ await delay(350);
140
+ const pendingBeforeRun = fs.existsSync(pendingPath) ? pendingTime(pendingPath) : null;
141
+ fs.rmSync(pendingPath, { force: true });
142
+ let result = await generateOnce({ workReceiptHome, now });
143
+ if (pendingBeforeRun && crossesConfiguredDay(workReceiptHome, now, pendingBeforeRun)) {
144
+ result = await generateOnce({ workReceiptHome, now: pendingBeforeRun });
145
+ }
146
+ if (fs.existsSync(pendingPath)) {
147
+ const nextRunAt = pendingTime(pendingPath);
148
+ fs.rmSync(pendingPath, { force: true });
149
+ await delay(350);
150
+ result = await generateOnce({ workReceiptHome, now: nextRunAt });
151
+ }
152
+ return result;
153
+ } finally {
154
+ fs.rmSync(lockPath, { force: true });
155
+ }
156
+ }
157
+
158
+ async function main() {
159
+ const workReceiptHome = argumentValue("--work-receipt-home");
160
+ if (!workReceiptHome) return;
161
+ const triggeredAtValue = new Date(argumentValue("--triggered-at") || Date.now());
162
+ const triggeredAt = Number.isNaN(triggeredAtValue.getTime()) ? new Date() : triggeredAtValue;
163
+ try {
164
+ await runAutomaticReceipt({ workReceiptHome: path.resolve(workReceiptHome), now: triggeredAt });
165
+ } catch {
166
+ // Automatic receipt failures must never interrupt the Codex turn.
167
+ }
168
+ }
169
+
170
+ if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
171
+ await main();
172
+ }